Compare commits
332 Commits
10ec47611b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42fc9950f5 | ||
|
|
f0ca8b407e | ||
|
|
099f5aa287 | ||
|
|
16544b59bd | ||
|
|
f24b288de3 | ||
|
|
3c40cf9ef4 | ||
|
|
aa5f711fe4 | ||
|
|
5a83d89509 | ||
|
|
bb15c78ca9 | ||
|
|
4be2d7be8f | ||
|
|
9396fbd81a | ||
|
|
ec1a681924 | ||
|
|
36612ecc9f | ||
|
|
badde91336 | ||
|
|
c3344288a8 | ||
|
|
021b351248 | ||
|
|
b782d5c8f9 | ||
|
|
45b264dbc4 | ||
|
|
24ce637f6e | ||
|
|
075d42ecab | ||
|
|
379d08b594 | ||
|
|
d54855bf19 | ||
|
|
5eca4b7155 | ||
|
|
e340368ef0 | ||
|
|
38ff3f61bd | ||
|
|
664741504f | ||
|
|
c830642dc8 | ||
|
|
29026175b7 | ||
|
|
fe6a6df6a3 | ||
|
|
3c4d76d4a9 | ||
|
|
c7a1b7066a | ||
|
|
8a50bc586d | ||
|
|
c1b1713699 | ||
|
|
c9d21bcfaa | ||
|
|
97983156c6 | ||
|
|
46f7b0c98a | ||
|
|
13b374d7f8 | ||
|
|
981e561c2d | ||
|
|
a1ff2433e4 | ||
|
|
a50a6e9697 | ||
|
|
c74898049a | ||
|
|
25623b735c | ||
|
|
1050ed84f2 | ||
|
|
4cf1e550b7 | ||
|
|
66ac81de65 | ||
|
|
ea29136f25 | ||
|
|
641a317873 | ||
|
|
5dd9bd9344 | ||
|
|
b7427aa973 | ||
|
|
48ca088a2c | ||
|
|
b9081f26a8 | ||
|
|
f10108bd48 | ||
|
|
f1e418ad83 | ||
|
|
9a02b350c2 | ||
|
|
c390b5291b | ||
|
|
b2d55feb98 | ||
|
|
54cad8b92b | ||
|
|
b3a69c6797 | ||
|
|
62a9ab811d | ||
|
|
df436ff84d | ||
|
|
1606d60a06 | ||
|
|
f327f82677 | ||
|
|
2c99169ce9 | ||
|
|
9ca46dc29d | ||
|
|
3f79645bb8 | ||
|
|
8468134919 | ||
|
|
d9638cdc69 | ||
|
|
2d2b8835b1 | ||
|
|
22836dafb2 | ||
|
|
1336590241 | ||
|
|
78e7c92893 | ||
|
|
e59b6c1832 | ||
|
|
e343ab8b4e | ||
|
|
b9b69a4966 | ||
|
|
bdc9b3f291 | ||
|
|
8b8279878a | ||
|
|
f095e18e95 | ||
|
|
904d4ec7f7 | ||
|
|
baaa724595 | ||
|
|
8ff4203152 | ||
|
|
72c978ee75 | ||
|
|
d25f25ee52 | ||
|
|
2adfa7f7a3 | ||
|
|
96afe8dcb7 | ||
|
|
1b0b1c570d | ||
|
|
73e157cc13 | ||
|
|
18bdac03b1 | ||
|
|
173a0a9f95 | ||
|
|
01eef4eead | ||
|
|
a499446366 | ||
|
|
a6bf84d6b8 | ||
|
|
aaaf25350c | ||
|
|
432a9feff9 | ||
|
|
ee76a61240 | ||
|
|
85228b134b | ||
|
|
d97167b21c | ||
|
|
69e24e7d79 | ||
|
|
02248ccc38 | ||
|
|
e67fded321 | ||
|
|
621902b3e8 | ||
|
|
380289d484 | ||
|
|
d67a2fde88 | ||
|
|
215652d47c | ||
|
|
b49574f39b | ||
|
|
d4a9441b54 | ||
|
|
5518161adf | ||
|
|
4c329bbfb3 | ||
|
|
381951bd91 | ||
|
|
fdc3c36cd7 | ||
|
|
64b3e4b069 | ||
|
|
c4acc2159e | ||
|
|
a0740b81f5 | ||
|
|
82d74a01b5 | ||
|
|
bb139b4afd | ||
|
|
40e6eab42d | ||
|
|
d9b7bd32b7 | ||
|
|
76772140aa | ||
|
|
314fec5c47 | ||
|
|
e261113134 | ||
|
|
7693265b62 | ||
|
|
2d8d2b360f | ||
|
|
37e9b89057 | ||
|
|
467be0493c | ||
|
|
6f3a650e69 | ||
|
|
38727c5eeb | ||
|
|
d4a3b1cce9 | ||
|
|
356e84b262 | ||
|
|
57fd3e88bc | ||
|
|
4d8229ab46 | ||
|
|
d43662b2dc | ||
|
|
3095fc7387 | ||
|
|
63e28d9126 | ||
|
|
761127650f | ||
|
|
67273d6a80 | ||
|
|
15362edc87 | ||
|
|
40df13e756 | ||
|
|
f2634328ec | ||
|
|
53863657d7 | ||
|
|
ba0cbfbbb6 | ||
|
|
a8513b339c | ||
|
|
21f12501ca | ||
|
|
6760ca5e5d | ||
|
|
82ab5cd9f5 | ||
|
|
5a99543fd6 | ||
|
|
79fdee1e29 | ||
|
|
acfaad67f7 | ||
|
|
e664102807 | ||
|
|
026d0c8d6b | ||
|
|
33845c70d7 | ||
|
|
fa18c72cf7 | ||
|
|
c5aa4b5332 | ||
|
|
6adbd88b22 | ||
|
|
c65fd67b8e | ||
|
|
62f67b7893 | ||
|
|
58ff05c5e6 | ||
|
|
899606b5c0 | ||
|
|
67a1148e85 | ||
|
|
00206ef794 | ||
|
|
e1186ab532 | ||
|
|
01ca8ca127 | ||
|
|
2ac11350e6 | ||
|
|
891fb2a10c | ||
|
|
8da6d8bc0e | ||
|
|
8c98546fbf | ||
|
|
9a7d4b3175 | ||
|
|
479e1918f5 | ||
|
|
9d5fceee5c | ||
|
|
efc3a88052 | ||
|
|
deeb70ba0d | ||
|
|
187b0276d1 | ||
|
|
48d34a5eeb | ||
|
|
78674714b1 | ||
|
|
1a6d32c16b | ||
|
|
7a277a86eb | ||
|
|
8c02dc15c0 | ||
|
|
4045def0a7 | ||
|
|
460cf34252 | ||
|
|
ea75c1eabc | ||
|
|
0fb57d7c94 | ||
|
|
edcc19e5a4 | ||
|
|
8ad05cdeb9 | ||
|
|
e7e605ea00 | ||
|
|
6fa7ae5e8f | ||
|
|
b3c3faa06d | ||
|
|
5162a71be4 | ||
|
|
0d3de9dc48 | ||
|
|
2a68ffcb90 | ||
|
|
10dc27a2c4 | ||
|
|
465efbeb0e | ||
|
|
24683f23a5 | ||
|
|
bbae2740a7 | ||
|
|
224738f2e7 | ||
|
|
e9c0cc03f0 | ||
|
|
ebc3efba6a | ||
|
|
0f9b7fcf68 | ||
|
|
304e779fd0 | ||
|
|
50ec1ee6c2 | ||
|
|
33cad06155 | ||
|
|
e61a13961a | ||
|
|
e017eb1009 | ||
|
|
71db1d9973 | ||
|
|
0e4d184059 | ||
|
|
11c38dbabb | ||
|
|
39b95b3663 | ||
|
|
8cdb2d3b98 | ||
|
|
338af14b6b | ||
|
|
50fa771ca4 | ||
|
|
4dc06184de | ||
|
|
1dca61eece | ||
|
|
c78e25deb2 | ||
|
|
688db466ac | ||
|
|
293dc7925b | ||
|
|
9da5db084e | ||
|
|
4b38a793c6 | ||
|
|
7404e73fd3 | ||
|
|
d80b0b155e | ||
|
|
1198c2e73a | ||
|
|
7ad68885d3 | ||
|
|
491abf891f | ||
|
|
2bcb907914 | ||
|
|
b2c635b7ef | ||
|
|
caf0ec85a1 | ||
|
|
06dbaf5756 | ||
|
|
bd3159cd39 | ||
|
|
4637275529 | ||
|
|
a5e6be7f4b | ||
|
|
c47ec63e50 | ||
|
|
98f8157bea | ||
|
|
cc6b1e999a | ||
|
|
582c92f090 | ||
|
|
bee1019fd6 | ||
|
|
12c17d3bfd | ||
|
|
846f3e8b80 | ||
|
|
6e2c17de29 | ||
|
|
580b53ed6b | ||
|
|
862002c0cf | ||
|
|
b454fd5516 | ||
|
|
a2f7df448c | ||
|
|
4fcec03a59 | ||
|
|
a3d0a46b44 | ||
|
|
98f415d7d4 | ||
|
|
993b5ac55a | ||
|
|
5416f18f4b | ||
|
|
c3ee7d0d42 | ||
|
|
9f75866ce7 | ||
|
|
79eca14f4c | ||
|
|
fcfef379be | ||
|
|
75f785d8da | ||
|
|
05291c9373 | ||
|
|
73fbe98d86 | ||
|
|
898353e8c6 | ||
|
|
ba8acc1c27 | ||
|
|
3884b850c8 | ||
|
|
05f5a3519e | ||
|
|
b5ae28944d | ||
|
|
d1c9256ed8 | ||
|
|
854fcaf66f | ||
|
|
62984b5d18 | ||
|
|
39a04cc5e9 | ||
|
|
29498a2f6a | ||
|
|
c25ef9f2cc | ||
|
|
4af91235e1 | ||
|
|
80338d1973 | ||
|
|
aed7d1f735 | ||
|
|
9f322003db | ||
|
|
bbc44c3d97 | ||
|
|
d4d6e5e480 | ||
|
|
fe6ac23329 | ||
|
|
0ce63bacd7 | ||
|
|
a16039e920 | ||
|
|
db0151a616 | ||
|
|
bd5b1db5a0 | ||
|
|
23d237fa20 | ||
|
|
2853ca98a0 | ||
|
|
cc64b568a0 | ||
|
|
ba72ce40cd | ||
|
|
7935bd6938 | ||
|
|
c1399fb44e | ||
|
|
1f3b961fa0 | ||
|
|
86904bc01f | ||
|
|
4a664914e9 | ||
|
|
6021b9603c | ||
|
|
956bc82f7f | ||
|
|
c4949eceb5 | ||
|
|
0d68a83520 | ||
|
|
a671e935be | ||
|
|
ac421fcc2e | ||
|
|
985f7bcf44 | ||
|
|
43d11503c9 | ||
|
|
2ea340d4e4 | ||
|
|
e6f2ff5587 | ||
|
|
05bf5ce122 | ||
|
|
a4df95e049 | ||
|
|
7358371727 | ||
|
|
3f5ba532c6 | ||
|
|
f67cc3b36b | ||
|
|
7c5fe019f1 | ||
|
|
28677b9b49 | ||
|
|
0619b3e529 | ||
|
|
e26406c4fc | ||
|
|
44f9e9f7c3 | ||
|
|
0434c0b5df | ||
|
|
31ac11038e | ||
|
|
cf83e9ef35 | ||
|
|
7e95cf64c6 | ||
|
|
a06b5f5ce2 | ||
|
|
9ade5b138f | ||
|
|
cf7f443e42 | ||
|
|
725f60aba6 | ||
|
|
1f57333cb0 | ||
|
|
12b1165938 | ||
|
|
953e8aba99 | ||
|
|
8f0072dedf | ||
|
|
e01a605bb0 | ||
|
|
5bad9a1e71 | ||
|
|
1abd6b4165 | ||
|
|
32eaa2db3d | ||
|
|
9e1852a219 | ||
|
|
594d8c954c | ||
|
|
10edbe8fff | ||
|
|
4faf8469e3 | ||
|
|
e8b2732692 | ||
|
|
e6debef4fd | ||
|
|
7699276c22 | ||
|
|
08f6f12764 | ||
|
|
59ce10b986 | ||
|
|
473570f398 | ||
|
|
efa79e56cb | ||
|
|
d9bb529c21 | ||
|
|
fa6c682fd2 | ||
|
|
11c9599863 | ||
|
|
50d38b86a2 |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
bin/
|
||||
.env
|
||||
*.bak
|
||||
118
.package
Normal file
118
.package
Normal file
@@ -0,0 +1,118 @@
|
||||
{
|
||||
required = {
|
||||
'platform',
|
||||
},
|
||||
title = "Inventory Manager (Unstable)",
|
||||
description = "UNSTABLE/DEV — Automated inventory management system for CC:Tweaked. Uses cc-platform-core. May have breaking changes. Install 'inventory-manager' for the stable version.",
|
||||
repository = "gitea://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/main/",
|
||||
exclude = {
|
||||
"^web/", "^__tests__/", "^startup/",
|
||||
"%.md$", "%.json$", "^%.git", "^LICENSE$", "^node_modules/",
|
||||
"%.bak$", "^listDevicesByType%.lua$",
|
||||
},
|
||||
install = [[
|
||||
local cfgDir = "usr/config/inventory-manager"
|
||||
if not fs.isDir(cfgDir) then fs.makeDir(cfgDir) end
|
||||
local function ask(prompt, default)
|
||||
if default and #default > 0 then
|
||||
write(prompt .. " [" .. default .. "]: ")
|
||||
else
|
||||
write(prompt .. ": ")
|
||||
end
|
||||
local val = read()
|
||||
if not val or #val == 0 then return default end
|
||||
return val
|
||||
end
|
||||
|
||||
print("")
|
||||
print("-- Inventory Manager Setup --")
|
||||
print("")
|
||||
print("Which role(s) will this computer run?")
|
||||
print(" 1) Inventory Manager (main controller)")
|
||||
print(" 2) Inventory Client (display-only)")
|
||||
print(" 3) Web Bridge (HTTP forwarder)")
|
||||
print(" 4) Mining Turtle (cobble miner)")
|
||||
print(" 5) Skip setup")
|
||||
print("")
|
||||
write("Choice (1/2/3/4/5): ")
|
||||
local choice = read()
|
||||
|
||||
if choice == "1" then
|
||||
print("")
|
||||
print("-- Manager Configuration --")
|
||||
local monitorSide = ask("Monitor side", "left")
|
||||
local smelterMonitorSide = ask("Smelter monitor side", "top")
|
||||
local dropperName = ask("Dropper peripheral name", "minecraft:dropper_9")
|
||||
local barrelName = ask("Barrel peripheral name", "minecraft:barrel_0")
|
||||
local serverUrl = ask("Web server URL (blank to skip)", "")
|
||||
|
||||
local cfg = {
|
||||
monitorSide = monitorSide,
|
||||
smelterMonitorSide = smelterMonitorSide,
|
||||
dropperName = dropperName,
|
||||
barrelName = barrelName,
|
||||
}
|
||||
local f = fs.open(fs.combine(cfgDir, ".manager_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved manager config.")
|
||||
|
||||
if serverUrl and #serverUrl > 0 then
|
||||
local bcfg = { serverUrl = serverUrl }
|
||||
local bf = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
|
||||
bf.write(textutils.serialiseJSON(bcfg))
|
||||
bf.close()
|
||||
print("Saved web bridge config.")
|
||||
end
|
||||
|
||||
elseif choice == "2" then
|
||||
print("")
|
||||
print("-- Client Configuration --")
|
||||
local monitorSide = ask("Monitor side", "left")
|
||||
local smelterMonitorSide = ask("Smelter monitor side", "top")
|
||||
local dropperName = ask("Dropper peripheral name (blank if none)", "")
|
||||
local barrelName = ask("Barrel peripheral name (blank if none)", "")
|
||||
|
||||
local cfg = {
|
||||
monitorSide = monitorSide,
|
||||
smelterMonitorSide = smelterMonitorSide,
|
||||
}
|
||||
if dropperName and #dropperName > 0 then cfg.dropperName = dropperName end
|
||||
if barrelName and #barrelName > 0 then cfg.barrelName = barrelName end
|
||||
|
||||
local f = fs.open(fs.combine(cfgDir, ".client_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved client config.")
|
||||
|
||||
elseif choice == "3" then
|
||||
print("")
|
||||
print("-- Web Bridge Configuration --")
|
||||
local serverUrl = ask("Web server URL", "http://localhost")
|
||||
|
||||
local cfg = { serverUrl = serverUrl }
|
||||
local f = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved web bridge config.")
|
||||
|
||||
elseif choice == "4" then
|
||||
print("")
|
||||
print("-- Mining Turtle Configuration --")
|
||||
local mineInterval = ask("Mine interval in seconds", "0.5")
|
||||
local dumpThreshold = ask("Dump when N slots full", "14")
|
||||
|
||||
local cfg = {
|
||||
mineInterval = tonumber(mineInterval) or 0.5,
|
||||
dumpThreshold = tonumber(dumpThreshold) or 14,
|
||||
}
|
||||
local f = fs.open(fs.combine(cfgDir, ".miner_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved miner config.")
|
||||
|
||||
else
|
||||
print("Skipped — edit config files manually later.")
|
||||
end
|
||||
]],
|
||||
}
|
||||
171
README.md
171
README.md
@@ -1,2 +1,173 @@
|
||||
# Inventory-Manager-CC
|
||||
|
||||
A Minecraft inventory management system built on [CC:Tweaked](https://tweaked.cc/). Automates storage sorting, smelting, crafting, and item dispensing across networked computers, turtles, and peripherals — with an optional web dashboard for remote monitoring and control.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Minecraft World │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ modem channels 4200-4204 │
|
||||
│ │ inventoryManager.lua │◄──────────────────────┐ │
|
||||
│ │ (master computer) │───────────────────────┤ │
|
||||
│ └──────┬───────────────┘ │ │
|
||||
│ │ peripherals │ │
|
||||
│ ┌─────┴──────┬───────────┐ ┌──────────┴────────┐ │
|
||||
│ │ chests / │ furnaces │ │ inventoryClient │ │
|
||||
│ │ barrels │ smokers │ │ (display client) │ │
|
||||
│ │ │ blasters │ └───────────────────┘ │
|
||||
│ └────────────┴───────────┘ │
|
||||
│ ┌──────────────────┐ ┌────────────────────┐ │
|
||||
│ │ craftingTurtle.lua│ │dropperController │ │
|
||||
│ │ (auto-craft) │ │ (redstone pulses) │ │
|
||||
│ └──────────────────┘ └────────────────────┘ │
|
||||
│ ┌──────────────────────┐ HTTP │
|
||||
│ │ inventoryWebBridge.lua│─────────────────┐ │
|
||||
│ └──────────────────────┘ │ │
|
||||
└────────────────────────────────────────────┼─────────────────┘
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Web Server :3001 │ ◄── SQLite
|
||||
└────────┬─────────┘
|
||||
│ WebSocket + REST
|
||||
┌────────▼─────────┐
|
||||
│ React Dashboard │ (port 80)
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### In-Game (Lua)
|
||||
|
||||
| File | Role | Description |
|
||||
|------|------|-------------|
|
||||
| `inventoryManager.lua` | **Master Controller** | The brain — scans all networked chests, auto-sorts/defrags items, auto-smelts ores and food, dispatches crafting jobs, manages composting, fires alerts on low stock, and broadcasts state on modem channel 4200. Requires an attached monitor and wired modem. |
|
||||
| `inventoryClient.lua` | **Display Client** | A read-only dashboard that mirrors the master's state on a separate monitor. Supports search, pagination, and sending item orders back to the master. |
|
||||
| `craftingTurtle.lua` | **Crafting Worker** | Runs on a Crafting Turtle. When the master places ingredients in its grid, it auto-crafts and reports results. Must be connected to the master via wired network. |
|
||||
| `dropperController.lua` | **Dropper Driver** | Pulses redstone to fire items out of a dropper block until empty. Runs on a computer adjacent to the dropper. |
|
||||
| `inventoryWebBridge.lua` | **Web Bridge** | Bridges the in-game modem network to the external web server over HTTP/WebSocket. Forwards state from the master and relays commands from the web dashboard back into the game. |
|
||||
| `miningTurtle.lua` | **Mining Turtle** | Runs on a mining turtle connected to the wired network. Continuously mines downward, auto-dumps inventory to networked storage, auto-refuels from storage, and triggers master scans after dumping. |
|
||||
| `listDevicesByType.lua` | **Diagnostic Utility** | Lists all peripherals on the wired network grouped by type. Useful for discovering connected chests, furnaces, etc. |
|
||||
| `autorun/startup.lua` | **Opus Autorun** | Auto-detects the computer's role from config files and launches the appropriate program. For use with [Opus OS](https://github.com/kepler155c/opus). |
|
||||
|
||||
### Web Stack (Docker)
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| **Server** (`web/server/`) | Express + WebSocket API backed by SQLite. Receives state from the Lua bridge, persists item history, and pushes real-time updates to browser clients. |
|
||||
| **Client** (`web/client/`) | React SPA (Vite + Zustand) served via Nginx. Tabbed panels for inventory grid, smelting, crafting, analytics, alerts, and settings. |
|
||||
| **Docker Compose** | Orchestrates server and client containers on an internal bridge network. Only port 80 is exposed; the Nginx client proxies API/WS requests to the server. |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Minecraft with [CC:Tweaked](https://tweaked.cc/) installed
|
||||
- [Opus OS](https://github.com/kepler155c/opus) installed on all CC:Tweaked computers (required for the UI framework)
|
||||
- A CC:Tweaked computer with a wired modem and monitor attached
|
||||
- HTTP access enabled in the CC:Tweaked config (for the web bridge)
|
||||
|
||||
### 1. Install via Opus Package Manager (Recommended)
|
||||
|
||||
On any CC:Tweaked computer running Opus, open the shell and run:
|
||||
|
||||
```
|
||||
package install inventory-manager
|
||||
```
|
||||
|
||||
An interactive setup wizard will guide you through role selection (Manager, Client, Web Bridge, or Mining Turtle) and peripheral configuration. The package installs all required files and an autorun script that launches the correct program on boot. Reboot after installation.
|
||||
|
||||
To update later:
|
||||
|
||||
```
|
||||
package update inventory-manager
|
||||
```
|
||||
|
||||
### 2. Start the Web Dashboard (Optional)
|
||||
|
||||
On the host machine, navigate to the `web/` directory and run:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The dashboard will be available at `http://localhost` on port 80.
|
||||
|
||||
### Manual Install (Without Opus)
|
||||
|
||||
If you are not using Opus, you can install individual components with `wget`. Note that the monitor dashboard requires the Opus UI framework (`opus.ui`), so Opus is still needed on computers with monitors.
|
||||
|
||||
<details>
|
||||
<summary>Click to expand manual install instructions</summary>
|
||||
|
||||
#### Master Controller
|
||||
|
||||
On your main CC:Tweaked computer (with the monitor and wired modem):
|
||||
|
||||
```
|
||||
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/startup/manager.lua startup.lua
|
||||
```
|
||||
|
||||
#### Display Client
|
||||
|
||||
On a separate CC:Tweaked computer with a monitor:
|
||||
|
||||
```
|
||||
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/startup/client.lua startup.lua
|
||||
```
|
||||
|
||||
#### Crafting Turtle
|
||||
|
||||
On a **Crafting Turtle** connected to the wired network:
|
||||
|
||||
```
|
||||
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/startup/turtle.lua startup.lua
|
||||
```
|
||||
|
||||
#### Mining Turtle
|
||||
|
||||
On a **Mining Turtle** connected to the wired network:
|
||||
|
||||
```
|
||||
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/startup/miner.lua startup.lua
|
||||
```
|
||||
|
||||
#### Web Bridge
|
||||
|
||||
On any CC:Tweaked computer with a wired modem and HTTP access:
|
||||
|
||||
```
|
||||
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/startup/bridge.lua startup.lua
|
||||
```
|
||||
|
||||
These startup scripts auto-download all required files from the repository before launching.
|
||||
|
||||
</details>
|
||||
|
||||
## Modem Channels
|
||||
|
||||
| Channel | Purpose |
|
||||
|---------|---------|
|
||||
| 4200 | Master → Clients/Bridge (state broadcast) |
|
||||
| 4201 | Clients/Bridge → Master (orders & commands) |
|
||||
| 4202 | Master → Client (order/craft result replies) |
|
||||
| 4203 | Master → Crafting Turtle (craft requests) |
|
||||
| 4204 | Crafting Turtle → Master (craft results) |
|
||||
| 4205 | System channel (remote reboot all computers) |
|
||||
|
||||
## Startup Scripts
|
||||
|
||||
The `startup/` directory contains self-updating startup scripts that download the latest code from the git repository before launching. These are used by the manual install method above and can also be used standalone:
|
||||
|
||||
| Script | Role |
|
||||
|--------|------|
|
||||
| `startup/manager.lua` | Master controller with auto-update for all manager modules |
|
||||
| `startup/client.lua` | Client with auto-update, first-run setup wizard, and optional dropper |
|
||||
| `startup/turtle.lua` | Crafting turtle with auto-update |
|
||||
| `startup/bridge.lua` | Web bridge with auto-update |
|
||||
| `startup/miner.lua` | Mining turtle with auto-update |
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE) for details.
|
||||
|
||||
92
autorun/startup.lua
Normal file
92
autorun/startup.lua
Normal file
@@ -0,0 +1,92 @@
|
||||
-- Inventory Manager - Opus autorun script
|
||||
-- Detects configured role and launches the appropriate program at boot
|
||||
|
||||
local fs = _G.fs
|
||||
local kernel = _G.kernel
|
||||
local os = _G.os
|
||||
local peripheral = _G.peripheral
|
||||
local shell = _ENV.shell
|
||||
|
||||
local BASE = 'packages/inventory-manager'
|
||||
local CFG = 'usr/config/inventory-manager'
|
||||
|
||||
-------------------------------------------------
|
||||
-- Determine role from config files written during install
|
||||
-------------------------------------------------
|
||||
|
||||
local function cfgExists(name)
|
||||
return fs.exists(fs.combine(CFG, name))
|
||||
or fs.exists(fs.combine(BASE, name))
|
||||
end
|
||||
|
||||
local role
|
||||
if cfgExists('.manager_config') then
|
||||
role = 'manager'
|
||||
elseif cfgExists('.client_config') then
|
||||
role = 'client'
|
||||
elseif cfgExists('.webbridge_config') then
|
||||
role = 'bridge'
|
||||
elseif cfgExists('.miner_config') then
|
||||
role = 'miner'
|
||||
elseif _G.turtle then
|
||||
role = 'turtle'
|
||||
end
|
||||
|
||||
if not role then return end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Register reboot listener as a kernel hook
|
||||
-- Allows remote reboot via modem (channel 4205)
|
||||
-------------------------------------------------
|
||||
|
||||
local SYSTEM_CHANNEL = 4205
|
||||
|
||||
local modem = peripheral.find('modem')
|
||||
if modem then
|
||||
modem.open(SYSTEM_CHANNEL)
|
||||
|
||||
kernel.hook('modem_message', function(_, data)
|
||||
-- data = { side, channel, replyChannel, message, distance }
|
||||
if data[2] == SYSTEM_CHANNEL
|
||||
and type(data[4]) == 'table'
|
||||
and data[4].type == 'reboot'
|
||||
then
|
||||
local target = data[4].target or 'all'
|
||||
if target == 'all'
|
||||
or target == role
|
||||
or target == tostring(os.getComputerID())
|
||||
then
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Launch the program for this role
|
||||
-------------------------------------------------
|
||||
|
||||
local programs = {
|
||||
manager = 'inventoryManager.lua',
|
||||
client = 'inventoryClient.lua',
|
||||
bridge = 'inventoryWebBridge.lua',
|
||||
turtle = 'craftingTurtle.lua',
|
||||
miner = 'miningTurtle.lua',
|
||||
}
|
||||
|
||||
local program = fs.combine(BASE, programs[role])
|
||||
|
||||
if shell.openForegroundTab then
|
||||
shell.openForegroundTab(program)
|
||||
|
||||
-- Client role also runs the dropper controller
|
||||
if role == 'client' then
|
||||
local dropper = fs.combine(BASE, 'dropperController.lua')
|
||||
if fs.exists(dropper) then
|
||||
shell.openTab(dropper)
|
||||
end
|
||||
end
|
||||
else
|
||||
-- No multishell — run directly (blocks boot)
|
||||
shell.run(program)
|
||||
end
|
||||
@@ -1,45 +1,333 @@
|
||||
-- Crafting Turtle Script
|
||||
-- Run this on the crafting turtle (turtle with crafting table).
|
||||
-- Listens for craft commands from the master computer via wired modem.
|
||||
-- Listens for craft commands from the master via wired modem.
|
||||
-- Pulls ingredients from chests, crafts, and pushes results back.
|
||||
-- Requires a wired modem attached to the turtle.
|
||||
|
||||
local CRAFT_CHANNEL = 4203
|
||||
local CRAFT_REPLY_CHANNEL = 4204
|
||||
local shell = _ENV.shell
|
||||
|
||||
-- Find modem (wired or wireless)
|
||||
local modem = peripheral.find("modem")
|
||||
if not modem then
|
||||
print("[ERR] No modem found! Attach a wired modem.")
|
||||
local function fatal(msg)
|
||||
printError(msg)
|
||||
print("\nPress any key to exit...")
|
||||
os.pullEvent("key")
|
||||
return
|
||||
end
|
||||
|
||||
modem.open(CRAFT_CHANNEL)
|
||||
-------------------------------------------------
|
||||
-- Default configuration (overridden by .turtle_config)
|
||||
-------------------------------------------------
|
||||
|
||||
-- Modem channels (must match inventoryManager.lua)
|
||||
local CRAFT_CHANNEL = 4203 -- master -> turtle (craft requests)
|
||||
local CRAFT_REPLY_CHANNEL = 4204 -- turtle -> master (craft results)
|
||||
|
||||
-- Crafting grid slots in a turtle's 4x4 inventory
|
||||
local CRAFT_SLOTS = {1, 2, 3, 5, 6, 7, 9, 10, 11}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path (survives Opus package updates)
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local TURTLE_CONFIG_FILE = _configPath(".turtle_config")
|
||||
|
||||
local function loadConfig()
|
||||
if not fs.exists(TURTLE_CONFIG_FILE) then return end
|
||||
local f = fs.open(TURTLE_CONFIG_FILE, "r")
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, data)
|
||||
if not ok or not cfg then
|
||||
print("[WARN] Failed to parse " .. TURTLE_CONFIG_FILE)
|
||||
return
|
||||
end
|
||||
if cfg.craftChannel then CRAFT_CHANNEL = cfg.craftChannel end
|
||||
if cfg.craftReplyChannel then CRAFT_REPLY_CHANNEL = cfg.craftReplyChannel end
|
||||
print("[CONFIG] Loaded from " .. TURTLE_CONFIG_FILE)
|
||||
end
|
||||
|
||||
loadConfig()
|
||||
|
||||
-------------------------------------------------
|
||||
-- Setup
|
||||
-------------------------------------------------
|
||||
|
||||
print("=================================")
|
||||
print(" Crafting Turtle (Listener)")
|
||||
print(" Crafting Turtle (Modem-Based)")
|
||||
print("=================================")
|
||||
print("")
|
||||
print("Listening on channel " .. CRAFT_CHANNEL)
|
||||
print("Ready for craft commands from master.")
|
||||
print("")
|
||||
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
if channel == CRAFT_CHANNEL and type(message) == "table" and message.type == "craft" then
|
||||
local count = message.count or 1
|
||||
print(string.format("[CRAFT] Received craft request (count=%d)", count))
|
||||
-- Verify this is a crafting turtle
|
||||
if not turtle or not turtle.craft then
|
||||
return fatal("[ERR] No turtle.craft() available!\n This must be a Crafting Turtle.")
|
||||
end
|
||||
|
||||
local ok, err = turtle.craft(count)
|
||||
-- Find wired modem and get our network name
|
||||
local modem = nil
|
||||
local modemSide = nil
|
||||
local selfName = nil
|
||||
|
||||
if ok then
|
||||
print("[CRAFT] Success!")
|
||||
else
|
||||
print("[CRAFT] Failed: " .. tostring(err))
|
||||
for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
|
||||
if peripheral.getType(side) == "modem" then
|
||||
local m = peripheral.wrap(side)
|
||||
if m.getNameLocal then
|
||||
local name = m.getNameLocal()
|
||||
if name then
|
||||
modem = m
|
||||
modemSide = side
|
||||
selfName = name
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
modem.transmit(replyChannel, CRAFT_CHANNEL, {
|
||||
type = "craft_result",
|
||||
success = ok,
|
||||
error = not ok and tostring(err) or nil,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
if not modem or not selfName then
|
||||
return fatal("[ERR] No wired modem found!\n Attach a wired modem to the turtle\n and connect it to the network.")
|
||||
end
|
||||
|
||||
print("[OK] Modem: " .. modemSide)
|
||||
print("[OK] Network name: " .. selfName)
|
||||
|
||||
-- Open channel for receiving craft commands
|
||||
modem.open(CRAFT_CHANNEL)
|
||||
print("[OK] Listening on channel " .. CRAFT_CHANNEL)
|
||||
|
||||
print("")
|
||||
print("Waiting for craft commands from master...")
|
||||
print("")
|
||||
|
||||
-------------------------------------------------
|
||||
-- Helpers
|
||||
-------------------------------------------------
|
||||
|
||||
local function clearInventory(chests)
|
||||
-- Pull all items from turtle slots into chests (using chest.pullItems)
|
||||
local cleared = 0
|
||||
for slot = 1, 16 do
|
||||
if turtle.getItemCount(slot) > 0 then
|
||||
for _, chestName in ipairs(chests) do
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.pullItems then
|
||||
local ok, n = pcall(chest.pullItems, selfName, slot)
|
||||
if ok and n and n > 0 then
|
||||
cleared = cleared + n
|
||||
if turtle.getItemCount(slot) == 0 then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return cleared
|
||||
end
|
||||
|
||||
local function describeGrid()
|
||||
local parts = {}
|
||||
for _, slot in ipairs(CRAFT_SLOTS) do
|
||||
local detail = turtle.getItemDetail(slot)
|
||||
if detail then
|
||||
table.insert(parts, string.format(" slot %d: %s x%d", slot, detail.name, detail.count))
|
||||
end
|
||||
end
|
||||
return table.concat(parts, "\n")
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Craft command handler
|
||||
-------------------------------------------------
|
||||
|
||||
local function handleCraftCommand(message)
|
||||
-- message format from master:
|
||||
-- {
|
||||
-- type = "craft_request",
|
||||
-- recipeIdx = <number>,
|
||||
-- output = <string>, -- expected output item name
|
||||
-- slots = { [turtleSlot] = { chestName=..., chestSlot=..., itemName=..., count=... }, ... },
|
||||
-- returnChests = { "chest_0", "chest_1", ... },
|
||||
-- }
|
||||
|
||||
local slots = message.slots
|
||||
local returnChests = message.returnChests
|
||||
local output = message.output or "unknown"
|
||||
|
||||
if not slots or not returnChests then
|
||||
print("[CRAFT] Invalid command: missing slots or returnChests")
|
||||
return { type = "craft_result", success = false, error = "Invalid command" }
|
||||
end
|
||||
|
||||
print(string.format("[CRAFT] Received craft request: %s", output))
|
||||
|
||||
-- 1. Clear turtle inventory (safety — should be empty)
|
||||
clearInventory(returnChests)
|
||||
|
||||
-- 2. Pull ingredients from chests into crafting grid slots
|
||||
local placedItems = {} -- turtleSlot -> itemName
|
||||
local allPlaced = true
|
||||
|
||||
for turtleSlotStr, info in pairs(slots) do
|
||||
local turtleSlot = tonumber(turtleSlotStr)
|
||||
local itemName = info.itemName
|
||||
local count = info.count or 1
|
||||
local placed = 0
|
||||
|
||||
-- Try the suggested chest+slot first
|
||||
local chest = peripheral.wrap(info.chestName)
|
||||
if chest then
|
||||
local ok, n = pcall(chest.pushItems, selfName, info.chestSlot, count, turtleSlot)
|
||||
if ok and n and n > 0 then
|
||||
placed = n
|
||||
end
|
||||
end
|
||||
|
||||
-- If we didn't get enough, search ALL chests on the network for this item
|
||||
if placed < count then
|
||||
local remaining = count - placed
|
||||
if placed == 0 then
|
||||
print(string.format("[CRAFT] %s not at %s:%d, searching network...",
|
||||
itemName, info.chestName, info.chestSlot))
|
||||
else
|
||||
print(string.format("[CRAFT] Got %d/%d %s from hint, searching for %d more...",
|
||||
placed, count, itemName, remaining))
|
||||
end
|
||||
|
||||
for _, chestName in ipairs(returnChests) do
|
||||
if remaining <= 0 then break end
|
||||
local ch = peripheral.wrap(chestName)
|
||||
if ch then
|
||||
local contents = ch.list()
|
||||
if contents then
|
||||
for slot, slotItem in pairs(contents) do
|
||||
if slotItem.name == itemName then
|
||||
local ok, n = pcall(ch.pushItems, selfName, slot, remaining, turtleSlot)
|
||||
if ok and n and n > 0 then
|
||||
placed = placed + n
|
||||
remaining = remaining - n
|
||||
if remaining <= 0 then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if placed > 0 then
|
||||
placedItems[turtleSlot] = itemName
|
||||
print(string.format("[CRAFT] Placed %s x%d in slot %d", itemName, placed, turtleSlot))
|
||||
else
|
||||
print(string.format("[CRAFT] Could not find %s anywhere on network!", itemName))
|
||||
allPlaced = false
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not allPlaced then
|
||||
-- Abort: push everything back
|
||||
print("[CRAFT] Failed to place all ingredients, aborting")
|
||||
local returnedItems = {}
|
||||
for slot, itemName in pairs(placedItems) do
|
||||
returnedItems[tostring(slot)] = itemName
|
||||
end
|
||||
clearInventory(returnChests)
|
||||
return {
|
||||
type = "craft_result",
|
||||
success = false,
|
||||
error = "Failed to pull ingredients",
|
||||
returnedItems = returnedItems,
|
||||
}
|
||||
end
|
||||
|
||||
-- 3. Craft
|
||||
print("[CRAFT] Grid contents:")
|
||||
print(describeGrid())
|
||||
print("[CRAFT] Attempting craft...")
|
||||
|
||||
local craftOk, craftErr = turtle.craft()
|
||||
|
||||
if not craftOk then
|
||||
print("[CRAFT] Failed: " .. tostring(craftErr))
|
||||
-- Collect what's still in the grid for the master to track
|
||||
local returnedItems = {}
|
||||
for slot, itemName in pairs(placedItems) do
|
||||
returnedItems[tostring(slot)] = itemName
|
||||
end
|
||||
-- Push ingredients back
|
||||
clearInventory(returnChests)
|
||||
return {
|
||||
type = "craft_result",
|
||||
success = false,
|
||||
error = "Craft failed: " .. tostring(craftErr),
|
||||
returnedItems = returnedItems,
|
||||
}
|
||||
end
|
||||
|
||||
-- 4. Collect results info
|
||||
local results = {}
|
||||
local totalOutput = 0
|
||||
for slot = 1, 16 do
|
||||
local detail = turtle.getItemDetail(slot)
|
||||
if detail then
|
||||
table.insert(results, { name = detail.name, count = detail.count, slot = slot })
|
||||
totalOutput = totalOutput + detail.count
|
||||
end
|
||||
end
|
||||
|
||||
print("[CRAFT] Success! Output:")
|
||||
for _, r in ipairs(results) do
|
||||
print(string.format(" %s x%d", r.name, r.count))
|
||||
end
|
||||
|
||||
-- 5. Push results back to chests
|
||||
local pushed = clearInventory(returnChests)
|
||||
print(string.format("[CRAFT] Pushed %d items back to storage", pushed))
|
||||
|
||||
return {
|
||||
type = "craft_result",
|
||||
success = true,
|
||||
output = output,
|
||||
totalOutput = totalOutput,
|
||||
results = results,
|
||||
}
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Main loop: listen for modem commands
|
||||
-------------------------------------------------
|
||||
|
||||
local ok, err = pcall(function()
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||
|
||||
if channel == CRAFT_CHANNEL and type(message) == "table" then
|
||||
if message.type == "craft_request" then
|
||||
local result = handleCraftCommand(message)
|
||||
-- Send result back to master
|
||||
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, result)
|
||||
elseif message.type == "ping" then
|
||||
-- Health check from master
|
||||
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, {
|
||||
type = "pong",
|
||||
name = selfName,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
fatal("[ERR] Crafting turtle crashed:\n" .. tostring(err))
|
||||
end
|
||||
|
||||
20
data/alerts.lua
Normal file
20
data/alerts.lua
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Low-stock alerts.
|
||||
-- When a tracked item drops below 'min', an alert
|
||||
-- is shown on the inventory monitor.
|
||||
-- Each entry: { name = "mod:item", min = N, label = "Display Name" }
|
||||
|
||||
return {
|
||||
{ name = "minecraft:coal", min = 64, label = "Coal" },
|
||||
{ name = "minecraft:charcoal", min = 64, label = "Charcoal" },
|
||||
{ name = "minecraft:torch", min = 64, label = "Torches" },
|
||||
{ name = "minecraft:arrow", min = 64, label = "Arrows" },
|
||||
{ name = "minecraft:cooked_beef", min = 32, label = "Steak" },
|
||||
{ name = "minecraft:cooked_porkchop",min = 32, label = "Porkchops" },
|
||||
{ name = "minecraft:bread", min = 32, label = "Bread" },
|
||||
{ name = "minecraft:iron_ingot", min = 64, label = "Iron" },
|
||||
{ name = "minecraft:gold_ingot", min = 32, label = "Gold" },
|
||||
{ name = "minecraft:diamond", min = 16, label = "Diamond" },
|
||||
{ name = "minecraft:bone_meal", min = 32, label = "Bone Meal" },
|
||||
{ name = "minecraft:oak_planks", min = 64, label = "Planks" },
|
||||
{ name = "minecraft:cobblestone", min = 128, label = "Cobblestone" },
|
||||
}
|
||||
14
data/auto_craft.lua
Normal file
14
data/auto_craft.lua
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Auto-craft rules: items that should be automatically crafted when stock exceeds reserve.
|
||||
-- The system will keep `reserve` of the input item and craft all excess into the output.
|
||||
-- Requires a crafting turtle to be connected.
|
||||
--
|
||||
-- Format: { input = "mod:item", reserve = N, output = "mod:output_item" }
|
||||
-- The output item must have a recipe in data/craftable.lua (or learned via recipeBook).
|
||||
-- Recursive crafting is used, so intermediate steps are handled automatically.
|
||||
|
||||
return {
|
||||
-- Convert excess bamboo into planks (keeps 128 raw bamboo as reserve)
|
||||
{ input = "minecraft:bamboo", reserve = 128, output = "minecraft:bamboo_planks" },
|
||||
-- Convert excess bamboo blocks into planks too (keeps 64 blocks as reserve)
|
||||
{ input = "minecraft:bamboo_block", reserve = 64, output = "minecraft:bamboo_planks" },
|
||||
}
|
||||
121
data/compostable.lua
Normal file
121
data/compostable.lua
Normal file
@@ -0,0 +1,121 @@
|
||||
-- Compostable items (pushed into dropper, which distributes to composters via hoppers).
|
||||
-- Bone meal returns through hopper.
|
||||
-- Each entry is a Minecraft item ID string.
|
||||
|
||||
local items = {
|
||||
-- Seeds & crops
|
||||
"minecraft:wheat_seeds",
|
||||
"minecraft:beetroot_seeds",
|
||||
"minecraft:pumpkin_seeds",
|
||||
"minecraft:melon_seeds",
|
||||
"minecraft:torchflower_seeds",
|
||||
"minecraft:pitcher_pod",
|
||||
"minecraft:wheat",
|
||||
"minecraft:beetroot",
|
||||
"minecraft:carrot",
|
||||
"minecraft:melon_slice",
|
||||
"minecraft:pumpkin",
|
||||
"minecraft:carved_pumpkin",
|
||||
"minecraft:sweet_berries",
|
||||
"minecraft:glow_berries",
|
||||
-- Plant blocks
|
||||
"minecraft:tall_grass",
|
||||
"minecraft:short_grass",
|
||||
"minecraft:fern",
|
||||
"minecraft:large_fern",
|
||||
"minecraft:dead_bush",
|
||||
"minecraft:vine",
|
||||
"minecraft:hanging_roots",
|
||||
"minecraft:small_dripleaf",
|
||||
"minecraft:big_dripleaf",
|
||||
"minecraft:moss_block",
|
||||
"minecraft:moss_carpet",
|
||||
"minecraft:azalea",
|
||||
"minecraft:flowering_azalea",
|
||||
"minecraft:spore_blossom",
|
||||
"minecraft:seagrass",
|
||||
"minecraft:sea_pickle",
|
||||
"minecraft:lily_pad",
|
||||
"minecraft:sugar_cane",
|
||||
"minecraft:kelp",
|
||||
"minecraft:dried_kelp",
|
||||
"minecraft:cactus",
|
||||
"minecraft:nether_wart",
|
||||
"minecraft:crimson_fungus",
|
||||
"minecraft:warped_fungus",
|
||||
"minecraft:crimson_roots",
|
||||
"minecraft:warped_roots",
|
||||
"minecraft:shroomlight",
|
||||
"minecraft:weeping_vines",
|
||||
"minecraft:twisting_vines",
|
||||
-- Leaves
|
||||
"minecraft:oak_leaves",
|
||||
"minecraft:spruce_leaves",
|
||||
"minecraft:birch_leaves",
|
||||
"minecraft:jungle_leaves",
|
||||
"minecraft:acacia_leaves",
|
||||
"minecraft:dark_oak_leaves",
|
||||
"minecraft:mangrove_leaves",
|
||||
"minecraft:cherry_leaves",
|
||||
"minecraft:azalea_leaves",
|
||||
"minecraft:flowering_azalea_leaves",
|
||||
-- Flowers
|
||||
"minecraft:dandelion",
|
||||
"minecraft:poppy",
|
||||
"minecraft:blue_orchid",
|
||||
"minecraft:allium",
|
||||
"minecraft:azure_bluet",
|
||||
"minecraft:red_tulip",
|
||||
"minecraft:orange_tulip",
|
||||
"minecraft:white_tulip",
|
||||
"minecraft:pink_tulip",
|
||||
"minecraft:oxeye_daisy",
|
||||
"minecraft:cornflower",
|
||||
"minecraft:lily_of_the_valley",
|
||||
"minecraft:sunflower",
|
||||
"minecraft:lilac",
|
||||
"minecraft:rose_bush",
|
||||
"minecraft:peony",
|
||||
"minecraft:wither_rose",
|
||||
"minecraft:torchflower",
|
||||
"minecraft:pitcher_plant",
|
||||
-- Saplings
|
||||
"minecraft:oak_sapling",
|
||||
"minecraft:spruce_sapling",
|
||||
"minecraft:birch_sapling",
|
||||
"minecraft:jungle_sapling",
|
||||
"minecraft:acacia_sapling",
|
||||
"minecraft:dark_oak_sapling",
|
||||
"minecraft:mangrove_propagule",
|
||||
"minecraft:cherry_sapling",
|
||||
-- Food waste
|
||||
"minecraft:rotten_flesh",
|
||||
"minecraft:spider_eye",
|
||||
"minecraft:poisonous_potato",
|
||||
"minecraft:fermented_spider_eye",
|
||||
"minecraft:apple",
|
||||
"minecraft:bread",
|
||||
"minecraft:cookie",
|
||||
"minecraft:cake",
|
||||
"minecraft:pumpkin_pie",
|
||||
-- Farmer's Delight compostables
|
||||
"farmersdelight:tree_bark",
|
||||
"farmersdelight:straw",
|
||||
"farmersdelight:canvas",
|
||||
"farmersdelight:rice",
|
||||
"farmersdelight:rice_panicle",
|
||||
"farmersdelight:onion",
|
||||
"farmersdelight:tomato",
|
||||
"farmersdelight:cabbage",
|
||||
"farmersdelight:cabbage_leaf",
|
||||
}
|
||||
|
||||
-- Trash items: compostables with zero reserve (always fully composted)
|
||||
local trash = {
|
||||
["minecraft:rotten_flesh"] = true,
|
||||
["minecraft:spider_eye"] = true,
|
||||
["minecraft:poisonous_potato"] = true,
|
||||
["minecraft:fermented_spider_eye"] = true,
|
||||
}
|
||||
|
||||
return { items = items, trash = trash }
|
||||
345
data/craftable.lua
Normal file
345
data/craftable.lua
Normal file
@@ -0,0 +1,345 @@
|
||||
-- Crafting recipes (for networked crafting turtle).
|
||||
-- grid: 9 entries mapping to turtle slots 1-3, 5-7, 9-11
|
||||
-- Each recipe: { output = "mod:item", count = N, grid = { ... } }
|
||||
|
||||
return {
|
||||
-- Basic materials
|
||||
{
|
||||
output = "minecraft:oak_planks",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:oak_log", nil, nil,
|
||||
nil, nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:spruce_planks",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:spruce_log", nil, nil,
|
||||
nil, nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:birch_planks",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:birch_log", nil, nil,
|
||||
nil, nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:bamboo_block",
|
||||
count = 1,
|
||||
grid = {
|
||||
"minecraft:bamboo", "minecraft:bamboo", nil,
|
||||
"minecraft:bamboo", "minecraft:bamboo", nil,
|
||||
"minecraft:bamboo", "minecraft:bamboo", nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:bamboo_planks",
|
||||
count = 2,
|
||||
grid = {
|
||||
"minecraft:bamboo_block", nil, nil,
|
||||
nil, nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:stick",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:oak_planks", nil, nil,
|
||||
"minecraft:oak_planks", nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:oak_slab",
|
||||
count = 6,
|
||||
grid = {
|
||||
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
|
||||
nil, nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:torch",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:coal", nil, nil,
|
||||
"minecraft:stick", nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
-- Crafting & storage
|
||||
{
|
||||
output = "minecraft:crafting_table",
|
||||
count = 1,
|
||||
grid = {
|
||||
"minecraft:oak_planks", "minecraft:oak_planks", nil,
|
||||
"minecraft:oak_planks", "minecraft:oak_planks", nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:chest",
|
||||
count = 1,
|
||||
grid = {
|
||||
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
|
||||
"minecraft:oak_planks", nil, "minecraft:oak_planks",
|
||||
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:barrel",
|
||||
count = 1,
|
||||
grid = {
|
||||
"minecraft:oak_planks", "minecraft:oak_slab", "minecraft:oak_planks",
|
||||
"minecraft:oak_planks", nil, "minecraft:oak_planks",
|
||||
"minecraft:oak_planks", "minecraft:oak_slab", "minecraft:oak_planks",
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:hopper",
|
||||
count = 1,
|
||||
grid = {
|
||||
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
|
||||
"minecraft:iron_ingot", "minecraft:chest", "minecraft:iron_ingot",
|
||||
nil, "minecraft:iron_ingot", nil,
|
||||
},
|
||||
},
|
||||
-- Building
|
||||
{
|
||||
output = "minecraft:furnace",
|
||||
count = 1,
|
||||
grid = {
|
||||
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
|
||||
"minecraft:cobblestone", nil, "minecraft:cobblestone",
|
||||
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:cobblestone_slab",
|
||||
count = 6,
|
||||
grid = {
|
||||
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
|
||||
nil, nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:cobblestone_stairs",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:cobblestone", nil, nil,
|
||||
"minecraft:cobblestone", "minecraft:cobblestone", nil,
|
||||
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:cobblestone_wall",
|
||||
count = 6,
|
||||
grid = {
|
||||
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
|
||||
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:stone_bricks",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:stone", "minecraft:stone", nil,
|
||||
"minecraft:stone", "minecraft:stone", nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:stone_slab",
|
||||
count = 6,
|
||||
grid = {
|
||||
"minecraft:stone", "minecraft:stone", "minecraft:stone",
|
||||
nil, nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:stone_stairs",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:stone", nil, nil,
|
||||
"minecraft:stone", "minecraft:stone", nil,
|
||||
"minecraft:stone", "minecraft:stone", "minecraft:stone",
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:smooth_stone_slab",
|
||||
count = 6,
|
||||
grid = {
|
||||
"minecraft:smooth_stone", "minecraft:smooth_stone", "minecraft:smooth_stone",
|
||||
nil, nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:polished_andesite",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:andesite", "minecraft:andesite", nil,
|
||||
"minecraft:andesite", "minecraft:andesite", nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:polished_diorite",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:diorite", "minecraft:diorite", nil,
|
||||
"minecraft:diorite", "minecraft:diorite", nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:polished_granite",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:granite", "minecraft:granite", nil,
|
||||
"minecraft:granite", "minecraft:granite", nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:polished_deepslate",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:cobbled_deepslate", "minecraft:cobbled_deepslate", nil,
|
||||
"minecraft:cobbled_deepslate", "minecraft:cobbled_deepslate", nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:polished_tuff",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:tuff", "minecraft:tuff", nil,
|
||||
"minecraft:tuff", "minecraft:tuff", nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:ladder",
|
||||
count = 3,
|
||||
grid = {
|
||||
"minecraft:stick", nil, "minecraft:stick",
|
||||
"minecraft:stick", "minecraft:stick", "minecraft:stick",
|
||||
"minecraft:stick", nil, "minecraft:stick",
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:glass_pane",
|
||||
count = 16,
|
||||
grid = {
|
||||
"minecraft:glass", "minecraft:glass", "minecraft:glass",
|
||||
"minecraft:glass", "minecraft:glass", "minecraft:glass",
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:iron_bars",
|
||||
count = 16,
|
||||
grid = {
|
||||
"minecraft:iron_ingot", "minecraft:iron_ingot", "minecraft:iron_ingot",
|
||||
"minecraft:iron_ingot", "minecraft:iron_ingot", "minecraft:iron_ingot",
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
-- Tools & combat
|
||||
{
|
||||
output = "minecraft:bucket",
|
||||
count = 1,
|
||||
grid = {
|
||||
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
|
||||
nil, "minecraft:iron_ingot", nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:arrow",
|
||||
count = 4,
|
||||
grid = {
|
||||
"minecraft:flint", nil, nil,
|
||||
"minecraft:stick", nil, nil,
|
||||
"minecraft:feather", nil, nil,
|
||||
},
|
||||
},
|
||||
-- Redstone
|
||||
{
|
||||
output = "minecraft:piston",
|
||||
count = 1,
|
||||
grid = {
|
||||
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
|
||||
"minecraft:cobblestone", "minecraft:iron_ingot", "minecraft:cobblestone",
|
||||
"minecraft:cobblestone", "minecraft:redstone", "minecraft:cobblestone",
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:rail",
|
||||
count = 16,
|
||||
grid = {
|
||||
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
|
||||
"minecraft:iron_ingot", "minecraft:stick", "minecraft:iron_ingot",
|
||||
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:powered_rail",
|
||||
count = 6,
|
||||
grid = {
|
||||
"minecraft:gold_ingot", nil, "minecraft:gold_ingot",
|
||||
"minecraft:gold_ingot", "minecraft:stick", "minecraft:gold_ingot",
|
||||
"minecraft:gold_ingot", "minecraft:redstone", "minecraft:gold_ingot",
|
||||
},
|
||||
},
|
||||
-- Food & misc
|
||||
{
|
||||
output = "minecraft:bread",
|
||||
count = 1,
|
||||
grid = {
|
||||
"minecraft:wheat", "minecraft:wheat", "minecraft:wheat",
|
||||
nil, nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:paper",
|
||||
count = 3,
|
||||
grid = {
|
||||
"minecraft:sugar_cane", "minecraft:sugar_cane", "minecraft:sugar_cane",
|
||||
nil, nil, nil,
|
||||
nil, nil, nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:compass",
|
||||
count = 1,
|
||||
grid = {
|
||||
nil, "minecraft:iron_ingot", nil,
|
||||
"minecraft:iron_ingot", "minecraft:redstone", "minecraft:iron_ingot",
|
||||
nil, "minecraft:iron_ingot", nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
output = "minecraft:clock",
|
||||
count = 1,
|
||||
grid = {
|
||||
nil, "minecraft:gold_ingot", nil,
|
||||
"minecraft:gold_ingot", "minecraft:redstone", "minecraft:gold_ingot",
|
||||
nil, "minecraft:gold_ingot", nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
20
data/fuel.lua
Normal file
20
data/fuel.lua
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Fuel items, ordered by preference (best first).
|
||||
-- burn_time = how many items one fuel smelts.
|
||||
-- Add/remove entries to change which fuels are accepted.
|
||||
|
||||
return {
|
||||
{ name = "minecraft:coal", burn_time = 8 },
|
||||
{ name = "minecraft:charcoal", burn_time = 8 },
|
||||
{ name = "minecraft:coal_block", burn_time = 80 },
|
||||
{ name = "minecraft:blaze_rod", burn_time = 12 },
|
||||
{ name = "minecraft:dried_kelp_block", burn_time = 20 },
|
||||
{ name = "minecraft:oak_planks", burn_time = 1.5 },
|
||||
{ name = "minecraft:spruce_planks",burn_time = 1.5 },
|
||||
{ name = "minecraft:birch_planks", burn_time = 1.5 },
|
||||
{ name = "minecraft:jungle_planks",burn_time = 1.5 },
|
||||
{ name = "minecraft:acacia_planks",burn_time = 1.5 },
|
||||
{ name = "minecraft:dark_oak_planks",burn_time = 1.5 },
|
||||
{ name = "minecraft:mangrove_planks",burn_time = 1.5 },
|
||||
{ name = "minecraft:cherry_planks",burn_time = 1.5 },
|
||||
{ name = "minecraft:stick", burn_time = 0.5 },
|
||||
}
|
||||
156
data/smeltable.lua
Normal file
156
data/smeltable.lua
Normal file
@@ -0,0 +1,156 @@
|
||||
-- Smeltable items: input -> output
|
||||
-- Items in chests matching a key here get auto-smelted.
|
||||
-- Add/remove entries to control what gets cooked.
|
||||
--
|
||||
-- Each entry: ["mod:item"] = { result = "mod:output", furnaces = { ... } }
|
||||
-- Valid furnace types: "minecraft:furnace", "minecraft:smoker", "minecraft:blast_furnace"
|
||||
|
||||
return {
|
||||
-- Ores (furnace + blast furnace only)
|
||||
["minecraft:raw_iron"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["minecraft:raw_gold"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["minecraft:raw_copper"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["minecraft:iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["minecraft:gold_ore"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["minecraft:copper_ore"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["minecraft:deepslate_iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["minecraft:deepslate_gold_ore"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["minecraft:deepslate_copper_ore"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["minecraft:ancient_debris"] = { result = "minecraft:netherite_scrap",furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
-- Sand / stone / clay (regular furnace only — blast furnace rejects these)
|
||||
["minecraft:sand"] = { result = "minecraft:glass", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:red_sand"] = { result = "minecraft:glass", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:cobblestone"] = { result = "minecraft:stone", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:stone"] = { result = "minecraft:smooth_stone", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:clay_ball"] = { result = "minecraft:brick", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:netherrack"] = { result = "minecraft:nether_brick", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:sandstone"] = { result = "minecraft:smooth_sandstone", furnaces = {"minecraft:furnace"} },
|
||||
-- Food (furnace + smoker only)
|
||||
["minecraft:beef"] = { result = "minecraft:cooked_beef", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["minecraft:porkchop"] = { result = "minecraft:cooked_porkchop", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["minecraft:chicken"] = { result = "minecraft:cooked_chicken", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["minecraft:mutton"] = { result = "minecraft:cooked_mutton", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["minecraft:rabbit"] = { result = "minecraft:cooked_rabbit", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["minecraft:cod"] = { result = "minecraft:cooked_cod", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["minecraft:salmon"] = { result = "minecraft:cooked_salmon", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["minecraft:potato"] = { result = "minecraft:baked_potato", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["minecraft:kelp"] = { result = "minecraft:dried_kelp", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
-- Misc (furnace only)
|
||||
["minecraft:wet_sponge"] = { result = "minecraft:sponge", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:cactus"] = { result = "minecraft:green_dye", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:sea_pickle"] = { result = "minecraft:lime_dye", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:oak_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:spruce_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:birch_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:jungle_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:acacia_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:dark_oak_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:mangrove_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||||
["minecraft:cherry_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||||
|
||||
-------------------------------------------------
|
||||
-- Mythic Metals — raw ores (furnace + blast furnace)
|
||||
-------------------------------------------------
|
||||
["mythicmetals:raw_adamantite"] = { result = "mythicmetals:adamantite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_aquarium"] = { result = "mythicmetals:aquarium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_banglum"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_carmot"] = { result = "mythicmetals:carmot_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_kyber"] = { result = "mythicmetals:kyber_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_manganese"] = { result = "mythicmetals:manganese_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_midas_gold"] = { result = "mythicmetals:midas_gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_mythril"] = { result = "mythicmetals:mythril_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_orichalcum"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_palladium"] = { result = "mythicmetals:palladium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_prometheum"] = { result = "mythicmetals:prometheum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_quadrillum"] = { result = "mythicmetals:quadrillum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_runite"] = { result = "mythicmetals:runite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_star_platinum"] = { result = "mythicmetals:star_platinum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_stormyx"] = { result = "mythicmetals:stormyx_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_tin"] = { result = "mythicmetals:tin_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:raw_silver"] = { result = "mythicmetals:silver_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
|
||||
-------------------------------------------------
|
||||
-- Mythic Metals — ore blocks (furnace + blast furnace)
|
||||
-------------------------------------------------
|
||||
["mythicmetals:adamantite_ore"] = { result = "mythicmetals:adamantite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_adamantite_ore"] = { result = "mythicmetals:adamantite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:aquarium_ore"] = { result = "mythicmetals:aquarium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:banglum_ore"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_banglum_ore"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:nether_banglum_ore"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:carmot_ore"] = { result = "mythicmetals:carmot_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_carmot_ore"] = { result = "mythicmetals:carmot_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:kyber_ore"] = { result = "mythicmetals:kyber_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_kyber_ore"] = { result = "mythicmetals:kyber_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:manganese_ore"] = { result = "mythicmetals:manganese_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_manganese_ore"] = { result = "mythicmetals:manganese_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:midas_gold_ore"] = { result = "mythicmetals:midas_gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:mythril_ore"] = { result = "mythicmetals:mythril_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_mythril_ore"] = { result = "mythicmetals:mythril_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:orichalcum_ore"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_orichalcum_ore"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:end_stone_orichalcum_ore"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:palladium_ore"] = { result = "mythicmetals:palladium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:prometheum_ore"] = { result = "mythicmetals:prometheum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_prometheum_ore"] = { result = "mythicmetals:prometheum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:quadrillum_ore"] = { result = "mythicmetals:quadrillum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_quadrillum_ore"] = { result = "mythicmetals:quadrillum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:runite_ore"] = { result = "mythicmetals:runite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_runite_ore"] = { result = "mythicmetals:runite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:star_platinum_ore"] = { result = "mythicmetals:star_platinum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:end_stone_star_platinum_ore"] = { result = "mythicmetals:star_platinum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:stormyx_ore"] = { result = "mythicmetals:stormyx_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:end_stone_stormyx_ore"] = { result = "mythicmetals:stormyx_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:tin_ore"] = { result = "mythicmetals:tin_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_tin_ore"] = { result = "mythicmetals:tin_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:silver_ore"] = { result = "mythicmetals:silver_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["mythicmetals:deepslate_silver_ore"] = { result = "mythicmetals:silver_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
|
||||
-------------------------------------------------
|
||||
-- Ad Astra — raw ores + planetary ores (furnace + blast furnace)
|
||||
-------------------------------------------------
|
||||
["ad_astra:raw_desh"] = { result = "ad_astra:desh_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:raw_ostrum"] = { result = "ad_astra:ostrum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:raw_calorite"] = { result = "ad_astra:calorite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:moon_desh_ore"] = { result = "ad_astra:desh_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:deepslate_desh_ore"] = { result = "ad_astra:desh_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:mars_ostrum_ore"] = { result = "ad_astra:ostrum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:deepslate_ostrum_ore"] = { result = "ad_astra:ostrum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:venus_calorite_ore"] = { result = "ad_astra:calorite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:deepslate_calorite_ore"] = { result = "ad_astra:calorite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:moon_iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:mars_iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:venus_gold_ore"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:venus_diamond_ore"] = { result = "minecraft:diamond", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:mars_diamond_ore"] = { result = "minecraft:diamond", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["ad_astra:moon_ice_shard_ore"] = { result = "ad_astra:ice_shard", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
|
||||
-------------------------------------------------
|
||||
-- Create — zinc + crushed ores (furnace + blast furnace)
|
||||
-------------------------------------------------
|
||||
["create:raw_zinc"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["create:zinc_ore"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["create:deepslate_zinc_ore"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["create:crushed_raw_iron"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["create:crushed_raw_gold"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["create:crushed_raw_copper"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["create:crushed_raw_zinc"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
|
||||
-------------------------------------------------
|
||||
-- BetterEnd — thallasium (furnace + blast furnace)
|
||||
-------------------------------------------------
|
||||
["betterend:thallasium_raw"] = { result = "betterend:thallasium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
["betterend:thallasium_ore"] = { result = "betterend:thallasium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||||
|
||||
-------------------------------------------------
|
||||
-- Farmer's Delight — food (furnace + smoker)
|
||||
-------------------------------------------------
|
||||
["farmersdelight:chicken_cuts"] = { result = "farmersdelight:cooked_chicken_cuts", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["farmersdelight:bacon"] = { result = "farmersdelight:cooked_bacon", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["farmersdelight:cod_slice"] = { result = "farmersdelight:cooked_cod_slice", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["farmersdelight:salmon_slice"] = { result = "farmersdelight:cooked_salmon_slice", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["farmersdelight:mutton_chops"] = { result = "farmersdelight:cooked_mutton_chops", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["farmersdelight:ham"] = { result = "farmersdelight:smoked_ham", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
["farmersdelight:minced_beef"] = { result = "farmersdelight:beef_patty", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||||
}
|
||||
20
data/stock_limits.lua
Normal file
20
data/stock_limits.lua
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Stock limits: maximum quantities to keep in storage.
|
||||
-- When an item exceeds its limit, the excess is pushed to the trash dropper.
|
||||
-- Set up a dropper facing lava, cactus, or void to destroy excess items.
|
||||
--
|
||||
-- Format: ["mod:item"] = maxCount
|
||||
-- Only items listed here are subject to auto-discard; everything else is unlimited.
|
||||
|
||||
return {
|
||||
["minecraft:cobblestone"] = 8192, -- 128 slots (~5 chests)
|
||||
["minecraft:stone"] = 4096,
|
||||
["minecraft:smooth_stone"] = 4096,
|
||||
["minecraft:cobbled_deepslate"] = 4096,
|
||||
["minecraft:netherrack"] = 2048,
|
||||
["minecraft:dirt"] = 2048,
|
||||
["minecraft:gravel"] = 2048,
|
||||
["minecraft:andesite"] = 2048,
|
||||
["minecraft:diorite"] = 2048,
|
||||
["minecraft:granite"] = 2048,
|
||||
["minecraft:tuff"] = 1024,
|
||||
}
|
||||
@@ -1,10 +1,41 @@
|
||||
-- Dropper Controller: Runs on Computer 1 (next to dropper_0)
|
||||
-- Watches the dropper and pulses redstone until it's empty.
|
||||
-- Dropper Controller
|
||||
-- Watches a dropper peripheral and pulses redstone until it's empty.
|
||||
|
||||
local REDSTONE_SIDE = "back" -- side facing dropper_0
|
||||
local DROPPER_SIDE = "back" -- side where the dropper peripheral is
|
||||
local REDSTONE_SIDE = "back" -- side facing dropper (for redstone output)
|
||||
local PULSE_TIME = 0.3 -- redstone pulse duration in seconds
|
||||
local POLL_INTERVAL = 0.5 -- how often to check the dropper
|
||||
local DROPPER_SIDE = "back" -- side where the dropper is (as peripheral)
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return fs.combine(_baseDir, rel)
|
||||
end
|
||||
|
||||
local CONFIG_FILE = _configPath(".dropper_config")
|
||||
|
||||
if fs.exists(CONFIG_FILE) then
|
||||
local f = fs.open(CONFIG_FILE, "r")
|
||||
local raw = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, raw)
|
||||
if ok and cfg then
|
||||
if cfg.dropperSide then DROPPER_SIDE = cfg.dropperSide end
|
||||
if cfg.redstoneSide then REDSTONE_SIDE = cfg.redstoneSide end
|
||||
if cfg.pulseTime then PULSE_TIME = cfg.pulseTime end
|
||||
if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Helpers
|
||||
|
||||
28
etc/apps.db
Normal file
28
etc/apps.db
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
[ "im_inventory_manager" ] = {
|
||||
title = "Inv Manager",
|
||||
category = "Inventory",
|
||||
run = "inventoryManager.lua",
|
||||
},
|
||||
[ "im_inventory_client" ] = {
|
||||
title = "Inv Display",
|
||||
category = "Inventory",
|
||||
run = "inventoryClient.lua",
|
||||
},
|
||||
[ "im_crafting_turtle" ] = {
|
||||
title = "Crafting Turtle",
|
||||
category = "Inventory",
|
||||
run = "craftingTurtle.lua",
|
||||
requires = "turtle",
|
||||
},
|
||||
[ "im_web_bridge" ] = {
|
||||
title = "Inv Web Bridge",
|
||||
category = "Inventory",
|
||||
run = "inventoryWebBridge.lua",
|
||||
},
|
||||
[ "im_dropper" ] = {
|
||||
title = "Dropper Ctrl",
|
||||
category = "Inventory",
|
||||
run = "dropperController.lua",
|
||||
},
|
||||
}
|
||||
1284
inventoryClient.lua
1284
inventoryClient.lua
File diff suppressed because it is too large
Load Diff
3851
inventoryManager.lua
3851
inventoryManager.lua
File diff suppressed because it is too large
Load Diff
3019
inventoryManager.lua.bak
Normal file
3019
inventoryManager.lua.bak
Normal file
File diff suppressed because it is too large
Load Diff
458
inventoryWebBridge.lua
Normal file
458
inventoryWebBridge.lua
Normal file
@@ -0,0 +1,458 @@
|
||||
-- Inventory Manager Web Bridge
|
||||
-- Runs on a CC:Tweaked computer with a modem and HTTP access.
|
||||
-- Listens to the inventory master's broadcasts via modem and
|
||||
-- forwards state to the web server via HTTP.
|
||||
-- Also polls the web server for commands and sends them to the master.
|
||||
--
|
||||
-- Uses cc-platform-core for shared infrastructure (config, HTTP, modem, WS).
|
||||
-- Service-specific logic (command dispatch, state forwarding) remains here.
|
||||
--
|
||||
-- Transport: WebSocket (primary) with HTTP polling (fallback).
|
||||
-- When a WS connection to /ws/bridge is active, commands arrive in
|
||||
-- real-time and state/results are pushed over the socket. If the WS
|
||||
-- drops, the bridge seamlessly falls back to HTTP polling until
|
||||
-- reconnection.
|
||||
--
|
||||
-- Channel mode: 'current' by default (legacy channels active).
|
||||
-- Set channelMode = 'dual' or 'target' in platform config to migrate.
|
||||
|
||||
local WebBridge = require('platform.webbridge')
|
||||
local Channels = require('platform.channels')
|
||||
|
||||
-------------------------------------------------
|
||||
-- Configuration (via platform)
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
|
||||
local config, configSource = WebBridge.loadConfig({
|
||||
serverUrl = "http://localhost",
|
||||
pollInterval = 0.5,
|
||||
stateInterval = 1,
|
||||
apiKey = nil,
|
||||
}, {
|
||||
"usr/config/inventory-manager/.webbridge_config",
|
||||
fs.combine(_baseDir, ".webbridge_config"),
|
||||
})
|
||||
|
||||
local SERVER_URL = config.serverUrl
|
||||
local POLL_INTERVAL = config.pollInterval
|
||||
local STATE_INTERVAL = config.stateInterval
|
||||
local API_KEY = config.apiKey
|
||||
|
||||
if configSource then
|
||||
print("[CONFIG] Loaded from " .. configSource)
|
||||
end
|
||||
|
||||
-- Channels from platform registry (matches inventoryManager.lua)
|
||||
local BROADCAST_CHANNEL = Channels.get('inventory.broadcast')
|
||||
local ORDER_CHANNEL = Channels.get('inventory.order')
|
||||
local BRIDGE_REPLY_CHANNEL = Channels.get('inventory.bridge')
|
||||
|
||||
-------------------------------------------------
|
||||
-- State
|
||||
-------------------------------------------------
|
||||
|
||||
local latestState = nil -- last broadcast from master
|
||||
local modem = nil
|
||||
local modemName = nil
|
||||
local running = true
|
||||
|
||||
-- WebSocket state (real-time transport, with HTTP polling fallback)
|
||||
local ws = nil -- active WebSocket handle (nil when not connected)
|
||||
local wsConnected = false -- gates WS vs HTTP transport selection
|
||||
local wsHasSynced = false -- true after first command_batch received from server
|
||||
|
||||
-- Reliable modem delivery: pending commands awaiting manager acknowledgment.
|
||||
-- processCommand() inserts here; modemListener removes on result receipt.
|
||||
-- A retry task periodically re-transmits unacknowledged commands.
|
||||
local pendingModem = {} -- commandId -> { payload, channel, replyChannel, sent, retries }
|
||||
local MODEM_RETRY_INTERVAL = 2 -- seconds between retry sweeps
|
||||
local MODEM_RETRY_MAX = 5 -- max retransmissions before giving up
|
||||
local MODEM_RETRY_DELAY = 2 -- seconds before first retry (per command)
|
||||
|
||||
-------------------------------------------------
|
||||
-- HTTP helpers (thin wrappers around platform)
|
||||
-------------------------------------------------
|
||||
|
||||
local function httpPost(path, body)
|
||||
local result, err = WebBridge.httpPost(SERVER_URL .. path, body,
|
||||
WebBridge.authHeaders(API_KEY))
|
||||
if not result and err then
|
||||
print(string.format("[ERR] HTTP POST %s: %s", path, tostring(err)))
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function httpGet(path)
|
||||
local rawBody, err = WebBridge.httpGet(SERVER_URL .. path,
|
||||
WebBridge.authHeaders(API_KEY))
|
||||
if not rawBody then
|
||||
if err then
|
||||
print(string.format("[ERR] HTTP GET %s: %s", path, tostring(err)))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
return textutils.unserialiseJSON(rawBody)
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Forward state to web server
|
||||
-------------------------------------------------
|
||||
|
||||
local function forwardState()
|
||||
if not latestState then return end
|
||||
httpPost("/api/bridge/state", latestState)
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- WebSocket helpers (real-time transport)
|
||||
-------------------------------------------------
|
||||
|
||||
--- Build the WebSocket bridge URL from server config.
|
||||
-- Converts http(s):// to ws(s):// and appends /ws/bridge path.
|
||||
-- @return string WebSocket URL with optional API key
|
||||
local function getWsUrl()
|
||||
local wsUrl = SERVER_URL:gsub("^http", "ws") .. "/ws/bridge"
|
||||
if API_KEY then
|
||||
wsUrl = wsUrl .. "?key=" .. textutils.urlEncode(API_KEY)
|
||||
end
|
||||
return wsUrl
|
||||
end
|
||||
|
||||
--- Send a JSON message via WebSocket if connected.
|
||||
-- @param data table Data to send (serialized to JSON automatically)
|
||||
-- @return boolean true if sent successfully, false if WS unavailable
|
||||
local function wsSend(data)
|
||||
if ws and wsConnected then
|
||||
local ok = pcall(ws.send, textutils.serialiseJSON(data))
|
||||
return ok
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Process commands from web server
|
||||
-------------------------------------------------
|
||||
|
||||
local function processCommand(cmd)
|
||||
if not modem then return end
|
||||
|
||||
local action = cmd.action
|
||||
if not action then return end
|
||||
|
||||
print(string.format("[CMD] %s", action))
|
||||
|
||||
-- Build the modem payload from the server command
|
||||
local payload
|
||||
if action == "order" then
|
||||
payload = {
|
||||
type = "order",
|
||||
commandId = cmd.commandId,
|
||||
itemName = cmd.itemName,
|
||||
amount = cmd.amount,
|
||||
dropperName = cmd.dropperName,
|
||||
}
|
||||
elseif action == "scan" then
|
||||
payload = { type = "scan", commandId = cmd.commandId }
|
||||
elseif action == "toggle_pause" then
|
||||
payload = { type = "toggle_pause", commandId = cmd.commandId }
|
||||
elseif action == "toggle_recipe" then
|
||||
payload = { type = "toggle_recipe", commandId = cmd.commandId, recipe = cmd.recipe }
|
||||
elseif action == "enable_all" then
|
||||
payload = { type = "enable_all", commandId = cmd.commandId }
|
||||
elseif action == "disable_all" then
|
||||
payload = { type = "disable_all", commandId = cmd.commandId }
|
||||
elseif action == "sort_barrel" then
|
||||
payload = { type = "sort_barrel", commandId = cmd.commandId, barrelName = cmd.barrelName }
|
||||
elseif action == "craft" then
|
||||
payload = { type = "craft", commandId = cmd.commandId, recipeIdx = cmd.recipeIdx }
|
||||
elseif action == "recursive_craft" then
|
||||
payload = { type = "recursive_craft", commandId = cmd.commandId, itemName = cmd.itemName, count = cmd.count }
|
||||
elseif action == "learn_crafting_recipe" then
|
||||
payload = { type = "learn_crafting_recipe", commandId = cmd.commandId, output = cmd.output, count = cmd.count, grid = cmd.grid }
|
||||
elseif action == "learn_smelting_recipe" then
|
||||
payload = { type = "learn_smelting_recipe", commandId = cmd.commandId, input = cmd.input, result = cmd.result, furnaces = cmd.furnaces }
|
||||
elseif action == "forget_recipe" then
|
||||
payload = { type = "forget_recipe", commandId = cmd.commandId, recipe = cmd.recipe }
|
||||
elseif action == "sync_disabled_recipes" then
|
||||
payload = { type = "sync_disabled_recipes", commandId = cmd.commandId, disabledRecipes = cmd.disabledRecipes, smeltingPaused = cmd.smeltingPaused }
|
||||
elseif action == "reboot" then
|
||||
payload = { type = "reboot", commandId = cmd.commandId, target = cmd.target or "all" }
|
||||
else
|
||||
print("[CMD] Unknown action: " .. tostring(action))
|
||||
return
|
||||
end
|
||||
|
||||
-- Transmit and track for reliable delivery
|
||||
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, payload)
|
||||
if payload.commandId then
|
||||
pendingModem[payload.commandId] = {
|
||||
payload = payload,
|
||||
sent = os.clock(),
|
||||
retries = 0,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Tasks
|
||||
-------------------------------------------------
|
||||
|
||||
-- Task 1: Listen for modem broadcasts from master
|
||||
local function modemListener()
|
||||
while running do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
if channel == BROADCAST_CHANNEL and type(message) == "table" then
|
||||
if message.type == "state" then
|
||||
latestState = message
|
||||
end
|
||||
elseif channel == BRIDGE_REPLY_CHANNEL and type(message) == "table" then
|
||||
-- Clear pending retry on any response matching a commandId
|
||||
if message.commandId and pendingModem[message.commandId] then
|
||||
pendingModem[message.commandId] = nil
|
||||
end
|
||||
-- Forward command results back to web server
|
||||
local resultType = message.type
|
||||
if resultType == "order_result" or resultType == "craft_result"
|
||||
or resultType == "recursive_craft_result" or resultType == "find_item_result" then
|
||||
local resultPayload = {
|
||||
action = resultType,
|
||||
commandId = message.commandId,
|
||||
success = message.success,
|
||||
message = message.message,
|
||||
error = message.error,
|
||||
}
|
||||
-- WS-first: send as command_result via WebSocket if connected
|
||||
local sent = wsSend({
|
||||
type = "command_result",
|
||||
action = resultPayload.action,
|
||||
commandId = resultPayload.commandId,
|
||||
success = resultPayload.success,
|
||||
message = resultPayload.message,
|
||||
error = resultPayload.error,
|
||||
})
|
||||
-- HTTP fallback: POST to /api/bridge/result if WS unavailable
|
||||
if not sent then
|
||||
local fwdOk, fwdErr = pcall(httpPost, "/api/bridge/result", resultPayload)
|
||||
if not fwdOk then
|
||||
print(string.format("[ERR] Forward result %s: %s", resultType, tostring(fwdErr)))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Task 2: Forward state to web server periodically
|
||||
-- Uses WebSocket if connected; falls back to HTTP POST.
|
||||
local function stateForwarder()
|
||||
while running do
|
||||
if latestState then
|
||||
-- WS-first: send state directly via WebSocket
|
||||
if not wsSend(latestState) then
|
||||
-- HTTP fallback: POST to /api/bridge/state
|
||||
local ok, err = pcall(forwardState)
|
||||
if not ok then
|
||||
print(string.format("[ERR] State forward: %s", tostring(err)))
|
||||
end
|
||||
end
|
||||
end
|
||||
sleep(STATE_INTERVAL)
|
||||
end
|
||||
end
|
||||
|
||||
-- Task 3: Poll web server for commands (HTTP fallback)
|
||||
-- Active when WS is disconnected, or as safety net until first WS sync.
|
||||
local lastProcessedId = 0 -- track highest processed command ID for dedup
|
||||
|
||||
local function commandPoller()
|
||||
while running do
|
||||
-- HTTP polling is a fallback; also runs until WS initial sync completes
|
||||
if not wsConnected or not wsHasSynced then
|
||||
local ok, err = pcall(function()
|
||||
local result = httpGet("/api/bridge/commands")
|
||||
if result and result.commands and #result.commands > 0 then
|
||||
local maxId = lastProcessedId
|
||||
-- Process each command, skipping already-processed ones
|
||||
for _, cmd in ipairs(result.commands) do
|
||||
local cmdId = cmd.id or 0
|
||||
if cmdId > lastProcessedId then
|
||||
local cmdOk, cmdErr = pcall(processCommand, cmd)
|
||||
if not cmdOk then
|
||||
print(string.format("[ERR] Process cmd %s: %s", tostring(cmd.action), tostring(cmdErr)))
|
||||
end
|
||||
if cmdId > maxId then maxId = cmdId end
|
||||
end
|
||||
end
|
||||
-- Acknowledge up to the highest processed ID
|
||||
if maxId > lastProcessedId then
|
||||
lastProcessedId = maxId
|
||||
httpPost("/api/bridge/commands/ack", { lastProcessedId = lastProcessedId })
|
||||
end
|
||||
end
|
||||
end)
|
||||
if not ok then
|
||||
print(string.format("[ERR] Command poll: %s", tostring(err)))
|
||||
end
|
||||
end
|
||||
sleep(POLL_INTERVAL)
|
||||
end
|
||||
end
|
||||
|
||||
-- Task 4: Heartbeat / connection status display
|
||||
local function heartbeat()
|
||||
local connected = false
|
||||
while running do
|
||||
local ok, result = pcall(function()
|
||||
return httpGet("/api/health")
|
||||
end)
|
||||
local nowConnected = ok and result ~= nil
|
||||
if nowConnected ~= connected then
|
||||
connected = nowConnected
|
||||
if connected then
|
||||
print("[WEB] Connected to web server")
|
||||
else
|
||||
print("[WEB] Disconnected from web server")
|
||||
end
|
||||
end
|
||||
sleep(5)
|
||||
end
|
||||
end
|
||||
|
||||
-- Task 5: WebSocket real-time connection (primary transport)
|
||||
-- Maintains a persistent WebSocket link to the server for:
|
||||
-- - Receiving commands in real-time (replaces HTTP polling when active)
|
||||
-- - Sending state updates and command results via wsSend()
|
||||
-- Reconnects automatically on failure; HTTP polling resumes as fallback.
|
||||
-- Channel mode: 'current' by default — dual/target configurable via platform.
|
||||
local function wsConnector()
|
||||
local wsUrl = getWsUrl()
|
||||
print("[WS] Connecting to " .. wsUrl)
|
||||
|
||||
WebBridge.wsConnect(wsUrl, {
|
||||
onConnect = function(wsHandle)
|
||||
ws = wsHandle
|
||||
wsConnected = true
|
||||
print("[WS] Connected — real-time mode active")
|
||||
-- Push current state immediately on reconnect
|
||||
if latestState then
|
||||
wsSend(latestState)
|
||||
end
|
||||
end,
|
||||
|
||||
onMessage = function(wsHandle, data)
|
||||
-- Initial sync: server sends pending commands as a batch on connect
|
||||
if data.type == 'command_batch' and data.commands then
|
||||
wsHasSynced = true
|
||||
for _, cmd in ipairs(data.commands) do
|
||||
local cmdOk, cmdErr = pcall(processCommand, cmd)
|
||||
if not cmdOk then
|
||||
print(string.format("[ERR] WS batch cmd %s: %s",
|
||||
tostring(cmd.action), tostring(cmdErr)))
|
||||
end
|
||||
end
|
||||
if #data.commands > 0 then
|
||||
print(string.format("[WS] Processed %d synced command(s)", #data.commands))
|
||||
end
|
||||
-- Server pushes commands via WebSocket (replaces HTTP polling)
|
||||
elseif data.action then
|
||||
local cmdOk, cmdErr = pcall(processCommand, data)
|
||||
if not cmdOk then
|
||||
print(string.format("[ERR] WS cmd %s: %s",
|
||||
tostring(data.action), tostring(cmdErr)))
|
||||
end
|
||||
end
|
||||
end,
|
||||
|
||||
onDisconnect = function()
|
||||
ws = nil
|
||||
wsConnected = false
|
||||
wsHasSynced = false
|
||||
print("[WS] Disconnected — HTTP polling fallback active")
|
||||
end,
|
||||
|
||||
onError = function(err)
|
||||
print(string.format("[WS] Connection error: %s", tostring(err)))
|
||||
end,
|
||||
}, {
|
||||
reconnectDelay = 5,
|
||||
receiveTimeout = 30,
|
||||
})
|
||||
end
|
||||
|
||||
-- Task 6: Retry unacknowledged modem commands
|
||||
-- The manager deduplicates by commandId, so retransmits are safe.
|
||||
local function modemRetry()
|
||||
while running do
|
||||
local now = os.clock()
|
||||
for cmdId, entry in pairs(pendingModem) do
|
||||
if now - entry.sent >= MODEM_RETRY_DELAY then
|
||||
if entry.retries >= MODEM_RETRY_MAX then
|
||||
print(string.format("[RETRY] Giving up on %s after %d retries",
|
||||
tostring(cmdId), entry.retries))
|
||||
pendingModem[cmdId] = nil
|
||||
else
|
||||
entry.retries = entry.retries + 1
|
||||
entry.sent = now
|
||||
print(string.format("[RETRY] Re-transmit %s (attempt %d/%d)",
|
||||
tostring(cmdId), entry.retries, MODEM_RETRY_MAX))
|
||||
pcall(modem.transmit, ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, entry.payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
sleep(MODEM_RETRY_INTERVAL)
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Main
|
||||
-------------------------------------------------
|
||||
|
||||
local function main()
|
||||
print("===================================")
|
||||
print(" Inventory Manager - Web Bridge")
|
||||
print("===================================")
|
||||
print("")
|
||||
|
||||
-- Config already loaded at require-time via WebBridge.loadConfig above
|
||||
|
||||
modem, modemName = WebBridge.findModem()
|
||||
if modem then
|
||||
WebBridge.openChannels(modem,
|
||||
{ 'inventory.broadcast', 'inventory.bridge' })
|
||||
local modemType = modem.isWireless and (modem.isWireless() and "wireless" or "wired") or "unknown"
|
||||
print(string.format("[OK] Modem: %s (%s)", modemName, modemType))
|
||||
print(string.format("[OK] TX ch %d, listen ch %d/%d",
|
||||
ORDER_CHANNEL, BROADCAST_CHANNEL, BRIDGE_REPLY_CHANNEL))
|
||||
else
|
||||
print("[WARN] No modem found! Bridge needs a modem.")
|
||||
print(" Attach a modem and restart.")
|
||||
return
|
||||
end
|
||||
|
||||
print("[OK] Server URL: " .. SERVER_URL)
|
||||
print("[OK] Poll interval: " .. POLL_INTERVAL .. "s")
|
||||
print("[OK] State interval: " .. STATE_INTERVAL .. "s")
|
||||
if API_KEY then
|
||||
print("[OK] API key configured")
|
||||
else
|
||||
print("[WARN] No API key set (open access)")
|
||||
end
|
||||
print("[OK] Transport: WebSocket (primary) + HTTP polling (fallback)")
|
||||
print("")
|
||||
print("Bridge is running. Press Ctrl+T to stop.")
|
||||
print("Listening for master broadcasts on ch " .. BROADCAST_CHANNEL)
|
||||
print("")
|
||||
|
||||
parallel.waitForAny(
|
||||
modemListener,
|
||||
stateForwarder,
|
||||
commandPoller,
|
||||
heartbeat,
|
||||
wsConnector,
|
||||
modemRetry
|
||||
)
|
||||
end
|
||||
|
||||
main()
|
||||
334
lib/craft.lua
Normal file
334
lib/craft.lua
Normal file
@@ -0,0 +1,334 @@
|
||||
-- lib/craft.lua — Recursive crafting engine
|
||||
-- Adapted from opus-apps milo/apis/craft2.lua for Inventory Manager.
|
||||
-- Supports multi-step crafting chains with cycle detection.
|
||||
--
|
||||
-- Usage:
|
||||
-- local craft = dofile("lib/craft.lua")
|
||||
-- craft.init(recipeBook, ops.getItemTotal)
|
||||
-- local ok, err = craft.executeChain("minecraft:chest", 1, ctx)
|
||||
|
||||
local craft = {}
|
||||
|
||||
local recipeBook = nil -- lib/recipeBook instance
|
||||
local getItemTotal = nil -- function(itemName) -> number
|
||||
|
||||
--- Initialize the crafting engine.
|
||||
-- @param rb recipeBook instance (lib/recipeBook.lua)
|
||||
-- @param getTotal function(itemName) returning stock count
|
||||
function craft.init(rb, getTotal)
|
||||
recipeBook = rb
|
||||
getItemTotal = getTotal
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Ingredient analysis
|
||||
-------------------------------------------------
|
||||
|
||||
--- Sum ingredients across a recipe grid.
|
||||
-- @return { [itemName] = count }
|
||||
function craft.sumIngredients(recipe)
|
||||
return recipeBook.getIngredients(recipe)
|
||||
end
|
||||
|
||||
--- Recursively determine how many items can be crafted.
|
||||
-- Follows sub-recipe chains with cycle detection.
|
||||
-- @param recipe crafting recipe table
|
||||
-- @param count desired output quantity
|
||||
-- @return craftable amount (may be less than count)
|
||||
function craft.getCraftableAmount(recipe, count)
|
||||
local path = { [recipe.output] = true }
|
||||
|
||||
local function check(r, summed, cnt, p)
|
||||
local can = 0
|
||||
for _ = 1, cnt do
|
||||
for ingr, needed in pairs(craft.sumIngredients(r)) do
|
||||
local avail = summed[ingr] or getItemTotal(ingr)
|
||||
-- Try sub-crafting if we don't have enough
|
||||
if avail < needed then
|
||||
local sub = recipeBook.getCraftingRecipe(ingr)
|
||||
if sub and not p[ingr] then
|
||||
local sp = {}
|
||||
for k, v in pairs(p) do sp[k] = v end
|
||||
sp[sub.output] = true
|
||||
avail = avail + check(sub, summed, needed - avail, sp)
|
||||
end
|
||||
end
|
||||
if avail < needed then return can end
|
||||
summed[ingr] = avail - needed
|
||||
end
|
||||
can = can + r.count
|
||||
end
|
||||
return can
|
||||
end
|
||||
|
||||
return check(recipe, {}, math.ceil(count / recipe.count), path)
|
||||
end
|
||||
|
||||
--- Build a full resource list for a recipe (what's needed, available, missing).
|
||||
-- Recursively expands sub-recipes.
|
||||
-- @param recipe crafting recipe table
|
||||
-- @param count desired output quantity
|
||||
-- @return { [itemName] = { name, have, total, used, need, craftable } }
|
||||
function craft.getResourceList(recipe, count)
|
||||
local summed = {}
|
||||
|
||||
local function sum(r, cnt, path)
|
||||
for ingr, per in pairs(craft.sumIngredients(r)) do
|
||||
local need = per * cnt
|
||||
if not summed[ingr] then
|
||||
summed[ingr] = {
|
||||
name = ingr,
|
||||
have = getItemTotal(ingr),
|
||||
total = 0,
|
||||
used = 0,
|
||||
need = 0,
|
||||
craftable = recipeBook.isCraftable(ingr),
|
||||
}
|
||||
end
|
||||
local e = summed[ingr]
|
||||
e.total = e.total + need
|
||||
local canUse = math.min(e.have, need)
|
||||
e.used = e.used + canUse
|
||||
e.have = e.have - canUse
|
||||
local short = need - canUse
|
||||
if short > 0 then
|
||||
local sub = recipeBook.getCraftingRecipe(ingr)
|
||||
if sub and not path[ingr] then
|
||||
local p = {}
|
||||
for k, v in pairs(path) do p[k] = v end
|
||||
p[sub.output] = true
|
||||
sum(sub, math.ceil(short / sub.count), p)
|
||||
else
|
||||
e.need = e.need + short
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sum(recipe, math.ceil(count / recipe.count), { [recipe.output] = true })
|
||||
return summed
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Crafting chain planning
|
||||
-------------------------------------------------
|
||||
|
||||
--- Plan a crafting chain (bottom-up order: sub-ingredients first).
|
||||
-- @param targetItem string — output item name
|
||||
-- @param count number — desired quantity
|
||||
-- @return array of steps { recipe, count (batches), output, outputCount } or nil, error
|
||||
function craft.planChain(targetItem, count)
|
||||
local recipe = recipeBook.getCraftingRecipe(targetItem)
|
||||
if not recipe then return nil, "No recipe for " .. targetItem end
|
||||
|
||||
local steps = {}
|
||||
local visited = {}
|
||||
|
||||
local function plan(r, cnt, depth)
|
||||
if depth > 20 then return false, "Recipe chain too deep (cycle?)" end
|
||||
local batches = math.ceil(cnt / r.count)
|
||||
|
||||
-- Plan sub-ingredients first (depth-first)
|
||||
for ingr, per in pairs(craft.sumIngredients(r)) do
|
||||
local need = per * batches
|
||||
local have = getItemTotal(ingr)
|
||||
if have < need and not visited[ingr] then
|
||||
local sub = recipeBook.getCraftingRecipe(ingr)
|
||||
if sub then
|
||||
local ok, err = plan(sub, need - have, depth + 1)
|
||||
if not ok then return false, err end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Add this step after all sub-steps
|
||||
if not visited[r.output] then
|
||||
visited[r.output] = true
|
||||
table.insert(steps, {
|
||||
recipe = r,
|
||||
count = batches,
|
||||
output = r.output,
|
||||
outputCount = batches * r.count,
|
||||
})
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local ok, err = plan(recipe, count, 0)
|
||||
if not ok then return nil, err end
|
||||
return steps
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Craft execution (remote turtle protocol)
|
||||
-------------------------------------------------
|
||||
|
||||
--- Execute a single craft batch via remote turtle.
|
||||
-- Uses the existing IM craft_request/craft_result modem protocol.
|
||||
-- @param recipe crafting recipe table
|
||||
-- @param ctx context table with ops, cfg, state, log, craftTurtleName, networkModem
|
||||
-- @return success, error_message
|
||||
function craft.executeSingleCraft(recipe, ctx, batches)
|
||||
batches = batches or 1
|
||||
local ops = ctx.ops
|
||||
local cfg = ctx.cfg
|
||||
local st = ctx.state
|
||||
|
||||
if not ctx.craftTurtleName then return false, "No turtle" end
|
||||
if not ctx.networkModem then return false, "No modem" end
|
||||
if not peripheral.isPresent(ctx.craftTurtleName) then return false, "Turtle offline" end
|
||||
|
||||
-- Clamp batches to 64 (max stack size per slot)
|
||||
batches = math.min(batches, 64)
|
||||
|
||||
local chests = ops.getChests()
|
||||
local slotMap = {}
|
||||
local reserved = {}
|
||||
|
||||
-- Map each grid position to a chest slot, pulling `batches` items per slot
|
||||
for gridPos = 1, 9 do
|
||||
local itemName = recipe.grid[gridPos]
|
||||
if itemName then
|
||||
local tSlot = cfg.GRID_TO_SLOT[gridPos]
|
||||
local found = false
|
||||
|
||||
if st.cache.catalogue[itemName] then
|
||||
for _, src in ipairs(st.cache.catalogue[itemName]) do
|
||||
local chest = ops.wrapCached(src.chest)
|
||||
if chest then
|
||||
for slot, si in pairs(chest.list()) do
|
||||
local key = src.chest .. ":" .. slot
|
||||
if si.name == itemName and not reserved[key] then
|
||||
local pullCount = math.min(batches, si.count)
|
||||
slotMap[tostring(tSlot)] = {
|
||||
chestName = src.chest,
|
||||
chestSlot = slot,
|
||||
itemName = itemName,
|
||||
count = pullCount,
|
||||
}
|
||||
reserved[key] = true
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if found then break end
|
||||
end
|
||||
end
|
||||
|
||||
if not found then
|
||||
return false, "Missing ingredient: " .. itemName
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Send craft request to turtle
|
||||
ctx.networkModem.transmit(cfg.CRAFT_CHANNEL, cfg.CRAFT_REPLY_CHANNEL, {
|
||||
type = "craft_request",
|
||||
output = recipe.output,
|
||||
slots = slotMap,
|
||||
returnChests = chests,
|
||||
})
|
||||
|
||||
-- Optimistically update cache
|
||||
for _, info in pairs(slotMap) do
|
||||
st.adjustCache(info.itemName, info.chestName, -info.count)
|
||||
end
|
||||
|
||||
-- Wait for turtle reply
|
||||
local deadline = os.clock() + cfg.CRAFT_TIMEOUT
|
||||
local result
|
||||
local buffered = {}
|
||||
|
||||
while os.clock() < deadline do
|
||||
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
|
||||
local event, p1, p2, p3, p4, p5 = os.pullEvent()
|
||||
|
||||
if event == "modem_message" then
|
||||
if p2 == cfg.CRAFT_REPLY_CHANNEL and type(p4) == "table" and p4.type == "craft_result" then
|
||||
result = p4
|
||||
break
|
||||
elseif p2 == cfg.ORDER_CHANNEL then
|
||||
table.insert(buffered, { event, p1, p2, p3, p4, p5 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Re-queue any buffered messages
|
||||
for _, msg in ipairs(buffered) do
|
||||
os.queueEvent(table.unpack(msg))
|
||||
end
|
||||
|
||||
if not result then
|
||||
return false, "Turtle timeout"
|
||||
end
|
||||
|
||||
if result.success then
|
||||
local totalOutput = result.totalOutput or recipe.count
|
||||
if result.results then
|
||||
for _, r in ipairs(result.results) do
|
||||
if #chests > 0 then
|
||||
st.adjustCache(r.name, chests[1], r.count)
|
||||
end
|
||||
end
|
||||
elseif #chests > 0 then
|
||||
st.adjustCache(recipe.output, chests[1], totalOutput)
|
||||
end
|
||||
return true
|
||||
else
|
||||
-- Restore cache on failure
|
||||
for _, info in pairs(slotMap) do
|
||||
st.adjustCache(info.itemName, info.chestName, info.count)
|
||||
end
|
||||
return false, result.error or "Craft failed"
|
||||
end
|
||||
end
|
||||
|
||||
--- Execute a full crafting chain (all steps, bottom-up).
|
||||
-- @param targetItem string — desired output item name
|
||||
-- @param count number — desired quantity
|
||||
-- @param ctx context table
|
||||
-- @return success, error_message
|
||||
function craft.executeChain(targetItem, count, ctx)
|
||||
local steps, err = craft.planChain(targetItem, count)
|
||||
if not steps then return false, err end
|
||||
|
||||
ctx.log.info("CRAFT", "Chain: %d steps for %s x%d", #steps, targetItem, count)
|
||||
|
||||
for i, step in ipairs(steps) do
|
||||
ctx.log.info("CRAFT", "Step %d/%d: %s x%d (%d batches)",
|
||||
i, #steps, step.output, step.outputCount, step.count)
|
||||
|
||||
ctx.state.activity.crafting = true
|
||||
ctx.state.needsRedraw = true
|
||||
ctx.state.smelterNeedsRedraw = true
|
||||
|
||||
-- Batch in chunks of 64 (max stack per turtle slot)
|
||||
local remaining = step.count
|
||||
while remaining > 0 do
|
||||
local chunk = math.min(remaining, 64)
|
||||
local ok, batchErr = craft.executeSingleCraft(step.recipe, ctx, chunk)
|
||||
if not ok then
|
||||
ctx.state.activity.crafting = false
|
||||
ctx.state.needsRedraw = true
|
||||
ctx.log.error("CRAFT", "Chain failed at step %d: %s", i, batchErr)
|
||||
return false, string.format("Step %d/%d failed: %s", i, #steps, batchErr)
|
||||
end
|
||||
remaining = remaining - chunk
|
||||
-- Brief pause between chunks to let turtle finish
|
||||
if remaining > 0 then os.sleep(0.3) end
|
||||
end
|
||||
|
||||
ctx.log.info("CRAFT", "Step %d/%d complete: %s x%d", i, #steps, step.output, step.outputCount)
|
||||
-- Pause between steps to let items settle in storage
|
||||
if i < #steps then os.sleep(0.5) end
|
||||
end
|
||||
|
||||
ctx.state.activity.crafting = false
|
||||
ctx.state.needsRedraw = true
|
||||
ctx.state.smelterNeedsRedraw = true
|
||||
ctx.log.info("CRAFT", "Chain complete: %s x%d", targetItem, count)
|
||||
return true
|
||||
end
|
||||
|
||||
return craft
|
||||
114
lib/itemDB.lua
Normal file
114
lib/itemDB.lua
Normal file
@@ -0,0 +1,114 @@
|
||||
-- lib/itemDB.lua — Item display name database
|
||||
-- Learns display names from getItemDetail() and persists to disk.
|
||||
-- Adapted from opus-apps core.itemDB for Inventory Manager.
|
||||
--
|
||||
-- Usage:
|
||||
-- local itemDB = dofile("lib/itemDB.lua")
|
||||
-- itemDB.init(".item_names.db")
|
||||
-- itemDB.learn("minecraft:diamond", "Diamond")
|
||||
-- print(itemDB.getName("minecraft:diamond")) --> "Diamond"
|
||||
|
||||
local itemDB = {}
|
||||
|
||||
local nameData = {}
|
||||
local DATA_FILE = nil
|
||||
local dirty = false
|
||||
|
||||
-------------------------------------------------
|
||||
-- Initialization and persistence
|
||||
-------------------------------------------------
|
||||
|
||||
function itemDB.init(dataFile)
|
||||
DATA_FILE = dataFile
|
||||
itemDB.load()
|
||||
end
|
||||
|
||||
function itemDB.load()
|
||||
if not DATA_FILE or not fs.exists(DATA_FILE) then return end
|
||||
pcall(function()
|
||||
local f = fs.open(DATA_FILE, "r")
|
||||
local raw = f.readAll()
|
||||
f.close()
|
||||
local data = textutils.unserialise(raw)
|
||||
if type(data) == "table" then
|
||||
nameData = data
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function itemDB.flush()
|
||||
if not dirty or not DATA_FILE then return end
|
||||
pcall(function()
|
||||
local f = fs.open(DATA_FILE, "w")
|
||||
f.write(textutils.serialise(nameData))
|
||||
f.close()
|
||||
end)
|
||||
dirty = false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Name resolution
|
||||
-------------------------------------------------
|
||||
|
||||
--- Get a human-readable display name for an item.
|
||||
-- Falls back to generating a name from the item ID.
|
||||
-- @param itemName string or table with .name field
|
||||
-- @return string
|
||||
function itemDB.getName(itemName)
|
||||
if type(itemName) == "table" then itemName = itemName.name end
|
||||
if not itemName then return "?" end
|
||||
if nameData[itemName] then return nameData[itemName] end
|
||||
-- Generate readable name from item ID (e.g. "minecraft:iron_ingot" -> "Iron Ingot")
|
||||
local short = itemName:gsub("^[%w_]+:", "")
|
||||
return short:gsub("_", " "):gsub("(%a)([%w_']*)", function(first, rest)
|
||||
return first:upper() .. rest:lower()
|
||||
end)
|
||||
end
|
||||
|
||||
--- Learn a display name for an item.
|
||||
-- @param itemName string or table with .name and optional .displayName
|
||||
-- @param displayName string (optional if itemName is a table)
|
||||
function itemDB.learn(itemName, displayName)
|
||||
if type(itemName) == "table" then
|
||||
displayName = displayName or itemName.displayName
|
||||
itemName = itemName.name
|
||||
end
|
||||
if itemName and displayName and displayName ~= "" then
|
||||
if nameData[itemName] ~= displayName then
|
||||
nameData[itemName] = displayName
|
||||
dirty = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Learn from a getItemDetail() result table.
|
||||
-- @param detail table with .name and .displayName fields
|
||||
function itemDB.learnFromDetail(detail)
|
||||
if detail and detail.name and detail.displayName then
|
||||
itemDB.learn(detail.name, detail.displayName)
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Queries
|
||||
-------------------------------------------------
|
||||
|
||||
--- Check if an item's display name has been learned.
|
||||
function itemDB.isKnown(itemName)
|
||||
if type(itemName) == "table" then itemName = itemName.name end
|
||||
return nameData[itemName] ~= nil
|
||||
end
|
||||
|
||||
--- Get all known name mappings.
|
||||
function itemDB.getAllNames()
|
||||
return nameData
|
||||
end
|
||||
|
||||
--- Get count of known items.
|
||||
function itemDB.count()
|
||||
local n = 0
|
||||
for _ in pairs(nameData) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
return itemDB
|
||||
45
lib/log.lua
Normal file
45
lib/log.lua
Normal file
@@ -0,0 +1,45 @@
|
||||
-- lib/log.lua — Structured logging for CC:Tweaked programs
|
||||
-- Usage:
|
||||
-- local log = dofile("lib/log.lua")
|
||||
-- log.info("SMELT", "Loaded %d recipes", count)
|
||||
-- log.warn("NET", "No modem found")
|
||||
-- log.setLevel("DEBUG") -- show everything
|
||||
--
|
||||
-- Levels: DEBUG < INFO < WARN < ERROR
|
||||
-- Default level: INFO (suppresses DEBUG)
|
||||
|
||||
local LEVELS = { DEBUG = 1, INFO = 2, WARN = 3, ERROR = 4 }
|
||||
local minLevel = LEVELS.INFO
|
||||
|
||||
local log = {}
|
||||
|
||||
--- Set minimum log level (string: "DEBUG", "INFO", "WARN", "ERROR")
|
||||
function log.setLevel(level)
|
||||
minLevel = LEVELS[level] or LEVELS.INFO
|
||||
end
|
||||
|
||||
--- Get the current minimum level name
|
||||
function log.getLevel()
|
||||
for name, val in pairs(LEVELS) do
|
||||
if val == minLevel then return name end
|
||||
end
|
||||
return "INFO"
|
||||
end
|
||||
|
||||
local function emit(levelName, tag, msg, ...)
|
||||
if LEVELS[levelName] < minLevel then return end
|
||||
local text
|
||||
if select("#", ...) > 0 then
|
||||
text = string.format(msg, ...)
|
||||
else
|
||||
text = msg
|
||||
end
|
||||
print(string.format("[%s][%s] %s", levelName, tag, text))
|
||||
end
|
||||
|
||||
function log.debug(tag, msg, ...) emit("DEBUG", tag, msg, ...) end
|
||||
function log.info(tag, msg, ...) emit("INFO", tag, msg, ...) end
|
||||
function log.warn(tag, msg, ...) emit("WARN", tag, msg, ...) end
|
||||
function log.error(tag, msg, ...) emit("ERROR", tag, msg, ...) end
|
||||
|
||||
return log
|
||||
266
lib/recipeBook.lua
Normal file
266
lib/recipeBook.lua
Normal file
@@ -0,0 +1,266 @@
|
||||
-- lib/recipeBook.lua — Unified recipe database with learning support
|
||||
-- Loads built-in recipes from data files + user-learned recipes from disk.
|
||||
-- Compatible with Prominence II Hasturian Era modpack (or any modded setup).
|
||||
--
|
||||
-- Usage:
|
||||
-- local recipeBook = dofile("lib/recipeBook.lua")
|
||||
-- recipeBook.init(".recipes.db")
|
||||
-- recipeBook.loadLegacyCrafting(dofile("data/craftable.lua"))
|
||||
-- recipeBook.loadLegacySmelting(dofile("data/smeltable.lua"))
|
||||
-- recipeBook.learnCraftingRecipe("mod:item", 4, { ... })
|
||||
-- recipeBook.flush()
|
||||
|
||||
local recipeBook = {}
|
||||
|
||||
local recipes = {
|
||||
crafting = {}, -- keyed by output item name
|
||||
smelting = {}, -- keyed by input item name
|
||||
}
|
||||
local RECIPE_FILE = nil
|
||||
local dirty = false
|
||||
|
||||
-------------------------------------------------
|
||||
-- Initialization and persistence
|
||||
-------------------------------------------------
|
||||
|
||||
function recipeBook.init(recipeFile)
|
||||
RECIPE_FILE = recipeFile
|
||||
recipeBook.loadUserRecipes()
|
||||
end
|
||||
|
||||
--- Load legacy crafting recipes from data/craftable.lua format.
|
||||
-- Does not overwrite recipes already loaded (user-learned take priority).
|
||||
function recipeBook.loadLegacyCrafting(craftableArray)
|
||||
for _, recipe in ipairs(craftableArray) do
|
||||
if not recipes.crafting[recipe.output] then
|
||||
recipes.crafting[recipe.output] = {
|
||||
output = recipe.output,
|
||||
count = recipe.count,
|
||||
grid = recipe.grid,
|
||||
source = "builtin",
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Load legacy smelting recipes from data/smeltable.lua format.
|
||||
-- Does not overwrite recipes already loaded (user-learned take priority).
|
||||
function recipeBook.loadLegacySmelting(smeltableTable)
|
||||
for input, recipe in pairs(smeltableTable) do
|
||||
if not recipes.smelting[input] then
|
||||
recipes.smelting[input] = {
|
||||
input = input,
|
||||
result = recipe.result,
|
||||
furnaces = recipe.furnaces,
|
||||
source = "builtin",
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Load user-learned recipes from disk.
|
||||
function recipeBook.loadUserRecipes()
|
||||
if not RECIPE_FILE or not fs.exists(RECIPE_FILE) then return end
|
||||
pcall(function()
|
||||
local f = fs.open(RECIPE_FILE, "r")
|
||||
local raw = f.readAll()
|
||||
f.close()
|
||||
local data = textutils.unserialise(raw)
|
||||
if type(data) == "table" then
|
||||
for output, recipe in pairs(data.crafting or {}) do
|
||||
recipe.source = "learned"
|
||||
recipe.output = output
|
||||
recipes.crafting[output] = recipe
|
||||
end
|
||||
for input, recipe in pairs(data.smelting or {}) do
|
||||
recipe.source = "learned"
|
||||
recipe.input = input
|
||||
recipes.smelting[input] = recipe
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Save user-learned recipes to disk.
|
||||
function recipeBook.flush()
|
||||
if not dirty or not RECIPE_FILE then return end
|
||||
pcall(function()
|
||||
local uc, us = {}, {}
|
||||
for output, r in pairs(recipes.crafting) do
|
||||
if r.source == "learned" then
|
||||
uc[output] = { output = r.output, count = r.count, grid = r.grid }
|
||||
end
|
||||
end
|
||||
for input, r in pairs(recipes.smelting) do
|
||||
if r.source == "learned" then
|
||||
us[input] = { result = r.result, furnaces = r.furnaces }
|
||||
end
|
||||
end
|
||||
local f = fs.open(RECIPE_FILE, "w")
|
||||
f.write(textutils.serialise({ crafting = uc, smelting = us }))
|
||||
f.close()
|
||||
end)
|
||||
dirty = false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Learning / forgetting recipes
|
||||
-------------------------------------------------
|
||||
|
||||
--- Learn a new crafting recipe (or overwrite an existing one).
|
||||
-- @param output string — output item name (e.g. "minecraft:stick")
|
||||
-- @param count number — items produced per craft
|
||||
-- @param grid table — 9-entry grid array
|
||||
function recipeBook.learnCraftingRecipe(output, count, grid)
|
||||
recipes.crafting[output] = {
|
||||
output = output,
|
||||
count = count,
|
||||
grid = grid,
|
||||
source = "learned",
|
||||
}
|
||||
dirty = true
|
||||
end
|
||||
|
||||
--- Learn a new smelting recipe.
|
||||
-- @param input string — input item name
|
||||
-- @param result string — output item name
|
||||
-- @param furnaces table — array of furnace type strings (default: {"minecraft:furnace"})
|
||||
function recipeBook.learnSmeltingRecipe(input, result, furnaces)
|
||||
recipes.smelting[input] = {
|
||||
input = input,
|
||||
result = result,
|
||||
furnaces = furnaces or { "minecraft:furnace" },
|
||||
source = "learned",
|
||||
}
|
||||
dirty = true
|
||||
end
|
||||
|
||||
--- Forget a learned crafting recipe (built-in recipes cannot be forgotten).
|
||||
-- @return true if removed, false if recipe was built-in or not found
|
||||
function recipeBook.forgetCraftingRecipe(output)
|
||||
if recipes.crafting[output] and recipes.crafting[output].source == "learned" then
|
||||
recipes.crafting[output] = nil
|
||||
dirty = true
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Forget a learned smelting recipe.
|
||||
function recipeBook.forgetSmeltingRecipe(input)
|
||||
if recipes.smelting[input] and recipes.smelting[input].source == "learned" then
|
||||
recipes.smelting[input] = nil
|
||||
dirty = true
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Lookups
|
||||
-------------------------------------------------
|
||||
|
||||
--- Get a crafting recipe by output item name.
|
||||
function recipeBook.getCraftingRecipe(output)
|
||||
return recipes.crafting[output]
|
||||
end
|
||||
|
||||
--- Get a smelting recipe by input item name.
|
||||
function recipeBook.getSmeltingRecipe(input)
|
||||
return recipes.smelting[input]
|
||||
end
|
||||
|
||||
--- Find any recipe (crafting or smelting) that produces a given item.
|
||||
-- @return recipe, recipeType ("crafting" or "smelting") or nil
|
||||
function recipeBook.findRecipeFor(itemName)
|
||||
if recipes.crafting[itemName] then
|
||||
return recipes.crafting[itemName], "crafting"
|
||||
end
|
||||
for input, recipe in pairs(recipes.smelting) do
|
||||
if recipe.result == itemName then
|
||||
return recipe, "smelting"
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Backward-compatible accessors
|
||||
-------------------------------------------------
|
||||
|
||||
--- Get all crafting recipes as an indexed array (compat with cfg.CRAFTABLE).
|
||||
-- Sorted by output name.
|
||||
function recipeBook.getCraftingList()
|
||||
local list = {}
|
||||
for _, recipe in pairs(recipes.crafting) do
|
||||
table.insert(list, recipe)
|
||||
end
|
||||
table.sort(list, function(a, b) return a.output < b.output end)
|
||||
return list
|
||||
end
|
||||
|
||||
--- Get all smelting recipes as a keyed table (compat with cfg.SMELTABLE).
|
||||
function recipeBook.getSmeltingTable()
|
||||
local result = {}
|
||||
for input, recipe in pairs(recipes.smelting) do
|
||||
result[input] = recipe
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Utilities
|
||||
-------------------------------------------------
|
||||
|
||||
--- Get summed ingredients for a crafting recipe.
|
||||
-- @return table { [itemName] = count }
|
||||
function recipeBook.getIngredients(recipe)
|
||||
local ingredients = {}
|
||||
if recipe and recipe.grid then
|
||||
for _, item in ipairs(recipe.grid) do
|
||||
if item then
|
||||
ingredients[item] = (ingredients[item] or 0) + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
return ingredients
|
||||
end
|
||||
|
||||
--- Quick craftability check.
|
||||
function recipeBook.isCraftable(itemName)
|
||||
return recipes.crafting[itemName] ~= nil
|
||||
end
|
||||
|
||||
--- Quick smeltability check.
|
||||
function recipeBook.isSmeltable(itemName)
|
||||
return recipes.smelting[itemName] ~= nil
|
||||
end
|
||||
|
||||
--- Count total recipes.
|
||||
-- @return craftCount, smeltCount
|
||||
function recipeBook.count()
|
||||
local cc, sc = 0, 0
|
||||
for _ in pairs(recipes.crafting) do cc = cc + 1 end
|
||||
for _ in pairs(recipes.smelting) do sc = sc + 1 end
|
||||
return cc, sc
|
||||
end
|
||||
|
||||
--- Find all crafting recipes that use a given item as an ingredient.
|
||||
-- @param ingredientName string — the input item to search for
|
||||
-- @return array of recipe tables
|
||||
function recipeBook.findRecipesUsing(ingredientName)
|
||||
local results = {}
|
||||
for _, recipe in pairs(recipes.crafting) do
|
||||
if recipe.grid then
|
||||
for _, item in ipairs(recipe.grid) do
|
||||
if item == ingredientName then
|
||||
table.insert(results, recipe)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return results
|
||||
end
|
||||
|
||||
return recipeBook
|
||||
216
lib/ui.lua
Normal file
216
lib/ui.lua
Normal file
@@ -0,0 +1,216 @@
|
||||
-- lib/ui.lua — Shared UI helpers for manager & client
|
||||
-- Usage: local ui = dofile("lib/ui.lua")
|
||||
--
|
||||
-- Set ui.draw to the current render target (window/monitor)
|
||||
-- before calling any drawing functions.
|
||||
|
||||
local ui = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Drawing target — set this before drawing
|
||||
-------------------------------------------------
|
||||
|
||||
ui.draw = nil
|
||||
|
||||
-------------------------------------------------
|
||||
-- Drawing primitives
|
||||
-------------------------------------------------
|
||||
|
||||
function ui.monWrite(x, y, text, fg, bg)
|
||||
ui.draw.setCursorPos(x, y)
|
||||
if fg then ui.draw.setTextColor(fg) end
|
||||
if bg then ui.draw.setBackgroundColor(bg) end
|
||||
ui.draw.write(text)
|
||||
end
|
||||
|
||||
function ui.monFill(y, color)
|
||||
local w, _ = ui.draw.getSize()
|
||||
ui.draw.setCursorPos(1, y)
|
||||
ui.draw.setBackgroundColor(color)
|
||||
ui.draw.write(string.rep(" ", w))
|
||||
end
|
||||
|
||||
function ui.monCenter(y, text, fg, bg)
|
||||
local w, _ = ui.draw.getSize()
|
||||
local x = math.floor((w - #text) / 2) + 1
|
||||
ui.monWrite(x, y, text, fg, bg)
|
||||
end
|
||||
|
||||
function ui.monBar(x, y, width, ratio, barColor, bgColor)
|
||||
local filled = math.floor(ratio * width)
|
||||
ui.draw.setCursorPos(x, y)
|
||||
ui.draw.setBackgroundColor(barColor)
|
||||
ui.draw.write(string.rep(" ", filled))
|
||||
ui.draw.setBackgroundColor(bgColor)
|
||||
ui.draw.write(string.rep(" ", width - filled))
|
||||
end
|
||||
|
||||
function ui.drawButton(x, y, text, fg, bg, padLeft, padRight)
|
||||
padLeft = padLeft or 1
|
||||
padRight = padRight or 1
|
||||
local full = string.rep(" ", padLeft) .. text .. string.rep(" ", padRight)
|
||||
ui.monWrite(x, y, full, fg, bg)
|
||||
return x, y, x + #full - 1, y
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Touch zone management
|
||||
-------------------------------------------------
|
||||
|
||||
--- Append a clickable zone to a pending-zone list.
|
||||
-- @param zones table — the pending zone array to append to
|
||||
function ui.addZone(zones, x1, y1, x2, y2, action, data)
|
||||
table.insert(zones, {
|
||||
x1 = x1, y1 = y1, x2 = x2, y2 = y2,
|
||||
action = action, data = data,
|
||||
})
|
||||
end
|
||||
|
||||
--- Find which zone was hit.
|
||||
-- @param zones table — the active zone array to search
|
||||
function ui.hitTest(zones, x, y)
|
||||
for _, zone in ipairs(zones) do
|
||||
if x >= zone.x1 and x <= zone.x2 and y >= zone.y1 and y <= zone.y2 then
|
||||
return zone.action, zone.data
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Monitor setup
|
||||
-------------------------------------------------
|
||||
|
||||
--- Wrap and configure the main inventory monitor.
|
||||
-- @param side string — preferred peripheral side/name
|
||||
-- @param excludeSide string — side to skip (e.g. smelter monitor)
|
||||
-- @return mon, monName or nil, nil
|
||||
function ui.setupMonitor(side, excludeSide)
|
||||
local mon = peripheral.wrap(side)
|
||||
local monName
|
||||
if mon and mon.setTextScale then
|
||||
monName = side
|
||||
else
|
||||
mon = nil
|
||||
end
|
||||
if not mon then
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "monitor" and name ~= excludeSide then
|
||||
mon = peripheral.wrap(name)
|
||||
monName = name
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not mon then return nil, nil end
|
||||
mon.setTextScale(0.5)
|
||||
mon.clear()
|
||||
return mon, monName
|
||||
end
|
||||
|
||||
--- Wrap and configure the smelter monitor.
|
||||
-- @param side string — preferred peripheral side/name
|
||||
-- @param excludeName string — main monitor name to skip
|
||||
-- @return smelterMon, smelterMonName or nil, nil
|
||||
function ui.setupSmelterMonitor(side, excludeName)
|
||||
local smelterMon = peripheral.wrap(side)
|
||||
local smelterMonName
|
||||
if smelterMon and smelterMon.setTextScale then
|
||||
smelterMonName = side
|
||||
else
|
||||
smelterMon = nil
|
||||
end
|
||||
if not smelterMon then
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "monitor" and name ~= excludeName then
|
||||
smelterMon = peripheral.wrap(name)
|
||||
smelterMonName = name
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not smelterMon then return nil, nil end
|
||||
smelterMon.setTextScale(0.5)
|
||||
smelterMon.clear()
|
||||
return smelterMon, smelterMonName
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Item filtering
|
||||
-------------------------------------------------
|
||||
|
||||
--- Filter an item list by search query.
|
||||
-- @param itemList table — array of { name, total }
|
||||
-- @param searchQuery string — filter text (case-insensitive, plain match)
|
||||
-- @return filtered table — matching subset
|
||||
function ui.getFilteredItems(itemList, searchQuery)
|
||||
local filtered = {}
|
||||
for _, item in ipairs(itemList) do
|
||||
if searchQuery == "" then
|
||||
table.insert(filtered, item)
|
||||
else
|
||||
local lower = item.name:lower():gsub("minecraft:", ""):gsub("_", " ")
|
||||
if lower:find(searchQuery:lower(), 1, true) then
|
||||
table.insert(filtered, item)
|
||||
end
|
||||
end
|
||||
end
|
||||
return filtered
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Crafting recipe helpers
|
||||
-------------------------------------------------
|
||||
|
||||
--- Count ingredients needed for one craft.
|
||||
function ui.getRecipeIngredients(recipe)
|
||||
local ingredients = {}
|
||||
for _, item in ipairs(recipe.grid) do
|
||||
if item then
|
||||
ingredients[item] = (ingredients[item] or 0) + 1
|
||||
end
|
||||
end
|
||||
return ingredients
|
||||
end
|
||||
|
||||
--- Check if a recipe can be crafted with current stock.
|
||||
-- @param recipe table — recipe with .grid
|
||||
-- @param getTotal function(itemName) -> number — returns stock count
|
||||
function ui.canCraftRecipe(recipe, getTotal)
|
||||
local ingredients = ui.getRecipeIngredients(recipe)
|
||||
for itemName, needed in pairs(ingredients) do
|
||||
if (getTotal(itemName) or 0) < needed then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
--- How many batches can be crafted.
|
||||
-- @param recipe table — recipe with .grid
|
||||
-- @param getTotal function(itemName) -> number
|
||||
function ui.maxCraftBatches(recipe, getTotal)
|
||||
local ingredients = ui.getRecipeIngredients(recipe)
|
||||
local minBatches = math.huge
|
||||
for itemName, needed in pairs(ingredients) do
|
||||
local batches = math.floor((getTotal(itemName) or 0) / needed)
|
||||
if batches < minBatches then minBatches = batches end
|
||||
end
|
||||
if minBatches == math.huge then return 0 end
|
||||
return minBatches
|
||||
end
|
||||
|
||||
--- Get list of ingredients where stock < needed.
|
||||
-- @param recipe table — recipe with .grid
|
||||
-- @param getTotal function(itemName) -> number
|
||||
function ui.getMissingIngredients(recipe, getTotal)
|
||||
local ingredients = ui.getRecipeIngredients(recipe)
|
||||
local missing = {}
|
||||
for itemName, needed in pairs(ingredients) do
|
||||
local have = getTotal(itemName) or 0
|
||||
if have < needed then
|
||||
table.insert(missing, { name = itemName, have = have, need = needed })
|
||||
end
|
||||
end
|
||||
return missing
|
||||
end
|
||||
|
||||
return ui
|
||||
233
manager/config.lua
Normal file
233
manager/config.lua
Normal file
@@ -0,0 +1,233 @@
|
||||
-- manager/config.lua — Configuration constants, data tables, and lookup structures
|
||||
-- Usage: local cfg = dofile(_path("manager/config.lua"))(log, _path)
|
||||
|
||||
return function(log, _path)
|
||||
|
||||
-- Fall back to CWD-relative if _path not provided (standalone use)
|
||||
if not _path then _path = function(p) return p end end
|
||||
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local C = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Default configuration (overridden by .manager_config)
|
||||
-------------------------------------------------
|
||||
|
||||
C.DROPPER_NAME = "minecraft:dropper_9"
|
||||
C.BARREL_NAME = "minecraft:barrel_0"
|
||||
C.POLL_INTERVAL = 2
|
||||
C.MONITOR_SIDE = "left"
|
||||
C.SCAN_INTERVAL = 120
|
||||
C.SMELT_INTERVAL = 3
|
||||
C.SMELT_RESERVE = 128
|
||||
C.DEFRAG_INTERVAL = 600
|
||||
C.COMPOST_INTERVAL = 3
|
||||
C.ALERT_INTERVAL = 15
|
||||
C.CACHE_FILE = _configPath(".inventory_cache")
|
||||
C.SMELTER_MONITOR_SIDE = "top"
|
||||
C.BILLBOARD_MONITOR = "" -- network name e.g. "monitor_0"; auto-detects if empty
|
||||
C.BILLBOARD_TOP_ITEMS = 20 -- max items in billboard bar chart
|
||||
C.BILLBOARD_TEXT_SCALE = 1 -- 0.5 = tiny, 1 = normal, 2 = large, 5 = huge
|
||||
C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes")
|
||||
|
||||
-- Network
|
||||
C.BROADCAST_CHANNEL = 4200
|
||||
C.ORDER_CHANNEL = 4201
|
||||
C.BROADCAST_INTERVAL = 1
|
||||
C.CRAFT_CHANNEL = 4203
|
||||
C.CRAFT_REPLY_CHANNEL = 4204
|
||||
C.SYSTEM_CHANNEL = 4205
|
||||
|
||||
-- Crafting
|
||||
C.CRAFT_TIMEOUT = 15
|
||||
C.GRID_TO_SLOT = {1, 2, 3, 5, 6, 7, 9, 10, 11}
|
||||
|
||||
-- Compost (overridable via config file)
|
||||
C.COMPOST_RESERVE = 128
|
||||
C.COMPOST_DROPPER = "minecraft:dropper_10"
|
||||
C.COMPOST_HOPPER = "minecraft:hopper_0"
|
||||
|
||||
-- Collection hoppers: periodically emptied into storage (e.g. egg spawner, mob farm)
|
||||
C.COLLECTION_HOPPERS = { "minecraft:hopper_1" }
|
||||
C.COLLECTION_INTERVAL = 3 -- seconds between hopper collection
|
||||
|
||||
-- Stock limits / auto-discard (overridable via config file)
|
||||
C.TRASH_DROPPERS = { -- droppers facing lava/void for destroying excess items
|
||||
"minecraft:dropper_11",
|
||||
"minecraft:dropper_12",
|
||||
"minecraft:dropper_13",
|
||||
"minecraft:dropper_14",
|
||||
"minecraft:dropper_15",
|
||||
"minecraft:dropper_16",
|
||||
}
|
||||
C.DISCARD_INTERVAL = 5 -- seconds between discard checks
|
||||
|
||||
-- Auto-craft (overridable via config file)
|
||||
C.AUTO_CRAFT_INTERVAL = 10 -- seconds between auto-craft checks
|
||||
C.AUTO_CRAFT_OUTPUT_CAP = 512 -- max items of any output before smart-craft stops crafting it
|
||||
C.AUTO_CRAFT_FROM_EXCESS = true -- auto-discover recipes for over-stocked items
|
||||
|
||||
-- Peripheral
|
||||
C.PERIPHERAL_CACHE_TTL = 5
|
||||
|
||||
-- Parallel scanning
|
||||
C.PARALLEL_SCAN_CHUNKS = 8
|
||||
|
||||
-- Storage priority (higher = preferred; keyed by chest peripheral name)
|
||||
C.CHEST_PRIORITY = {}
|
||||
|
||||
-- Builder / supply manifest
|
||||
C.SUPPLY_CHEST = "" -- peripheral name of supply chest (empty = disabled)
|
||||
C.SUPPLY_INTERVAL = 10 -- seconds between supply checks
|
||||
C.SUPPLY_MANIFEST = {} -- { { name = "mod:item", count = N }, ... }
|
||||
|
||||
-- Furnace types
|
||||
C.FURNACE_TYPES = {
|
||||
"minecraft:furnace",
|
||||
"minecraft:smoker",
|
||||
"minecraft:blast_furnace",
|
||||
}
|
||||
C.SLOT_INPUT = 1
|
||||
C.SLOT_FUEL = 2
|
||||
C.SLOT_OUTPUT = 3
|
||||
|
||||
-------------------------------------------------
|
||||
-- Config file loader
|
||||
-------------------------------------------------
|
||||
|
||||
local CONFIG_FILE = _configPath(".manager_config")
|
||||
|
||||
function C.loadConfig()
|
||||
if not fs.exists(CONFIG_FILE) then return end
|
||||
local f = fs.open(CONFIG_FILE, "r")
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, data)
|
||||
if not ok or not cfg then
|
||||
log.warn("CONFIG", "Failed to parse %s", CONFIG_FILE)
|
||||
return
|
||||
end
|
||||
C._raw = cfg
|
||||
if cfg.dropperName then C.DROPPER_NAME = cfg.dropperName end
|
||||
if cfg.barrelName then C.BARREL_NAME = cfg.barrelName end
|
||||
if cfg.monitorSide then C.MONITOR_SIDE = cfg.monitorSide end
|
||||
if cfg.smelterMonitorSide then C.SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end
|
||||
if cfg.billboardMonitor then C.BILLBOARD_MONITOR = cfg.billboardMonitor end
|
||||
if cfg.billboardTopItems then C.BILLBOARD_TOP_ITEMS = cfg.billboardTopItems end
|
||||
if cfg.billboardTextScale then C.BILLBOARD_TEXT_SCALE = cfg.billboardTextScale end
|
||||
if cfg.pollInterval then C.POLL_INTERVAL = cfg.pollInterval end
|
||||
if cfg.scanInterval then C.SCAN_INTERVAL = cfg.scanInterval end
|
||||
if cfg.smeltInterval then C.SMELT_INTERVAL = cfg.smeltInterval end
|
||||
if cfg.defragInterval then C.DEFRAG_INTERVAL = cfg.defragInterval end
|
||||
if cfg.compostInterval then C.COMPOST_INTERVAL = cfg.compostInterval end
|
||||
if cfg.alertInterval then C.ALERT_INTERVAL = cfg.alertInterval end
|
||||
if cfg.broadcastInterval then C.BROADCAST_INTERVAL = cfg.broadcastInterval end
|
||||
if cfg.smeltReserve then C.SMELT_RESERVE = cfg.smeltReserve end
|
||||
if cfg.broadcastChannel then C.BROADCAST_CHANNEL = cfg.broadcastChannel end
|
||||
if cfg.orderChannel then C.ORDER_CHANNEL = cfg.orderChannel end
|
||||
if cfg.craftChannel then C.CRAFT_CHANNEL = cfg.craftChannel end
|
||||
if cfg.craftReplyChannel then C.CRAFT_REPLY_CHANNEL = cfg.craftReplyChannel end
|
||||
if cfg.compostReserve then C.COMPOST_RESERVE = cfg.compostReserve end
|
||||
if cfg.compostDropper then C.COMPOST_DROPPER = cfg.compostDropper end
|
||||
if cfg.compostHopper then C.COMPOST_HOPPER = cfg.compostHopper end
|
||||
if cfg.collectionHoppers then C.COLLECTION_HOPPERS = cfg.collectionHoppers end
|
||||
if cfg.collectionInterval then C.COLLECTION_INTERVAL = cfg.collectionInterval end
|
||||
if cfg.trashDroppers then C.TRASH_DROPPERS = cfg.trashDroppers end
|
||||
if cfg.discardInterval then C.DISCARD_INTERVAL = cfg.discardInterval end
|
||||
if cfg.autoCraftInterval then C.AUTO_CRAFT_INTERVAL = cfg.autoCraftInterval end
|
||||
if cfg.autoCraftOutputCap then C.AUTO_CRAFT_OUTPUT_CAP = cfg.autoCraftOutputCap end
|
||||
if cfg.autoCraftFromExcess ~= nil then C.AUTO_CRAFT_FROM_EXCESS = cfg.autoCraftFromExcess end
|
||||
if cfg.stockLimits then
|
||||
for item, limit in pairs(cfg.stockLimits) do
|
||||
C.STOCK_LIMITS[item] = limit -- merge / override per-item
|
||||
end
|
||||
end
|
||||
if cfg.parallelScanChunks then C.PARALLEL_SCAN_CHUNKS = cfg.parallelScanChunks end
|
||||
if cfg.chestPriority then C.CHEST_PRIORITY = cfg.chestPriority end
|
||||
if cfg.supplyChest then C.SUPPLY_CHEST = cfg.supplyChest end
|
||||
if cfg.supplyInterval then C.SUPPLY_INTERVAL = cfg.supplyInterval end
|
||||
if cfg.supplyManifest then C.SUPPLY_MANIFEST = cfg.supplyManifest end
|
||||
if cfg.logLevel then log.setLevel(cfg.logLevel) end
|
||||
log.info("CONFIG", "Loaded from %s", CONFIG_FILE)
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Data tables
|
||||
-------------------------------------------------
|
||||
|
||||
C.FUEL_LIST = dofile(_path("data/fuel.lua"))
|
||||
local _compostData = dofile(_path("data/compostable.lua"))
|
||||
C.COMPOSTABLE = _compostData.items
|
||||
C.COMPOST_TRASH = _compostData.trash
|
||||
C.STOCK_LIMITS = dofile(_path("data/stock_limits.lua"))
|
||||
C.AUTO_CRAFT_RULES = dofile(_path("data/auto_craft.lua"))
|
||||
C.LOW_STOCK_ALERTS = dofile(_path("data/alerts.lua"))
|
||||
|
||||
-- Recipe book: merges built-in recipes + user-learned recipes
|
||||
local recipeBook = dofile(_path("lib/recipeBook.lua"))
|
||||
recipeBook.init(_configPath(".recipes.db"))
|
||||
recipeBook.loadLegacyCrafting(dofile(_path("data/craftable.lua")))
|
||||
recipeBook.loadLegacySmelting(dofile(_path("data/smeltable.lua")))
|
||||
C.recipeBook = recipeBook
|
||||
C.SMELTABLE = recipeBook.getSmeltingTable()
|
||||
C.CRAFTABLE = recipeBook.getCraftingList()
|
||||
|
||||
-- Rebuild furnace/smelt indices from current recipe data
|
||||
function C.rebuildIndices()
|
||||
for _, recipe in pairs(C.SMELTABLE) do
|
||||
recipe.furnaceSet = {}
|
||||
for _, ft in ipairs(recipe.furnaces) do
|
||||
recipe.furnaceSet[ft] = true
|
||||
end
|
||||
end
|
||||
C.smeltCandidatesByType = {}
|
||||
for _, ftype in ipairs(C.FURNACE_TYPES) do
|
||||
C.smeltCandidatesByType[ftype] = {}
|
||||
end
|
||||
for itemName, recipe in pairs(C.SMELTABLE) do
|
||||
local isFood = recipe.furnaceSet["minecraft:smoker"] or false
|
||||
for ft in pairs(recipe.furnaceSet) do
|
||||
table.insert(C.smeltCandidatesByType[ft], { name = itemName, recipe = recipe, food = isFood })
|
||||
end
|
||||
end
|
||||
for _, list in pairs(C.smeltCandidatesByType) do
|
||||
table.sort(list, function(a, b)
|
||||
if a.food ~= b.food then return a.food end
|
||||
return a.name < b.name
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- Refresh recipes from recipeBook and rebuild all indices
|
||||
function C.refreshRecipes()
|
||||
C.SMELTABLE = C.recipeBook.getSmeltingTable()
|
||||
C.CRAFTABLE = C.recipeBook.getCraftingList()
|
||||
C.rebuildIndices()
|
||||
end
|
||||
|
||||
C.rebuildIndices()
|
||||
|
||||
-- Build fuel set for quick lookup
|
||||
C.FUEL_SET = {}
|
||||
for _, f in ipairs(C.FUEL_LIST) do C.FUEL_SET[f.name] = true end
|
||||
|
||||
-- Build compostable set for quick lookup
|
||||
C.COMPOSTABLE_SET = {}
|
||||
for _, name in ipairs(C.COMPOSTABLE) do C.COMPOSTABLE_SET[name] = true end
|
||||
|
||||
-- Build stock limits set for quick lookup
|
||||
C.STOCK_LIMITS_SET = {}
|
||||
for name, _ in pairs(C.STOCK_LIMITS) do C.STOCK_LIMITS_SET[name] = true end
|
||||
|
||||
return C
|
||||
|
||||
end
|
||||
2011
manager/display.lua
Normal file
2011
manager/display.lua
Normal file
File diff suppressed because it is too large
Load Diff
998
manager/display.lua.bak
Normal file
998
manager/display.lua.bak
Normal file
@@ -0,0 +1,998 @@
|
||||
-- manager/display.lua — Dashboard rendering and touch handlers
|
||||
-- Usage: local display = dofile("manager/display.lua")(ctx)
|
||||
|
||||
return function(ctx)
|
||||
|
||||
local cfg = ctx.cfg
|
||||
local state = ctx.state
|
||||
local log = ctx.log
|
||||
local ui = ctx.ui
|
||||
local ops = ctx.ops
|
||||
|
||||
local cache = state.cache
|
||||
local activity = state.activity
|
||||
|
||||
local D = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Monitor handles (set during init)
|
||||
-------------------------------------------------
|
||||
|
||||
D.mon = nil
|
||||
D.monName = nil
|
||||
D.smelterMon = nil
|
||||
D.smelterMonName = nil
|
||||
|
||||
function D.setupMonitor()
|
||||
D.mon, D.monName = ui.setupMonitor(cfg.MONITOR_SIDE, cfg.SMELTER_MONITOR_SIDE)
|
||||
return D.mon ~= nil
|
||||
end
|
||||
|
||||
function D.setupSmelterMonitor()
|
||||
D.smelterMon, D.smelterMonName = ui.setupSmelterMonitor(cfg.SMELTER_MONITOR_SIDE, D.monName)
|
||||
return D.smelterMon ~= nil
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Main dashboard UI state
|
||||
-------------------------------------------------
|
||||
|
||||
local selectedAmount = 1
|
||||
local amountOptions = {1, 4, 8, 16, 32, 64}
|
||||
|
||||
local touchZones = {}
|
||||
local pendingZones = {}
|
||||
|
||||
local currentPage = 1
|
||||
local totalPages = 1
|
||||
local searchQuery = ""
|
||||
local showKeyboard = false
|
||||
|
||||
local kbRows = {
|
||||
{"Q","W","E","R","T","Y","U","I","O","P"},
|
||||
{"A","S","D","F","G","H","J","K","L"},
|
||||
{"Z","X","C","V","B","N","M"},
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Smelter dashboard UI state
|
||||
-------------------------------------------------
|
||||
|
||||
local smelterView = "status"
|
||||
local smelterPage = 1
|
||||
local smelterTotalPages = 1
|
||||
local smelterTouchZones = {}
|
||||
local smelterPendingZones = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Drawing helpers (delegated to shared ui module)
|
||||
-------------------------------------------------
|
||||
|
||||
local draw = nil
|
||||
|
||||
local function setDrawTarget(target)
|
||||
draw = target
|
||||
ui.draw = target
|
||||
end
|
||||
|
||||
local function monWrite(x, y, text, fg, bg) ui.monWrite(x, y, text, fg, bg) end
|
||||
local function monFill(y, color) ui.monFill(y, color) end
|
||||
local function monCenter(y, text, fg, bg) ui.monCenter(y, text, fg, bg) end
|
||||
local function monBar(x, y, w, r, bc, bgc) ui.monBar(x, y, w, r, bc, bgc) end
|
||||
local function drawButton(x, y, t, fg, bg, pl, pr) return ui.drawButton(x, y, t, fg, bg, pl, pr) end
|
||||
|
||||
local function addZone(x1, y1, x2, y2, action, data)
|
||||
ui.addZone(pendingZones, x1, y1, x2, y2, action, data)
|
||||
end
|
||||
|
||||
local function hitTest(x, y)
|
||||
return ui.hitTest(touchZones, x, y)
|
||||
end
|
||||
|
||||
local function addSmelterZone(x1, y1, x2, y2, action, data)
|
||||
ui.addZone(smelterPendingZones, x1, y1, x2, y2, action, data)
|
||||
end
|
||||
|
||||
local function smelterHitTest(x, y)
|
||||
return ui.hitTest(smelterTouchZones, x, y)
|
||||
end
|
||||
|
||||
local function getFilteredItems()
|
||||
state.ensureItemList()
|
||||
return ui.getFilteredItems(cache.itemList, searchQuery)
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Main dashboard drawing
|
||||
-------------------------------------------------
|
||||
|
||||
function D.drawDashboard()
|
||||
if not D.mon then return end
|
||||
|
||||
local w, h = D.mon.getSize()
|
||||
pendingZones = {}
|
||||
|
||||
setDrawTarget(window.create(D.mon, 1, 1, w, h, false))
|
||||
draw.setBackgroundColor(colors.black)
|
||||
draw.clear()
|
||||
|
||||
-- Title bar
|
||||
monFill(1, colors.blue)
|
||||
monCenter(1, " ** INVENTORY MANAGER ** ", colors.white, colors.blue)
|
||||
|
||||
-- Status bar
|
||||
monFill(2, colors.gray)
|
||||
local statusParts = {}
|
||||
table.insert(statusParts, string.format(" Chests: %d", cache.chestCount))
|
||||
table.insert(statusParts, cache.dropperOk and "Dropper: OK" or "Dropper: --")
|
||||
table.insert(statusParts, cache.barrelOk and "Barrel: OK" or "Barrel: --")
|
||||
if cache.furnaceCount and cache.furnaceCount > 0 then
|
||||
table.insert(statusParts, string.format("Furnaces: %d", cache.furnaceCount))
|
||||
end
|
||||
|
||||
local actParts = {}
|
||||
if activity.sorting then table.insert(actParts, "SORTING") end
|
||||
if activity.dispensing then table.insert(actParts, "DISPENSING") end
|
||||
if activity.smelting then table.insert(actParts, "SMELTING") end
|
||||
if activity.scanning then table.insert(actParts, "SCANNING") end
|
||||
if activity.defragging then table.insert(actParts, "DEFRAG") end
|
||||
if activity.composting then table.insert(actParts, "COMPOST") end
|
||||
|
||||
monWrite(2, 2, table.concat(statusParts, " | "), colors.white, colors.gray)
|
||||
|
||||
if #actParts > 0 then
|
||||
local actStr = " " .. table.concat(actParts, " | ") .. " "
|
||||
monWrite(w - #actStr, 2, actStr, colors.white, colors.orange)
|
||||
end
|
||||
|
||||
-- Divider
|
||||
monFill(3, colors.lightBlue)
|
||||
monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.cyan, colors.lightBlue)
|
||||
|
||||
-- Storage capacity
|
||||
monFill(4, colors.black)
|
||||
local capLabel = string.format(" Storage: %d/%d slots (%d free)",
|
||||
cache.usedSlots, cache.totalSlots, cache.freeSlots)
|
||||
monWrite(2, 4, capLabel, colors.lightGray, colors.black)
|
||||
|
||||
local barStart = #capLabel + 4
|
||||
local barWidth = w - barStart - 2
|
||||
if barWidth > 4 then
|
||||
local barColor = colors.lime
|
||||
if cache.usedRatio > 0.9 then barColor = colors.red
|
||||
elseif cache.usedRatio > 0.7 then barColor = colors.orange
|
||||
elseif cache.usedRatio > 0.5 then barColor = colors.yellow
|
||||
end
|
||||
monBar(barStart, 4, barWidth, cache.usedRatio, barColor, colors.gray)
|
||||
local pctStr = string.format(" %d%% ", math.floor(cache.usedRatio * 100))
|
||||
local pctX = barStart + math.floor(barWidth / 2) - math.floor(#pctStr / 2)
|
||||
monWrite(pctX, 4, pctStr, colors.white, barColor)
|
||||
end
|
||||
|
||||
-- Amount selector (row 5)
|
||||
monFill(5, colors.black)
|
||||
monWrite(2, 5, "Qty:", colors.lightGray, colors.black)
|
||||
local btnX = 7
|
||||
for _, amt in ipairs(amountOptions) do
|
||||
local label = tostring(amt)
|
||||
local bg = (amt == selectedAmount) and colors.cyan or colors.gray
|
||||
local fg = (amt == selectedAmount) and colors.white or colors.lightGray
|
||||
local x1, y1, x2, y2 = drawButton(btnX, 5, label, fg, bg)
|
||||
addZone(x1, y1, x2, y2, "amount", amt)
|
||||
btnX = x2 + 2
|
||||
end
|
||||
|
||||
local refreshBg = activity.scanning and colors.yellow or colors.green
|
||||
local refreshFg = activity.scanning and colors.black or colors.white
|
||||
local refreshTxt = activity.scanning and "Scanning" or "Refresh"
|
||||
local scanX = w - #refreshTxt - 3
|
||||
local sx1, sy1, sx2, sy2 = drawButton(scanX, 5, refreshTxt, refreshFg, refreshBg, 1, 1)
|
||||
addZone(sx1, sy1, sx2, sy2, "scan", nil)
|
||||
|
||||
-- Search bar + Pagination (row 6)
|
||||
monFill(6, colors.black)
|
||||
|
||||
local kbLabel = showKeyboard and " X " or " ? "
|
||||
local kbBg = showKeyboard and colors.red or colors.purple
|
||||
monWrite(2, 6, kbLabel, colors.white, kbBg)
|
||||
addZone(2, 6, 4, 6, "kb_toggle", nil)
|
||||
|
||||
local queryDisplay = searchQuery
|
||||
if showKeyboard then
|
||||
queryDisplay = queryDisplay .. "|"
|
||||
elseif queryDisplay == "" then
|
||||
queryDisplay = "search..."
|
||||
end
|
||||
local fieldW = math.floor(w * 0.4)
|
||||
if fieldW < 10 then fieldW = 10 end
|
||||
local displayText = queryDisplay:sub(1, fieldW)
|
||||
displayText = displayText .. string.rep("_", math.max(0, fieldW - #displayText))
|
||||
monWrite(6, 6, displayText,
|
||||
(searchQuery == "" and not showKeyboard) and colors.gray or colors.white,
|
||||
colors.black)
|
||||
addZone(6, 6, 5 + fieldW, 6, "kb_toggle", nil)
|
||||
|
||||
local filteredItems = getFilteredItems()
|
||||
|
||||
local maxRows = h - 10
|
||||
if maxRows < 1 then maxRows = 1 end
|
||||
totalPages = math.max(1, math.ceil(#filteredItems / maxRows))
|
||||
if currentPage > totalPages then currentPage = totalPages end
|
||||
if currentPage < 1 then currentPage = 1 end
|
||||
|
||||
local pageStr = string.format("Pg %d/%d", currentPage, totalPages)
|
||||
local navW = 3 + 1 + #pageStr + 1 + 3
|
||||
local navX = w - navW
|
||||
|
||||
if currentPage > 1 then
|
||||
monWrite(navX, 6, " < ", colors.white, colors.gray)
|
||||
addZone(navX, 6, navX + 2, 6, "page_prev", nil)
|
||||
else
|
||||
monWrite(navX, 6, " < ", colors.lightGray, colors.black)
|
||||
end
|
||||
|
||||
monWrite(navX + 4, 6, pageStr, colors.lightGray, colors.black)
|
||||
|
||||
local nextX = navX + 4 + #pageStr + 1
|
||||
if currentPage < totalPages then
|
||||
monWrite(nextX, 6, " > ", colors.white, colors.gray)
|
||||
addZone(nextX, 6, nextX + 2, 6, "page_next", nil)
|
||||
else
|
||||
monWrite(nextX, 6, " > ", colors.lightGray, colors.black)
|
||||
end
|
||||
|
||||
-- Column headers (row 7)
|
||||
local row = 7
|
||||
monFill(row, colors.gray)
|
||||
monWrite(2, row, "#", colors.lightGray, colors.gray)
|
||||
monWrite(5, row, "Item", colors.lightGray, colors.gray)
|
||||
monWrite(w - 22, row, "Qty", colors.lightGray, colors.gray)
|
||||
monWrite(w - 14, row, "Stock", colors.lightGray, colors.gray)
|
||||
monWrite(w - 1, row, ">", colors.lightGray, colors.gray)
|
||||
row = row + 1
|
||||
|
||||
-- Item rows
|
||||
local maxCount = 0
|
||||
for _, item in ipairs(filteredItems) do
|
||||
if item.total > maxCount then maxCount = item.total end
|
||||
end
|
||||
if maxCount == 0 then maxCount = 1 end
|
||||
|
||||
local startIdx = (currentPage - 1) * maxRows + 1
|
||||
local endIdx = math.min(startIdx + maxRows - 1, #filteredItems)
|
||||
|
||||
if #filteredItems == 0 then
|
||||
monFill(8, colors.black)
|
||||
monFill(9, colors.black)
|
||||
if searchQuery ~= "" then
|
||||
monCenter(9, "No items match \"" .. searchQuery .. "\"", colors.gray, colors.black)
|
||||
else
|
||||
monCenter(9, "No items in storage", colors.gray, colors.black)
|
||||
end
|
||||
row = 10
|
||||
else
|
||||
for i = startIdx, endIdx do
|
||||
local item = filteredItems[i]
|
||||
local y = row
|
||||
local short = item.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
short = short:sub(1,1):upper() .. short:sub(2)
|
||||
|
||||
local maxNameLen = w - 30
|
||||
if #short > maxNameLen then
|
||||
short = short:sub(1, maxNameLen - 2) .. ".."
|
||||
end
|
||||
|
||||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||||
monFill(y, rowBg)
|
||||
|
||||
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
|
||||
monWrite(5, y, short, colors.white, rowBg)
|
||||
monWrite(w - 22, y, tostring(item.total), colors.yellow, rowBg)
|
||||
|
||||
local ratio = item.total / maxCount
|
||||
local barColor = colors.lime
|
||||
if ratio < 0.25 then barColor = colors.red
|
||||
elseif ratio < 0.5 then barColor = colors.orange
|
||||
end
|
||||
monBar(w - 14, y, 12, ratio, barColor, rowBg == colors.gray and colors.lightGray or colors.gray)
|
||||
|
||||
monWrite(w - 1, y, ">", colors.orange, rowBg)
|
||||
addZone(1, y, w, y, "order", item.name)
|
||||
|
||||
row = row + 1
|
||||
end
|
||||
end
|
||||
|
||||
local lastItemRow = h - 3
|
||||
while row <= lastItemRow do
|
||||
monFill(row, colors.black)
|
||||
row = row + 1
|
||||
end
|
||||
|
||||
if showKeyboard then
|
||||
local keyW = 3
|
||||
local keyGap = 1
|
||||
|
||||
local kbDefs = {
|
||||
{ keys = kbRows[1], specials = {{ label = " Bksp ", action = "kb_bksp", bg = colors.red }} },
|
||||
{ keys = kbRows[2], specials = {{ label = " Done ", action = "kb_done", bg = colors.green }} },
|
||||
{ keys = kbRows[3], specials = {
|
||||
{ label = " Space ", action = "kb_space", bg = colors.lightGray },
|
||||
{ label = " Clr ", action = "kb_clear", bg = colors.orange },
|
||||
}},
|
||||
}
|
||||
|
||||
for rowIdx, def in ipairs(kbDefs) do
|
||||
local y = h - 3 + rowIdx
|
||||
monFill(y, colors.black)
|
||||
|
||||
local keysW = #def.keys * keyW + math.max(0, #def.keys - 1) * keyGap
|
||||
local specialsW = 0
|
||||
for _, sp in ipairs(def.specials) do
|
||||
specialsW = specialsW + keyGap + #sp.label
|
||||
end
|
||||
local rowW = keysW + specialsW
|
||||
local x = math.floor((w - rowW) / 2) + 1
|
||||
|
||||
for ki, key in ipairs(def.keys) do
|
||||
monWrite(x, y, " " .. key .. " ", colors.white, colors.gray)
|
||||
addZone(x, y, x + keyW - 1, y, "kb_key", key:lower())
|
||||
x = x + keyW
|
||||
if ki < #def.keys then x = x + keyGap end
|
||||
end
|
||||
|
||||
for _, sp in ipairs(def.specials) do
|
||||
x = x + keyGap
|
||||
monWrite(x, y, sp.label, colors.white, sp.bg)
|
||||
addZone(x, y, x + #sp.label - 1, y, sp.action, nil)
|
||||
x = x + #sp.label
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Status message
|
||||
monFill(h - 2, colors.black)
|
||||
if #state.activeAlerts > 0 then
|
||||
local alertIdx = math.floor(os.epoch("utc") / 2000) % #state.activeAlerts + 1
|
||||
local a = state.activeAlerts[alertIdx]
|
||||
local alertMsg = string.format(" LOW STOCK: %s (%d/%d) ", a.label, a.current, a.min)
|
||||
monCenter(h - 2, alertMsg, colors.white, colors.red)
|
||||
elseif state.statusTimer > 0 and #state.statusMessage > 0 then
|
||||
monCenter(h - 2, state.statusMessage, state.statusColor, colors.black)
|
||||
end
|
||||
|
||||
-- Footer
|
||||
state.ensureItemList()
|
||||
monFill(h - 1, colors.gray)
|
||||
local footerLeft = string.format(" Total: %d items | %d types ",
|
||||
cache.grandTotal, #cache.itemList)
|
||||
monWrite(2, h - 1, footerLeft, colors.white, colors.gray)
|
||||
|
||||
if searchQuery ~= "" then
|
||||
local filterNote = string.format("| Showing %d ", #filteredItems)
|
||||
monWrite(2 + #footerLeft + 1, h - 1, filterNote, colors.yellow, colors.gray)
|
||||
end
|
||||
|
||||
local timeStr = textutils.formatTime(os.time(), true)
|
||||
monWrite(w - #timeStr - 1, h - 1, timeStr, colors.lightGray, colors.gray)
|
||||
|
||||
-- Bottom accent
|
||||
monFill(h, colors.blue)
|
||||
local bottomMsg = " Tap item to order "
|
||||
if activity.dispensing then
|
||||
bottomMsg = " DISPENSING... "
|
||||
elseif activity.smelting then
|
||||
bottomMsg = " SMELTING... "
|
||||
elseif activity.sorting then
|
||||
bottomMsg = " SORTING BARREL... "
|
||||
elseif activity.defragging then
|
||||
bottomMsg = " DEFRAGMENTING... "
|
||||
elseif activity.composting then
|
||||
bottomMsg = " COMPOSTING... "
|
||||
end
|
||||
monCenter(h, bottomMsg, colors.lightBlue, colors.blue)
|
||||
end
|
||||
|
||||
draw.setVisible(true)
|
||||
touchZones = pendingZones
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Smelter dashboard drawing
|
||||
-------------------------------------------------
|
||||
|
||||
function D.drawSmelterDashboard()
|
||||
if not D.smelterMon then return end
|
||||
|
||||
local w, h = D.smelterMon.getSize()
|
||||
smelterPendingZones = {}
|
||||
|
||||
setDrawTarget(window.create(D.smelterMon, 1, 1, w, h, false))
|
||||
draw.setBackgroundColor(colors.black)
|
||||
draw.clear()
|
||||
|
||||
-- Title bar
|
||||
monFill(1, colors.purple)
|
||||
monCenter(1, " ** SMELTER DASHBOARD ** ", colors.white, colors.purple)
|
||||
|
||||
-- Status bar
|
||||
monFill(2, colors.gray)
|
||||
local activeCount = 0
|
||||
for _, fs in ipairs(cache.furnaceStatus or {}) do
|
||||
if fs.active then activeCount = activeCount + 1 end
|
||||
end
|
||||
local statusStr = string.format(" Furnaces: %d Active: %d",
|
||||
cache.furnaceCount or 0, activeCount)
|
||||
monWrite(2, 2, statusStr, colors.white, colors.gray)
|
||||
|
||||
local pauseLabel = state.smeltingPaused and " PAUSED " or " ACTIVE "
|
||||
local pauseBg = state.smeltingPaused and colors.red or colors.lime
|
||||
local pauseFg = state.smeltingPaused and colors.white or colors.black
|
||||
monWrite(w - #pauseLabel, 2, pauseLabel, pauseFg, pauseBg)
|
||||
addSmelterZone(w - #pauseLabel, 2, w - 1, 2, "toggle_pause", nil)
|
||||
|
||||
-- Divider
|
||||
monFill(3, colors.magenta)
|
||||
monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.pink, colors.magenta)
|
||||
|
||||
-- Tab row
|
||||
monFill(4, colors.black)
|
||||
local tabStatusBg = smelterView == "status" and colors.purple or colors.gray
|
||||
local tabSmeltBg = smelterView == "smelt" and colors.purple or colors.gray
|
||||
local tabCraftBg = smelterView == "craft" and colors.purple or colors.gray
|
||||
local tabMissingBg = smelterView == "missing" and colors.purple or colors.gray
|
||||
local bx1, by1, bx2, by2
|
||||
bx1, by1, bx2, by2 = drawButton(2, 4, "Status", colors.white, tabStatusBg)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "tab", "status")
|
||||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Smelt", colors.white, tabSmeltBg)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "tab", "smelt")
|
||||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Craft", colors.white, tabCraftBg)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "tab", "craft")
|
||||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Missing", colors.white, tabMissingBg)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "tab", "missing")
|
||||
|
||||
local craftAvailCount = nil
|
||||
|
||||
if smelterView == "status" then
|
||||
-- Furnace Status View
|
||||
monFill(5, colors.gray)
|
||||
local outCol = math.floor(w * 0.40)
|
||||
local fuelCol = math.floor(w * 0.65)
|
||||
local statCol = w - 6
|
||||
monWrite(2, 5, "#", colors.lightGray, colors.gray)
|
||||
monWrite(4, 5, "T", colors.lightGray, colors.gray)
|
||||
monWrite(6, 5, "Input", colors.lightGray, colors.gray)
|
||||
monWrite(outCol, 5, "Output", colors.lightGray, colors.gray)
|
||||
monWrite(fuelCol, 5, "Fuel", colors.lightGray, colors.gray)
|
||||
monWrite(statCol, 5, "State", colors.lightGray, colors.gray)
|
||||
|
||||
local furnaceList = cache.furnaceStatus or {}
|
||||
local maxRows = h - 8
|
||||
if maxRows < 1 then maxRows = 1 end
|
||||
smelterTotalPages = math.max(1, math.ceil(#furnaceList / maxRows))
|
||||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||||
if smelterPage < 1 then smelterPage = 1 end
|
||||
|
||||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||||
local endIdx = math.min(startIdx + maxRows - 1, #furnaceList)
|
||||
|
||||
local row = 6
|
||||
if #furnaceList == 0 then
|
||||
monFill(7, colors.black)
|
||||
monCenter(7, "No furnaces found on network", colors.gray, colors.black)
|
||||
row = 8
|
||||
else
|
||||
for i = startIdx, endIdx do
|
||||
local fs = furnaceList[i]
|
||||
local y = row
|
||||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||||
monFill(y, rowBg)
|
||||
|
||||
monWrite(2, y, string.format("%d", i), colors.lightBlue, rowBg)
|
||||
|
||||
local typeAbbr = "F"
|
||||
local typeColor = colors.orange
|
||||
if fs.type == "minecraft:smoker" then
|
||||
typeAbbr = "S"
|
||||
typeColor = colors.green
|
||||
elseif fs.type == "minecraft:blast_furnace" then
|
||||
typeAbbr = "B"
|
||||
typeColor = colors.cyan
|
||||
end
|
||||
monWrite(4, y, typeAbbr, typeColor, rowBg)
|
||||
|
||||
if fs.input then
|
||||
local inName = fs.input.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
local maxIn = outCol - 8
|
||||
if #inName > maxIn then inName = inName:sub(1, maxIn - 2) .. ".." end
|
||||
monWrite(6, y, inName, colors.white, rowBg)
|
||||
monWrite(outCol - 4, y, "x" .. fs.input.count, colors.yellow, rowBg)
|
||||
else
|
||||
monWrite(6, y, "(empty)", colors.lightGray, rowBg)
|
||||
end
|
||||
|
||||
if fs.output then
|
||||
local outName = fs.output.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
local maxOut = fuelCol - outCol - 5
|
||||
if #outName > maxOut then outName = outName:sub(1, maxOut - 2) .. ".." end
|
||||
monWrite(outCol, y, outName, colors.white, rowBg)
|
||||
monWrite(fuelCol - 4, y, "x" .. fs.output.count, colors.yellow, rowBg)
|
||||
else
|
||||
monWrite(outCol, y, "-", colors.lightGray, rowBg)
|
||||
end
|
||||
|
||||
if fs.fuel then
|
||||
local fuelName = fs.fuel.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
local maxFuel = statCol - fuelCol - 4
|
||||
if #fuelName > maxFuel then fuelName = fuelName:sub(1, maxFuel - 2) .. ".." end
|
||||
monWrite(fuelCol, y, fuelName, colors.white, rowBg)
|
||||
monWrite(statCol - 4, y, "x" .. fs.fuel.count, colors.yellow, rowBg)
|
||||
else
|
||||
monWrite(fuelCol, y, "-", colors.lightGray, rowBg)
|
||||
end
|
||||
|
||||
if state.smeltingPaused then
|
||||
monWrite(statCol, y, "PAUSE", colors.red, rowBg)
|
||||
elseif fs.active then
|
||||
monWrite(statCol, y, " COOK", colors.lime, rowBg)
|
||||
elseif fs.input and not fs.fuel then
|
||||
monWrite(statCol, y, "FUEL?", colors.orange, rowBg)
|
||||
else
|
||||
monWrite(statCol, y, " IDLE", colors.lightGray, rowBg)
|
||||
end
|
||||
|
||||
row = row + 1
|
||||
end
|
||||
end
|
||||
|
||||
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
|
||||
|
||||
elseif smelterView == "smelt" then
|
||||
-- Smelt Recipe Manager View
|
||||
local recipeList = {}
|
||||
for inputName, recipe in pairs(cfg.SMELTABLE) do
|
||||
local short = inputName:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
short = short:sub(1,1):upper() .. short:sub(2)
|
||||
local resultShort = recipe.result:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
resultShort = resultShort:sub(1,1):upper() .. resultShort:sub(2)
|
||||
local types = ""
|
||||
if recipe.furnaceSet["minecraft:furnace"] then types = types .. "F" end
|
||||
if recipe.furnaceSet["minecraft:smoker"] then types = types .. "S" end
|
||||
if recipe.furnaceSet["minecraft:blast_furnace"] then types = types .. "B" end
|
||||
local enabled = not state.disabledRecipes[inputName]
|
||||
local inStorage = 0
|
||||
if cache.catalogue[inputName] then
|
||||
for _, s in ipairs(cache.catalogue[inputName]) do
|
||||
inStorage = inStorage + s.total
|
||||
end
|
||||
end
|
||||
table.insert(recipeList, {
|
||||
inputName = inputName,
|
||||
inputShort = short,
|
||||
resultShort = resultShort,
|
||||
types = types,
|
||||
enabled = enabled,
|
||||
inStorage = inStorage,
|
||||
})
|
||||
end
|
||||
table.sort(recipeList, function(a, b) return a.inputShort < b.inputShort end)
|
||||
|
||||
local arrowCol = math.floor(w * 0.30)
|
||||
local typeCol = math.floor(w * 0.60)
|
||||
local stockCol = math.floor(w * 0.72)
|
||||
local toggleCol = w - 5
|
||||
|
||||
monFill(5, colors.gray)
|
||||
monWrite(2, 5, "Input", colors.lightGray, colors.gray)
|
||||
monWrite(arrowCol, 5, "Output", colors.lightGray, colors.gray)
|
||||
monWrite(typeCol, 5, "Type", colors.lightGray, colors.gray)
|
||||
monWrite(stockCol, 5, "Stock", colors.lightGray, colors.gray)
|
||||
monWrite(toggleCol, 5, "On?", colors.lightGray, colors.gray)
|
||||
|
||||
local bulkX = w - 22
|
||||
bx1, by1, bx2, by2 = drawButton(bulkX, 4, "All On", colors.white, colors.green)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "enable_all", nil)
|
||||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "All Off", colors.white, colors.red)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "disable_all", nil)
|
||||
|
||||
local maxRows = h - 8
|
||||
if maxRows < 1 then maxRows = 1 end
|
||||
smelterTotalPages = math.max(1, math.ceil(#recipeList / maxRows))
|
||||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||||
if smelterPage < 1 then smelterPage = 1 end
|
||||
|
||||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||||
local endIdx = math.min(startIdx + maxRows - 1, #recipeList)
|
||||
|
||||
local row = 6
|
||||
for i = startIdx, endIdx do
|
||||
local r = recipeList[i]
|
||||
local y = row
|
||||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||||
monFill(y, rowBg)
|
||||
|
||||
local maxInputLen = arrowCol - 3
|
||||
local inputDisplay = r.inputShort
|
||||
if #inputDisplay > maxInputLen then
|
||||
inputDisplay = inputDisplay:sub(1, maxInputLen - 2) .. ".."
|
||||
end
|
||||
monWrite(2, y, inputDisplay, colors.white, rowBg)
|
||||
|
||||
local maxOutLen = typeCol - arrowCol - 2
|
||||
local outDisplay = r.resultShort
|
||||
if #outDisplay > maxOutLen then
|
||||
outDisplay = outDisplay:sub(1, maxOutLen - 2) .. ".."
|
||||
end
|
||||
monWrite(arrowCol, y, outDisplay, colors.lightBlue, rowBg)
|
||||
|
||||
monWrite(typeCol, y, r.types, colors.orange, rowBg)
|
||||
monWrite(stockCol, y, tostring(r.inStorage), colors.yellow, rowBg)
|
||||
|
||||
if r.enabled then
|
||||
monWrite(toggleCol, y, " ON ", colors.white, colors.green)
|
||||
else
|
||||
monWrite(toggleCol, y, " OFF", colors.white, colors.red)
|
||||
end
|
||||
addSmelterZone(1, y, w, y, "toggle_recipe", r.inputName)
|
||||
|
||||
row = row + 1
|
||||
end
|
||||
|
||||
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
|
||||
|
||||
elseif smelterView == "craft" then
|
||||
-- Available Crafting Recipes
|
||||
local turtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName)
|
||||
local tLabel = turtleOk and " Turtle OK " or " No Turtle "
|
||||
local tBg = turtleOk and colors.lime or colors.red
|
||||
local tFg = turtleOk and colors.black or colors.white
|
||||
monWrite(w - #tLabel, 4, tLabel, tFg, tBg)
|
||||
|
||||
local availList = {}
|
||||
for idx, recipe in ipairs(cfg.CRAFTABLE) do
|
||||
if ops.canCraftRecipe(recipe) then
|
||||
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
short = short:sub(1,1):upper() .. short:sub(2)
|
||||
local batches = ops.maxCraftBatches(recipe)
|
||||
table.insert(availList, {
|
||||
idx = idx,
|
||||
short = short,
|
||||
count = recipe.count,
|
||||
batches = batches,
|
||||
})
|
||||
end
|
||||
end
|
||||
craftAvailCount = #availList
|
||||
|
||||
monFill(5, colors.gray)
|
||||
local makeCol = w - 6
|
||||
monWrite(2, 5, "#", colors.lightGray, colors.gray)
|
||||
monWrite(4, 5, "Output", colors.lightGray, colors.gray)
|
||||
monWrite(math.floor(w * 0.45), 5, "Yield", colors.lightGray, colors.gray)
|
||||
monWrite(math.floor(w * 0.60), 5, "Can Make", colors.lightGray, colors.gray)
|
||||
monWrite(makeCol, 5, "Go", colors.lightGray, colors.gray)
|
||||
|
||||
local maxRows = h - 8
|
||||
if maxRows < 1 then maxRows = 1 end
|
||||
smelterTotalPages = math.max(1, math.ceil(#availList / maxRows))
|
||||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||||
if smelterPage < 1 then smelterPage = 1 end
|
||||
|
||||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||||
local endIdx = math.min(startIdx + maxRows - 1, #availList)
|
||||
|
||||
local row = 6
|
||||
if #availList == 0 then
|
||||
monFill(7, colors.black)
|
||||
monCenter(7, "No recipes available to craft", colors.gray, colors.black)
|
||||
row = 8
|
||||
else
|
||||
for i = startIdx, endIdx do
|
||||
local r = availList[i]
|
||||
local y = row
|
||||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||||
monFill(y, rowBg)
|
||||
|
||||
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
|
||||
|
||||
local maxNameLen = math.floor(w * 0.40)
|
||||
local nameDisplay = r.short
|
||||
if #nameDisplay > maxNameLen then
|
||||
nameDisplay = nameDisplay:sub(1, maxNameLen - 2) .. ".."
|
||||
end
|
||||
monWrite(4, y, nameDisplay, colors.white, rowBg)
|
||||
|
||||
monWrite(math.floor(w * 0.45), y, "x" .. r.count, colors.yellow, rowBg)
|
||||
monWrite(math.floor(w * 0.60), y,
|
||||
string.format("x%d", r.batches), colors.lime, rowBg)
|
||||
|
||||
if turtleOk then
|
||||
monWrite(makeCol, y, " MAKE ", colors.white, colors.green)
|
||||
addSmelterZone(makeCol, y, makeCol + 5, y, "craft", r.idx)
|
||||
else
|
||||
monWrite(makeCol, y, " ---- ", colors.gray, colors.black)
|
||||
end
|
||||
|
||||
row = row + 1
|
||||
end
|
||||
end
|
||||
|
||||
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
|
||||
|
||||
elseif smelterView == "missing" then
|
||||
-- Unavailable Crafting Recipes
|
||||
local missList = {}
|
||||
for idx, recipe in ipairs(cfg.CRAFTABLE) do
|
||||
if not ops.canCraftRecipe(recipe) then
|
||||
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
short = short:sub(1,1):upper() .. short:sub(2)
|
||||
local missing = ops.getMissingIngredients(recipe)
|
||||
local parts = {}
|
||||
for _, m in ipairs(missing) do
|
||||
local mShort = m.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
table.insert(parts, string.format("%s %d/%d", mShort, m.have, m.need))
|
||||
end
|
||||
table.insert(missList, {
|
||||
idx = idx,
|
||||
short = short,
|
||||
count = recipe.count,
|
||||
summary = table.concat(parts, ", "),
|
||||
})
|
||||
end
|
||||
end
|
||||
craftAvailCount = #cfg.CRAFTABLE - #missList
|
||||
|
||||
monFill(5, colors.gray)
|
||||
monWrite(2, 5, "#", colors.lightGray, colors.gray)
|
||||
monWrite(4, 5, "Output", colors.lightGray, colors.gray)
|
||||
monWrite(math.floor(w * 0.35), 5, "Missing (have/need)", colors.lightGray, colors.gray)
|
||||
|
||||
local maxRows = h - 8
|
||||
if maxRows < 1 then maxRows = 1 end
|
||||
smelterTotalPages = math.max(1, math.ceil(#missList / maxRows))
|
||||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||||
if smelterPage < 1 then smelterPage = 1 end
|
||||
|
||||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||||
local endIdx = math.min(startIdx + maxRows - 1, #missList)
|
||||
|
||||
local row = 6
|
||||
if #missList == 0 then
|
||||
monFill(7, colors.black)
|
||||
monCenter(7, "All recipes can be crafted!", colors.lime, colors.black)
|
||||
row = 8
|
||||
else
|
||||
for i = startIdx, endIdx do
|
||||
local r = missList[i]
|
||||
local y = row
|
||||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||||
monFill(y, rowBg)
|
||||
|
||||
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
|
||||
|
||||
local nameCol = math.floor(w * 0.35) - 5
|
||||
local nameDisplay = r.short .. " x" .. r.count
|
||||
if #nameDisplay > nameCol then
|
||||
nameDisplay = nameDisplay:sub(1, nameCol - 2) .. ".."
|
||||
end
|
||||
monWrite(4, y, nameDisplay, colors.white, rowBg)
|
||||
|
||||
local missCol = math.floor(w * 0.35)
|
||||
local missW = w - missCol - 1
|
||||
local summaryDisplay = r.summary
|
||||
if #summaryDisplay > missW then
|
||||
summaryDisplay = summaryDisplay:sub(1, missW - 2) .. ".."
|
||||
end
|
||||
monWrite(missCol, y, summaryDisplay, colors.red, rowBg)
|
||||
|
||||
row = row + 1
|
||||
end
|
||||
end
|
||||
|
||||
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
|
||||
end
|
||||
|
||||
-- Pagination (h - 1)
|
||||
monFill(h - 1, colors.gray)
|
||||
local pageStr = string.format("Pg %d/%d", smelterPage, smelterTotalPages)
|
||||
monCenter(h - 1, pageStr, colors.white, colors.gray)
|
||||
|
||||
if smelterPage > 1 then
|
||||
monWrite(2, h - 1, " < ", colors.white, colors.lightGray)
|
||||
addSmelterZone(2, h - 1, 4, h - 1, "page_prev", nil)
|
||||
end
|
||||
if smelterPage < smelterTotalPages then
|
||||
monWrite(w - 3, h - 1, " > ", colors.white, colors.lightGray)
|
||||
addSmelterZone(w - 3, h - 1, w - 1, h - 1, "page_next", nil)
|
||||
end
|
||||
|
||||
-- Bottom accent
|
||||
monFill(h, colors.purple)
|
||||
local bottomMsg = ""
|
||||
if smelterView == "status" or smelterView == "smelt" then
|
||||
local enabledCount = 0
|
||||
local totalRecipes = 0
|
||||
for _ in pairs(cfg.SMELTABLE) do totalRecipes = totalRecipes + 1 end
|
||||
for inputName in pairs(cfg.SMELTABLE) do
|
||||
if not state.disabledRecipes[inputName] then enabledCount = enabledCount + 1 end
|
||||
end
|
||||
bottomMsg = string.format(" Smelt: %d/%d enabled ", enabledCount, totalRecipes)
|
||||
if activity.smelting then bottomMsg = " SMELTING... " end
|
||||
elseif smelterView == "craft" then
|
||||
bottomMsg = " Tap MAKE to craft "
|
||||
if activity.crafting then bottomMsg = " CRAFTING... " end
|
||||
elseif smelterView == "missing" then
|
||||
local availC = craftAvailCount or 0
|
||||
bottomMsg = string.format(" Available: %d/%d recipes ", availC, #cfg.CRAFTABLE)
|
||||
end
|
||||
monCenter(h, bottomMsg, colors.pink, colors.purple)
|
||||
|
||||
draw.setVisible(true)
|
||||
smelterTouchZones = smelterPendingZones
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Touch handlers
|
||||
-------------------------------------------------
|
||||
|
||||
function D.handleTouch(x, y)
|
||||
local action, data = hitTest(x, y)
|
||||
if not action then
|
||||
log.debug("TOUCH", "No zone hit")
|
||||
return
|
||||
end
|
||||
|
||||
if action == "amount" then
|
||||
selectedAmount = data
|
||||
log.debug("UI", "Amount set to %s", data)
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "order" then
|
||||
local itemName = data
|
||||
if itemName then
|
||||
local short = itemName:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
state.statusMessage = string.format("Ordering %s x%d...", short, selectedAmount)
|
||||
state.statusColor = colors.cyan
|
||||
state.statusTimer = 10
|
||||
activity.dispensing = true
|
||||
state.needsRedraw = true
|
||||
ops.orderItem(itemName, selectedAmount)
|
||||
end
|
||||
|
||||
elseif action == "scan" then
|
||||
state.statusMessage = "Refreshing..."
|
||||
state.statusColor = colors.cyan
|
||||
state.statusTimer = 3
|
||||
state.needsRedraw = true
|
||||
log.debug("UI", "Manual refresh")
|
||||
|
||||
elseif action == "kb_toggle" then
|
||||
showKeyboard = not showKeyboard
|
||||
log.debug("UI", "Keyboard %s", showKeyboard and "open" or "closed")
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "kb_key" then
|
||||
if #searchQuery < 30 then
|
||||
searchQuery = searchQuery .. data
|
||||
end
|
||||
currentPage = 1
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "kb_bksp" then
|
||||
if #searchQuery > 0 then
|
||||
searchQuery = searchQuery:sub(1, -2)
|
||||
end
|
||||
currentPage = 1
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "kb_space" then
|
||||
if #searchQuery < 30 then
|
||||
searchQuery = searchQuery .. " "
|
||||
end
|
||||
currentPage = 1
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "kb_done" then
|
||||
showKeyboard = false
|
||||
log.debug("UI", "Keyboard closed")
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "kb_clear" then
|
||||
searchQuery = ""
|
||||
currentPage = 1
|
||||
log.debug("UI", "Search cleared")
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "page_prev" then
|
||||
if currentPage > 1 then
|
||||
currentPage = currentPage - 1
|
||||
log.debug("UI", "Page %d", currentPage)
|
||||
end
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "page_next" then
|
||||
if currentPage < totalPages then
|
||||
currentPage = currentPage + 1
|
||||
log.debug("UI", "Page %d", currentPage)
|
||||
end
|
||||
state.needsRedraw = true
|
||||
end
|
||||
end
|
||||
|
||||
function D.handleSmelterTouch(x, y)
|
||||
local action, data = smelterHitTest(x, y)
|
||||
if not action then return end
|
||||
|
||||
if action == "tab" then
|
||||
smelterView = data
|
||||
smelterPage = 1
|
||||
log.debug("UI", "Tab: %s", data)
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "toggle_pause" then
|
||||
state.smeltingPaused = not state.smeltingPaused
|
||||
log.debug("UI", "Smelting %s", state.smeltingPaused and "PAUSED" or "RESUMED")
|
||||
ops.saveDisabledRecipes()
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "toggle_recipe" then
|
||||
if state.disabledRecipes[data] then
|
||||
state.disabledRecipes[data] = nil
|
||||
else
|
||||
state.disabledRecipes[data] = true
|
||||
end
|
||||
local short = data:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
log.debug("UI", "Recipe %s: %s", short, state.disabledRecipes[data] and "OFF" or "ON")
|
||||
ops.saveDisabledRecipes()
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "enable_all" then
|
||||
state.disabledRecipes = {}
|
||||
log.debug("UI", "All recipes enabled")
|
||||
ops.saveDisabledRecipes()
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "disable_all" then
|
||||
for inputName in pairs(cfg.SMELTABLE) do
|
||||
state.disabledRecipes[inputName] = true
|
||||
end
|
||||
log.debug("UI", "All recipes disabled")
|
||||
ops.saveDisabledRecipes()
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "page_prev" then
|
||||
if smelterPage > 1 then
|
||||
smelterPage = smelterPage - 1
|
||||
end
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "page_next" then
|
||||
if smelterPage < smelterTotalPages then
|
||||
smelterPage = smelterPage + 1
|
||||
end
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "craft" then
|
||||
local recipeIdx = data
|
||||
local recipe = cfg.CRAFTABLE[recipeIdx]
|
||||
if recipe then
|
||||
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
log.info("CRAFT", "Craft request: %s (#%d)", short, recipeIdx)
|
||||
local ok, err = ops.craftItem(recipeIdx)
|
||||
if ok then
|
||||
state.statusMessage = "Crafted " .. short .. " x" .. recipe.count
|
||||
state.statusColor = colors.lime
|
||||
else
|
||||
state.statusMessage = "Craft failed: " .. (err or "unknown")
|
||||
state.statusColor = colors.red
|
||||
end
|
||||
state.statusTimer = 5
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return D
|
||||
|
||||
end
|
||||
1454
manager/operations.lua
Normal file
1454
manager/operations.lua
Normal file
File diff suppressed because it is too large
Load Diff
141
manager/state.lua
Normal file
141
manager/state.lua
Normal file
@@ -0,0 +1,141 @@
|
||||
-- manager/state.lua — Shared mutable state, cache, and coordination flags
|
||||
-- Usage: local state = dofile("manager/state.lua")()
|
||||
|
||||
return function()
|
||||
|
||||
local S = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Cached data (updated by background scanner)
|
||||
-------------------------------------------------
|
||||
|
||||
S.cache = {
|
||||
catalogue = {},
|
||||
itemList = {},
|
||||
itemListDirty = false,
|
||||
grandTotal = 0,
|
||||
chestCount = 0,
|
||||
totalSlots = 0,
|
||||
usedSlots = 0,
|
||||
freeSlots = 0,
|
||||
usedRatio = 0,
|
||||
dropperOk = false,
|
||||
barrelOk = false,
|
||||
furnaceCount = 0,
|
||||
furnaceStatus = {},
|
||||
droppers = {},
|
||||
}
|
||||
|
||||
-- Client-registered droppers, keyed by clientId
|
||||
S.clientDroppers = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Activity flags (shown on monitor)
|
||||
-------------------------------------------------
|
||||
|
||||
S.activity = {
|
||||
sorting = false,
|
||||
dispensing = false,
|
||||
scanning = false,
|
||||
smelting = false,
|
||||
defragging = false,
|
||||
composting = false,
|
||||
crafting = false,
|
||||
discarding = false,
|
||||
autocrafting = false,
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
-- State version tracking (for delta broadcasting)
|
||||
-------------------------------------------------
|
||||
|
||||
S.stateVersion = 0
|
||||
S.lastBroadcastVersion = -1
|
||||
S.configDirty = true
|
||||
|
||||
function S.bumpStateVersion()
|
||||
S.stateVersion = S.stateVersion + 1
|
||||
S.billboardNeedsRedraw = true
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- UI coordination flags
|
||||
-------------------------------------------------
|
||||
|
||||
S.needsRedraw = true
|
||||
S.smelterNeedsRedraw = true
|
||||
S.billboardNeedsRedraw = true
|
||||
S.statusMessage = ""
|
||||
S.statusColor = colors.white
|
||||
S.statusTimer = 0
|
||||
|
||||
-------------------------------------------------
|
||||
-- Alerts and smelting control
|
||||
-------------------------------------------------
|
||||
|
||||
S.activeAlerts = {}
|
||||
S.smeltingPaused = false
|
||||
S.disabledRecipes = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Instant cache adjustment (no scan needed)
|
||||
-------------------------------------------------
|
||||
|
||||
function S.adjustCache(itemName, chestName, delta)
|
||||
if delta == 0 then return end
|
||||
|
||||
local cat = S.cache.catalogue
|
||||
if delta > 0 then
|
||||
if not cat[itemName] then cat[itemName] = {} end
|
||||
local found = false
|
||||
for _, entry in ipairs(cat[itemName]) do
|
||||
if entry.chest == chestName then
|
||||
entry.total = entry.total + delta
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
table.insert(cat[itemName], { chest = chestName, total = delta })
|
||||
end
|
||||
else
|
||||
if cat[itemName] then
|
||||
for idx, entry in ipairs(cat[itemName]) do
|
||||
if entry.chest == chestName then
|
||||
entry.total = entry.total + delta
|
||||
if entry.total <= 0 then
|
||||
table.remove(cat[itemName], idx)
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
if #cat[itemName] == 0 then
|
||||
cat[itemName] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
S.cache.itemListDirty = true
|
||||
S.cache.grandTotal = S.cache.grandTotal + delta
|
||||
S.bumpStateVersion()
|
||||
end
|
||||
|
||||
function S.ensureItemList()
|
||||
if not S.cache.itemListDirty then return end
|
||||
local itemList = {}
|
||||
local grandTotal = 0
|
||||
for name, sources in pairs(S.cache.catalogue) do
|
||||
local total = 0
|
||||
for _, s in ipairs(sources) do total = total + s.total end
|
||||
grandTotal = grandTotal + total
|
||||
table.insert(itemList, { name = name, total = total })
|
||||
end
|
||||
table.sort(itemList, function(a, b) return a.total > b.total end)
|
||||
S.cache.itemList = itemList
|
||||
S.cache.grandTotal = grandTotal
|
||||
S.cache.itemListDirty = false
|
||||
end
|
||||
|
||||
return S
|
||||
|
||||
end
|
||||
414
miningTurtle.lua
Normal file
414
miningTurtle.lua
Normal file
@@ -0,0 +1,414 @@
|
||||
-- Mining Turtle Script
|
||||
-- Sits on top of an infinite cobblestone generator.
|
||||
-- Continuously mines the block below and pushes items into
|
||||
-- networked storage via wired modem.
|
||||
-- Requires a wired modem attached to the turtle.
|
||||
|
||||
local shell = _ENV.shell
|
||||
|
||||
local function fatal(msg)
|
||||
printError(msg)
|
||||
print("\nPress any key to exit...")
|
||||
os.pullEvent("key")
|
||||
return
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Default configuration (overridden by .miner_config)
|
||||
-------------------------------------------------
|
||||
|
||||
local MINE_INTERVAL = 0.5 -- seconds between dig attempts
|
||||
local DUMP_THRESHOLD = 14 -- dump when this many slots are occupied
|
||||
local DUMP_INTERVAL = 30 -- force dump every N seconds even if not full
|
||||
local FUEL_SLOT = 16 -- slot used for pulling/burning fuel
|
||||
local FUEL_THRESHOLD = 200 -- pull fuel when below this level
|
||||
local SYSTEM_CHANNEL = 4205 -- remote reboot channel
|
||||
local ORDER_CHANNEL = 4201 -- channel to talk to manager
|
||||
local FUEL_REQUEST_TIMEOUT = 5 -- seconds to wait for manager reply
|
||||
|
||||
-- Fuel items the turtle will look for in storage (best first)
|
||||
local FUEL_ITEMS = {
|
||||
"minecraft:coal",
|
||||
"minecraft:charcoal",
|
||||
"minecraft:coal_block",
|
||||
"minecraft:blaze_rod",
|
||||
"minecraft:dried_kelp_block",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path (survives Opus package updates)
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local CONFIG_FILE = _configPath(".miner_config")
|
||||
|
||||
local function loadConfig()
|
||||
if not fs.exists(CONFIG_FILE) then return end
|
||||
local f = fs.open(CONFIG_FILE, "r")
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, data)
|
||||
if not ok or not cfg then
|
||||
print("[WARN] Failed to parse " .. CONFIG_FILE)
|
||||
return
|
||||
end
|
||||
if cfg.mineInterval then MINE_INTERVAL = cfg.mineInterval end
|
||||
if cfg.dumpThreshold then DUMP_THRESHOLD = cfg.dumpThreshold end
|
||||
if cfg.dumpInterval then DUMP_INTERVAL = cfg.dumpInterval end
|
||||
if cfg.fuelSlot then FUEL_SLOT = cfg.fuelSlot end
|
||||
print("[CONFIG] Loaded from " .. CONFIG_FILE)
|
||||
end
|
||||
|
||||
loadConfig()
|
||||
|
||||
-------------------------------------------------
|
||||
-- Setup
|
||||
-------------------------------------------------
|
||||
|
||||
print("=================================")
|
||||
print(" Mining Turtle (Cobble Miner)")
|
||||
print("=================================")
|
||||
print("")
|
||||
|
||||
if not turtle then
|
||||
return fatal("[ERR] Not a turtle!")
|
||||
end
|
||||
|
||||
-- Find wired modem and get our network name
|
||||
local modem = nil
|
||||
local modemSide = nil
|
||||
local selfName = nil
|
||||
|
||||
for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
|
||||
if peripheral.getType(side) == "modem" then
|
||||
local m = peripheral.wrap(side)
|
||||
if m.getNameLocal then
|
||||
local name = m.getNameLocal()
|
||||
if name then
|
||||
modem = m
|
||||
modemSide = side
|
||||
selfName = name
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not modem or not selfName then
|
||||
return fatal("[ERR] No wired modem found!\n Attach a wired modem to the turtle\n and connect it to the network.")
|
||||
end
|
||||
|
||||
print("[OK] Modem: " .. modemSide)
|
||||
print("[OK] Network name: " .. selfName)
|
||||
print("[OK] Mine interval: " .. MINE_INTERVAL .. "s")
|
||||
print("[OK] Dump threshold: " .. DUMP_THRESHOLD .. " slots")
|
||||
print("")
|
||||
|
||||
-------------------------------------------------
|
||||
-- Find chests on the network to dump into
|
||||
-------------------------------------------------
|
||||
|
||||
local function getChests()
|
||||
local chests = {}
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
local ptype = peripheral.getType(name)
|
||||
if ptype == "minecraft:chest" or ptype == "minecraft:barrel" or
|
||||
ptype == "minecraft:shulker_box" then
|
||||
table.insert(chests, name)
|
||||
end
|
||||
end
|
||||
return chests
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Dump inventory into networked storage
|
||||
-- Uses pull approach: wrap the remote chest and call
|
||||
-- chest.pullItems(selfName, slot) — avoids needing
|
||||
-- to wrap the turtle's own peripheral.
|
||||
-------------------------------------------------
|
||||
|
||||
local function dumpInventory()
|
||||
local chests = getChests()
|
||||
if #chests == 0 then
|
||||
print("[DUMP] No chests on network!")
|
||||
return 0
|
||||
end
|
||||
|
||||
local totalPushed = 0
|
||||
for slot = 1, 16 do
|
||||
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
|
||||
for _, chestName in ipairs(chests) do
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.pullItems then
|
||||
local ok, n = pcall(chest.pullItems, selfName, slot)
|
||||
if ok and n and n > 0 then
|
||||
totalPushed = totalPushed + n
|
||||
if turtle.getItemCount(slot) == 0 then
|
||||
break -- slot empty, move to next
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Notify manager to refresh its cache so counts update immediately
|
||||
if totalPushed > 0 then
|
||||
pcall(function()
|
||||
modem.transmit(ORDER_CHANNEL, ORDER_CHANNEL, { type = "scan" })
|
||||
end)
|
||||
end
|
||||
|
||||
return totalPushed
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Count occupied inventory slots
|
||||
-------------------------------------------------
|
||||
|
||||
local function occupiedSlots()
|
||||
local count = 0
|
||||
for slot = 1, 16 do
|
||||
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Auto-refuel: ask manager for fuel, pull directly
|
||||
-------------------------------------------------
|
||||
|
||||
--- Try to burn whatever is already in FUEL_SLOT
|
||||
local function burnFuelSlot()
|
||||
if FUEL_SLOT <= 0 then return false end
|
||||
local prev = turtle.getSelectedSlot()
|
||||
turtle.select(FUEL_SLOT)
|
||||
if turtle.getItemCount() > 0 then
|
||||
local ok = turtle.refuel()
|
||||
turtle.select(prev)
|
||||
return ok
|
||||
end
|
||||
turtle.select(prev)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Ask the inventory manager where fuel items are
|
||||
local function askManagerForFuel()
|
||||
local replyChannel = ORDER_CHANNEL + 100 + os.getComputerID()
|
||||
modem.open(replyChannel)
|
||||
|
||||
modem.transmit(ORDER_CHANNEL, replyChannel, {
|
||||
type = "find_item",
|
||||
items = FUEL_ITEMS,
|
||||
limit = 1,
|
||||
})
|
||||
|
||||
local deadline = os.clock() + FUEL_REQUEST_TIMEOUT
|
||||
local result = nil
|
||||
|
||||
while os.clock() < deadline do
|
||||
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
|
||||
local event, p1, p2, p3, p4 = os.pullEvent()
|
||||
if event == "modem_message" and p2 == replyChannel then
|
||||
if type(p4) == "table" and p4.type == "find_item_result" then
|
||||
result = p4.results
|
||||
break
|
||||
end
|
||||
elseif event == "timer" and p1 == timerId then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
modem.close(replyChannel)
|
||||
return result
|
||||
end
|
||||
|
||||
--- Pull fuel from a specific chest+slot (told by manager)
|
||||
local function pullFuelFromSource(source)
|
||||
if FUEL_SLOT <= 0 then return false end
|
||||
local chest = peripheral.wrap(source.chest)
|
||||
if not chest then return false end
|
||||
local ok, n = pcall(chest.pushItems, selfName, source.slot, 64, FUEL_SLOT)
|
||||
if ok and n and n > 0 then
|
||||
print(string.format("[FUEL] Pulled %s x%d from %s", source.name, n, source.chest))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function pullFuelFromStorage()
|
||||
-- Ask manager first (it knows exactly where fuel is)
|
||||
local sources = askManagerForFuel()
|
||||
if sources and #sources > 0 then
|
||||
for _, source in ipairs(sources) do
|
||||
if pullFuelFromSource(source) then return true end
|
||||
end
|
||||
end
|
||||
|
||||
-- Fallback: scan chests directly (in case manager is offline)
|
||||
local fuelSet = {}
|
||||
for _, name in ipairs(FUEL_ITEMS) do fuelSet[name] = true end
|
||||
local chests = getChests()
|
||||
for _, chestName in ipairs(chests) do
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.list then
|
||||
for slot, item in pairs(chest.list() or {}) do
|
||||
if fuelSet[item.name] then
|
||||
local ok, n = pcall(chest.pushItems, selfName, slot, 64, FUEL_SLOT)
|
||||
if ok and n and n > 0 then
|
||||
print(string.format("[FUEL] Pulled %s x%d (fallback)", item.name, n))
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function autoRefuel()
|
||||
if turtle.getFuelLevel() == "unlimited" then return end
|
||||
if turtle.getFuelLevel() >= FUEL_THRESHOLD then return end
|
||||
|
||||
-- First try burning whatever is already in the fuel slot
|
||||
if burnFuelSlot() then
|
||||
print("[FUEL] Burned local fuel. Level: " .. turtle.getFuelLevel())
|
||||
return
|
||||
end
|
||||
|
||||
-- Pull fresh fuel from networked storage
|
||||
if pullFuelFromStorage() then
|
||||
burnFuelSlot()
|
||||
print("[FUEL] Refueled from storage. Level: " .. turtle.getFuelLevel())
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Stats
|
||||
-------------------------------------------------
|
||||
|
||||
local totalMined = 0
|
||||
local totalDumped = 0
|
||||
local startTime = os.clock()
|
||||
|
||||
local function printStats()
|
||||
local elapsed = os.clock() - startTime
|
||||
local mins = math.floor(elapsed / 60)
|
||||
local secs = math.floor(elapsed % 60)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("=================================")
|
||||
print(" Mining Turtle - Running")
|
||||
print("=================================")
|
||||
print(string.format(" Mined: %d blocks", totalMined))
|
||||
print(string.format(" Dumped: %d items", totalDumped))
|
||||
print(string.format(" Uptime: %dm %ds", mins, secs))
|
||||
if turtle.getFuelLevel() ~= "unlimited" then
|
||||
print(string.format(" Fuel: %d", turtle.getFuelLevel()))
|
||||
end
|
||||
print(string.format(" Inv used: %d/16 slots", occupiedSlots()))
|
||||
print("=================================")
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Main mining loop
|
||||
-------------------------------------------------
|
||||
|
||||
local function mineLoop()
|
||||
local lastDump = os.clock()
|
||||
|
||||
while true do
|
||||
-- Auto-refuel if low
|
||||
autoRefuel()
|
||||
|
||||
-- Check fuel — keep trying to pull from storage
|
||||
if turtle.getFuelLevel() ~= "unlimited" and turtle.getFuelLevel() < 1 then
|
||||
print("[WARN] Out of fuel! Searching storage...")
|
||||
while turtle.getFuelLevel() < 1 do
|
||||
if pullFuelFromStorage() then
|
||||
burnFuelSlot()
|
||||
end
|
||||
if turtle.getFuelLevel() < 1 then
|
||||
printStats()
|
||||
sleep(10)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Mine the block below
|
||||
if turtle.detectDown() then
|
||||
local ok, err = turtle.digDown()
|
||||
if ok then
|
||||
totalMined = totalMined + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Dump inventory if threshold reached or timer expired
|
||||
local now = os.clock()
|
||||
if occupiedSlots() >= DUMP_THRESHOLD or (now - lastDump) >= DUMP_INTERVAL then
|
||||
if occupiedSlots() > 0 then
|
||||
local pushed = dumpInventory()
|
||||
totalDumped = totalDumped + pushed
|
||||
if pushed > 0 then
|
||||
print(string.format("[DUMP] Pushed %d items to storage", pushed))
|
||||
end
|
||||
end
|
||||
lastDump = os.clock()
|
||||
end
|
||||
|
||||
-- Refresh display periodically
|
||||
printStats()
|
||||
|
||||
sleep(MINE_INTERVAL)
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Reboot listener (remote reboot support)
|
||||
-------------------------------------------------
|
||||
|
||||
local function rebootListener()
|
||||
modem.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == "miner" or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received.")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Entry point
|
||||
-------------------------------------------------
|
||||
|
||||
print("Starting mining loop...")
|
||||
print("Press Ctrl+T to stop.")
|
||||
print("")
|
||||
sleep(1)
|
||||
|
||||
local ok, err = pcall(function()
|
||||
parallel.waitForAny(mineLoop, rebootListener)
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
fatal("[ERR] Mining turtle crashed:\n" .. tostring(err))
|
||||
end
|
||||
82
startup/bridge.lua
Normal file
82
startup/bridge.lua
Normal file
@@ -0,0 +1,82 @@
|
||||
-- startup.lua for Web Bridge computer
|
||||
-- Auto-updates from git then launches inventoryWebBridge.lua
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
local FILES = {
|
||||
["inventoryWebBridge.lua"] = "inventoryWebBridge.lua",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
local function download(remotePath, localPath)
|
||||
local url = REPO_RAW .. "/" .. remotePath
|
||||
local response = http.get(url)
|
||||
if response then
|
||||
local f = fs.open(localPath, "w")
|
||||
f.write(response.readAll())
|
||||
f.close()
|
||||
response.close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("==================================")
|
||||
print(" Web Bridge - Startup")
|
||||
print(" Computer ID: " .. os.getComputerID())
|
||||
print("==================================")
|
||||
print("")
|
||||
|
||||
local updated, failed = 0, 0
|
||||
for localPath, remotePath in pairs(FILES) do
|
||||
write(" " .. localPath .. " ... ")
|
||||
if download(remotePath, localPath) then
|
||||
print("OK")
|
||||
updated = updated + 1
|
||||
else
|
||||
print("FAIL")
|
||||
failed = failed + 1
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if failed > 0 then
|
||||
print(string.format("Updated %d files, %d failed.", updated, failed))
|
||||
else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
print("")
|
||||
print("Starting inventoryWebBridge...")
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this computer on remote command
|
||||
local SYSTEM_CHANNEL = 4205
|
||||
local ROLE = "bridge"
|
||||
|
||||
local function rebootListener()
|
||||
local m = peripheral.find("modem")
|
||||
if not m then return end
|
||||
m.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == ROLE or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received. Rebooting...")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(
|
||||
function() shell.run("inventoryWebBridge.lua") end,
|
||||
rebootListener
|
||||
)
|
||||
203
startup/client.lua
Normal file
203
startup/client.lua
Normal file
@@ -0,0 +1,203 @@
|
||||
-- startup.lua for Inventory Client computer
|
||||
-- Auto-updates from git then launches inventoryClient + dropperController in parallel
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
-- Persistent config directory (survives Opus package updates)
|
||||
local PERSIST_DIR = "usr/config/inventory-manager"
|
||||
if not fs.isDir(PERSIST_DIR) then fs.makeDir(PERSIST_DIR) end
|
||||
|
||||
local SETUP_FILE = fs.combine(PERSIST_DIR, ".client_setup")
|
||||
local DROPPER_CONFIG = fs.combine(PERSIST_DIR, ".dropper_config")
|
||||
|
||||
-- Files to download (destination -> repo path)
|
||||
local FILES = {
|
||||
["inventoryClient.lua"] = "inventoryClient.lua",
|
||||
["dropperController.lua"] = "dropperController.lua",
|
||||
["lib/log.lua"] = "lib/log.lua",
|
||||
["lib/ui.lua"] = "lib/ui.lua",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
local function ensureDir(filePath)
|
||||
local dir = filePath:match("^(.+)/")
|
||||
if dir and not fs.isDir(dir) then
|
||||
fs.makeDir(dir)
|
||||
end
|
||||
end
|
||||
|
||||
local function download(remotePath, localPath)
|
||||
local url = REPO_RAW .. "/" .. remotePath
|
||||
local response = http.get(url)
|
||||
if response then
|
||||
ensureDir(localPath)
|
||||
local f = fs.open(localPath, "w")
|
||||
f.write(response.readAll())
|
||||
f.close()
|
||||
response.close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("==================================")
|
||||
print(" Inventory Client - Startup")
|
||||
print(" Computer ID: " .. os.getComputerID())
|
||||
print("==================================")
|
||||
print("")
|
||||
|
||||
-- Update files
|
||||
local updated, failed = 0, 0
|
||||
for localPath, remotePath in pairs(FILES) do
|
||||
write(" " .. localPath .. " ... ")
|
||||
if download(remotePath, localPath) then
|
||||
print("OK")
|
||||
updated = updated + 1
|
||||
else
|
||||
print("FAIL")
|
||||
failed = failed + 1
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if failed > 0 then
|
||||
print(string.format("Updated %d files, %d failed.", updated, failed))
|
||||
print("Continuing with existing files...")
|
||||
else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- First-run setup
|
||||
-------------------------------------------------
|
||||
|
||||
local VALID_SIDES = { "top", "bottom", "left", "right", "front", "back" }
|
||||
local function isValidSide(s)
|
||||
for _, v in ipairs(VALID_SIDES) do
|
||||
if v == s then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function firstRunSetup()
|
||||
if fs.exists(SETUP_FILE) then return end
|
||||
|
||||
print("")
|
||||
print("========== First-Run Setup ==========")
|
||||
print("")
|
||||
write("Is there a dropper next to this computer? (y/n): ")
|
||||
local answer = read():lower():sub(1, 1)
|
||||
|
||||
if answer == "y" then
|
||||
-- Ask which side the dropper is on
|
||||
local side
|
||||
while true do
|
||||
print("")
|
||||
print("Valid sides: top, bottom, left, right, front, back")
|
||||
write("Which side is the dropper on? ")
|
||||
side = read():lower():gsub("%s+", "")
|
||||
if isValidSide(side) then
|
||||
break
|
||||
end
|
||||
print("Invalid side. Please try again.")
|
||||
end
|
||||
|
||||
-- Ask which side to pulse redstone from (defaults to same side)
|
||||
print("")
|
||||
write("Redstone output side [" .. side .. "]: ")
|
||||
local rsSide = read():lower():gsub("%s+", "")
|
||||
if rsSide == "" then rsSide = side end
|
||||
if not isValidSide(rsSide) then
|
||||
print("Invalid side, defaulting to " .. side)
|
||||
rsSide = side
|
||||
end
|
||||
|
||||
-- Save dropper config
|
||||
local cfg = textutils.serialiseJSON({
|
||||
dropperSide = side,
|
||||
redstoneSide = rsSide,
|
||||
enabled = true,
|
||||
})
|
||||
local f = fs.open(DROPPER_CONFIG, "w")
|
||||
f.write(cfg)
|
||||
f.close()
|
||||
print("")
|
||||
print("Dropper configured: peripheral=" .. side .. ", redstone=" .. rsSide)
|
||||
else
|
||||
-- No dropper - save config with enabled=false
|
||||
local f = fs.open(DROPPER_CONFIG, "w")
|
||||
f.write(textutils.serialiseJSON({ enabled = false }))
|
||||
f.close()
|
||||
print("No dropper configured.")
|
||||
end
|
||||
|
||||
-- Mark setup as complete
|
||||
local f = fs.open(SETUP_FILE, "w")
|
||||
f.write("done")
|
||||
f.close()
|
||||
|
||||
print("")
|
||||
print("Setup complete!")
|
||||
print("(Delete " .. SETUP_FILE .. " to re-run setup)")
|
||||
sleep(2)
|
||||
end
|
||||
|
||||
firstRunSetup()
|
||||
|
||||
-------------------------------------------------
|
||||
-- Determine if dropper controller should run
|
||||
-------------------------------------------------
|
||||
|
||||
local dropperEnabled = false
|
||||
if fs.exists(DROPPER_CONFIG) then
|
||||
local f = fs.open(DROPPER_CONFIG, "r")
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, f.readAll())
|
||||
f.close()
|
||||
if ok and cfg and cfg.enabled then
|
||||
dropperEnabled = true
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if dropperEnabled then
|
||||
print("Starting inventoryClient + dropperController...")
|
||||
else
|
||||
print("Starting inventoryClient (no dropper)...")
|
||||
end
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this computer on remote command
|
||||
local SYSTEM_CHANNEL = 4205
|
||||
local ROLE = "client"
|
||||
|
||||
local function rebootListener()
|
||||
local m = peripheral.find("modem")
|
||||
if not m then return end
|
||||
m.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == ROLE or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received. Rebooting...")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local tasks = {
|
||||
function() shell.run("inventoryClient.lua") end,
|
||||
rebootListener,
|
||||
}
|
||||
if dropperEnabled then
|
||||
table.insert(tasks, function() shell.run("dropperController.lua") end)
|
||||
end
|
||||
|
||||
parallel.waitForAny(table.unpack(tasks))
|
||||
110
startup/manager.lua
Normal file
110
startup/manager.lua
Normal file
@@ -0,0 +1,110 @@
|
||||
-- startup.lua for Inventory Manager computer
|
||||
-- Auto-updates from git then launches inventoryManager.lua
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
-- Files to download (destination -> repo path)
|
||||
local FILES = {
|
||||
["inventoryManager.lua"] = "inventoryManager.lua",
|
||||
["manager/config.lua"] = "manager/config.lua",
|
||||
["manager/state.lua"] = "manager/state.lua",
|
||||
["manager/operations.lua"] = "manager/operations.lua",
|
||||
["manager/display.lua"] = "manager/display.lua",
|
||||
["lib/log.lua"] = "lib/log.lua",
|
||||
["lib/ui.lua"] = "lib/ui.lua",
|
||||
["lib/itemDB.lua"] = "lib/itemDB.lua",
|
||||
["lib/craft.lua"] = "lib/craft.lua",
|
||||
["lib/recipeBook.lua"] = "lib/recipeBook.lua",
|
||||
["data/smeltable.lua"] = "data/smeltable.lua",
|
||||
["data/fuel.lua"] = "data/fuel.lua",
|
||||
["data/compostable.lua"] = "data/compostable.lua",
|
||||
["data/craftable.lua"] = "data/craftable.lua",
|
||||
["data/alerts.lua"] = "data/alerts.lua",
|
||||
["data/stock_limits.lua"] = "data/stock_limits.lua",
|
||||
["data/auto_craft.lua"] = "data/auto_craft.lua",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
local function ensureDir(filePath)
|
||||
local dir = filePath:match("^(.+)/")
|
||||
if dir and not fs.isDir(dir) then
|
||||
fs.makeDir(dir)
|
||||
end
|
||||
end
|
||||
|
||||
local function download(remotePath, localPath)
|
||||
local url = REPO_RAW .. "/" .. remotePath
|
||||
local response = http.get(url)
|
||||
if response then
|
||||
ensureDir(localPath)
|
||||
local f = fs.open(localPath, "w")
|
||||
f.write(response.readAll())
|
||||
f.close()
|
||||
response.close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("==================================")
|
||||
print(" Inventory Manager - Startup")
|
||||
print(" Computer ID: " .. os.getComputerID())
|
||||
print("==================================")
|
||||
print("")
|
||||
|
||||
-- Update files
|
||||
local updated, failed = 0, 0
|
||||
for localPath, remotePath in pairs(FILES) do
|
||||
write(" " .. localPath .. " ... ")
|
||||
if download(remotePath, localPath) then
|
||||
print("OK")
|
||||
updated = updated + 1
|
||||
else
|
||||
print("FAIL")
|
||||
failed = failed + 1
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if failed > 0 then
|
||||
print(string.format("Updated %d files, %d failed.", updated, failed))
|
||||
print("Continuing with existing files...")
|
||||
else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
print("")
|
||||
print("Starting inventoryManager...")
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this computer when the manager sends
|
||||
-- a reboot command to itself (target = "all" or "manager")
|
||||
local SYSTEM_CHANNEL = 4205
|
||||
local ROLE = "manager"
|
||||
|
||||
local function rebootListener()
|
||||
local m = peripheral.find("modem")
|
||||
if not m then return end
|
||||
m.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == ROLE or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received. Rebooting...")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(
|
||||
function() shell.run("inventoryManager.lua") end,
|
||||
rebootListener
|
||||
)
|
||||
82
startup/miner.lua
Normal file
82
startup/miner.lua
Normal file
@@ -0,0 +1,82 @@
|
||||
-- startup.lua for Mining Turtle
|
||||
-- Auto-updates from git then launches miningTurtle.lua
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
local FILES = {
|
||||
["miningTurtle.lua"] = "miningTurtle.lua",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
local function download(remotePath, localPath)
|
||||
local url = REPO_RAW .. "/" .. remotePath
|
||||
local response = http.get(url)
|
||||
if response then
|
||||
local f = fs.open(localPath, "w")
|
||||
f.write(response.readAll())
|
||||
f.close()
|
||||
response.close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("==================================")
|
||||
print(" Mining Turtle - Startup")
|
||||
print(" Computer ID: " .. os.getComputerID())
|
||||
print("==================================")
|
||||
print("")
|
||||
|
||||
local updated, failed = 0, 0
|
||||
for localPath, remotePath in pairs(FILES) do
|
||||
write(" " .. localPath .. " ... ")
|
||||
if download(remotePath, localPath) then
|
||||
print("OK")
|
||||
updated = updated + 1
|
||||
else
|
||||
print("FAIL")
|
||||
failed = failed + 1
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if failed > 0 then
|
||||
print(string.format("Updated %d files, %d failed.", updated, failed))
|
||||
else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
print("")
|
||||
print("Starting miningTurtle...")
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this turtle on remote command
|
||||
local SYSTEM_CHANNEL = 4205
|
||||
local ROLE = "miner"
|
||||
|
||||
local function rebootListener()
|
||||
local m = peripheral.find("modem")
|
||||
if not m then return end
|
||||
m.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == ROLE or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received. Rebooting...")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(
|
||||
function() shell.run("miningTurtle.lua") end,
|
||||
rebootListener
|
||||
)
|
||||
82
startup/turtle.lua
Normal file
82
startup/turtle.lua
Normal file
@@ -0,0 +1,82 @@
|
||||
-- startup.lua for Crafting Turtle
|
||||
-- Auto-updates from git then launches craftingTurtle.lua
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
local FILES = {
|
||||
["craftingTurtle.lua"] = "craftingTurtle.lua",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
local function download(remotePath, localPath)
|
||||
local url = REPO_RAW .. "/" .. remotePath
|
||||
local response = http.get(url)
|
||||
if response then
|
||||
local f = fs.open(localPath, "w")
|
||||
f.write(response.readAll())
|
||||
f.close()
|
||||
response.close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("==================================")
|
||||
print(" Crafting Turtle - Startup")
|
||||
print(" Computer ID: " .. os.getComputerID())
|
||||
print("==================================")
|
||||
print("")
|
||||
|
||||
local updated, failed = 0, 0
|
||||
for localPath, remotePath in pairs(FILES) do
|
||||
write(" " .. localPath .. " ... ")
|
||||
if download(remotePath, localPath) then
|
||||
print("OK")
|
||||
updated = updated + 1
|
||||
else
|
||||
print("FAIL")
|
||||
failed = failed + 1
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if failed > 0 then
|
||||
print(string.format("Updated %d files, %d failed.", updated, failed))
|
||||
else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
print("")
|
||||
print("Starting craftingTurtle...")
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this turtle on remote command
|
||||
local SYSTEM_CHANNEL = 4205
|
||||
local ROLE = "turtle"
|
||||
|
||||
local function rebootListener()
|
||||
local m = peripheral.find("modem")
|
||||
if not m then return end
|
||||
m.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == ROLE or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received. Rebooting...")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(
|
||||
function() shell.run("craftingTurtle.lua") end,
|
||||
rebootListener
|
||||
)
|
||||
3
web/client/.dockerignore
Normal file
3
web/client/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
26
web/client/Dockerfile
Normal file
26
web/client/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# Stage 1: Build the React app
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
# Accept API key at build time so Vite can inline it
|
||||
ARG VITE_API_KEY=""
|
||||
ENV VITE_API_KEY=${VITE_API_KEY}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
15
web/client/index.html
Normal file
15
web/client/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Inventory Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
75
web/client/nginx.conf
Normal file
75
web/client/nginx.conf
Normal file
@@ -0,0 +1,75 @@
|
||||
# Texture proxy cache zone (100MB, inactive entries purged after 7 days)
|
||||
proxy_cache_path /tmp/nginx_texture_cache levels=1:2 keys_zone=textures:10m
|
||||
max_size=100m inactive=7d use_temp_path=off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
server_tokens off;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "0" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Serve static files, fallback to index.html for SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend server
|
||||
location /api/ {
|
||||
proxy_pass http://server:3001/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy and cache texture requests
|
||||
location /api/texture/ {
|
||||
proxy_pass http://server:3001/api/texture/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Let the server set Cache-Control, but also cache at nginx level
|
||||
proxy_cache textures;
|
||||
proxy_cache_valid 200 7d;
|
||||
proxy_cache_valid 404 1d;
|
||||
expires 7d;
|
||||
add_header X-Nginx-Cache $upstream_cache_status;
|
||||
}
|
||||
|
||||
# Proxy WebSocket for browser clients
|
||||
location /ws {
|
||||
proxy_pass http://server:3001/ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# Proxy WebSocket for bridge clients
|
||||
location /ws/bridge {
|
||||
proxy_pass http://server:3001/ws/bridge;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
1791
web/client/package-lock.json
generated
Normal file
1791
web/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web/client/package.json
Normal file
22
web/client/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "inventory-manager-client",
|
||||
"version": "1.0.0",
|
||||
"description": "CC:Tweaked Inventory Manager - Web Dashboard",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
452
web/client/src/App.css
Normal file
452
web/client/src/App.css
Normal file
@@ -0,0 +1,452 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Inventory Manager Dashboard
|
||||
============================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&display=swap');
|
||||
|
||||
/* === Cross-link Button === */
|
||||
.cross-link-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 2px solid var(--mc-dark);
|
||||
background: #2e4a8b;
|
||||
color: var(--mc-text-aqua);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 1px 0 #4466bb, inset 0 -1px 0 #1a2a66;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cross-link-btn:hover {
|
||||
background: #3e5a9b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
:root {
|
||||
--mc-dark: #1a1a1a;
|
||||
--mc-darker: #0e0e0e;
|
||||
--mc-stone: #7b7b7b;
|
||||
--mc-stone-light: #9d9d9d;
|
||||
--mc-stone-dark: #4b4b4b;
|
||||
--mc-dirt: #6b5030;
|
||||
--mc-dirt-dark: #4a3520;
|
||||
--mc-oak: #8b6d3c;
|
||||
--mc-oak-dark: #6b4e28;
|
||||
--mc-oak-light: #b89b60;
|
||||
--mc-grass: #5b8731;
|
||||
--mc-grass-light: #80b94e;
|
||||
--mc-grass-dark: #3d6b1a;
|
||||
--mc-inv-bg: #c6c6c6;
|
||||
--mc-inv-slot: #8b8b8b;
|
||||
--mc-inv-slot-border: #373737;
|
||||
--mc-inv-slot-light: #ffffff;
|
||||
--mc-text-white: #e0e0e0;
|
||||
--mc-text-gray: #a0a0a0;
|
||||
--mc-text-dark: #404040;
|
||||
--mc-text-yellow: #ffff55;
|
||||
--mc-text-green: #55ff55;
|
||||
--mc-text-red: #ff5555;
|
||||
--mc-text-aqua: #55ffff;
|
||||
--mc-text-gold: #ffaa00;
|
||||
--mc-text-light-purple: #ff55ff;
|
||||
--mc-text-blue: #5555ff;
|
||||
--mc-diamond: #4aedd9;
|
||||
--mc-emerald: #17dd62;
|
||||
--mc-redstone: #ff0000;
|
||||
--mc-lapis: #345ec3;
|
||||
--mc-border-highlight: #ffffff55;
|
||||
--mc-border-shadow: #00000088;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
background: #2c2c2c;
|
||||
color: var(--mc-text-white);
|
||||
image-rendering: pixelated;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Header === */
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--mc-oak-dark);
|
||||
border-bottom: 3px solid var(--mc-dark);
|
||||
box-shadow:
|
||||
inset 0 2px 0 var(--mc-oak-light),
|
||||
inset 0 -2px 0 var(--mc-dirt-dark);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text-yellow);
|
||||
text-shadow: 2px 2px 0 var(--mc-dark);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* === Alerts Bell === */
|
||||
.alerts-bell {
|
||||
position: relative;
|
||||
background: none;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-radius: 0;
|
||||
transition: all 0.1s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.alerts-bell:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.alerts-bell.has-alerts {
|
||||
animation: bell-shake 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.alerts-bell-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background: var(--mc-text-red);
|
||||
color: white;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
padding: 1px 3px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--mc-dark);
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
@keyframes bell-shake {
|
||||
0%, 80%, 100% { transform: rotate(0deg); }
|
||||
85% { transform: rotate(8deg); }
|
||||
90% { transform: rotate(-8deg); }
|
||||
95% { transform: rotate(4deg); }
|
||||
}
|
||||
|
||||
/* === Connection Status === */
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 2px solid var(--mc-dark);
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
.connection-status.connected {
|
||||
background: var(--mc-grass-dark);
|
||||
color: var(--mc-text-green);
|
||||
box-shadow: inset 0 1px 0 var(--mc-grass-light), inset 0 -1px 0 #2a5010;
|
||||
}
|
||||
|
||||
.connection-status.disconnected {
|
||||
background: #6b1a1a;
|
||||
color: var(--mc-text-red);
|
||||
box-shadow: inset 0 1px 0 #a03030, inset 0 -1px 0 #400e0e;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 0;
|
||||
animation: mc-blink 1.5s steps(2) infinite;
|
||||
}
|
||||
|
||||
.connected .status-dot {
|
||||
background: var(--mc-text-green);
|
||||
box-shadow: 0 0 4px var(--mc-text-green);
|
||||
}
|
||||
|
||||
.disconnected .status-dot {
|
||||
background: var(--mc-text-red);
|
||||
box-shadow: 0 0 4px var(--mc-text-red);
|
||||
}
|
||||
|
||||
@keyframes mc-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.last-update-text {
|
||||
font-size: 0.65rem;
|
||||
color: var(--mc-text-gray, #aaa);
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === Command Toast === */
|
||||
.command-toast {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: 2px solid var(--mc-dark);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
z-index: 1000;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
animation: toast-in 0.2s ease;
|
||||
}
|
||||
|
||||
.command-toast.success {
|
||||
background: var(--mc-grass-dark);
|
||||
color: var(--mc-text-green);
|
||||
}
|
||||
|
||||
.command-toast.error {
|
||||
background: #6b1a1a;
|
||||
color: var(--mc-text-red);
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
/* === Main Content === */
|
||||
.app-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Sidebar === */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: #333;
|
||||
border-right: 3px solid var(--mc-dark);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* === Main Panel === */
|
||||
.main-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Panel Tabs === */
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background: #3b3b3b;
|
||||
border-bottom: 3px solid var(--mc-dark);
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-tabs button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid var(--mc-dark);
|
||||
background: #5a5a5a;
|
||||
color: var(--mc-text-gray);
|
||||
border-radius: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #777;
|
||||
}
|
||||
|
||||
.panel-tabs button:hover {
|
||||
background: #6b6b6b;
|
||||
color: var(--mc-text-white);
|
||||
}
|
||||
|
||||
.panel-tabs button.active {
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.panel-content-wrapper {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* === Scrollbar === */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #5a5a5a;
|
||||
border: 1px solid var(--mc-dark);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b6b6b;
|
||||
}
|
||||
|
||||
/* === Section styling === */
|
||||
.detail-section {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.875rem;
|
||||
background: #3b3b3b;
|
||||
border: 3px solid var(--mc-dark);
|
||||
border-radius: 0;
|
||||
box-shadow:
|
||||
inset 0 2px 0 #555,
|
||||
inset 0 -2px 0 #2a2a2a;
|
||||
}
|
||||
|
||||
.detail-section h3 {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--mc-text-gold);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* === MC Button === */
|
||||
.mc-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 2px solid var(--mc-dark);
|
||||
border-radius: 0;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.05s;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
background: #6b6b6b;
|
||||
box-shadow: inset 0 2px 0 #999, inset 0 -2px 0 #444;
|
||||
}
|
||||
|
||||
.mc-btn:hover {
|
||||
background: #7b7b7b;
|
||||
box-shadow: inset 0 2px 0 #aaa, inset 0 -2px 0 #555;
|
||||
}
|
||||
|
||||
.mc-btn:active {
|
||||
background: #555;
|
||||
box-shadow: inset 0 2px 0 #333, inset 0 -2px 0 #777;
|
||||
}
|
||||
|
||||
.mc-btn.green {
|
||||
background: var(--mc-grass-dark);
|
||||
box-shadow: inset 0 2px 0 var(--mc-grass-light), inset 0 -2px 0 #2a5010;
|
||||
}
|
||||
.mc-btn.green:hover { background: var(--mc-grass); }
|
||||
|
||||
.mc-btn.red {
|
||||
background: #8b2e2e;
|
||||
box-shadow: inset 0 2px 0 #bb4444, inset 0 -2px 0 #661a1a;
|
||||
}
|
||||
.mc-btn.red:hover { background: #a03e3e; }
|
||||
|
||||
.mc-btn.blue {
|
||||
background: #2e4a8b;
|
||||
box-shadow: inset 0 2px 0 #4466bb, inset 0 -2px 0 #1a2a66;
|
||||
}
|
||||
.mc-btn.blue:hover { background: #3e5a9b; }
|
||||
|
||||
.mc-btn.gold {
|
||||
background: #8b6b2e;
|
||||
box-shadow: inset 0 2px 0 #bb9944, inset 0 -2px 0 #664a1a;
|
||||
}
|
||||
.mc-btn.gold:hover { background: #9b7b3e; }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
border-right: none;
|
||||
border-bottom: 3px solid var(--mc-dark);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.app-header h1 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.panel-tabs button {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
164
web/client/src/App.jsx
Normal file
164
web/client/src/App.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import InventoryGrid from './components/InventoryGrid';
|
||||
import StorageOverview from './components/StorageOverview';
|
||||
import SmeltingPanel from './components/SmeltingPanel';
|
||||
import CraftingPanel from './components/CraftingPanel';
|
||||
import AlertsPanel from './components/AlertsPanel';
|
||||
import AnalyticsPanel from './components/AnalyticsPanel';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { useInventoryStore } from './store/inventoryStore';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const connect = useInventoryStore((state) => state.connect);
|
||||
const connected = useInventoryStore((state) => state.connected);
|
||||
const bridgeConnected = useInventoryStore((state) => state.bridgeConnected);
|
||||
const lastUpdate = useInventoryStore((state) => state.lastUpdate);
|
||||
const commandResult = useInventoryStore((state) => state.commandResult);
|
||||
const alerts = useInventoryStore((state) => state.alerts) || [];
|
||||
const [panelTab, setPanelTab] = useState('inventory');
|
||||
const [showAlerts, setShowAlerts] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || 'https://turtles.spatulaa.com';
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
// Re-render every 5s so the "last updated" text stays fresh
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => forceRender((n) => n + 1), 5000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const staleSecs = lastUpdate ? Math.floor((Date.now() - lastUpdate) / 1000) : null;
|
||||
|
||||
const renderPanelContent = () => {
|
||||
switch (panelTab) {
|
||||
case 'inventory':
|
||||
return <InventoryGrid />;
|
||||
case 'smelting':
|
||||
return <SmeltingPanel />;
|
||||
case 'crafting':
|
||||
return <CraftingPanel />;
|
||||
case 'analytics':
|
||||
return <AnalyticsPanel />;
|
||||
default:
|
||||
return <InventoryGrid />;
|
||||
}
|
||||
};
|
||||
|
||||
const triggeredAlerts = alerts.filter((a) => a.triggered !== false);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="app-header">
|
||||
<h1>⛏️ Inventory Manager</h1>
|
||||
<div className="header-right">
|
||||
{/* Cross-link to Turtle Dashboard */}
|
||||
<a
|
||||
href={turtleDashboardUrl}
|
||||
className="cross-link-btn"
|
||||
title="Open Turtle Control Dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🐢 Turtles
|
||||
</a>
|
||||
{/* Settings gear button */}
|
||||
<button
|
||||
className="settings-gear"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
title="Settings"
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
{/* Alerts bell button */}
|
||||
<button
|
||||
className={`alerts-bell ${triggeredAlerts.length > 0 ? 'has-alerts' : ''}`}
|
||||
onClick={() => setShowAlerts(!showAlerts)}
|
||||
title="Low-stock alerts"
|
||||
>
|
||||
🔔
|
||||
{triggeredAlerts.length > 0 && (
|
||||
<span className="alerts-bell-badge">{triggeredAlerts.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className={`connection-status ${connected ? 'connected' : 'disconnected'}`}>
|
||||
<span className="status-dot"></span>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
{connected && (
|
||||
<div className={`connection-status ${bridgeConnected ? 'connected' : 'disconnected'}`} title="Minecraft CC:Tweaked bridge">
|
||||
<span className="status-dot"></span>
|
||||
{bridgeConnected ? 'Bridge OK' : 'Bridge Off'}
|
||||
</div>
|
||||
)}
|
||||
{staleSecs !== null && staleSecs > 0 && (
|
||||
<span className="last-update-text" title="Time since last data update">
|
||||
{staleSecs < 60 ? `${staleSecs}s ago` : `${Math.floor(staleSecs / 60)}m ago`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{commandResult && (
|
||||
<div className={`command-toast ${commandResult.success ? 'success' : 'error'}`}>
|
||||
{commandResult.message || commandResult.error || (commandResult.success ? 'OK' : 'Failed')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alerts popup overlay */}
|
||||
<AlertsPanel isOpen={showAlerts} onClose={() => setShowAlerts(false)} />
|
||||
|
||||
{/* Settings overlay */}
|
||||
<SettingsPanel isOpen={showSettings} onClose={() => setShowSettings(false)} />
|
||||
|
||||
<div className="app-content">
|
||||
<div className="sidebar">
|
||||
<ErrorBoundary>
|
||||
<StorageOverview />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div className="main-panel">
|
||||
<div className="panel-tabs">
|
||||
<button
|
||||
className={panelTab === 'inventory' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('inventory')}
|
||||
>
|
||||
📦 Inventory
|
||||
</button>
|
||||
<button
|
||||
className={panelTab === 'smelting' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('smelting')}
|
||||
>
|
||||
🔥 Smelting
|
||||
</button>
|
||||
<button
|
||||
className={panelTab === 'crafting' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('crafting')}
|
||||
>
|
||||
🔨 Crafting
|
||||
</button>
|
||||
<button
|
||||
className={panelTab === 'analytics' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('analytics')}
|
||||
>
|
||||
📊 Analytics
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-content-wrapper">
|
||||
<ErrorBoundary>
|
||||
{renderPanelContent()}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
167
web/client/src/components/AlertsPanel.css
Normal file
167
web/client/src/components/AlertsPanel.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/* ===== Alerts Popup Overlay ===== */
|
||||
.alerts-overlay {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
animation: alerts-slide-in 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes alerts-slide-in {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.alerts-popup {
|
||||
width: 340px;
|
||||
max-height: 400px;
|
||||
background: #333;
|
||||
border: 3px solid var(--mc-dark);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.6),
|
||||
inset 0 2px 0 #555,
|
||||
inset 0 -2px 0 #2a2a2a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.alerts-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #3b3b3b;
|
||||
border-bottom: 2px solid var(--mc-dark);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alerts-popup-header h3 {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mc-text-yellow);
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
flex: 1;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.alerts-close {
|
||||
background: none;
|
||||
border: 2px solid #555;
|
||||
color: var(--mc-text-gray);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.alerts-close:hover {
|
||||
color: var(--mc-text-red);
|
||||
border-color: var(--mc-text-red);
|
||||
}
|
||||
|
||||
.alerts-popup-body {
|
||||
overflow-y: auto;
|
||||
max-height: 330px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--mc-text-gray);
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--mc-dark);
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.alert-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem;
|
||||
background: #3b3b3b;
|
||||
border: 3px solid var(--mc-dark);
|
||||
box-shadow: inset 0 2px 0 #555, inset 0 -2px 0 #2a2a2a;
|
||||
}
|
||||
|
||||
.alert-card.triggered {
|
||||
border-color: #661a1a;
|
||||
background: #3b2a2a;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.alert-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.alert-item-name {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
.alert-details {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-gray);
|
||||
}
|
||||
|
||||
.alert-details strong {
|
||||
color: var(--mc-text-white);
|
||||
}
|
||||
|
||||
.alert-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
border: 2px solid var(--mc-dark);
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-badge.low {
|
||||
background: #6b1a1a;
|
||||
color: var(--mc-text-red);
|
||||
}
|
||||
|
||||
.alert-badge.ok {
|
||||
background: var(--mc-grass-dark);
|
||||
color: var(--mc-text-green);
|
||||
}
|
||||
|
||||
.no-alerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
color: var(--mc-text-gray);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.no-alerts-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
92
web/client/src/components/AlertsPanel.jsx
Normal file
92
web/client/src/components/AlertsPanel.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import { formatItemName } from '../utils/itemUtils';
|
||||
import ItemIcon from './ItemIcon';
|
||||
import './AlertsPanel.css';
|
||||
|
||||
function AlertsPanel({ isOpen, onClose }) {
|
||||
const alerts = useInventoryStore((state) => state.alerts) || [];
|
||||
const panelRef = useRef(null);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleClickOutside = (e) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target)) {
|
||||
// Check if the click was on the bell button (has alerts-bell class)
|
||||
if (e.target.closest('.alerts-bell')) return;
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleEsc = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => document.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="alerts-overlay" ref={panelRef}>
|
||||
<div className="alerts-popup">
|
||||
<div className="alerts-popup-header">
|
||||
<h3>🔔 Low-Stock Alerts</h3>
|
||||
<span className="alert-count">
|
||||
{alerts.length} alert{alerts.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<button className="alerts-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="alerts-popup-body">
|
||||
{alerts.length > 0 ? (
|
||||
<div className="alert-list">
|
||||
{alerts.map((alert, idx) => {
|
||||
const itemName = alert.item || alert.name || alert.label || '';
|
||||
const isTriggered = alert.triggered !== undefined ? alert.triggered : true;
|
||||
const current = alert.current ?? '?';
|
||||
const threshold = alert.threshold || alert.min || '?';
|
||||
return (
|
||||
<div key={idx} className={`alert-card ${isTriggered ? 'triggered' : 'ok'}`}>
|
||||
<div className="alert-icon">
|
||||
{isTriggered ? '🔴' : '🟢'}
|
||||
</div>
|
||||
<div className="alert-info">
|
||||
<div className="alert-item-row">
|
||||
<ItemIcon itemName={itemName} size={20} />
|
||||
<span className="alert-item-name">{formatItemName(itemName)}</span>
|
||||
</div>
|
||||
<div className="alert-details">
|
||||
<span className="alert-stock">
|
||||
Stock: <strong>{current}</strong>
|
||||
</span>
|
||||
<span className="alert-threshold">
|
||||
Min: <strong>{threshold}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`alert-badge ${isTriggered ? 'low' : 'ok'}`}>
|
||||
{isTriggered ? 'LOW' : 'OK'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-alerts">
|
||||
<span className="no-alerts-icon">✅</span>
|
||||
<span>All stock levels are OK</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlertsPanel;
|
||||
718
web/client/src/components/AnalyticsPanel.css
Normal file
718
web/client/src/components/AnalyticsPanel.css
Normal file
@@ -0,0 +1,718 @@
|
||||
/* ============================================
|
||||
Analytics Panel — Minecraft Dashboard
|
||||
============================================ */
|
||||
|
||||
.analytics-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ===== Hero Stats Row ===== */
|
||||
.analytics-hero {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%);
|
||||
border: 2px solid #333;
|
||||
border-radius: 2px;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.hero-stat::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hero-stat--items::before { background: var(--mc-text-green); }
|
||||
.hero-stat--types::before { background: var(--mc-text-aqua); }
|
||||
.hero-stat--chests::before { background: var(--mc-text-gold); }
|
||||
.hero-stat--furnaces::before { background: var(--mc-text-red); }
|
||||
.hero-stat--recipes::before { background: var(--mc-text-light-purple); }
|
||||
|
||||
.hero-stat:hover {
|
||||
border-color: #555;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.hero-stat-icon {
|
||||
font-size: 1.25rem;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.hero-stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.hero-stat-value {
|
||||
font-size: 1rem;
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-stat-label {
|
||||
font-size: 0.45rem;
|
||||
color: var(--mc-text-gray);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-stat-delta {
|
||||
font-size: 0.45rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.hero-stat-delta.positive {
|
||||
color: var(--mc-text-green);
|
||||
}
|
||||
|
||||
.hero-stat-delta.negative {
|
||||
color: var(--mc-text-red);
|
||||
}
|
||||
|
||||
.hero-active {
|
||||
color: var(--mc-text-green);
|
||||
}
|
||||
|
||||
/* ===== Analytics Grid ===== */
|
||||
.analytics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ===== Cards ===== */
|
||||
.analytics-card {
|
||||
padding: 0.875rem;
|
||||
background: linear-gradient(180deg, #3b3b3b 0%, #333 100%);
|
||||
border: 2px solid #444;
|
||||
border-radius: 2px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 #555,
|
||||
inset 0 -1px 0 #2a2a2a,
|
||||
0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.analytics-card:hover {
|
||||
border-color: #555;
|
||||
box-shadow:
|
||||
inset 0 1px 0 #555,
|
||||
inset 0 -1px 0 #2a2a2a,
|
||||
0 6px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.analytics-card-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.625rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-size: 0.7rem;
|
||||
color: var(--mc-text-gold);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
font-size: 0.45rem;
|
||||
color: var(--mc-text-gray);
|
||||
background: #2a2a2a;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 1px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ===== Chart Area ===== */
|
||||
.chart-wrapper {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 1px;
|
||||
padding: 0.5rem 0.25rem 0.25rem;
|
||||
}
|
||||
|
||||
/* ===== Multi-line Scrollable Chart ===== */
|
||||
.multiline-chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.multiline-chart-container.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.multiline-chart-container:not(.dragging) {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.chart-tooltip {
|
||||
position: absolute;
|
||||
background: #1a1a1acc;
|
||||
border: 1px solid #555;
|
||||
border-radius: 2px;
|
||||
padding: 0.375rem 0.5rem;
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
min-width: 100px;
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.chart-tooltip-time {
|
||||
font-size: 0.5rem;
|
||||
color: var(--mc-text-gray);
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.chart-tooltip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.5rem;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
|
||||
.chart-tooltip-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-tooltip-name {
|
||||
color: var(--mc-text-gray);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.chart-tooltip-val {
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.chart-scrollbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0 0.125rem;
|
||||
}
|
||||
|
||||
.chart-scrollbar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-scrollbar-thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #555, #666);
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s;
|
||||
min-width: 12px;
|
||||
}
|
||||
|
||||
.chart-scrollbar-thumb:hover,
|
||||
.multiline-chart-container.dragging .chart-scrollbar-thumb {
|
||||
background: linear-gradient(90deg, #777, #888);
|
||||
}
|
||||
|
||||
.chart-scrollbar-hint {
|
||||
font-size: 0.4rem;
|
||||
color: #444;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ===== Tracked Items Chips Bar ===== */
|
||||
.tracked-items-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tracked-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #555;
|
||||
border-radius: 2px;
|
||||
font-size: 0.5rem;
|
||||
color: var(--mc-text-white);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.tracked-chip:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.tracked-chip-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tracked-chip-name {
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tracked-chip-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 0.55rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.15rem;
|
||||
font-family: 'Silkscreen', monospace;
|
||||
line-height: 1;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.tracked-chip-remove:hover {
|
||||
color: var(--mc-text-red);
|
||||
}
|
||||
|
||||
.svg-chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mc-text-gray);
|
||||
font-size: 0.65rem;
|
||||
gap: 0.375rem;
|
||||
background: #222;
|
||||
border: 1px dashed #444;
|
||||
border-radius: 2px;
|
||||
padding: 1.5rem;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.chart-empty--compact {
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.chart-empty-icon {
|
||||
font-size: 1.5rem;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.chart-empty-sub {
|
||||
font-size: 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* ===== Storage Health Card ===== */
|
||||
.analytics-card--overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.overview-split {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.overview-stats-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.mini-stat-label {
|
||||
font-size: 0.5rem;
|
||||
color: var(--mc-text-gray);
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mini-stat-value {
|
||||
font-size: 0.7rem;
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
/* ===== Capacity Gauge ===== */
|
||||
.capacity-gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.capacity-gauge svg {
|
||||
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
.capacity-gauge-detail {
|
||||
font-size: 0.45rem;
|
||||
color: var(--mc-text-gray);
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== Category Donut ===== */
|
||||
.analytics-card--categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.donut-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.donut-chart {
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
.donut-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.donut-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.5rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.donut-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 1px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.donut-legend-label {
|
||||
color: var(--mc-text-gray);
|
||||
flex: 1;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.donut-legend-value {
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
/* ===== Bar Chart ===== */
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
animation: bar-slide-in 0.4s ease both;
|
||||
}
|
||||
|
||||
@keyframes bar-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.bar-rank {
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bar-rank-num {
|
||||
font-size: 0.5rem;
|
||||
color: #555;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.5rem;
|
||||
color: var(--mc-text-gray);
|
||||
min-width: 90px;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bar-label span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
flex: 1;
|
||||
height: 18px;
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
border-radius: 0 1px 1px 0;
|
||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.15);
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-white);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
/* ===== Item History Search ===== */
|
||||
.item-history-search {
|
||||
position: relative;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.item-history-search .search-input {
|
||||
width: 100%;
|
||||
background: #222;
|
||||
border: 2px solid #444;
|
||||
color: var(--mc-text-white);
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
font-size: 0.6rem;
|
||||
padding: 0.5rem 0.5rem 0.5rem 1.75rem;
|
||||
outline: none;
|
||||
border-radius: 1px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.item-history-search .search-input:focus {
|
||||
border-color: var(--mc-text-aqua);
|
||||
box-shadow: 0 0 8px rgba(85, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.item-history-search .search-input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.item-history-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #222;
|
||||
border: 2px solid #555;
|
||||
border-top: none;
|
||||
z-index: 50;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.item-history-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-white);
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.item-history-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-history-option:hover {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.option-count {
|
||||
margin-left: auto;
|
||||
color: var(--mc-text-gray);
|
||||
font-size: 0.45rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.item-history-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.item-history-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 900px) {
|
||||
.analytics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.analytics-hero {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.overview-split {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.donut-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.analytics-panel {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
min-width: 80px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.hero-stat-value {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
min-width: 70px;
|
||||
max-width: 70px;
|
||||
}
|
||||
}
|
||||
845
web/client/src/components/AnalyticsPanel.jsx
Normal file
845
web/client/src/components/AnalyticsPanel.jsx
Normal file
@@ -0,0 +1,845 @@
|
||||
import React, { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import { formatItemName, formatCount, getItemCategory, ITEM_CATEGORIES } from '../utils/itemUtils';
|
||||
import ItemIcon from './ItemIcon';
|
||||
import './AnalyticsPanel.css';
|
||||
|
||||
// ===== Palette for multi-line series =====
|
||||
const SERIES_COLORS = [
|
||||
'#55ff55', '#55ffff', '#ffaa00', '#ff55ff', '#ff5555',
|
||||
'#5555ff', '#ffff55', '#ff8844', '#44ddaa', '#dd88ff',
|
||||
'#88ccff', '#ffcc88',
|
||||
];
|
||||
|
||||
// ===== Smooth curve through points (cardinal spline) =====
|
||||
function smoothPath(pts) {
|
||||
if (!pts || pts.length === 0) return '';
|
||||
if (pts.length < 3) {
|
||||
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
|
||||
}
|
||||
let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(0, i - 1)];
|
||||
const p1 = pts[i];
|
||||
const p2 = pts[i + 1];
|
||||
const p3 = pts[Math.min(pts.length - 1, i + 2)];
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||
d += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// ===== Multi-line scrollable SVG chart =====
|
||||
function MultiLineChart({ series, height = 220, id = 'ml-chart' }) {
|
||||
// series = [{ name, color, data: [{ time, value }] }, ...]
|
||||
const containerRef = useRef(null);
|
||||
const [containerWidth, setContainerWidth] = useState(700);
|
||||
const [viewStart, setViewStart] = useState(0); // 0..1 fractional scroll position
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartView, setDragStartView] = useState(0);
|
||||
const [tooltip, setTooltip] = useState(null);
|
||||
|
||||
// Use a visible window size — show ~30 data points at a time
|
||||
const VISIBLE_WINDOW = 30;
|
||||
|
||||
// Measure container
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const obs = new ResizeObserver(([e]) => setContainerWidth(e.contentRect.width));
|
||||
obs.observe(containerRef.current);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
// Collect all unique timestamps across all series
|
||||
const allTimestamps = useMemo(() => {
|
||||
const tsSet = new Set();
|
||||
(series || []).forEach((s) => (s.data || []).forEach((d) => tsSet.add(d.time)));
|
||||
return [...tsSet].sort((a, b) => new Date(a) - new Date(b));
|
||||
}, [series]);
|
||||
|
||||
const totalPoints = allTimestamps.length;
|
||||
const canScroll = totalPoints > VISIBLE_WINDOW;
|
||||
|
||||
// Compute the visible slice of timestamps
|
||||
const maxOffset = Math.max(0, totalPoints - VISIBLE_WINDOW);
|
||||
const scrollOffset = Math.round(viewStart * maxOffset);
|
||||
const visibleTimestamps = canScroll
|
||||
? allTimestamps.slice(scrollOffset, scrollOffset + VISIBLE_WINDOW)
|
||||
: allTimestamps;
|
||||
|
||||
// Reset scroll when series change significantly
|
||||
useEffect(() => {
|
||||
setViewStart(1); // default: show latest
|
||||
}, [series.length]);
|
||||
|
||||
// Build data per series for visible window
|
||||
const pad = { top: 24, right: 14, bottom: 28, left: 52 };
|
||||
const w = containerWidth - pad.left - pad.right;
|
||||
const h = height - pad.top - pad.bottom;
|
||||
|
||||
const computedSeries = useMemo(() => {
|
||||
if (visibleTimestamps.length < 1) return [];
|
||||
|
||||
// Build a timestamp → index map for x positioning
|
||||
const tsIdx = {};
|
||||
visibleTimestamps.forEach((ts, i) => { tsIdx[ts] = i; });
|
||||
const count = visibleTimestamps.length;
|
||||
|
||||
// Global min/max over ALL visible series in this window
|
||||
let globalMin = Infinity;
|
||||
let globalMax = -Infinity;
|
||||
const seriesLookups = (series || []).map((s) => {
|
||||
const lookup = {};
|
||||
(s.data || []).forEach((d) => { lookup[d.time] = d.value; });
|
||||
return lookup;
|
||||
});
|
||||
|
||||
seriesLookups.forEach((lookup) => {
|
||||
visibleTimestamps.forEach((ts) => {
|
||||
const v = lookup[ts];
|
||||
if (v != null) {
|
||||
if (v < globalMin) globalMin = v;
|
||||
if (v > globalMax) globalMax = v;
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!isFinite(globalMin)) globalMin = 0;
|
||||
if (!isFinite(globalMax)) globalMax = 1;
|
||||
const range = globalMax - globalMin || 1;
|
||||
|
||||
return (series || []).map((s, si) => {
|
||||
const lookup = seriesLookups[si];
|
||||
const points = [];
|
||||
visibleTimestamps.forEach((ts, i) => {
|
||||
const val = lookup[ts];
|
||||
if (val != null) {
|
||||
points.push({
|
||||
x: pad.left + (i / Math.max(count - 1, 1)) * w,
|
||||
y: pad.top + h - ((val - globalMin) / range) * h,
|
||||
value: val,
|
||||
time: ts,
|
||||
});
|
||||
}
|
||||
});
|
||||
const path = smoothPath(points);
|
||||
const lastPt = points.length > 0 ? points[points.length - 1] : null;
|
||||
return { ...s, points, path, lastPt, globalMin, globalMax, range };
|
||||
});
|
||||
}, [series, visibleTimestamps, w, h, pad.left, pad.top]);
|
||||
|
||||
// Y axis labels
|
||||
const yLabels = useMemo(() => {
|
||||
if (computedSeries.length === 0) return [];
|
||||
const { globalMin, range } = computedSeries[0];
|
||||
return [0, 0.25, 0.5, 0.75, 1].map((frac) => ({
|
||||
y: pad.top + h * (1 - frac),
|
||||
text: formatCount(Math.round(globalMin + range * frac)),
|
||||
}));
|
||||
}, [computedSeries, h, pad.top]);
|
||||
|
||||
// X axis labels
|
||||
const xLabels = useMemo(() => {
|
||||
const count = Math.min(6, visibleTimestamps.length);
|
||||
if (count < 2) return [];
|
||||
const indices = Array.from({ length: count }, (_, i) => Math.round(i * (visibleTimestamps.length - 1) / (count - 1)));
|
||||
return indices.filter((idx) => visibleTimestamps[idx]).map((idx) => ({
|
||||
x: pad.left + (idx / Math.max(visibleTimestamps.length - 1, 1)) * w,
|
||||
text: formatTime(visibleTimestamps[idx]),
|
||||
}));
|
||||
}, [visibleTimestamps, w, pad.left]);
|
||||
|
||||
// Scroll handler (wheel)
|
||||
const handleWheel = useCallback((e) => {
|
||||
if (!canScroll) return;
|
||||
e.preventDefault();
|
||||
setViewStart((prev) => {
|
||||
const delta = e.deltaY > 0 ? 0.05 : -0.05;
|
||||
return Math.max(0, Math.min(1, prev + delta));
|
||||
});
|
||||
}, [canScroll]);
|
||||
|
||||
// Drag-to-scroll handlers
|
||||
const handleMouseDown = useCallback((e) => {
|
||||
if (!canScroll) return;
|
||||
setIsDragging(true);
|
||||
setDragStartX(e.clientX);
|
||||
setDragStartView(viewStart);
|
||||
}, [canScroll, viewStart]);
|
||||
|
||||
const handleMouseMove = useCallback((e) => {
|
||||
if (!isDragging || !canScroll) return;
|
||||
const dx = e.clientX - dragStartX;
|
||||
const pctDelta = -dx / containerWidth;
|
||||
setViewStart(Math.max(0, Math.min(1, dragStartView + pctDelta)));
|
||||
}, [isDragging, canScroll, dragStartX, dragStartView, containerWidth]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// Tooltip handler
|
||||
const handleChartMouseMove = useCallback((e) => {
|
||||
if (isDragging || !containerRef.current || computedSeries.length === 0) {
|
||||
setTooltip(null);
|
||||
return;
|
||||
}
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
// Find closest timestamp index
|
||||
const relX = mx - pad.left;
|
||||
if (relX < 0 || relX > w) { setTooltip(null); return; }
|
||||
const frac = relX / w;
|
||||
const idx = Math.round(frac * (visibleTimestamps.length - 1));
|
||||
if (idx < 0 || idx >= visibleTimestamps.length) { setTooltip(null); return; }
|
||||
|
||||
const ts = visibleTimestamps[idx];
|
||||
const items = computedSeries.map((s) => {
|
||||
const pt = s.points.find((p) => p.time === ts);
|
||||
return pt ? { name: s.name, color: s.color, value: pt.value } : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
if (items.length === 0) { setTooltip(null); return; }
|
||||
const snapX = pad.left + (idx / Math.max(visibleTimestamps.length - 1, 1)) * w;
|
||||
setTooltip({ x: snapX, y: my, time: ts, items });
|
||||
}, [isDragging, computedSeries, w, pad.left, visibleTimestamps]);
|
||||
|
||||
const handleChartMouseLeave = useCallback(() => setTooltip(null), []);
|
||||
|
||||
const hasData = series && series.length > 0 && series.some((s) => s.data && s.data.length >= 2);
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="chart-empty" style={{ height }}>
|
||||
<span className="chart-empty-icon">📉</span>
|
||||
<span>Not enough data yet</span>
|
||||
<span className="chart-empty-sub">History is recorded every 5 minutes</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Scrollbar thumb position
|
||||
const thumbWidth = canScroll ? Math.max(15, (VISIBLE_WINDOW / totalPoints) * 100) : 100;
|
||||
const thumbLeft = canScroll ? viewStart * (100 - thumbWidth) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`multiline-chart-container ${isDragging ? 'dragging' : ''}`}
|
||||
ref={containerRef}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleChartMouseMove}
|
||||
onMouseLeave={handleChartMouseLeave}
|
||||
>
|
||||
<svg width={containerWidth} height={height} className="svg-chart" viewBox={`0 0 ${containerWidth} ${height}`} preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
{computedSeries.map((s, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<linearGradient id={`${id}-grad-${i}`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={s.color} stopOpacity="0.20" />
|
||||
<stop offset="100%" stopColor={s.color} stopOpacity="0.01" />
|
||||
</linearGradient>
|
||||
<filter id={`${id}-glow-${i}`}>
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
{/* Grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => (
|
||||
<line key={frac} x1={pad.left} y1={pad.top + h * (1 - frac)} x2={pad.left + w} y2={pad.top + h * (1 - frac)}
|
||||
stroke="#2a2a2a" strokeWidth="1" strokeDasharray={frac === 0 || frac === 1 ? 'none' : '3,3'} />
|
||||
))}
|
||||
|
||||
{/* Y axis labels */}
|
||||
{yLabels.map((l, i) => (
|
||||
<text key={i} x={pad.left - 6} y={l.y + 3} fill="#666" fontSize="7" textAnchor="end" fontFamily="'Silkscreen', monospace">{l.text}</text>
|
||||
))}
|
||||
|
||||
{/* X axis labels */}
|
||||
{xLabels.map((l, i) => (
|
||||
<text key={i} x={l.x} y={pad.top + h + 16} fill="#555" fontSize="7" textAnchor="middle" fontFamily="'Silkscreen', monospace">{l.text}</text>
|
||||
))}
|
||||
|
||||
{/* Series — area fills, lines, dots */}
|
||||
{computedSeries.map((s, si) => {
|
||||
if (s.points.length < 2) return null;
|
||||
const lineD = s.path;
|
||||
const areaD = lineD
|
||||
+ ` L ${s.points[s.points.length - 1].x.toFixed(1)} ${(pad.top + h).toFixed(1)}`
|
||||
+ ` L ${s.points[0].x.toFixed(1)} ${(pad.top + h).toFixed(1)} Z`;
|
||||
|
||||
return (
|
||||
<g key={si}>
|
||||
{/* Area fill (only for first series to keep it clean) */}
|
||||
{si === 0 && <path d={areaD} fill={`url(#${id}-grad-${si})`} />}
|
||||
{/* Line */}
|
||||
<path d={lineD} fill="none" stroke={s.color} strokeWidth="2" filter={`url(#${id}-glow-${si})`} strokeLinecap="round" opacity="0.9" />
|
||||
<path d={lineD} fill="none" stroke={s.color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
{/* Dots — fewer when dense */}
|
||||
{s.points.filter((_, i) => s.points.length <= 25 || i % Math.ceil(s.points.length / 18) === 0 || i === s.points.length - 1).map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r="2" fill={s.color} stroke="#1a1a1a" strokeWidth="0.8" opacity="0.7" />
|
||||
))}
|
||||
{/* Endpoint */}
|
||||
{s.lastPt && (
|
||||
<circle cx={s.lastPt.x} cy={s.lastPt.y} r="3.5" fill={s.color} stroke="#fff" strokeWidth="1" />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Tooltip crosshair */}
|
||||
{tooltip && (
|
||||
<line x1={tooltip.x} y1={pad.top} x2={tooltip.x} y2={pad.top + h} stroke="#666" strokeWidth="1" strokeDasharray="3,2" />
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* HTML tooltip overlay */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="chart-tooltip"
|
||||
style={{ left: Math.min(tooltip.x, containerWidth - 120), top: 8 }}
|
||||
>
|
||||
<div className="chart-tooltip-time">{formatTime(tooltip.time)}</div>
|
||||
{tooltip.items.map((item, i) => (
|
||||
<div key={i} className="chart-tooltip-row">
|
||||
<span className="chart-tooltip-dot" style={{ background: item.color }} />
|
||||
<span className="chart-tooltip-name">{formatItemName(item.name)}</span>
|
||||
<span className="chart-tooltip-val">{formatCount(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollbar */}
|
||||
{canScroll && (
|
||||
<div className="chart-scrollbar">
|
||||
<div className="chart-scrollbar-track">
|
||||
<div
|
||||
className="chart-scrollbar-thumb"
|
||||
style={{ width: `${thumbWidth}%`, left: `${thumbLeft}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="chart-scrollbar-hint">Scroll or drag to navigate</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Horizontal Bar Chart with rank medals =====
|
||||
function BarChart({ data, width = 400, height = 200, color = '#ffaa00' }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="chart-empty" style={{ height }}>
|
||||
<span>No data</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxVal = Math.max(...data.map((d) => d.value)) || 1;
|
||||
const medals = ['🥇', '🥈', '🥉'];
|
||||
const barColors = [
|
||||
'#ffcc00', '#e8b800', '#d4a800', '#c09800', '#aa8800',
|
||||
'#997a00', '#886c00', '#775e00', '#665100', '#554400',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bar-chart" style={{ width }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={d.name || i} className="bar-row" style={{ animationDelay: `${i * 50}ms` }}>
|
||||
<div className="bar-rank">{i < 3 ? medals[i] : <span className="bar-rank-num">#{i + 1}</span>}</div>
|
||||
<div className="bar-label">
|
||||
<ItemIcon itemName={d.name} size={16} />
|
||||
<span>{formatItemName(d.name)}</span>
|
||||
</div>
|
||||
<div className="bar-track">
|
||||
<div
|
||||
className="bar-fill"
|
||||
style={{
|
||||
width: `${(d.value / maxVal) * 100}%`,
|
||||
background: `linear-gradient(90deg, ${barColors[i] || color}, ${barColors[i] || color}88)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-value">{formatCount(d.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Category Donut Chart =====
|
||||
function CategoryDonut({ categories, size = 140 }) {
|
||||
const total = categories.reduce((s, c) => s + c.count, 0) || 1;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size * 0.36;
|
||||
const strokeWidth = size * 0.15;
|
||||
|
||||
const categoryColors = {
|
||||
blocks: '#8b6d3c',
|
||||
tools: '#9d9d9d',
|
||||
combat: '#ff5555',
|
||||
food: '#80b94e',
|
||||
redstone: '#ff0000',
|
||||
materials: '#4aedd9',
|
||||
misc: '#a0a0a0',
|
||||
};
|
||||
|
||||
let cumAngle = -90; // start from top
|
||||
const segments = categories.map((c) => {
|
||||
const angle = (c.count / total) * 360;
|
||||
const seg = { ...c, startAngle: cumAngle, angle, color: categoryColors[c.id] || '#666' };
|
||||
cumAngle += angle;
|
||||
return seg;
|
||||
});
|
||||
|
||||
// Convert angle to SVG arc
|
||||
const describeArc = (startAngle, endAngle) => {
|
||||
const start = polarToCartesian(cx, cy, r, endAngle);
|
||||
const end = polarToCartesian(cx, cy, r, startAngle);
|
||||
const largeArcFlag = endAngle - startAngle > 180 ? 1 : 0;
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
|
||||
};
|
||||
|
||||
const polarToCartesian = (cx, cy, r, angleDeg) => {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="donut-container">
|
||||
<svg width={size} height={size} className="donut-chart">
|
||||
{segments.map((seg, i) => (
|
||||
seg.angle > 0.5 && (
|
||||
<path
|
||||
key={i}
|
||||
d={describeArc(seg.startAngle, seg.startAngle + Math.max(seg.angle - 1, 0.5))}
|
||||
fill="none"
|
||||
stroke={seg.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="butt"
|
||||
opacity="0.85"
|
||||
/>
|
||||
)
|
||||
))}
|
||||
<text x={cx} y={cy - 4} textAnchor="middle" fill="#fff" fontSize="12" fontWeight="700" fontFamily="'Silkscreen', monospace">
|
||||
{categories.length}
|
||||
</text>
|
||||
<text x={cx} y={cy + 10} textAnchor="middle" fill="#888" fontSize="7" fontFamily="'Silkscreen', monospace">
|
||||
TYPES
|
||||
</text>
|
||||
</svg>
|
||||
<div className="donut-legend">
|
||||
{segments.filter(s => s.count > 0).map((seg) => (
|
||||
<div key={seg.id} className="donut-legend-item">
|
||||
<span className="donut-dot" style={{ background: seg.color }} />
|
||||
<span className="donut-legend-label">{seg.label}</span>
|
||||
<span className="donut-legend-value">{formatCount(seg.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Capacity Ring Gauge =====
|
||||
function CapacityGauge({ used, total, size = 100 }) {
|
||||
const pct = total > 0 ? Math.round((used / total) * 100) : 0;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size * 0.38;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const offset = circ - (pct / 100) * circ;
|
||||
|
||||
const getColor = (p) => {
|
||||
if (p >= 90) return '#ff5555';
|
||||
if (p >= 70) return '#ffaa00';
|
||||
return '#55ff55';
|
||||
};
|
||||
const color = getColor(pct);
|
||||
|
||||
return (
|
||||
<div className="capacity-gauge">
|
||||
<svg width={size} height={size}>
|
||||
<defs>
|
||||
<filter id="gauge-glow">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Track */}
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#2a2a2a" strokeWidth="8" />
|
||||
{/* Fill */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="8"
|
||||
strokeDasharray={circ}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
filter="url(#gauge-glow)"
|
||||
style={{ transition: 'stroke-dashoffset 1s ease' }}
|
||||
/>
|
||||
<text x={cx} y={cy - 2} textAnchor="middle" fill={color} fontSize="16" fontWeight="700" fontFamily="'Silkscreen', monospace">
|
||||
{pct}%
|
||||
</text>
|
||||
<text x={cx} y={cy + 12} textAnchor="middle" fill="#666" fontSize="6" fontFamily="'Silkscreen', monospace">
|
||||
CAPACITY
|
||||
</text>
|
||||
</svg>
|
||||
<div className="capacity-gauge-detail">
|
||||
{formatCount(used)} / {formatCount(total)} slots
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
const h = d.getHours().toString().padStart(2, '0');
|
||||
const m = d.getMinutes().toString().padStart(2, '0');
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = d.getDate().toString().padStart(2, '0');
|
||||
const h = d.getHours().toString().padStart(2, '0');
|
||||
const m = d.getMinutes().toString().padStart(2, '0');
|
||||
return `${month}/${day} ${h}:${m}`;
|
||||
}
|
||||
|
||||
function AnalyticsPanel() {
|
||||
const inventory = useInventoryStore((state) => state.inventory);
|
||||
const historySummary = useInventoryStore((state) => state.historySummary);
|
||||
const itemHistory = useInventoryStore((state) => state.itemHistory);
|
||||
const fetchHistorySummary = useInventoryStore((state) => state.fetchHistorySummary);
|
||||
const fetchItemHistory = useInventoryStore((state) => state.fetchItemHistory);
|
||||
const smeltable = useInventoryStore((state) => state.smeltable);
|
||||
const disabledRecipes = useInventoryStore((state) => state.disabledRecipes);
|
||||
|
||||
// Multi-item tracking
|
||||
const [trackedItems, setTrackedItems] = useState([]); // [{ name, color }]
|
||||
const [historySearch, setHistorySearch] = useState('');
|
||||
|
||||
// Fetch summary on mount
|
||||
useEffect(() => {
|
||||
fetchHistorySummary();
|
||||
const interval = setInterval(fetchHistorySummary, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHistorySummary]);
|
||||
|
||||
// Fetch history for each tracked item
|
||||
useEffect(() => {
|
||||
trackedItems.forEach((t) => {
|
||||
if (!itemHistory[t.name]) {
|
||||
fetchItemHistory(t.name);
|
||||
}
|
||||
});
|
||||
}, [trackedItems, fetchItemHistory, itemHistory]);
|
||||
|
||||
// Top 10 items by count
|
||||
const topItems = useMemo(() => {
|
||||
const items = inventory.itemList || [];
|
||||
return [...items]
|
||||
.sort((a, b) => (b.count || 0) - (a.count || 0))
|
||||
.slice(0, 10)
|
||||
.map((item) => ({ name: item.name, value: item.count || 0 }));
|
||||
}, [inventory.itemList]);
|
||||
|
||||
// Category breakdown
|
||||
const categoryData = useMemo(() => {
|
||||
const items = inventory.itemList || [];
|
||||
const catCounts = {};
|
||||
items.forEach((item) => {
|
||||
const cat = getItemCategory(item.name);
|
||||
catCounts[cat] = (catCounts[cat] || 0) + (item.count || 0);
|
||||
});
|
||||
return ITEM_CATEGORIES
|
||||
.filter(c => c.id !== 'all')
|
||||
.map(c => ({ ...c, count: catCounts[c.id] || 0 }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [inventory.itemList]);
|
||||
|
||||
// Storage history — as a single series (total)
|
||||
const storageSeries = useMemo(() => {
|
||||
if (!historySummary || historySummary.length === 0) return [];
|
||||
return [{
|
||||
name: 'Total',
|
||||
color: '#55ff55',
|
||||
data: historySummary.map((h) => ({ time: h.recordedAt, value: h.total })),
|
||||
}];
|
||||
}, [historySummary]);
|
||||
|
||||
// Multi-item trend series
|
||||
const itemTrendSeries = useMemo(() => {
|
||||
return trackedItems.map((t) => {
|
||||
const history = itemHistory[t.name];
|
||||
if (!history || history.length === 0) return { name: t.name, color: t.color, data: [] };
|
||||
return {
|
||||
name: t.name,
|
||||
color: t.color,
|
||||
data: history.slice().reverse().map((h) => ({ time: h.recordedAt, value: h.count })),
|
||||
};
|
||||
});
|
||||
}, [trackedItems, itemHistory]);
|
||||
|
||||
// Filtered items for search dropdown
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!historySearch) return [];
|
||||
const q = historySearch.toLowerCase();
|
||||
const trackedNames = new Set(trackedItems.map((t) => t.name));
|
||||
return (inventory.itemList || [])
|
||||
.filter((item) => {
|
||||
const name = (item.displayName || item.name || '').toLowerCase();
|
||||
return name.includes(q) && !trackedNames.has(item.name);
|
||||
})
|
||||
.slice(0, 8);
|
||||
}, [historySearch, inventory.itemList, trackedItems]);
|
||||
|
||||
// Smelting stats
|
||||
const smeltingStats = useMemo(() => {
|
||||
const entries = Object.entries(smeltable || {});
|
||||
const enabled = entries.filter(([k]) => !disabledRecipes[k]).length;
|
||||
const furnaces = Object.values(inventory.furnaceStatus || {});
|
||||
const activeFurnaces = furnaces.filter((f) => f && f.active).length;
|
||||
return {
|
||||
totalRecipes: entries.length,
|
||||
enabledRecipes: enabled,
|
||||
totalFurnaces: furnaces.length,
|
||||
activeFurnaces,
|
||||
};
|
||||
}, [smeltable, disabledRecipes, inventory.furnaceStatus]);
|
||||
|
||||
// Storage delta (from history)
|
||||
const storageDelta = useMemo(() => {
|
||||
if (!historySummary || historySummary.length < 2) return null;
|
||||
const latest = historySummary[historySummary.length - 1];
|
||||
const prev = historySummary[historySummary.length - 2];
|
||||
return latest.total - prev.total;
|
||||
}, [historySummary]);
|
||||
|
||||
// Add / remove tracked items
|
||||
const addTrackedItem = (itemName) => {
|
||||
if (trackedItems.length >= 12) return;
|
||||
if (trackedItems.some((t) => t.name === itemName)) return;
|
||||
const colorIdx = trackedItems.length % SERIES_COLORS.length;
|
||||
setTrackedItems((prev) => [...prev, { name: itemName, color: SERIES_COLORS[colorIdx] }]);
|
||||
setHistorySearch('');
|
||||
fetchItemHistory(itemName);
|
||||
};
|
||||
|
||||
const removeTrackedItem = (itemName) => {
|
||||
setTrackedItems((prev) => prev.filter((t) => t.name !== itemName));
|
||||
};
|
||||
|
||||
const capacityPct = inventory.totalSlots > 0 ? Math.round((inventory.usedSlots / inventory.totalSlots) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="analytics-panel">
|
||||
{/* Hero Stats Row */}
|
||||
<div className="analytics-hero">
|
||||
<div className="hero-stat hero-stat--items">
|
||||
<div className="hero-stat-icon">📦</div>
|
||||
<div className="hero-stat-content">
|
||||
<span className="hero-stat-value">{(inventory.grandTotal || 0).toLocaleString()}</span>
|
||||
<span className="hero-stat-label">Total Items</span>
|
||||
{storageDelta !== null && (
|
||||
<span className={`hero-stat-delta ${storageDelta >= 0 ? 'positive' : 'negative'}`}>
|
||||
{storageDelta >= 0 ? '▲' : '▼'} {Math.abs(storageDelta).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-stat hero-stat--types">
|
||||
<div className="hero-stat-icon">🧊</div>
|
||||
<div className="hero-stat-content">
|
||||
<span className="hero-stat-value">{(inventory.itemList || []).length}</span>
|
||||
<span className="hero-stat-label">Unique Types</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-stat hero-stat--chests">
|
||||
<div className="hero-stat-icon">🗄️</div>
|
||||
<div className="hero-stat-content">
|
||||
<span className="hero-stat-value">{inventory.chestCount || 0}</span>
|
||||
<span className="hero-stat-label">Chests</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-stat hero-stat--furnaces">
|
||||
<div className="hero-stat-icon">🔥</div>
|
||||
<div className="hero-stat-content">
|
||||
<span className="hero-stat-value">
|
||||
<span className="hero-active">{smeltingStats.activeFurnaces}</span>/{smeltingStats.totalFurnaces}
|
||||
</span>
|
||||
<span className="hero-stat-label">Furnaces</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-stat hero-stat--recipes">
|
||||
<div className="hero-stat-icon">📖</div>
|
||||
<div className="hero-stat-content">
|
||||
<span className="hero-stat-value">
|
||||
<span className="hero-active">{smeltingStats.enabledRecipes}</span>/{smeltingStats.totalRecipes}
|
||||
</span>
|
||||
<span className="hero-stat-label">Recipes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Grid */}
|
||||
<div className="analytics-grid">
|
||||
{/* Storage Over Time — full width */}
|
||||
<div className="analytics-card analytics-card-wide analytics-card--chart">
|
||||
<div className="card-header">
|
||||
<h3>📈 Storage Over Time</h3>
|
||||
{storageSeries.length > 0 && storageSeries[0].data.length >= 2 && (
|
||||
<span className="card-badge">{storageSeries[0].data.length} snapshots</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="chart-wrapper">
|
||||
<MultiLineChart series={storageSeries} height={200} id="storage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capacity + Category Breakdown */}
|
||||
<div className="analytics-card analytics-card--overview">
|
||||
<div className="card-header">
|
||||
<h3>⚡ Storage Health</h3>
|
||||
</div>
|
||||
<div className="overview-split">
|
||||
<CapacityGauge used={inventory.usedSlots || 0} total={inventory.totalSlots || 0} size={120} />
|
||||
<div className="overview-stats-col">
|
||||
<div className="mini-stat">
|
||||
<span className="mini-stat-label">Used Slots</span>
|
||||
<span className="mini-stat-value">{(inventory.usedSlots || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<span className="mini-stat-label">Free Slots</span>
|
||||
<span className="mini-stat-value" style={{color: 'var(--mc-text-green)'}}>{(inventory.freeSlots || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<span className="mini-stat-label">Total Slots</span>
|
||||
<span className="mini-stat-value">{(inventory.totalSlots || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<div className="analytics-card analytics-card--categories">
|
||||
<div className="card-header">
|
||||
<h3>📊 Category Breakdown</h3>
|
||||
</div>
|
||||
<CategoryDonut categories={categoryData} size={130} />
|
||||
</div>
|
||||
|
||||
{/* Top Items */}
|
||||
<div className="analytics-card analytics-card--ranking">
|
||||
<div className="card-header">
|
||||
<h3>🏆 Top Items</h3>
|
||||
<span className="card-badge">{(inventory.itemList || []).length} total</span>
|
||||
</div>
|
||||
<BarChart data={topItems} width={440} height={300} color="#ffaa00" />
|
||||
</div>
|
||||
|
||||
{/* Item Trend — multi-line, full width */}
|
||||
<div className="analytics-card analytics-card-wide analytics-card--trend">
|
||||
<div className="card-header">
|
||||
<h3>🔍 Item Trends</h3>
|
||||
<span className="card-badge">{trackedItems.length} / 12 tracked</span>
|
||||
</div>
|
||||
|
||||
{/* Search bar to add items */}
|
||||
<div className="item-history-search">
|
||||
<div className="search-input-wrapper">
|
||||
<span className="search-icon">🔎</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search and add items to track..."
|
||||
value={historySearch}
|
||||
onChange={(e) => setHistorySearch(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
{filteredItems.length > 0 && historySearch && (
|
||||
<div className="item-history-dropdown">
|
||||
{filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="item-history-option"
|
||||
onClick={() => addTrackedItem(item.name)}
|
||||
>
|
||||
<ItemIcon itemName={item.name} size={16} />
|
||||
<span>{formatItemName(item.name)}</span>
|
||||
<span className="option-count">{formatCount(item.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tracked item chips */}
|
||||
{trackedItems.length > 0 && (
|
||||
<div className="tracked-items-bar">
|
||||
{trackedItems.map((t) => (
|
||||
<div key={t.name} className="tracked-chip" style={{ borderColor: t.color }}>
|
||||
<span className="tracked-chip-dot" style={{ background: t.color }} />
|
||||
<ItemIcon itemName={t.name} size={14} />
|
||||
<span className="tracked-chip-name">{formatItemName(t.name)}</span>
|
||||
<button className="tracked-chip-remove" onClick={() => removeTrackedItem(t.name)} title="Remove">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
{itemTrendSeries.length > 0 && itemTrendSeries.some((s) => s.data.length >= 2) ? (
|
||||
<div className="chart-wrapper">
|
||||
<MultiLineChart series={itemTrendSeries} height={220} id="item-trends" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty chart-empty--compact">
|
||||
<span className="chart-empty-icon">📋</span>
|
||||
<span>{trackedItems.length > 0 ? 'Loading history data...' : 'Search and add items above to track their trends'}</span>
|
||||
<span className="chart-empty-sub">Each item gets its own colored line</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalyticsPanel;
|
||||
130
web/client/src/components/CraftingPanel.css
Normal file
130
web/client/src/components/CraftingPanel.css
Normal file
@@ -0,0 +1,130 @@
|
||||
.crafting-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.crafting-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--mc-dark);
|
||||
}
|
||||
|
||||
.crafting-header h2 {
|
||||
font-size: 1rem;
|
||||
color: var(--mc-text-yellow);
|
||||
text-shadow: 2px 2px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
.turtle-status {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 2px solid var(--mc-dark);
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
.turtle-status.online {
|
||||
background: var(--mc-grass-dark);
|
||||
color: var(--mc-text-green);
|
||||
}
|
||||
|
||||
.turtle-status.offline {
|
||||
background: #6b1a1a;
|
||||
color: var(--mc-text-red);
|
||||
}
|
||||
|
||||
.turtle-warning {
|
||||
padding: 0.5rem;
|
||||
background: #6b4a1a;
|
||||
border: 2px solid var(--mc-dark);
|
||||
color: var(--mc-text-gold);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
.craft-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.craft-card {
|
||||
padding: 0.75rem;
|
||||
background: #3b3b3b;
|
||||
border: 3px solid var(--mc-dark);
|
||||
box-shadow: inset 0 2px 0 #555, inset 0 -2px 0 #2a2a2a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.craft-output {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.375rem;
|
||||
border-bottom: 2px solid var(--mc-dark);
|
||||
}
|
||||
|
||||
.craft-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.craft-name {
|
||||
font-size: 0.8rem;
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
.craft-count {
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-gray);
|
||||
}
|
||||
|
||||
.craft-ingredients {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.craft-ingredient {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-gray);
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--mc-dark);
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.craft-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.craft-btn.crafting {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.craft-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
grid-column: 1 / -1;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--mc-text-gray);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
71
web/client/src/components/CraftingPanel.jsx
Normal file
71
web/client/src/components/CraftingPanel.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import { formatItemName } from '../utils/itemUtils';
|
||||
import ItemIcon from './ItemIcon';
|
||||
import './CraftingPanel.css';
|
||||
|
||||
function CraftingPanel() {
|
||||
const craftable = useInventoryStore((state) => state.craftable);
|
||||
const craftTurtleOk = useInventoryStore((state) => state.craftTurtleOk);
|
||||
const craftItem = useInventoryStore((state) => state.craftItem);
|
||||
const [craftingIdx, setCraftingIdx] = useState(null);
|
||||
|
||||
const handleCraft = async (idx) => {
|
||||
setCraftingIdx(idx);
|
||||
await craftItem(idx);
|
||||
setCraftingIdx(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="crafting-panel">
|
||||
<div className="crafting-header">
|
||||
<h2>🔨 Crafting</h2>
|
||||
<div className={`turtle-status ${craftTurtleOk ? 'online' : 'offline'}`}>
|
||||
🐢 {craftTurtleOk ? 'Turtle Online' : 'Turtle Offline'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!craftTurtleOk && (
|
||||
<div className="turtle-warning">
|
||||
⚠️ Crafting turtle is not connected. Crafting commands will fail.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="craft-list">
|
||||
{(craftable || []).map((recipe, idx) => (
|
||||
<div key={idx} className="craft-card">
|
||||
<div className="craft-output">
|
||||
<ItemIcon itemName={recipe.output || ''} size={32} />
|
||||
<div className="craft-info">
|
||||
<span className="craft-name">{formatItemName(recipe.output || '')}</span>
|
||||
<span className="craft-count">Produces {recipe.count || 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="craft-ingredients">
|
||||
{recipe.slots && Object.entries(recipe.slots).map(([slot, item]) => (
|
||||
<div key={slot} className="craft-ingredient">
|
||||
<ItemIcon itemName={item || ''} size={16} />
|
||||
<span>{formatItemName(item || '')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`mc-btn green craft-btn ${craftingIdx === idx ? 'crafting' : ''}`}
|
||||
onClick={() => handleCraft(idx + 1)}
|
||||
disabled={craftingIdx !== null || !craftTurtleOk}
|
||||
>
|
||||
{craftingIdx === idx + 1 ? '⏳ Crafting...' : '🔨 Craft'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{(!craftable || craftable.length === 0) && (
|
||||
<div className="no-data">No crafting recipes configured</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CraftingPanel;
|
||||
54
web/client/src/components/ErrorBoundary.jsx
Normal file
54
web/client/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '2rem',
|
||||
background: '#1a1a2e',
|
||||
color: '#ff6b6b',
|
||||
borderRadius: '8px',
|
||||
margin: '1rem',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
<h2>⚠️ Something went wrong</h2>
|
||||
<p style={{ color: '#ccc' }}>
|
||||
{this.state.error?.message || 'Unknown error'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#4a7c59',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
🔄 Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
495
web/client/src/components/InventoryGrid.css
Normal file
495
web/client/src/components/InventoryGrid.css
Normal file
@@ -0,0 +1,495 @@
|
||||
.inventory-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.inventory-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #3b3b3b;
|
||||
border-bottom: 2px solid var(--mc-dark);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--mc-dark);
|
||||
border: 2px solid #333;
|
||||
padding: 0.25rem 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 0.8rem;
|
||||
margin-right: 0.375rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--mc-text-white);
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
font-size: 0.7rem;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--mc-text-gray);
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--mc-text-gray);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: var(--mc-text-red);
|
||||
}
|
||||
|
||||
.sort-controls {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.item-count-label {
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-gray);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Creative Inventory Category Tabs ===== */
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0 6px;
|
||||
padding-top: 4px;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
margin: 0 0.5rem;
|
||||
margin-bottom: -3px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.category-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 8px 4px;
|
||||
background: #8b8b8b;
|
||||
border: 2px solid;
|
||||
border-color: #555 #fefefe #c6c6c6 #555;
|
||||
border-bottom: 2px solid #555;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-dark);
|
||||
font-weight: 700;
|
||||
text-shadow: none;
|
||||
transition: background 0.05s;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.category-tab:hover {
|
||||
background: #aaa;
|
||||
color: var(--mc-text-dark);
|
||||
}
|
||||
|
||||
.category-tab.active {
|
||||
background: var(--mc-inv-bg);
|
||||
color: var(--mc-text-dark);
|
||||
border-color: #fefefe #555 transparent #fefefe;
|
||||
text-shadow: none;
|
||||
z-index: 1;
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 0.5rem;
|
||||
color: #555;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.category-tab.active .category-count {
|
||||
color: var(--mc-text-dark);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.category-label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.inventory-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Item Grid */
|
||||
.item-grid-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.item-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
background: var(--mc-inv-bg);
|
||||
border: 3px solid var(--mc-dark);
|
||||
box-shadow:
|
||||
inset 3px 0 0 #fefefe,
|
||||
inset 0 3px 0 #fefefe,
|
||||
inset -3px 0 0 #555,
|
||||
inset 0 -3px 0 #555;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.item-slot {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--mc-inv-slot);
|
||||
border: 2px solid;
|
||||
border-color: var(--mc-inv-slot-border) var(--mc-inv-slot-light) var(--mc-inv-slot-light) var(--mc-inv-slot-border);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
padding: 2px;
|
||||
transition: background 0.05s;
|
||||
}
|
||||
|
||||
.item-slot:hover {
|
||||
background: #aaa;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.item-slot.selected {
|
||||
background: #b8d4f0;
|
||||
box-shadow: 0 0 0 2px var(--mc-text-aqua);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.item-slot .item-icon-img,
|
||||
.item-slot .item-icon-emoji {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.item-slot-count {
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
right: 2px;
|
||||
color: white;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow:
|
||||
1px 0 0 var(--mc-dark),
|
||||
-1px 0 0 var(--mc-dark),
|
||||
0 1px 0 var(--mc-dark),
|
||||
0 -1px 0 var(--mc-dark);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ===== Minecraft-Style Tooltip ===== */
|
||||
.mc-tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #100010f0;
|
||||
padding: 5px 8px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
white-space: nowrap;
|
||||
border: 1px solid #100010;
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 #5000ff50,
|
||||
inset 1px 0 0 0 #5000ff50,
|
||||
inset -1px 0 0 0 #28007f50,
|
||||
inset 0 -1px 0 0 #28007f50,
|
||||
0 0 0 1px #100010;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.item-slot:hover .mc-tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mc-tooltip-name {
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 #3e3e3e;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.mc-tooltip-count {
|
||||
color: #aaa;
|
||||
font-size: 0.55rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.mc-tooltip-id {
|
||||
color: #555;
|
||||
font-size: 0.45rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.empty-grid {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--mc-text-gray);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Detail Panel */
|
||||
.item-detail-panel {
|
||||
width: 280px;
|
||||
background: #333;
|
||||
border-left: 3px solid var(--mc-dark);
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--mc-dark);
|
||||
}
|
||||
|
||||
.detail-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-info h3 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--mc-text-white);
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
.detail-id {
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-gray);
|
||||
}
|
||||
|
||||
.detail-close {
|
||||
background: none;
|
||||
border: 2px solid #555;
|
||||
color: var(--mc-text-gray);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.detail-close:hover {
|
||||
color: var(--mc-text-red);
|
||||
border-color: var(--mc-text-red);
|
||||
}
|
||||
|
||||
/* Detail stats */
|
||||
.detail-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.detail-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.375rem;
|
||||
background: var(--mc-dark);
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.detail-stat .label {
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-gray);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.detail-stat .value {
|
||||
font-size: 0.8rem;
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Order section */
|
||||
.order-section {
|
||||
padding: 0.5rem;
|
||||
background: var(--mc-dark);
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.order-section h4 {
|
||||
font-size: 0.7rem;
|
||||
color: var(--mc-text-gold);
|
||||
margin-bottom: 0.5rem;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
.order-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.order-amount-controls {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.order-input {
|
||||
flex: 1;
|
||||
background: #2a2a2a;
|
||||
border: 2px solid #444;
|
||||
color: var(--mc-text-white);
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
padding: 0.25rem;
|
||||
outline: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.order-input:focus {
|
||||
border-color: var(--mc-text-green);
|
||||
}
|
||||
|
||||
.order-presets {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Dropper location selector */
|
||||
.dropper-select-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.dropper-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--mc-text-gold);
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
.dropper-select {
|
||||
background: #2a2a2a;
|
||||
border: 2px solid #444;
|
||||
color: var(--mc-text-white);
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
outline: none;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23aaa'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.dropper-select:hover {
|
||||
border-color: var(--mc-text-green);
|
||||
}
|
||||
|
||||
.dropper-select:focus {
|
||||
border-color: var(--mc-text-green);
|
||||
box-shadow: inset 0 0 4px rgba(85, 255, 85, 0.15);
|
||||
}
|
||||
|
||||
.dropper-select option {
|
||||
background: #1a1a1a;
|
||||
color: var(--mc-text-white);
|
||||
}
|
||||
|
||||
.order-btn {
|
||||
width: 100%;
|
||||
padding: 0.625rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.inventory-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item-detail-panel {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 3px solid var(--mc-dark);
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.item-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.item-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.category-tab {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
}
|
||||
230
web/client/src/components/InventoryGrid.jsx
Normal file
230
web/client/src/components/InventoryGrid.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import { formatItemName, getItemEmoji, formatCount, ITEM_CATEGORIES, getItemCategory } from '../utils/itemUtils';
|
||||
import ItemIcon from './ItemIcon';
|
||||
import './InventoryGrid.css';
|
||||
|
||||
function InventoryGrid() {
|
||||
const inventory = useInventoryStore((state) => state.inventory);
|
||||
const searchQuery = useInventoryStore((state) => state.searchQuery);
|
||||
const setSearchQuery = useInventoryStore((state) => state.setSearchQuery);
|
||||
const getFilteredItems = useInventoryStore((state) => state.getFilteredItems);
|
||||
const orderItem = useInventoryStore((state) => state.orderItem);
|
||||
const dropperNicknames = useInventoryStore((state) => state.dropperNicknames) || {};
|
||||
|
||||
const [selectedItemName, setSelectedItemName] = useState(null);
|
||||
const [orderAmount, setOrderAmount] = useState(1);
|
||||
const [sortBy, setSortBy] = useState('count');
|
||||
const [activeCategory, setActiveCategory] = useState('all');
|
||||
const [selectedDropper, setSelectedDropper] = useState('');
|
||||
|
||||
// Derive selectedItem from live inventory so count always reflects real-time data
|
||||
const selectedItem = useMemo(() => {
|
||||
if (!selectedItemName) return null;
|
||||
const items = inventory.itemList || [];
|
||||
return items.find((i) => i.name === selectedItemName) || null;
|
||||
}, [selectedItemName, inventory.itemList]);
|
||||
|
||||
const droppers = inventory.droppers || [];
|
||||
|
||||
const items = getFilteredItems();
|
||||
|
||||
const filteredByCategory = useMemo(() => {
|
||||
if (activeCategory === 'all') return items;
|
||||
return items.filter((item) => getItemCategory(item.name) === activeCategory);
|
||||
}, [items, activeCategory]);
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...filteredByCategory].sort((a, b) => {
|
||||
if (sortBy === 'count') return (b.count || 0) - (a.count || 0);
|
||||
if (sortBy === 'name') return (a.displayName || a.name || '').localeCompare(b.displayName || b.name || '');
|
||||
return 0;
|
||||
});
|
||||
}, [filteredByCategory, sortBy]);
|
||||
|
||||
const handleOrder = useCallback(async () => {
|
||||
if (!selectedItem || orderAmount <= 0) return;
|
||||
await orderItem(selectedItem.name, orderAmount, selectedDropper || undefined);
|
||||
}, [selectedItem, orderAmount, orderItem, selectedDropper]);
|
||||
|
||||
const handleItemClick = (item) => {
|
||||
setSelectedItemName(item.name);
|
||||
setOrderAmount(1);
|
||||
};
|
||||
|
||||
// Count items per category for badges
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts = { all: items.length };
|
||||
for (const cat of ITEM_CATEGORIES) {
|
||||
if (cat.id !== 'all') {
|
||||
counts[cat.id] = items.filter((item) => getItemCategory(item.name) === cat.id).length;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<div className="inventory-panel">
|
||||
{/* Search & controls bar */}
|
||||
<div className="inventory-toolbar">
|
||||
<div className="search-box">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search items..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button className="search-clear" onClick={() => setSearchQuery('')}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="sort-controls">
|
||||
<button
|
||||
className={`mc-btn ${sortBy === 'count' ? 'green' : ''}`}
|
||||
onClick={() => setSortBy('count')}
|
||||
>
|
||||
# Count
|
||||
</button>
|
||||
<button
|
||||
className={`mc-btn ${sortBy === 'name' ? 'green' : ''}`}
|
||||
onClick={() => setSortBy('name')}
|
||||
>
|
||||
A Name
|
||||
</button>
|
||||
</div>
|
||||
<div className="item-count-label">
|
||||
{sortedItems.length} types · {(inventory.grandTotal || 0).toLocaleString()} items
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creative inventory category tabs */}
|
||||
<div className="category-tabs">
|
||||
{ITEM_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`category-tab ${activeCategory === cat.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
title={cat.label}
|
||||
>
|
||||
<ItemIcon itemName={`minecraft:${cat.icon}`} size={20} />
|
||||
<span className="category-label">{cat.label}</span>
|
||||
{categoryCounts[cat.id] > 0 && (
|
||||
<span className="category-count">{categoryCounts[cat.id]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="inventory-body">
|
||||
{/* Item grid */}
|
||||
<div className="item-grid-wrapper">
|
||||
<div className="item-grid">
|
||||
{sortedItems.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className={`item-slot ${selectedItem?.name === item.name ? 'selected' : ''}`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<ItemIcon itemName={item.name} size={48} />
|
||||
<span className="item-slot-count">{formatCount(item.count)}</span>
|
||||
{/* Minecraft-style tooltip */}
|
||||
<div className="mc-tooltip">
|
||||
<div className="mc-tooltip-name">{formatItemName(item.name)}</div>
|
||||
<div className="mc-tooltip-count">{(item.count || 0).toLocaleString()} items</div>
|
||||
<div className="mc-tooltip-id">{item.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sortedItems.length === 0 && (
|
||||
<div className="empty-grid">
|
||||
{searchQuery ? `No items matching "${searchQuery}"` : activeCategory !== 'all' ? 'No items in this category' : 'No items in storage'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item detail / order panel */}
|
||||
{selectedItem && (
|
||||
<div className="item-detail-panel">
|
||||
<div className="detail-header">
|
||||
<ItemIcon itemName={selectedItem.name || ''} size={48} />
|
||||
<div className="detail-info">
|
||||
<h3>{formatItemName(selectedItem.name || '')}</h3>
|
||||
<span className="detail-id">{selectedItem.name || ''}</span>
|
||||
</div>
|
||||
<button className="detail-close" onClick={() => setSelectedItemName(null)}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="detail-stats">
|
||||
<div className="detail-stat">
|
||||
<span className="label">Total</span>
|
||||
<span className="value">{(selectedItem.count || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="detail-stat">
|
||||
<span className="label">Stacks</span>
|
||||
<span className="value">
|
||||
{Math.ceil((selectedItem.count || 0) / 64)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="order-section">
|
||||
<h4>📤 Order Items</h4>
|
||||
<div className="order-controls">
|
||||
{droppers.length >= 1 && (
|
||||
<div className="dropper-select-wrapper">
|
||||
<label className="dropper-label">📍 Dispense to:</label>
|
||||
<select
|
||||
className="dropper-select"
|
||||
value={selectedDropper}
|
||||
onChange={(e) => setSelectedDropper(e.target.value)}
|
||||
>
|
||||
{droppers.map((d) => {
|
||||
const nick = dropperNicknames[d.name];
|
||||
const shortName = d.name.replace(/^minecraft:/, '');
|
||||
const label = nick
|
||||
? `${nick} (${shortName})`
|
||||
: `${shortName}${d.isDefault ? ' (default)' : d.clientId ? ` (client ${d.clientId})` : ''}`;
|
||||
return (
|
||||
<option key={d.name} value={d.name}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="order-amount-controls">
|
||||
<button className="mc-btn" onClick={() => setOrderAmount(Math.max(1, orderAmount - 1))}>-</button>
|
||||
<input
|
||||
type="number"
|
||||
className="order-input"
|
||||
value={orderAmount}
|
||||
onChange={(e) => setOrderAmount(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
min="1"
|
||||
max={selectedItem.count || 1}
|
||||
/>
|
||||
<button className="mc-btn" onClick={() => setOrderAmount(Math.min(selectedItem.count || 1, orderAmount + 1))}>+</button>
|
||||
</div>
|
||||
<div className="order-presets">
|
||||
<button className="mc-btn" onClick={() => setOrderAmount(1)}>1</button>
|
||||
<button className="mc-btn" onClick={() => setOrderAmount(16)}>16</button>
|
||||
<button className="mc-btn" onClick={() => setOrderAmount(32)}>32</button>
|
||||
<button className="mc-btn" onClick={() => setOrderAmount(64)}>64</button>
|
||||
<button className="mc-btn" onClick={() => setOrderAmount(selectedItem.count || 1)}>All</button>
|
||||
</div>
|
||||
<button className="mc-btn green order-btn" onClick={handleOrder}>
|
||||
📤 Dispense x{orderAmount}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InventoryGrid;
|
||||
18
web/client/src/components/ItemIcon.css
Normal file
18
web/client/src/components/ItemIcon.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.item-icon-img {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
display: block;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
/* Slight border to separate from dark backgrounds */
|
||||
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.item-icon-emoji {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
119
web/client/src/components/ItemIcon.jsx
Normal file
119
web/client/src/components/ItemIcon.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
import { getItemEmoji } from '../utils/itemUtils';
|
||||
import './ItemIcon.css';
|
||||
|
||||
// Server-side smart resolution endpoint — handles aliases, animated frames,
|
||||
// carpet→wool, wood→log, derivatives, CC:Tweaked, Create, and prefix matching.
|
||||
const RESOLVE_BASE = '/api/texture/resolve';
|
||||
|
||||
// Items rendered as 3D entities in-game — no flat texture exists.
|
||||
// Skip to emoji immediately to avoid a wasted network request.
|
||||
const ENTITY_ONLY = new Set([
|
||||
'chest', 'ender_chest', 'trapped_chest', 'shield', 'conduit',
|
||||
'bell', 'decorated_pot', 'trident',
|
||||
]);
|
||||
|
||||
const DYE_COLORS = new Set([
|
||||
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime',
|
||||
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue',
|
||||
'brown', 'green', 'red', 'black',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if an item is known to have no flat texture (entity/model-only).
|
||||
*/
|
||||
function isEntityOnly(name) {
|
||||
if (ENTITY_ONLY.has(name)) return true;
|
||||
if (name.endsWith('_bed') && DYE_COLORS.has(name.slice(0, -'_bed'.length))) return true;
|
||||
if (name.endsWith('_banner') && DYE_COLORS.has(name.slice(0, -'_banner'.length))) return true;
|
||||
if (name.endsWith('_skull') || name.endsWith('_head')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the single resolve URL for an item.
|
||||
* The server handles all name resolution (aliases, suffixes, derivatives, etc.)
|
||||
*/
|
||||
function getTextureUrl(fullItemName) {
|
||||
const colonIdx = (fullItemName || '').indexOf(':');
|
||||
const namespace = colonIdx >= 0 ? fullItemName.substring(0, colonIdx) : 'minecraft';
|
||||
const shortName = colonIdx >= 0 ? fullItemName.substring(colonIdx + 1) : fullItemName;
|
||||
|
||||
// Entity-only items → instant emoji, no network request
|
||||
if (namespace === 'minecraft' && isEntityOnly(shortName)) return null;
|
||||
|
||||
return `${RESOLVE_BASE}/${namespace}/${shortName}.png`;
|
||||
}
|
||||
|
||||
// Cache of resolved icon URLs (avoid re-fetching on every render)
|
||||
const iconCache = new Map();
|
||||
|
||||
/**
|
||||
* Renders a Minecraft item icon using the official game textures.
|
||||
* The server's /api/texture/resolve endpoint does all the smart matching.
|
||||
* Falls back to emoji when no texture is found.
|
||||
*/
|
||||
function ItemIcon({ itemName, size = 32 }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!itemName) {
|
||||
return <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>📦</span>;
|
||||
}
|
||||
|
||||
const cacheKey = itemName.replace(/^minecraft:/, '');
|
||||
|
||||
// Check if we already know this item has no texture
|
||||
if (iconCache.get(cacheKey) === 'none' || failed) {
|
||||
return (
|
||||
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||
{getItemEmoji(itemName)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// If we have a cached URL, use it directly
|
||||
const cachedUrl = iconCache.get(cacheKey);
|
||||
if (cachedUrl && cachedUrl !== 'none') {
|
||||
return (
|
||||
<img
|
||||
className="item-icon-img"
|
||||
src={cachedUrl}
|
||||
alt={cacheKey}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const url = getTextureUrl(itemName);
|
||||
|
||||
if (!url) {
|
||||
iconCache.set(cacheKey, 'none');
|
||||
return (
|
||||
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||
{getItemEmoji(itemName)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="item-icon-img"
|
||||
src={url}
|
||||
alt={cacheKey}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
onLoad={() => {
|
||||
iconCache.set(cacheKey, url);
|
||||
}}
|
||||
onError={() => {
|
||||
iconCache.set(cacheKey, 'none');
|
||||
setFailed(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemIcon;
|
||||
317
web/client/src/components/SettingsPanel.css
Normal file
317
web/client/src/components/SettingsPanel.css
Normal file
@@ -0,0 +1,317 @@
|
||||
/* ============================================
|
||||
Settings Panel (overlay modal)
|
||||
============================================ */
|
||||
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: settingsFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes settingsFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
background: #2a2a2a;
|
||||
border: 3px solid var(--mc-stone-dark);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 var(--mc-border-highlight),
|
||||
inset -1px -1px 0 var(--mc-border-shadow),
|
||||
0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
width: 90%;
|
||||
max-width: 520px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: settingsSlideIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes settingsSlideIn {
|
||||
from { transform: translateY(-12px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--mc-oak-dark);
|
||||
border-bottom: 2px solid var(--mc-dark);
|
||||
box-shadow:
|
||||
inset 0 2px 0 var(--mc-oak-light),
|
||||
inset 0 -1px 0 var(--mc-dirt-dark);
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
font-size: 0.9rem;
|
||||
color: var(--mc-text-yellow);
|
||||
text-shadow: 2px 2px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
.settings-close {
|
||||
background: none;
|
||||
border: 2px solid transparent;
|
||||
color: var(--mc-text-gray);
|
||||
font-family: 'Silkscreen', monospace;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.4rem;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.settings-close:hover {
|
||||
color: var(--mc-text-red);
|
||||
border-color: var(--mc-text-red);
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--mc-text-gold);
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-gray);
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-empty {
|
||||
font-size: 0.65rem;
|
||||
color: var(--mc-text-gray);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* === Dropper nickname list === */
|
||||
|
||||
.dropper-nickname-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.dropper-nickname-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: #222;
|
||||
border: 2px solid #3a3a3a;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.dropper-nickname-row:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.dropper-nickname-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropper-raw-name {
|
||||
font-size: 0.65rem;
|
||||
color: var(--mc-text-white);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dropper-badge {
|
||||
font-size: 0.5rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.dropper-badge.default {
|
||||
background: var(--mc-grass-dark);
|
||||
color: var(--mc-text-green);
|
||||
border: 1px solid var(--mc-grass);
|
||||
}
|
||||
|
||||
.dropper-badge.client {
|
||||
background: #2a2854;
|
||||
color: var(--mc-text-aqua);
|
||||
border: 1px solid #4a48a4;
|
||||
}
|
||||
|
||||
.dropper-current-nick {
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-gold);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dropper-nickname-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nickname-input {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #444;
|
||||
color: var(--mc-text-white);
|
||||
font-family: 'Silkscreen', monospace;
|
||||
font-size: 0.6rem;
|
||||
padding: 0.3rem 0.4rem;
|
||||
outline: none;
|
||||
width: 120px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.nickname-input:focus {
|
||||
border-color: var(--mc-text-green);
|
||||
}
|
||||
|
||||
.nickname-input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.nick-btn {
|
||||
padding: 0.25rem 0.4rem !important;
|
||||
font-size: 0.65rem !important;
|
||||
min-width: unset !important;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* === Remote Reboot Section === */
|
||||
|
||||
.reboot-status {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
margin-bottom: 0.6rem;
|
||||
background: #1a1f2e;
|
||||
border: 2px solid #3a4a6a;
|
||||
color: var(--mc-text-aqua);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reboot-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.reboot-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: #222;
|
||||
border: 2px solid #3a3a3a;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.reboot-target:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.reboot-target-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reboot-target-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--mc-text-white);
|
||||
}
|
||||
|
||||
.reboot-target-desc {
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-gray);
|
||||
}
|
||||
|
||||
.reboot-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reboot-confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-btn.red {
|
||||
border-color: #aa3333;
|
||||
color: var(--mc-text-red);
|
||||
background: linear-gradient(180deg, #5a2020, #3a1010);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #7a3030,
|
||||
inset -1px -1px 0 #2a0808;
|
||||
}
|
||||
|
||||
.mc-btn.red:hover {
|
||||
background: linear-gradient(180deg, #7a3030, #4a1818);
|
||||
color: #ff6666;
|
||||
}
|
||||
|
||||
/* === Settings gear button (in header) === */
|
||||
|
||||
.settings-gear {
|
||||
background: none;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 1.15rem;
|
||||
padding: 0.2rem 0.35rem;
|
||||
transition: border-color 0.15s, transform 0.3s;
|
||||
color: var(--mc-text-gray);
|
||||
}
|
||||
|
||||
.settings-gear:hover {
|
||||
border-color: var(--mc-text-gold);
|
||||
transform: rotate(45deg);
|
||||
color: var(--mc-text-gold);
|
||||
}
|
||||
|
||||
/* === Responsive === */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.settings-panel {
|
||||
width: 96%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.nickname-input {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.dropper-nickname-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dropper-nickname-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
213
web/client/src/components/SettingsPanel.jsx
Normal file
213
web/client/src/components/SettingsPanel.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import './SettingsPanel.css';
|
||||
|
||||
const REBOOT_TARGETS = [
|
||||
{ value: 'all', label: 'All Devices', icon: '🔄', desc: 'Reboot everything (manager + clients + turtles + bridge)' },
|
||||
{ value: 'client', label: 'Clients', icon: '💻', desc: 'Reboot all inventory client displays' },
|
||||
{ value: 'turtle', label: 'Crafting Turtles', icon: '🐢', desc: 'Reboot all crafting turtles' },
|
||||
{ value: 'bridge', label: 'Web Bridge', icon: '🌉', desc: 'Reboot the HTTP/WS bridge computer' },
|
||||
{ value: 'manager', label: 'Manager', icon: '📦', desc: 'Reboot the inventory manager (will disconnect briefly)' },
|
||||
];
|
||||
|
||||
function SettingsPanel({ isOpen, onClose }) {
|
||||
const droppers = useInventoryStore((state) => state.inventory.droppers) || [];
|
||||
const dropperNicknames = useInventoryStore((state) => state.dropperNicknames) || {};
|
||||
const setDropperNickname = useInventoryStore((state) => state.setDropperNickname);
|
||||
const rebootComputers = useInventoryStore((state) => state.rebootComputers);
|
||||
|
||||
const [editingDropper, setEditingDropper] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [rebootConfirm, setRebootConfirm] = useState(null);
|
||||
const [rebootStatus, setRebootStatus] = useState(null);
|
||||
|
||||
const startEditing = useCallback((dropperName) => {
|
||||
setEditingDropper(dropperName);
|
||||
setEditValue(dropperNicknames[dropperName] || '');
|
||||
}, [dropperNicknames]);
|
||||
|
||||
const saveNickname = useCallback(async () => {
|
||||
if (!editingDropper) return;
|
||||
setSaving(true);
|
||||
await setDropperNickname(editingDropper, editValue);
|
||||
setSaving(false);
|
||||
setEditingDropper(null);
|
||||
setEditValue('');
|
||||
}, [editingDropper, editValue, setDropperNickname]);
|
||||
|
||||
const removeNickname = useCallback(async (dropperName) => {
|
||||
setSaving(true);
|
||||
await setDropperNickname(dropperName, '');
|
||||
setSaving(false);
|
||||
}, [setDropperNickname]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter') saveNickname();
|
||||
if (e.key === 'Escape') {
|
||||
setEditingDropper(null);
|
||||
setEditValue('');
|
||||
}
|
||||
}, [saveNickname]);
|
||||
|
||||
const handleReboot = useCallback(async (target) => {
|
||||
setRebootConfirm(null);
|
||||
setRebootStatus(`Sending reboot to ${target}...`);
|
||||
const result = await rebootComputers(target);
|
||||
if (result?.success) {
|
||||
setRebootStatus(`✅ Reboot sent to: ${target}`);
|
||||
} else {
|
||||
setRebootStatus(`❌ Failed: ${result?.error || 'Unknown error'}`);
|
||||
}
|
||||
setTimeout(() => setRebootStatus(null), 4000);
|
||||
}, [rebootComputers]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="settings-overlay" onClick={onClose}>
|
||||
<div className="settings-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="settings-header">
|
||||
<h2>⚙️ Settings</h2>
|
||||
<button className="settings-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
<div className="settings-section">
|
||||
<h3>🏷️ Dropper Nicknames</h3>
|
||||
<p className="settings-hint">
|
||||
Give your dispensers friendly names so they're easier to identify.
|
||||
</p>
|
||||
|
||||
{droppers.length === 0 ? (
|
||||
<div className="settings-empty">
|
||||
No droppers detected. Connect the bridge to discover droppers.
|
||||
</div>
|
||||
) : (
|
||||
<div className="dropper-nickname-list">
|
||||
{droppers.map((d) => {
|
||||
const nickname = dropperNicknames[d.name];
|
||||
const isEditing = editingDropper === d.name;
|
||||
const shortName = d.name.replace(/^minecraft:/, '');
|
||||
|
||||
return (
|
||||
<div key={d.name} className="dropper-nickname-row">
|
||||
<div className="dropper-nickname-info">
|
||||
<span className="dropper-raw-name">
|
||||
{shortName}
|
||||
{d.isDefault && <span className="dropper-badge default">default</span>}
|
||||
{d.clientId && <span className="dropper-badge client">client {d.clientId}</span>}
|
||||
</span>
|
||||
{nickname && !isEditing && (
|
||||
<span className="dropper-current-nick">"{nickname}"</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="dropper-nickname-actions">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="nickname-input"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter nickname..."
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
/>
|
||||
<button
|
||||
className="mc-btn green nick-btn"
|
||||
onClick={saveNickname}
|
||||
disabled={saving}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
className="mc-btn nick-btn"
|
||||
onClick={() => { setEditingDropper(null); setEditValue(''); }}
|
||||
disabled={saving}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="mc-btn nick-btn"
|
||||
onClick={() => startEditing(d.name)}
|
||||
title="Edit nickname"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
{nickname && (
|
||||
<button
|
||||
className="mc-btn red nick-btn"
|
||||
onClick={() => removeNickname(d.name)}
|
||||
title="Remove nickname"
|
||||
disabled={saving}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>🔄 Remote Reboot</h3>
|
||||
<p className="settings-hint">
|
||||
Reboot CC:Tweaked computers remotely. They will auto-update from git and restart.
|
||||
</p>
|
||||
|
||||
{rebootStatus && (
|
||||
<div className="reboot-status">{rebootStatus}</div>
|
||||
)}
|
||||
|
||||
<div className="reboot-grid">
|
||||
{REBOOT_TARGETS.map((t) => (
|
||||
<div key={t.value} className="reboot-target">
|
||||
<div className="reboot-target-info">
|
||||
<span className="reboot-target-label">{t.icon} {t.label}</span>
|
||||
<span className="reboot-target-desc">{t.desc}</span>
|
||||
</div>
|
||||
{rebootConfirm === t.value ? (
|
||||
<div className="reboot-confirm-actions">
|
||||
<button
|
||||
className="mc-btn red"
|
||||
onClick={() => handleReboot(t.value)}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
className="mc-btn"
|
||||
onClick={() => setRebootConfirm(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="mc-btn reboot-btn"
|
||||
onClick={() => setRebootConfirm(t.value)}
|
||||
>
|
||||
Reboot
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
223
web/client/src/components/SmeltingPanel.css
Normal file
223
web/client/src/components/SmeltingPanel.css
Normal file
@@ -0,0 +1,223 @@
|
||||
.smelting-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.smelting-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--mc-dark);
|
||||
}
|
||||
|
||||
.smelting-header h2 {
|
||||
font-size: 1rem;
|
||||
color: var(--mc-text-yellow);
|
||||
text-shadow: 2px 2px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
.smelting-paused-banner {
|
||||
padding: 0.5rem;
|
||||
background: #6b1a1a;
|
||||
border: 2px solid var(--mc-dark);
|
||||
color: var(--mc-text-red);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
animation: blink-warn 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink-warn {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Furnace grid */
|
||||
.furnace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.furnace-card {
|
||||
padding: 0.5rem;
|
||||
background: var(--mc-dark);
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.furnace-card.active {
|
||||
border-color: var(--mc-text-gold);
|
||||
box-shadow: 0 0 4px rgba(255, 170, 0, 0.2);
|
||||
}
|
||||
|
||||
.furnace-name {
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-gray);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.furnace-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
margin: 0.125rem 0;
|
||||
}
|
||||
|
||||
.slot-label {
|
||||
color: var(--mc-text-gray);
|
||||
font-weight: 700;
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
.furnace-empty {
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-gray);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Recipe groups */
|
||||
.recipe-bulk-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.recipe-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recipe-group {
|
||||
border: 2px solid #333;
|
||||
background: #2e2e2e;
|
||||
}
|
||||
|
||||
.recipe-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #3a3a3a;
|
||||
cursor: pointer;
|
||||
transition: background 0.05s;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.recipe-group-header:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.recipe-group-toggle {
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-gold);
|
||||
flex-shrink: 0;
|
||||
width: 0.8rem;
|
||||
}
|
||||
|
||||
.recipe-group-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recipe-group-count {
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-gray);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-group-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipe-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recipe-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--mc-dark);
|
||||
border: 2px solid #333;
|
||||
cursor: pointer;
|
||||
transition: background 0.05s;
|
||||
}
|
||||
|
||||
.recipe-item:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: var(--mc-text-gold);
|
||||
}
|
||||
|
||||
.recipe-item.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.recipe-toggle {
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-input,
|
||||
.recipe-output {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recipe-input span,
|
||||
.recipe-output span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipe-arrow {
|
||||
color: var(--mc-text-gold);
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-furnaces {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.furnace-tag {
|
||||
font-size: 0.5rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
background: #444;
|
||||
border: 1px solid #555;
|
||||
color: var(--mc-text-gray);
|
||||
}
|
||||
|
||||
.no-data {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--mc-text-gray);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
184
web/client/src/components/SmeltingPanel.jsx
Normal file
184
web/client/src/components/SmeltingPanel.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import { formatItemName } from '../utils/itemUtils';
|
||||
import ItemIcon from './ItemIcon';
|
||||
import './SmeltingPanel.css';
|
||||
|
||||
function SmeltingPanel() {
|
||||
const inventory = useInventoryStore((state) => state.inventory);
|
||||
const smeltingPaused = useInventoryStore((state) => state.smeltingPaused);
|
||||
const disabledRecipes = useInventoryStore((state) => state.disabledRecipes);
|
||||
const smeltable = useInventoryStore((state) => state.smeltable);
|
||||
const toggleSmelting = useInventoryStore((state) => state.toggleSmelting);
|
||||
const toggleRecipe = useInventoryStore((state) => state.toggleRecipe);
|
||||
const enableAllRecipes = useInventoryStore((state) => state.enableAllRecipes);
|
||||
const disableAllRecipes = useInventoryStore((state) => state.disableAllRecipes);
|
||||
|
||||
const [collapsedGroups, setCollapsedGroups] = useState({});
|
||||
|
||||
const furnaceStatus = inventory.furnaceStatus || {};
|
||||
const furnaceEntries = Object.entries(furnaceStatus);
|
||||
|
||||
const smeltableEntries = Object.entries(smeltable || {});
|
||||
|
||||
// Group recipes by output item
|
||||
const groupedRecipes = useMemo(() => {
|
||||
const groups = {};
|
||||
for (const [input, recipe] of smeltableEntries) {
|
||||
const result = (recipe && recipe.result) || 'unknown';
|
||||
if (!groups[result]) {
|
||||
groups[result] = [];
|
||||
}
|
||||
groups[result].push({ input, recipe });
|
||||
}
|
||||
// Sort groups by output name
|
||||
return Object.entries(groups).sort(([a], [b]) =>
|
||||
formatItemName(a).localeCompare(formatItemName(b))
|
||||
);
|
||||
}, [smeltableEntries]);
|
||||
|
||||
const toggleGroup = (groupKey) => {
|
||||
setCollapsedGroups((prev) => ({
|
||||
...prev,
|
||||
[groupKey]: !prev[groupKey],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="smelting-panel">
|
||||
{/* Header */}
|
||||
<div className="smelting-header">
|
||||
<h2>🔥 Smelting Dashboard</h2>
|
||||
<div className="smelting-controls">
|
||||
<button
|
||||
className={`mc-btn ${smeltingPaused ? 'green' : 'red'}`}
|
||||
onClick={toggleSmelting}
|
||||
>
|
||||
{smeltingPaused ? '▶ Resume' : '⏸ Pause'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{smeltingPaused && (
|
||||
<div className="smelting-paused-banner">⚠️ Smelting is PAUSED</div>
|
||||
)}
|
||||
|
||||
{/* Furnace status */}
|
||||
<div className="furnace-section detail-section">
|
||||
<h3>🔥 Furnaces ({inventory.furnaceCount || 0})</h3>
|
||||
{furnaceEntries.length > 0 ? (
|
||||
<div className="furnace-grid">
|
||||
{furnaceEntries.map(([name, status]) => (
|
||||
<div key={name} className={`furnace-card ${(status && status.active) ? 'active' : 'idle'}`}>
|
||||
<div className="furnace-name">{(name || '').replace(/^minecraft:/, '')}</div>
|
||||
<div className="furnace-status">
|
||||
{status && status.input && (
|
||||
<div className="furnace-slot">
|
||||
<span className="slot-label">In:</span>
|
||||
<ItemIcon itemName={status.input.name} size={16} />
|
||||
<span>{status.input.count || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{status && status.fuel && (
|
||||
<div className="furnace-slot">
|
||||
<span className="slot-label">Fuel:</span>
|
||||
<ItemIcon itemName={status.fuel.name} size={16} />
|
||||
<span>{status.fuel.count || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{status && status.output && (
|
||||
<div className="furnace-slot">
|
||||
<span className="slot-label">Out:</span>
|
||||
<ItemIcon itemName={status.output.name} size={16} />
|
||||
<span>{status.output.count || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{(!status || (!status.input && !status.fuel && !status.output)) && (
|
||||
<span className="furnace-empty">Empty</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-data">No furnace data available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recipe list - Grouped by output */}
|
||||
<div className="recipe-section detail-section">
|
||||
<h3>📋 Smelting Recipes ({smeltableEntries.length})</h3>
|
||||
<div className="recipe-bulk-controls">
|
||||
<button className="mc-btn green" onClick={enableAllRecipes}>✅ Enable All</button>
|
||||
<button className="mc-btn red" onClick={disableAllRecipes}>❌ Disable All</button>
|
||||
</div>
|
||||
|
||||
<div className="recipe-groups">
|
||||
{groupedRecipes.map(([outputName, recipes]) => {
|
||||
const isCollapsed = collapsedGroups[outputName];
|
||||
const enabledCount = recipes.filter((r) => !disabledRecipes[r.input]).length;
|
||||
return (
|
||||
<div key={outputName} className="recipe-group">
|
||||
<div
|
||||
className="recipe-group-header"
|
||||
onClick={() => toggleGroup(outputName)}
|
||||
>
|
||||
<span className="recipe-group-toggle">
|
||||
{isCollapsed ? '▶' : '▼'}
|
||||
</span>
|
||||
<ItemIcon itemName={outputName} size={24} />
|
||||
<span className="recipe-group-name">
|
||||
{formatItemName(outputName)}
|
||||
</span>
|
||||
<span className="recipe-group-count">
|
||||
{enabledCount}/{recipes.length} enabled
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="recipe-group-items">
|
||||
{recipes.map(({ input, recipe }) => {
|
||||
const isDisabled = disabledRecipes[input];
|
||||
const furnaces = (recipe && recipe.furnaces) || [];
|
||||
return (
|
||||
<div
|
||||
key={input}
|
||||
className={`recipe-item ${isDisabled ? 'disabled' : 'enabled'}`}
|
||||
onClick={() => toggleRecipe(input)}
|
||||
>
|
||||
<div className="recipe-toggle">
|
||||
{isDisabled ? '⬜' : '✅'}
|
||||
</div>
|
||||
<div className="recipe-input">
|
||||
<ItemIcon itemName={input} size={20} />
|
||||
<span>{formatItemName(input)}</span>
|
||||
</div>
|
||||
<span className="recipe-arrow">→</span>
|
||||
<div className="recipe-output">
|
||||
<ItemIcon itemName={outputName} size={20} />
|
||||
</div>
|
||||
<div className="recipe-furnaces">
|
||||
{furnaces.map((f) => (
|
||||
<span key={f} className="furnace-tag">
|
||||
{(f || '').replace(/^minecraft:/, '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{groupedRecipes.length === 0 && (
|
||||
<div className="no-data">No smelting recipes loaded</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SmeltingPanel;
|
||||
164
web/client/src/components/StorageOverview.css
Normal file
164
web/client/src/components/StorageOverview.css
Normal file
@@ -0,0 +1,164 @@
|
||||
.storage-overview {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.overview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--mc-dark);
|
||||
}
|
||||
|
||||
.overview-header h2 {
|
||||
font-size: 0.9rem;
|
||||
color: var(--mc-text-yellow);
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
/* Capacity */
|
||||
.capacity-section {
|
||||
padding: 0.5rem;
|
||||
background: var(--mc-dark);
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.capacity-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--mc-text-gray);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.capacity-percent.normal { color: var(--mc-text-green); }
|
||||
.capacity-percent.warning { color: var(--mc-text-gold); }
|
||||
.capacity-percent.critical { color: var(--mc-text-red); }
|
||||
|
||||
.capacity-bar {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: #333;
|
||||
border: 2px solid;
|
||||
border-color: #222 #555 #555 #222;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.capacity-fill {
|
||||
height: 100%;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
|
||||
.capacity-fill.normal { background: var(--mc-grass); }
|
||||
.capacity-fill.warning { background: var(--mc-text-gold); }
|
||||
.capacity-fill.critical { background: var(--mc-text-red); }
|
||||
|
||||
.capacity-detail {
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-gray);
|
||||
text-align: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.375rem;
|
||||
background: var(--mc-dark);
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-gray);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
.stat-value.ok { color: var(--mc-text-green); }
|
||||
.stat-value.offline { color: var(--mc-text-red); }
|
||||
|
||||
/* Peripherals */
|
||||
.peripherals-section h3,
|
||||
.activity-section h3 {
|
||||
font-size: 0.65rem;
|
||||
color: var(--mc-text-gold);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.375rem;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
.peripheral-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.peripheral-item {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
background: var(--mc-dark);
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.peripheral-item.ok { color: var(--mc-text-green); }
|
||||
.peripheral-item.error { color: var(--mc-text-red); }
|
||||
|
||||
/* Activity */
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.activity-item.active {
|
||||
background: var(--mc-grass-dark);
|
||||
color: var(--mc-text-green);
|
||||
animation: activity-pulse 2s infinite;
|
||||
}
|
||||
|
||||
.activity-item.idle {
|
||||
background: var(--mc-dark);
|
||||
color: var(--mc-text-gray);
|
||||
}
|
||||
|
||||
@keyframes activity-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Last update */
|
||||
.last-update {
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-gray);
|
||||
text-align: center;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #444;
|
||||
}
|
||||
123
web/client/src/components/StorageOverview.jsx
Normal file
123
web/client/src/components/StorageOverview.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import './StorageOverview.css';
|
||||
|
||||
function StorageOverview() {
|
||||
const inventory = useInventoryStore((state) => state.inventory);
|
||||
const activity = useInventoryStore((state) => state.activity);
|
||||
const connected = useInventoryStore((state) => state.connected);
|
||||
const lastUpdate = useInventoryStore((state) => state.lastUpdate);
|
||||
const craftTurtleOk = useInventoryStore((state) => state.craftTurtleOk);
|
||||
const requestScan = useInventoryStore((state) => state.requestScan);
|
||||
|
||||
const usedPercent = inventory.totalSlots > 0
|
||||
? Math.round((inventory.usedSlots / inventory.totalSlots) * 100)
|
||||
: 0;
|
||||
|
||||
const getUsageColor = () => {
|
||||
if (usedPercent >= 90) return 'critical';
|
||||
if (usedPercent >= 75) return 'warning';
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
const timeSinceUpdate = lastUpdate
|
||||
? Math.floor((Date.now() - lastUpdate) / 1000)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="storage-overview">
|
||||
<div className="overview-header">
|
||||
<h2>📊 Storage</h2>
|
||||
<button className="mc-btn blue" onClick={requestScan} title="Refresh inventory scan">
|
||||
🔍 Scan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Storage capacity bar */}
|
||||
<div className="capacity-section">
|
||||
<div className="capacity-label">
|
||||
<span>Capacity</span>
|
||||
<span className={`capacity-percent ${getUsageColor()}`}>{usedPercent}%</span>
|
||||
</div>
|
||||
<div className="capacity-bar">
|
||||
<div
|
||||
className={`capacity-fill ${getUsageColor()}`}
|
||||
style={{ width: `${usedPercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="capacity-detail">
|
||||
{inventory.usedSlots} / {inventory.totalSlots} slots
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Total Items</span>
|
||||
<span className="stat-value">{(inventory.grandTotal || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Item Types</span>
|
||||
<span className="stat-value">{(inventory.itemList || []).length}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Chests</span>
|
||||
<span className="stat-value">{inventory.chestCount || 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Free Slots</span>
|
||||
<span className="stat-value">{inventory.freeSlots || 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Furnaces</span>
|
||||
<span className="stat-value">{inventory.furnaceCount || 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Craft Turtle</span>
|
||||
<span className={`stat-value ${craftTurtleOk ? 'ok' : 'offline'}`}>
|
||||
{craftTurtleOk ? '✅ Online' : '❌ Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Peripherals */}
|
||||
<div className="peripherals-section">
|
||||
<h3>Peripherals</h3>
|
||||
<div className="peripheral-list">
|
||||
<div className={`peripheral-item ${inventory.dropperOk ? 'ok' : 'error'}`}>
|
||||
<span>{inventory.dropperOk ? '✅' : '❌'} Dropper</span>
|
||||
</div>
|
||||
<div className={`peripheral-item ${inventory.barrelOk ? 'ok' : 'error'}`}>
|
||||
<span>{inventory.barrelOk ? '✅' : '❌'} Barrel</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity indicators */}
|
||||
<div className="activity-section">
|
||||
<h3>Activity</h3>
|
||||
<div className="activity-list">
|
||||
{activity.sorting && <div className="activity-item active">📦 Sorting</div>}
|
||||
{activity.smelting && <div className="activity-item active">🔥 Smelting</div>}
|
||||
{activity.dispensing && <div className="activity-item active">📤 Dispensing</div>}
|
||||
{activity.composting && <div className="activity-item active">🌱 Composting</div>}
|
||||
{activity.defragging && <div className="activity-item active">🔧 Defragging</div>}
|
||||
{activity.crafting && <div className="activity-item active">🔨 Crafting</div>}
|
||||
{!activity.sorting && !activity.smelting && !activity.dispensing &&
|
||||
!activity.composting && !activity.defragging && !activity.crafting && (
|
||||
<div className="activity-item idle">💤 Idle</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last update */}
|
||||
{lastUpdate > 0 && (
|
||||
<div className="last-update">
|
||||
Last update: {timeSinceUpdate !== null ? `${timeSinceUpdate}s ago` : 'Never'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StorageOverview;
|
||||
9
web/client/src/main.jsx
Normal file
9
web/client/src/main.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
383
web/client/src/store/inventoryStore.js
Normal file
383
web/client/src/store/inventoryStore.js
Normal file
@@ -0,0 +1,383 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const _baseWsUrl = import.meta.env.VITE_WS_URL ||
|
||||
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||
const API_URL = import.meta.env.VITE_API_URL ||
|
||||
`${window.location.protocol}//${window.location.host}/api`;
|
||||
const API_KEY = import.meta.env.VITE_API_KEY || '';
|
||||
|
||||
// Append API key to WebSocket URL as query param
|
||||
const WS_URL = API_KEY ? `${_baseWsUrl}?key=${encodeURIComponent(API_KEY)}` : _baseWsUrl;
|
||||
|
||||
// Build common headers for authenticated requests
|
||||
function authHeaders(extra = {}) {
|
||||
const headers = { 'Content-Type': 'application/json', ...extra };
|
||||
if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
console.log('🔌 WebSocket URL:', _baseWsUrl);
|
||||
console.log('📡 API URL:', API_URL);
|
||||
if (API_KEY) console.log('🔒 API key configured');
|
||||
|
||||
// Generate a unique command ID for idempotent requests
|
||||
function newCommandId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// ========== Timers (module-level so they survive store re-creates) ==========
|
||||
let _httpPollTimer = null;
|
||||
let _wsHealthTimer = null;
|
||||
let _lastWsMessage = 0;
|
||||
let _reconnectDelay = 1000;
|
||||
|
||||
const HTTP_POLL_INTERVAL = 10_000; // Poll HTTP every 10 s as fallback
|
||||
const WS_STALE_TIMEOUT = 35_000; // If no WS message for 35 s → reconnect
|
||||
|
||||
function _applyStateData(data, current) {
|
||||
return {
|
||||
inventory: data.inventory || current.inventory,
|
||||
activity: data.activity || current.activity,
|
||||
alerts: data.alerts || current.alerts,
|
||||
smeltingPaused: data.smeltingPaused !== undefined ? data.smeltingPaused : current.smeltingPaused,
|
||||
disabledRecipes: data.disabledRecipes || current.disabledRecipes,
|
||||
smeltable: data.smeltable || current.smeltable,
|
||||
craftable: data.craftable || current.craftable,
|
||||
craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk,
|
||||
bridgeConnected: data.bridgeConnected !== undefined ? data.bridgeConnected : current.bridgeConnected,
|
||||
dropperNicknames: data.dropperNicknames || current.dropperNicknames,
|
||||
lastUpdate: data.lastUpdate != null ? data.lastUpdate : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export const useInventoryStore = create((set, get) => ({
|
||||
// State
|
||||
inventory: {
|
||||
itemList: [],
|
||||
grandTotal: 0,
|
||||
chestCount: 0,
|
||||
totalSlots: 0,
|
||||
usedSlots: 0,
|
||||
freeSlots: 0,
|
||||
usedRatio: 0,
|
||||
dropperOk: false,
|
||||
barrelOk: false,
|
||||
furnaceCount: 0,
|
||||
furnaceStatus: {},
|
||||
droppers: [],
|
||||
},
|
||||
activity: {},
|
||||
alerts: [],
|
||||
smeltingPaused: false,
|
||||
disabledRecipes: {},
|
||||
smeltable: {},
|
||||
craftable: [],
|
||||
craftTurtleOk: false,
|
||||
bridgeConnected: false,
|
||||
dropperNicknames: {},
|
||||
connected: false,
|
||||
ws: null,
|
||||
lastUpdate: 0,
|
||||
searchQuery: '',
|
||||
commandResult: null,
|
||||
historySummary: [],
|
||||
itemHistory: {},
|
||||
|
||||
// Fetch state via HTTP API (fallback / initial load)
|
||||
fetchState: async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/inventory`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const current = get();
|
||||
// Only apply if we have actual data with items
|
||||
if (data.inventory?.itemList?.length || !current.inventory.itemList?.length) {
|
||||
set(_applyStateData(data, current));
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent fail — WebSocket will provide data
|
||||
}
|
||||
},
|
||||
|
||||
// Start periodic HTTP polling (runs alongside WS for redundancy)
|
||||
_startHttpPoll: () => {
|
||||
if (_httpPollTimer) return;
|
||||
_httpPollTimer = setInterval(() => {
|
||||
get().fetchState();
|
||||
}, HTTP_POLL_INTERVAL);
|
||||
},
|
||||
|
||||
// Client-side WS health check — reconnect if no message received recently
|
||||
_startWsHealthCheck: () => {
|
||||
if (_wsHealthTimer) return;
|
||||
_wsHealthTimer = setInterval(() => {
|
||||
const ws = get().ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (_lastWsMessage > 0 && Date.now() - _lastWsMessage > WS_STALE_TIMEOUT) {
|
||||
console.warn('⚠️ WebSocket stale (no messages for', Math.round((Date.now() - _lastWsMessage) / 1000), 's) — reconnecting');
|
||||
ws.close(); // triggers onclose → reconnect
|
||||
_lastWsMessage = 0; // reset so we don't loop
|
||||
}
|
||||
}, 10_000);
|
||||
},
|
||||
|
||||
// WebSocket connection
|
||||
connect: () => {
|
||||
// Prevent duplicate connections
|
||||
const existing = get().ws;
|
||||
if (existing && (existing.readyState === WebSocket.CONNECTING || existing.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch state via HTTP immediately (don't wait for WS)
|
||||
get().fetchState();
|
||||
|
||||
// Ensure background timers are running
|
||||
get()._startHttpPoll();
|
||||
get()._startWsHealthCheck();
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('✅ Connected to server');
|
||||
_lastWsMessage = Date.now();
|
||||
_reconnectDelay = 1000;
|
||||
set({ connected: true, ws });
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
_lastWsMessage = Date.now();
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'initial_state' || data.type === 'state_update') {
|
||||
set(_applyStateData(data, get()));
|
||||
} else if (data.type === 'command_result') {
|
||||
set({ commandResult: data });
|
||||
// Clear after 5s
|
||||
setTimeout(() => {
|
||||
set((state) => {
|
||||
if (state.commandResult === data) {
|
||||
return { commandResult: null };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('❌ Disconnected from server');
|
||||
set({ connected: false, ws: null });
|
||||
const delay = _reconnectDelay;
|
||||
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
|
||||
setTimeout(() => {
|
||||
get().connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
},
|
||||
|
||||
// Search
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
|
||||
getFilteredItems: () => {
|
||||
const { inventory, searchQuery } = get();
|
||||
const items = inventory.itemList || [];
|
||||
if (!searchQuery) return items;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return items.filter((item) => {
|
||||
const name = (item.displayName || item.name || '').toLowerCase();
|
||||
return name.includes(q);
|
||||
});
|
||||
},
|
||||
|
||||
// Actions via REST API
|
||||
orderItem: async (itemName, amount, dropperName) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/order`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ itemName, amount, dropperName, commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error ordering item:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
requestScan: async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/scan`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error requesting scan:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
toggleSmelting: async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/smelting/toggle`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error toggling smelting:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
toggleRecipe: async (recipe) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/recipes/toggle`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ recipe, commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error toggling recipe:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
enableAllRecipes: async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/recipes/enable-all`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error enabling all recipes:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
disableAllRecipes: async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/recipes/disable-all`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error disabling all recipes:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
craftItem: async (recipeIdx) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/craft`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ recipeIdx, commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error crafting item:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
rebootComputers: async (target = 'all') => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/reboot`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ target, commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error sending reboot:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
sortBarrel: async (barrelName) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/sort-barrel`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ barrelName, commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error sorting barrel:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Dropper nicknames
|
||||
setDropperNickname: async (dropperName, nickname) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/dropper-nicknames`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ dropperName, nickname, commandId: newCommandId() }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.nicknames) {
|
||||
set({ dropperNicknames: result.nicknames });
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('❌ Error setting dropper nickname:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
fetchDropperNicknames: async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/dropper-nicknames`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
set({ dropperNicknames: data.nicknames || {} });
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching dropper nicknames:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Analytics - fetch aggregate storage history
|
||||
fetchHistorySummary: async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/history-summary`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
set({ historySummary: data.history || [] });
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching history summary:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Analytics - fetch single item history
|
||||
fetchItemHistory: async (itemName) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/history/${encodeURIComponent(itemName)}?limit=200`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
set((state) => ({
|
||||
itemHistory: { ...state.itemHistory, [itemName]: data.history || [] },
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching item history:', error);
|
||||
}
|
||||
},
|
||||
}));
|
||||
163
web/client/src/utils/itemUtils.js
Normal file
163
web/client/src/utils/itemUtils.js
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Minecraft item utility functions
|
||||
* Icons use official game textures via ItemIcon component
|
||||
*/
|
||||
|
||||
// Format an item name for display
|
||||
export function formatItemName(name) {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.replace(/^[a-z0-9_.-]+:/, '') // Strip mod prefix
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// ========== Creative Inventory Categories ==========
|
||||
|
||||
export const ITEM_CATEGORIES = [
|
||||
{ id: 'all', label: 'All', icon: 'chest' },
|
||||
{ id: 'blocks', label: 'Blocks', icon: 'bricks' },
|
||||
{ id: 'tools', label: 'Tools', icon: 'iron_pickaxe' },
|
||||
{ id: 'combat', label: 'Combat', icon: 'iron_sword' },
|
||||
{ id: 'food', label: 'Food', icon: 'apple' },
|
||||
{ id: 'redstone', label: 'Redstone', icon: 'redstone' },
|
||||
{ id: 'materials', label: 'Materials', icon: 'iron_ingot' },
|
||||
{ id: 'misc', label: 'Misc', icon: 'bone' },
|
||||
];
|
||||
|
||||
export function getItemCategory(itemName) {
|
||||
if (!itemName) return 'misc';
|
||||
const n = itemName.replace(/^[a-z0-9_.-]+:/, '');
|
||||
|
||||
// Redstone (check first - redstone_block should be redstone, not blocks)
|
||||
if (/redstone|repeater|comparator|piston|observer|dropper|dispenser|hopper|lever|tripwire|daylight|note_block|sculk|target/.test(n)) return 'redstone';
|
||||
|
||||
// Combat
|
||||
if (/sword|crossbow|trident|shield|helmet|chestplate|leggings|boots|horse_armor|arrow/.test(n)) return 'combat';
|
||||
if (/bow/.test(n) && !/bowl|elbow/.test(n)) return 'combat';
|
||||
|
||||
// Tools
|
||||
if (/pickaxe|shovel|_hoe|shears|fishing_rod|flint_and_steel|compass|clock|spyglass|bucket|name_tag|brush/.test(n)) return 'tools';
|
||||
if (/axe/.test(n) && !/pickaxe/.test(n)) return 'tools';
|
||||
|
||||
// Food
|
||||
if (/apple|bread|beef|porkchop|mutton|rabbit|salmon|potato|carrot|melon_slice|berries|cookie|pie|stew|soup|honey_bottle|dried_kelp|chorus_fruit|rotten_flesh|cooked_|baked_|golden_carrot/.test(n)) return 'food';
|
||||
if (/^cake$/.test(n)) return 'food';
|
||||
|
||||
// Materials
|
||||
if (/ingot|nugget|raw_iron|raw_gold|raw_copper|_shard|_dust|_powder|_scrap|bone_meal|slime_ball|magma_cream|ender_pearl|blaze|nether_star|ghast_tear|gunpowder|phantom_membrane|experience_bottle|ink_sac|nether_wart|_dye$/.test(n)) return 'materials';
|
||||
if (/^(diamond|emerald|coal|charcoal|quartz|bone|sugar|stick|flint|paper|wheat|egg|book|leather|string|feather)$/.test(n)) return 'materials';
|
||||
if (/seeds$|enchanted_book/.test(n)) return 'materials';
|
||||
|
||||
// Blocks
|
||||
if (/planks|_log|_wood|stone|brick|_slab|stairs|_wall|_fence|_door|trapdoor|wool|concrete|terracotta|glass|_pane|sandstone|deepslate|obsidian|ore|_block|leaves|torch|lantern|crafting_table|furnace|anvil|chest|barrel|sponge|carpet|banner/.test(n)) return 'blocks';
|
||||
if (/^(dirt|mud|clay|sand|red_sand|gravel|netherrack|tnt|cactus|pumpkin|melon|bamboo|cobblestone|ice|packed_ice|blue_ice)$/.test(n)) return 'blocks';
|
||||
|
||||
return 'misc';
|
||||
}
|
||||
|
||||
// Fallback emoji mapping for common item categories
|
||||
const CATEGORY_EMOJI = {
|
||||
ingot: '🪙',
|
||||
ore: '⛏️',
|
||||
raw: '🪨',
|
||||
log: '🪵',
|
||||
planks: '🪵',
|
||||
wool: '🧶',
|
||||
dye: '🎨',
|
||||
seed: '🌱',
|
||||
crop: '🌾',
|
||||
wheat: '🌾',
|
||||
food: '🍖',
|
||||
cooked: '🍗',
|
||||
beef: '🥩',
|
||||
porkchop: '🥩',
|
||||
chicken: '🍗',
|
||||
mutton: '🥩',
|
||||
cod: '🐟',
|
||||
salmon: '🐟',
|
||||
potato: '🥔',
|
||||
carrot: '🥕',
|
||||
apple: '🍎',
|
||||
bread: '🍞',
|
||||
sword: '⚔️',
|
||||
pickaxe: '⛏️',
|
||||
axe: '🪓',
|
||||
shovel: '',
|
||||
hoe: '🌿',
|
||||
helmet: '🪖',
|
||||
chestplate: '👕',
|
||||
leggings: '👖',
|
||||
boots: '👢',
|
||||
bow: '🏹',
|
||||
arrow: '🏹',
|
||||
shield: '🛡️',
|
||||
diamond: '💎',
|
||||
emerald: '💚',
|
||||
redstone: '🔴',
|
||||
lapis: '🔵',
|
||||
coal: '⚫',
|
||||
iron: '⬜',
|
||||
gold: '🟡',
|
||||
netherite: '⬛',
|
||||
stone: '🪨',
|
||||
cobblestone: '🪨',
|
||||
dirt: '🟫',
|
||||
sand: '🟨',
|
||||
gravel: '⬜',
|
||||
glass: '🔲',
|
||||
brick: '🧱',
|
||||
torch: '🔦',
|
||||
bone: '🦴',
|
||||
string: '🧵',
|
||||
leather: '🟤',
|
||||
feather: '🪶',
|
||||
egg: '🥚',
|
||||
book: '📕',
|
||||
paper: '📄',
|
||||
bucket: '🪣',
|
||||
potion: '🧪',
|
||||
pearl: '🟣',
|
||||
blaze: '🔥',
|
||||
gunpowder: '💣',
|
||||
sugar: '🍬',
|
||||
stick: '🪵',
|
||||
chest: '📦',
|
||||
furnace: '🔥',
|
||||
sponge: '🧽',
|
||||
kelp: '🌿',
|
||||
cactus: '🌵',
|
||||
};
|
||||
|
||||
export function getItemEmoji(itemName) {
|
||||
if (!itemName) return '📦';
|
||||
const name = itemName.replace(/^[a-z0-9_]+:/, '').toLowerCase();
|
||||
|
||||
// Check direct matches
|
||||
for (const [key, emoji] of Object.entries(CATEGORY_EMOJI)) {
|
||||
if (name.includes(key)) return emoji;
|
||||
}
|
||||
|
||||
return '📦';
|
||||
}
|
||||
|
||||
// Compact number formatting
|
||||
export function formatCount(count) {
|
||||
if (count == null) return '0';
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
// Get rarity color based on item name
|
||||
export function getRarityColor(itemName) {
|
||||
if (!itemName) return '#ffffff';
|
||||
const name = itemName.toLowerCase();
|
||||
if (name.includes('netherite')) return '#4a2a4a';
|
||||
if (name.includes('diamond')) return '#4aedd9';
|
||||
if (name.includes('emerald')) return '#17dd62';
|
||||
if (name.includes('gold') || name.includes('golden')) return '#ffaa00';
|
||||
if (name.includes('iron')) return '#d8d8d8';
|
||||
if (name.includes('enchanted') || name.includes('nether_star')) return '#ff55ff';
|
||||
return '#ffffff';
|
||||
}
|
||||
26
web/client/vite.config.js
Normal file
26
web/client/vite.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
cors: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://server:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://server:3001',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
cors: true,
|
||||
},
|
||||
});
|
||||
32
web/docker-compose.yml
Normal file
32
web/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
server:
|
||||
build: ./server
|
||||
networks:
|
||||
- inventory-network
|
||||
volumes:
|
||||
- server-data:/data
|
||||
environment:
|
||||
- API_KEY=${API_KEY:-}
|
||||
- TURTLE_SERVER_URL=${TURTLE_SERVER_URL:-}
|
||||
restart: unless-stopped
|
||||
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
args:
|
||||
VITE_API_KEY: ${API_KEY:-}
|
||||
ports:
|
||||
- "80:80"
|
||||
networks:
|
||||
- inventory-network
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
inventory-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
server-data:
|
||||
3
web/server/.dockerignore
Normal file
3
web/server/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
50
web/server/Dockerfile
Normal file
50
web/server/Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
||||
# Stage 1: Fetch platform server package from git
|
||||
FROM alpine:3.20 AS platform
|
||||
RUN apk add --no-cache git
|
||||
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||
ARG PLATFORM_BRANCH=master
|
||||
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||
&& rm -rf /src/server/node_modules /src/.git
|
||||
|
||||
# Stage 2: Node.js backend
|
||||
FROM node:20-alpine
|
||||
|
||||
# Build tools needed for better-sqlite3 native compilation
|
||||
# su-exec for dropping privileges in entrypoint
|
||||
# libstdc++ is kept at runtime (needed by better-sqlite3 native addon)
|
||||
RUN apk add --no-cache python3 make g++ su-exec libstdc++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy platform server package from the git-clone stage
|
||||
COPY --from=platform /src/server /app/platform-server/
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
# Rewrite file: dependency to use the local copy inside the container
|
||||
RUN sed -i 's|file:../../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||
&& rm -f package-lock.json
|
||||
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Remove build tools after install to keep image small
|
||||
# libstdc++ and su-exec are kept for runtime
|
||||
RUN apk del python3 make g++
|
||||
|
||||
COPY . .
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /data
|
||||
VOLUME /data
|
||||
|
||||
# Entrypoint fixes /data permissions then drops to node user
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "require('http').get('http://127.0.0.1:3001/api/health', r => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
207
web/server/__tests__/db.test.js
Normal file
207
web/server/__tests__/db.test.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Tests for the Inventory Manager database layer (db.js).
|
||||
*
|
||||
* Sets DB_PATH to a temp file BEFORE importing the module so all
|
||||
* operations hit a throwaway SQLite database. The underlying
|
||||
* better-sqlite3 `Database` instance is also imported so we can
|
||||
* DELETE rows between tests without reopening the file.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// DB_PATH is set via vitest.config.js env before this module loads.
|
||||
const TMP_DB = process.env.DB_PATH;
|
||||
import db from '../db.js';
|
||||
import {
|
||||
saveItems, loadItems,
|
||||
saveState, loadState,
|
||||
saveFurnaces, loadFurnaces,
|
||||
saveAlerts, loadAlerts,
|
||||
recordItemHistory, getHistory, getHistorySummary,
|
||||
saveFullState, flushPendingSave, loadFullState,
|
||||
closeDb,
|
||||
} from '../db.js';
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Wipe all rows from every table so each test starts fresh */
|
||||
function clearAllTables() {
|
||||
db.exec(`
|
||||
DELETE FROM items;
|
||||
DELETE FROM furnaces;
|
||||
DELETE FROM alerts;
|
||||
DELETE FROM state;
|
||||
DELETE FROM item_history;
|
||||
`);
|
||||
}
|
||||
|
||||
// ── lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { closeDb(); } catch { /* already closed */ }
|
||||
try { fs.unlinkSync(TMP_DB); } catch { /* ignore */ }
|
||||
try { fs.unlinkSync(TMP_DB + '-wal'); } catch { /* ignore */ }
|
||||
try { fs.unlinkSync(TMP_DB + '-shm'); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// ── tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('db – item persistence', () => {
|
||||
it('saveItems + loadItems round-trips item data', () => {
|
||||
const items = [
|
||||
{ name: 'minecraft:diamond', count: 64, displayName: 'Diamond' },
|
||||
{ name: 'minecraft:iron_ingot', count: 128, displayName: 'Iron Ingot' },
|
||||
];
|
||||
saveItems(items);
|
||||
|
||||
const loaded = loadItems();
|
||||
expect(loaded).toHaveLength(2);
|
||||
|
||||
// loadItems returns ORDER BY count DESC
|
||||
expect(loaded[0].name).toBe('minecraft:iron_ingot');
|
||||
expect(loaded[0].count).toBe(128);
|
||||
expect(loaded[1].name).toBe('minecraft:diamond');
|
||||
expect(loaded[1].count).toBe(64);
|
||||
});
|
||||
|
||||
it('saveItems upserts — updates existing rows instead of duplicating', () => {
|
||||
saveItems([{ name: 'minecraft:diamond', count: 10, displayName: 'Diamond' }]);
|
||||
saveItems([{ name: 'minecraft:diamond', count: 99, displayName: 'Diamond' }]);
|
||||
|
||||
const loaded = loadItems();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].count).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('db – state key-value store', () => {
|
||||
it('saveState + loadState round-trips JSON', () => {
|
||||
saveState('smeltingPaused', true);
|
||||
expect(loadState('smeltingPaused')).toBe(true);
|
||||
|
||||
saveState('disabledRecipes', { 'minecraft:iron_ingot': true });
|
||||
expect(loadState('disabledRecipes')).toEqual({ 'minecraft:iron_ingot': true });
|
||||
});
|
||||
|
||||
it('loadState returns defaultValue when key missing', () => {
|
||||
expect(loadState('nonexistent', 42)).toBe(42);
|
||||
expect(loadState('nonexistent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('db – furnace persistence', () => {
|
||||
it('saveFurnaces + loadFurnaces round-trips furnace status', () => {
|
||||
const furnaces = {
|
||||
'minecraft:furnace_0': {
|
||||
active: true,
|
||||
type: 'minecraft:furnace',
|
||||
input: { name: 'minecraft:raw_iron', count: 8 },
|
||||
fuel: { name: 'minecraft:coal', count: 3 },
|
||||
output: null,
|
||||
},
|
||||
};
|
||||
|
||||
saveFurnaces(furnaces);
|
||||
const loaded = loadFurnaces();
|
||||
|
||||
expect(loaded['minecraft:furnace_0']).toBeDefined();
|
||||
expect(loaded['minecraft:furnace_0'].active).toBe(true);
|
||||
expect(loaded['minecraft:furnace_0'].input.name).toBe('minecraft:raw_iron');
|
||||
});
|
||||
});
|
||||
|
||||
describe('db – alert persistence', () => {
|
||||
it('saveAlerts + loadAlerts round-trips triggered alerts', () => {
|
||||
saveAlerts([
|
||||
{ item: 'minecraft:diamond', triggered: true, current: 5, threshold: 10 },
|
||||
{ item: 'minecraft:coal', triggered: false, current: 200, threshold: 50 },
|
||||
]);
|
||||
|
||||
const loaded = loadAlerts();
|
||||
// loadAlerts only returns triggered=1
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].item).toBe('minecraft:diamond');
|
||||
expect(loaded[0].triggered).toBe(true);
|
||||
expect(loaded[0].current).toBe(5);
|
||||
expect(loaded[0].threshold).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('db – item history', () => {
|
||||
it('getHistory returns recorded snapshots ordered newest-first', () => {
|
||||
// recordItemHistory is throttled (5-min internal cooldown).
|
||||
// First call after module load will go through.
|
||||
recordItemHistory([
|
||||
{ name: 'minecraft:diamond', count: 64 },
|
||||
{ name: 'minecraft:iron_ingot', count: 128 },
|
||||
]);
|
||||
|
||||
const history = getHistory('minecraft:diamond', 10);
|
||||
expect(history.length).toBeGreaterThanOrEqual(1);
|
||||
expect(history[0].count).toBe(64);
|
||||
});
|
||||
|
||||
it('getHistorySummary groups by timestamp', () => {
|
||||
// May be throttled if previous test already recorded in this module load.
|
||||
// Insert directly via the underlying db for determinism.
|
||||
const now = Date.now();
|
||||
db.prepare('INSERT INTO item_history (name, count, recorded_at) VALUES (?, ?, ?)').run('a', 10, now);
|
||||
db.prepare('INSERT INTO item_history (name, count, recorded_at) VALUES (?, ?, ?)').run('b', 20, now);
|
||||
|
||||
const summary = getHistorySummary();
|
||||
expect(summary.length).toBeGreaterThanOrEqual(1);
|
||||
expect(summary[0].total).toBe(30); // 10 + 20
|
||||
});
|
||||
});
|
||||
|
||||
describe('db – full state round-trip', () => {
|
||||
it('saveFullState + flushPendingSave + loadFullState restores everything', () => {
|
||||
const state = {
|
||||
inventoryState: {
|
||||
itemList: [
|
||||
{ name: 'minecraft:diamond', count: 64, displayName: 'Diamond' },
|
||||
],
|
||||
grandTotal: 64,
|
||||
chestCount: 2,
|
||||
totalSlots: 54,
|
||||
usedSlots: 1,
|
||||
freeSlots: 53,
|
||||
usedRatio: 0.02,
|
||||
dropperOk: true,
|
||||
barrelOk: false,
|
||||
furnaceCount: 1,
|
||||
furnaceStatus: {
|
||||
'furnace_0': { active: false, type: 'minecraft:furnace', input: null, fuel: null, output: null },
|
||||
},
|
||||
droppers: ['dropper_0'],
|
||||
},
|
||||
activityState: { lastScan: 12345 },
|
||||
alertsState: [{ item: 'minecraft:coal', triggered: true, current: 3, threshold: 10 }],
|
||||
smeltingPaused: false,
|
||||
disabledRecipes: { 'minecraft:charcoal': true },
|
||||
smeltableRecipes: { 'minecraft:raw_iron': 'minecraft:iron_ingot' },
|
||||
craftableRecipes: [{ output: 'minecraft:chest', count: 1, slots: { 1: 'minecraft:planks' } }],
|
||||
craftTurtleOk: true,
|
||||
};
|
||||
|
||||
saveFullState(state);
|
||||
flushPendingSave(); // force the debounced write
|
||||
|
||||
const restored = loadFullState();
|
||||
expect(restored.inventoryState.itemList).toHaveLength(1);
|
||||
expect(restored.inventoryState.itemList[0].name).toBe('minecraft:diamond');
|
||||
expect(restored.inventoryState.grandTotal).toBe(64);
|
||||
expect(restored.smeltingPaused).toBe(false);
|
||||
expect(restored.disabledRecipes).toEqual({ 'minecraft:charcoal': true });
|
||||
expect(restored.craftTurtleOk).toBe(true);
|
||||
expect(restored.alertsState).toHaveLength(1);
|
||||
expect(restored.alertsState[0].item).toBe('minecraft:coal');
|
||||
});
|
||||
});
|
||||
308
web/server/__tests__/server.test.js
Normal file
308
web/server/__tests__/server.test.js
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Tests for Inventory Manager server logic.
|
||||
*
|
||||
* server.js is a monolith that starts listening on import, so we can't
|
||||
* import it directly in a test. Instead we extract and test the pure
|
||||
* helper logic that lives inside it:
|
||||
*
|
||||
* • Rate limiter algorithm
|
||||
* • Input validation rules (order, craft, reboot targets)
|
||||
* • Lua -> Frontend state normalization (itemList, furnaces, alerts, recipes)
|
||||
* • Idempotent command tracking
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Extracted: createRateLimiter ───────────────────────────────────────
|
||||
|
||||
function createRateLimiter(windowMs, max) {
|
||||
const hits = new Map();
|
||||
// Note: in tests we skip the setInterval cleanup
|
||||
return (req, res, next) => {
|
||||
const key = req.ip || 'unknown';
|
||||
const now = Date.now();
|
||||
const entry = hits.get(key);
|
||||
if (!entry || now - entry.start > windowMs) {
|
||||
hits.set(key, { start: now, count: 1 });
|
||||
return next();
|
||||
}
|
||||
entry.count++;
|
||||
if (entry.count > max) {
|
||||
res.set('Retry-After', String(Math.ceil((entry.start + windowMs - now) / 1000)));
|
||||
return res.status(429).json({ error: 'Too many requests — try again later' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
// ── Extracted: normalization helpers ───────────────────────────────────
|
||||
|
||||
function normalizeItemList(rawItems) {
|
||||
return Array.isArray(rawItems)
|
||||
? rawItems.map(item => ({
|
||||
name: item.name || '',
|
||||
count: item.total !== undefined ? item.total : (item.count || 0),
|
||||
displayName: item.displayName || item.name || '',
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeFurnaces(rawFurnaces) {
|
||||
let normalizedFurnaces = {};
|
||||
if (Array.isArray(rawFurnaces)) {
|
||||
rawFurnaces.forEach(f => {
|
||||
if (f && f.name) {
|
||||
normalizedFurnaces[f.name] = {
|
||||
active: f.active || false,
|
||||
type: f.type || 'minecraft:furnace',
|
||||
input: f.input || null,
|
||||
fuel: f.fuel || null,
|
||||
output: f.output || null,
|
||||
};
|
||||
}
|
||||
});
|
||||
} else if (typeof rawFurnaces === 'object') {
|
||||
normalizedFurnaces = rawFurnaces;
|
||||
}
|
||||
return normalizedFurnaces;
|
||||
}
|
||||
|
||||
function normalizeAlerts(rawAlerts) {
|
||||
return Array.isArray(rawAlerts)
|
||||
? rawAlerts.map(a => ({
|
||||
item: a.name || a.item || a.label || '',
|
||||
triggered: true,
|
||||
current: a.current || 0,
|
||||
threshold: a.min || a.threshold || 0,
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeCraftable(rawCraftable) {
|
||||
return Array.isArray(rawCraftable)
|
||||
? rawCraftable.map(recipe => {
|
||||
const slots = {};
|
||||
if (recipe.grid && Array.isArray(recipe.grid)) {
|
||||
recipe.grid.forEach((item, idx) => {
|
||||
if (item) slots[idx + 1] = item;
|
||||
});
|
||||
} else if (recipe.slots) {
|
||||
Object.assign(slots, recipe.slots);
|
||||
}
|
||||
return { output: recipe.output || '', count: recipe.count || 1, slots };
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
// ── Extracted: idempotent command tracking ─────────────────────────────
|
||||
|
||||
function createCommandTracker(ttl = 5 * 60 * 1000) {
|
||||
const processedCommands = new Map();
|
||||
return {
|
||||
check(commandId) {
|
||||
if (!commandId) return null;
|
||||
return processedCommands.get(commandId)?.result || null;
|
||||
},
|
||||
record(commandId, result) {
|
||||
if (!commandId) return;
|
||||
processedCommands.set(commandId, { result, timestamp: Date.now() });
|
||||
},
|
||||
size() { return processedCommands.size; },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createRateLimiter', () => {
|
||||
it('allows requests under the limit', () => {
|
||||
const limiter = createRateLimiter(60_000, 3);
|
||||
const req = { ip: '127.0.0.1' };
|
||||
const next = vi.fn();
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
|
||||
|
||||
limiter(req, res, next);
|
||||
limiter(req, res, next);
|
||||
limiter(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(3);
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks requests over the limit with 429', () => {
|
||||
const limiter = createRateLimiter(60_000, 2);
|
||||
const req = { ip: '10.0.0.1' };
|
||||
const next = vi.fn();
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
|
||||
|
||||
limiter(req, res, next); // 1 — allowed
|
||||
limiter(req, res, next); // 2 — allowed
|
||||
limiter(req, res, next); // 3 — blocked
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
expect(res.set).toHaveBeenCalledWith('Retry-After', expect.any(String));
|
||||
});
|
||||
|
||||
it('tracks different IPs independently', () => {
|
||||
const limiter = createRateLimiter(60_000, 1);
|
||||
const next = vi.fn();
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
|
||||
|
||||
limiter({ ip: 'a' }, res, next); // a:1 — allowed
|
||||
limiter({ ip: 'b' }, res, next); // b:1 — allowed
|
||||
limiter({ ip: 'a' }, res, next); // a:2 — blocked
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeItemList', () => {
|
||||
it('converts Lua-style { name, total } to { name, count }', () => {
|
||||
const raw = [
|
||||
{ name: 'minecraft:diamond', total: 64 },
|
||||
{ name: 'minecraft:coal', total: 200 },
|
||||
];
|
||||
const result = normalizeItemList(raw);
|
||||
expect(result[0]).toEqual({ name: 'minecraft:diamond', count: 64, displayName: 'minecraft:diamond' });
|
||||
expect(result[1].count).toBe(200);
|
||||
});
|
||||
|
||||
it('falls back to count if total is absent', () => {
|
||||
const raw = [{ name: 'minecraft:iron_ingot', count: 32, displayName: 'Iron Ingot' }];
|
||||
const result = normalizeItemList(raw);
|
||||
expect(result[0].count).toBe(32);
|
||||
expect(result[0].displayName).toBe('Iron Ingot');
|
||||
});
|
||||
|
||||
it('returns empty array for non-array input', () => {
|
||||
expect(normalizeItemList(null)).toEqual([]);
|
||||
expect(normalizeItemList('bad')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFurnaces', () => {
|
||||
it('converts Lua array-of-objects to keyed map', () => {
|
||||
const raw = [
|
||||
{ name: 'furnace_0', active: true, type: 'minecraft:blast_furnace', input: { name: 'iron' }, fuel: null, output: null },
|
||||
];
|
||||
const result = normalizeFurnaces(raw);
|
||||
expect(result['furnace_0'].active).toBe(true);
|
||||
expect(result['furnace_0'].type).toBe('minecraft:blast_furnace');
|
||||
});
|
||||
|
||||
it('passes through pre-keyed objects as-is', () => {
|
||||
const raw = { furnace_0: { active: false } };
|
||||
const result = normalizeFurnaces(raw);
|
||||
expect(result).toBe(raw); // same reference
|
||||
});
|
||||
|
||||
it('skips entries without a name', () => {
|
||||
const raw = [{ active: true }, { name: 'f1', active: false }];
|
||||
const result = normalizeFurnaces(raw);
|
||||
expect(Object.keys(result)).toEqual(['f1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAlerts', () => {
|
||||
it('normalizes Lua-style {label, current, min} fields', () => {
|
||||
const raw = [{ label: 'Diamond', current: 2, min: 10 }];
|
||||
const result = normalizeAlerts(raw);
|
||||
expect(result[0]).toEqual({ item: 'Diamond', triggered: true, current: 2, threshold: 10 });
|
||||
});
|
||||
|
||||
it('accepts name or item field', () => {
|
||||
expect(normalizeAlerts([{ name: 'A' }])[0].item).toBe('A');
|
||||
expect(normalizeAlerts([{ item: 'B' }])[0].item).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCraftable', () => {
|
||||
it('converts Lua grid (flat array of 9) to slots object', () => {
|
||||
const raw = [{
|
||||
output: 'minecraft:chest',
|
||||
count: 1,
|
||||
grid: [null, 'minecraft:planks', null, 'minecraft:planks', null, 'minecraft:planks', null, 'minecraft:planks', null],
|
||||
}];
|
||||
const result = normalizeCraftable(raw);
|
||||
expect(result[0].output).toBe('minecraft:chest');
|
||||
// grid indices are 0-based, slots keys are 1-based
|
||||
expect(result[0].slots).toEqual({ 2: 'minecraft:planks', 4: 'minecraft:planks', 6: 'minecraft:planks', 8: 'minecraft:planks' });
|
||||
});
|
||||
|
||||
it('passes through pre-slotted recipes', () => {
|
||||
const raw = [{ output: 'x', count: 2, slots: { 1: 'a', 5: 'b' } }];
|
||||
const result = normalizeCraftable(raw);
|
||||
expect(result[0].slots).toEqual({ 1: 'a', 5: 'b' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotent command tracking', () => {
|
||||
let tracker;
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = createCommandTracker();
|
||||
});
|
||||
|
||||
it('returns null for unseen commandId', () => {
|
||||
expect(tracker.check('cmd-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns cached result for repeated commandId', () => {
|
||||
const result = { success: true, message: 'Order sent' };
|
||||
tracker.record('cmd-1', result);
|
||||
expect(tracker.check('cmd-1')).toEqual(result);
|
||||
});
|
||||
|
||||
it('ignores null/undefined commandId', () => {
|
||||
tracker.record(null, { x: 1 });
|
||||
tracker.record(undefined, { x: 2 });
|
||||
expect(tracker.size()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not cross-contaminate different command IDs', () => {
|
||||
tracker.record('a', { val: 1 });
|
||||
tracker.record('b', { val: 2 });
|
||||
expect(tracker.check('a')).toEqual({ val: 1 });
|
||||
expect(tracker.check('b')).toEqual({ val: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation rules', () => {
|
||||
const VALID_REBOOT_TARGETS = ['all', 'manager', 'client', 'turtle', 'bridge'];
|
||||
|
||||
it('accepts valid reboot targets', () => {
|
||||
for (const t of VALID_REBOOT_TARGETS) {
|
||||
expect(VALID_REBOOT_TARGETS.includes(t) || /^\d+$/.test(t)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts numeric computer IDs', () => {
|
||||
expect(/^\d+$/.test('42')).toBe(true);
|
||||
expect(/^\d+$/.test('0')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid reboot targets', () => {
|
||||
for (const t of ['admin', 'drop ; rm -rf /', '', 'ALL']) {
|
||||
expect(VALID_REBOOT_TARGETS.includes(t) || /^\d+$/.test(t)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates order amount range (1–100000)', () => {
|
||||
const valid = (n) => Number.isFinite(n) && n >= 1 && n <= 100000;
|
||||
expect(valid(1)).toBe(true);
|
||||
expect(valid(100000)).toBe(true);
|
||||
expect(valid(0)).toBe(false);
|
||||
expect(valid(-1)).toBe(false);
|
||||
expect(valid(100001)).toBe(false);
|
||||
expect(valid(NaN)).toBe(false);
|
||||
expect(valid(Infinity)).toBe(false);
|
||||
});
|
||||
|
||||
it('validates craft recipeIdx range (1–1000)', () => {
|
||||
const valid = (n) => Number.isFinite(n) && n >= 1 && n <= 1000;
|
||||
expect(valid(1)).toBe(true);
|
||||
expect(valid(500)).toBe(true);
|
||||
expect(valid(0)).toBe(false);
|
||||
expect(valid(1001)).toBe(false);
|
||||
});
|
||||
});
|
||||
501
web/server/db.js
Normal file
501
web/server/db.js
Normal file
@@ -0,0 +1,501 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || '/data/inventory.db';
|
||||
|
||||
console.log(`[db] Opening database at ${DB_PATH} (uid=${process.getuid()})`);
|
||||
|
||||
// Ensure the directory exists
|
||||
const dbDir = dirname(DB_PATH);
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Verify directory is writable before opening SQLite
|
||||
try {
|
||||
accessSync(dbDir, fsConstants.W_OK);
|
||||
} catch {
|
||||
console.error(`[db] FATAL: directory ${dbDir} is not writable by uid ${process.getuid()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(DB_PATH);
|
||||
} catch (err) {
|
||||
console.error(`[db] FATAL: failed to open database: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Performance pragmas
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('synchronous = NORMAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('cache_size = -8000'); // 8MB cache
|
||||
db.pragma('temp_store = MEMORY');
|
||||
|
||||
console.log('[db] Database ready');
|
||||
|
||||
// ========== Schema ==========
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
name TEXT PRIMARY KEY,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS furnaces (
|
||||
name TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'minecraft:furnace',
|
||||
active INTEGER NOT NULL DEFAULT 0,
|
||||
input TEXT,
|
||||
fuel TEXT,
|
||||
output TEXT,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
item TEXT PRIMARY KEY,
|
||||
triggered INTEGER NOT NULL DEFAULT 0,
|
||||
current INTEGER NOT NULL DEFAULT 0,
|
||||
threshold INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
recorded_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_item_history_name ON item_history(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_item_history_time ON item_history(recorded_at);
|
||||
`);
|
||||
|
||||
// ========== Prepared Statements ==========
|
||||
|
||||
const upsertItem = db.prepare(`
|
||||
INSERT INTO items (name, count, display_name, updated_at)
|
||||
VALUES (@name, @count, @displayName, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
count = @count,
|
||||
display_name = @displayName,
|
||||
updated_at = @updatedAt
|
||||
`);
|
||||
|
||||
const upsertFurnace = db.prepare(`
|
||||
INSERT INTO furnaces (name, type, active, input, fuel, output, updated_at)
|
||||
VALUES (@name, @type, @active, @input, @fuel, @output, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
type = @type,
|
||||
active = @active,
|
||||
input = @input,
|
||||
fuel = @fuel,
|
||||
output = @output,
|
||||
updated_at = @updatedAt
|
||||
`);
|
||||
|
||||
const upsertAlert = db.prepare(`
|
||||
INSERT INTO alerts (item, triggered, current, threshold, updated_at)
|
||||
VALUES (@item, @triggered, @current, @threshold, @updatedAt)
|
||||
ON CONFLICT(item) DO UPDATE SET
|
||||
triggered = @triggered,
|
||||
current = @current,
|
||||
threshold = @threshold,
|
||||
updated_at = @updatedAt
|
||||
`);
|
||||
|
||||
const upsertState = db.prepare(`
|
||||
INSERT INTO state (key, value, updated_at)
|
||||
VALUES (@key, @value, @updatedAt)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = @value,
|
||||
updated_at = @updatedAt
|
||||
`);
|
||||
|
||||
const insertHistory = db.prepare(`
|
||||
INSERT INTO item_history (name, count, recorded_at)
|
||||
VALUES (@name, @count, @recordedAt)
|
||||
`);
|
||||
|
||||
const getState = db.prepare(`SELECT value FROM state WHERE key = ?`);
|
||||
const getAllItems = db.prepare(`SELECT name, count, display_name as displayName FROM items ORDER BY count DESC`);
|
||||
const getAllFurnaces = db.prepare(`SELECT * FROM furnaces`);
|
||||
const getAllAlerts = db.prepare(`SELECT item, triggered, current, threshold FROM alerts WHERE triggered = 1`);
|
||||
const clearAlertTriggered = db.prepare(`UPDATE alerts SET triggered = 0`);
|
||||
|
||||
const getItemHistory = db.prepare(`
|
||||
SELECT count, recorded_at as recordedAt
|
||||
FROM item_history
|
||||
WHERE name = ?
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
const getHistorySummaryStmt = db.prepare(`
|
||||
SELECT recorded_at as recordedAt, SUM(count) as total
|
||||
FROM item_history
|
||||
GROUP BY recorded_at
|
||||
ORDER BY recorded_at ASC
|
||||
`);
|
||||
|
||||
// Cleanup old history (keep last 7 days)
|
||||
const cleanupHistory = db.prepare(`
|
||||
DELETE FROM item_history WHERE recorded_at < ?
|
||||
`);
|
||||
|
||||
// ========== Batch Operations ==========
|
||||
|
||||
const saveItemsBatch = db.transaction((items, now) => {
|
||||
for (const item of items) {
|
||||
upsertItem.run({
|
||||
name: item.name || '',
|
||||
count: item.count || 0,
|
||||
displayName: item.displayName || item.name || '',
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const saveHistoryBatch = db.transaction((items, now) => {
|
||||
for (const item of items) {
|
||||
insertHistory.run({
|
||||
name: item.name || '',
|
||||
count: item.count || 0,
|
||||
recordedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const saveFurnacesBatch = db.transaction((furnaces, now) => {
|
||||
for (const [name, f] of Object.entries(furnaces)) {
|
||||
upsertFurnace.run({
|
||||
name,
|
||||
type: f.type || 'minecraft:furnace',
|
||||
active: f.active ? 1 : 0,
|
||||
input: f.input ? JSON.stringify(f.input) : null,
|
||||
fuel: f.fuel ? JSON.stringify(f.fuel) : null,
|
||||
output: f.output ? JSON.stringify(f.output) : null,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const saveAlertsBatch = db.transaction((alerts, now) => {
|
||||
// Clear old triggered status
|
||||
clearAlertTriggered.run();
|
||||
for (const alert of alerts) {
|
||||
upsertAlert.run({
|
||||
item: alert.item || '',
|
||||
triggered: alert.triggered ? 1 : 0,
|
||||
current: alert.current || 0,
|
||||
threshold: alert.threshold || 0,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Public API ==========
|
||||
|
||||
/**
|
||||
* Save inventory items to the database
|
||||
*/
|
||||
export function saveItems(items) {
|
||||
const now = Date.now();
|
||||
saveItemsBatch(items, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a snapshot of item counts for history tracking
|
||||
*/
|
||||
let lastHistoryRecord = 0;
|
||||
const HISTORY_INTERVAL = 5 * 60 * 1000; // Record history every 5 minutes
|
||||
|
||||
export function recordItemHistory(items) {
|
||||
const now = Date.now();
|
||||
if (now - lastHistoryRecord < HISTORY_INTERVAL) return;
|
||||
lastHistoryRecord = now;
|
||||
saveHistoryBatch(items, now);
|
||||
|
||||
// Cleanup entries older than 7 days
|
||||
cleanupHistory.run(now - 7 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save furnace status to the database
|
||||
*/
|
||||
export function saveFurnaces(furnaceStatus) {
|
||||
const now = Date.now();
|
||||
saveFurnacesBatch(furnaceStatus, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save alerts to the database
|
||||
*/
|
||||
export function saveAlerts(alerts) {
|
||||
const now = Date.now();
|
||||
saveAlertsBatch(alerts, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a key-value state entry (JSON serialized)
|
||||
*/
|
||||
export function saveState(key, value) {
|
||||
upsertState.run({
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a key-value state entry
|
||||
*/
|
||||
export function loadState(key, defaultValue = null) {
|
||||
const row = getState.get(key);
|
||||
if (!row) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(row.value);
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all persisted items
|
||||
*/
|
||||
export function loadItems() {
|
||||
return getAllItems.all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all persisted furnace statuses
|
||||
*/
|
||||
export function loadFurnaces() {
|
||||
const rows = getAllFurnaces.all();
|
||||
const result = {};
|
||||
for (const row of rows) {
|
||||
let input = null, fuel = null, output = null;
|
||||
try { input = row.input ? JSON.parse(row.input) : null; } catch { input = row.input; }
|
||||
try { fuel = row.fuel ? JSON.parse(row.fuel) : null; } catch { fuel = row.fuel; }
|
||||
try { output = row.output ? JSON.parse(row.output) : null; } catch { output = row.output; }
|
||||
result[row.name] = {
|
||||
active: !!row.active,
|
||||
type: row.type,
|
||||
input,
|
||||
fuel,
|
||||
output,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all triggered alerts
|
||||
*/
|
||||
export function loadAlerts() {
|
||||
return getAllAlerts.all().map(row => ({
|
||||
...row,
|
||||
triggered: !!row.triggered,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item count history for a specific item
|
||||
*/
|
||||
export function getHistory(itemName, limit = 100) {
|
||||
return getItemHistory.all(itemName, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregate total item count over time
|
||||
*/
|
||||
export function getHistorySummary() {
|
||||
return getHistorySummaryStmt.all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the full last-known inventory state from DB
|
||||
*/
|
||||
export function loadFullState() {
|
||||
const items = loadItems();
|
||||
const furnaces = loadFurnaces();
|
||||
const alerts = loadAlerts();
|
||||
const smeltingPaused = loadState('smeltingPaused', false);
|
||||
const disabledRecipes = loadState('disabledRecipes', {});
|
||||
const smeltableRecipes = loadState('smeltableRecipes', {});
|
||||
const craftableRecipes = loadState('craftableRecipes', []);
|
||||
const craftTurtleOk = loadState('craftTurtleOk', false);
|
||||
const activity = loadState('activity', {});
|
||||
const lastUpdate = loadState('lastUpdate', 0);
|
||||
|
||||
// Reconstruct inventoryState
|
||||
const inventoryMeta = loadState('inventoryMeta', {});
|
||||
const inventoryState = {
|
||||
itemList: items,
|
||||
grandTotal: inventoryMeta.grandTotal || 0,
|
||||
chestCount: inventoryMeta.chestCount || 0,
|
||||
totalSlots: inventoryMeta.totalSlots || 0,
|
||||
usedSlots: inventoryMeta.usedSlots || 0,
|
||||
freeSlots: inventoryMeta.freeSlots || 0,
|
||||
usedRatio: inventoryMeta.usedRatio || 0,
|
||||
dropperOk: inventoryMeta.dropperOk || false,
|
||||
barrelOk: inventoryMeta.barrelOk || false,
|
||||
furnaceCount: inventoryMeta.furnaceCount || 0,
|
||||
furnaceStatus: furnaces,
|
||||
droppers: Array.isArray(inventoryMeta.droppers) ? inventoryMeta.droppers : [],
|
||||
};
|
||||
|
||||
return {
|
||||
inventoryState,
|
||||
activityState: activity,
|
||||
alertsState: alerts,
|
||||
smeltingPaused,
|
||||
disabledRecipes,
|
||||
smeltableRecipes,
|
||||
craftableRecipes,
|
||||
craftTurtleOk,
|
||||
lastUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the full state to DB in one transaction.
|
||||
* Uses a single flat transaction (no nested sub-transactions).
|
||||
*/
|
||||
const _saveFullStateTransaction = db.transaction((state, now) => {
|
||||
// Items — inline loop instead of calling saveItemsBatch to avoid nested transaction
|
||||
if (state.inventoryState?.itemList?.length) {
|
||||
for (const item of state.inventoryState.itemList) {
|
||||
upsertItem.run({
|
||||
name: item.name || '',
|
||||
count: item.count || 0,
|
||||
displayName: item.displayName || item.name || '',
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Inventory metadata (totals, slot info, etc.)
|
||||
if (state.inventoryState) {
|
||||
const { itemList, furnaceStatus, ...meta } = state.inventoryState;
|
||||
upsertState.run({ key: 'inventoryMeta', value: JSON.stringify(meta), updatedAt: now });
|
||||
}
|
||||
|
||||
// Furnaces — inline loop
|
||||
if (state.inventoryState?.furnaceStatus && typeof state.inventoryState.furnaceStatus === 'object') {
|
||||
for (const [name, f] of Object.entries(state.inventoryState.furnaceStatus)) {
|
||||
upsertFurnace.run({
|
||||
name,
|
||||
type: f.type || 'minecraft:furnace',
|
||||
active: f.active ? 1 : 0,
|
||||
input: f.input ? JSON.stringify(f.input) : null,
|
||||
fuel: f.fuel ? JSON.stringify(f.fuel) : null,
|
||||
output: f.output ? JSON.stringify(f.output) : null,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Alerts — inline loop
|
||||
if (Array.isArray(state.alertsState)) {
|
||||
clearAlertTriggered.run();
|
||||
for (const alert of state.alertsState) {
|
||||
upsertAlert.run({
|
||||
item: alert.item || '',
|
||||
triggered: alert.triggered ? 1 : 0,
|
||||
current: alert.current || 0,
|
||||
threshold: alert.threshold || 0,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Key-value state
|
||||
if (state.activityState !== undefined) {
|
||||
upsertState.run({ key: 'activity', value: JSON.stringify(state.activityState), updatedAt: now });
|
||||
}
|
||||
if (state.smeltingPaused !== undefined) {
|
||||
upsertState.run({ key: 'smeltingPaused', value: JSON.stringify(state.smeltingPaused), updatedAt: now });
|
||||
}
|
||||
if (state.disabledRecipes !== undefined) {
|
||||
upsertState.run({ key: 'disabledRecipes', value: JSON.stringify(state.disabledRecipes), updatedAt: now });
|
||||
}
|
||||
if (state.smeltableRecipes !== undefined) {
|
||||
upsertState.run({ key: 'smeltableRecipes', value: JSON.stringify(state.smeltableRecipes), updatedAt: now });
|
||||
}
|
||||
if (state.craftableRecipes !== undefined) {
|
||||
upsertState.run({ key: 'craftableRecipes', value: JSON.stringify(state.craftableRecipes), updatedAt: now });
|
||||
}
|
||||
if (state.craftTurtleOk !== undefined) {
|
||||
upsertState.run({ key: 'craftTurtleOk', value: JSON.stringify(state.craftTurtleOk), updatedAt: now });
|
||||
}
|
||||
upsertState.run({ key: 'lastUpdate', value: JSON.stringify(now), updatedAt: now });
|
||||
});
|
||||
|
||||
/**
|
||||
* Debounced save — buffers rapid updates and writes to DB at most every SAVE_INTERVAL ms.
|
||||
* This prevents DB writes from blocking WebSocket broadcasts on every bridge update.
|
||||
*/
|
||||
const SAVE_INTERVAL = 2000; // Write at most every 2 seconds
|
||||
let pendingSaveState = null;
|
||||
let saveTimer = null;
|
||||
|
||||
export function saveFullState(state) {
|
||||
pendingSaveState = state;
|
||||
if (!saveTimer) {
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
if (pendingSaveState) {
|
||||
const toSave = pendingSaveState;
|
||||
pendingSaveState = null;
|
||||
try {
|
||||
_saveFullStateTransaction(toSave, Date.now());
|
||||
} catch (err) {
|
||||
console.error('❌ DB save error:', err.message);
|
||||
}
|
||||
}
|
||||
}, SAVE_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any pending save immediately (used during shutdown)
|
||||
*/
|
||||
export function flushPendingSave() {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
if (pendingSaveState) {
|
||||
const toSave = pendingSaveState;
|
||||
pendingSaveState = null;
|
||||
try {
|
||||
_saveFullStateTransaction(toSave, Date.now());
|
||||
} catch (err) {
|
||||
console.error('❌ DB flush error:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection gracefully
|
||||
*/
|
||||
export function closeDb() {
|
||||
flushPendingSave();
|
||||
if (db.open) {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
23
web/server/docker-entrypoint.sh
Executable file
23
web/server/docker-entrypoint.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "[entrypoint] Starting up..."
|
||||
|
||||
# Ensure data directory exists and is writable by the node user
|
||||
mkdir -p /data
|
||||
chown -R node:node /data
|
||||
echo "[entrypoint] /data permissions fixed"
|
||||
|
||||
# Download textures if cache is empty (first run)
|
||||
TEXTURE_DIR="/data/texture-cache/minecraft"
|
||||
if [ ! -d "$TEXTURE_DIR" ] || [ -z "$(ls -A "$TEXTURE_DIR" 2>/dev/null)" ]; then
|
||||
echo "[entrypoint] Downloading textures (first run)..."
|
||||
su-exec node node /app/download-textures.js /data/texture-cache
|
||||
echo "[entrypoint] Texture download complete"
|
||||
else
|
||||
echo "[entrypoint] Texture cache exists, skipping download"
|
||||
fi
|
||||
|
||||
# Drop privileges and exec the CMD
|
||||
echo "[entrypoint] Dropping to user 'node', running: $*"
|
||||
exec su-exec node "$@"
|
||||
213
web/server/download-textures.js
Normal file
213
web/server/download-textures.js
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* download-textures.js — Bulk-download item/block textures for all supported
|
||||
* mods into the local texture cache.
|
||||
*
|
||||
* Supported mods (Prominence Hasturian Era II modpack):
|
||||
* Minecraft, Create, CC:Tweaked, Mythic Metals, Farmer's Delight,
|
||||
* Ad Astra, BetterEnd, Applied Energistics 2, Twilight Forest
|
||||
*
|
||||
* Run once (or on container start) to pre-populate the cache so the proxy
|
||||
* never needs to hit upstream for known textures.
|
||||
*
|
||||
* Usage: node download-textures.js [cacheDir]
|
||||
* Default: /data/texture-cache (matches server.js)
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CACHE_DIR = process.argv[2] || process.env.TEXTURE_CACHE_DIR || '/data/texture-cache';
|
||||
|
||||
// ── Upstream repos (same namespaces as TEXTURE_UPSTREAMS in server.js) ─────
|
||||
|
||||
const REPOS = {
|
||||
minecraft: {
|
||||
api: 'https://api.github.com/repos/InventivetalentDev/minecraft-assets/git/trees/1.21.4?recursive=1',
|
||||
raw: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4',
|
||||
prefix: 'assets/minecraft/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
create: {
|
||||
api: 'https://api.github.com/repos/Creators-of-Create/Create/git/trees/mc1.20.1/dev?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev',
|
||||
prefix: 'src/main/resources/assets/create/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
computercraft: {
|
||||
api: 'https://api.github.com/repos/cc-tweaked/CC-Tweaked/git/trees/mc-1.20.x?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x',
|
||||
prefix: 'projects/common/src/main/resources/assets/computercraft/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
mythicmetals: {
|
||||
api: 'https://api.github.com/repos/Noaaan/MythicMetals/git/trees/1.20?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/Noaaan/MythicMetals/1.20',
|
||||
prefix: 'src/main/resources/assets/mythicmetals/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
farmersdelight: {
|
||||
api: 'https://api.github.com/repos/vectorwing/FarmersDelight/git/trees/1.20?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/vectorwing/FarmersDelight/1.20',
|
||||
prefix: 'src/main/resources/assets/farmersdelight/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
ad_astra: {
|
||||
api: 'https://api.github.com/repos/terrarium-earth/Ad-Astra/git/trees/1.20.1?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/terrarium-earth/Ad-Astra/1.20.1',
|
||||
prefix: 'common/src/main/resources/assets/ad_astra/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
betterend: {
|
||||
api: 'https://api.github.com/repos/quiqueck/BetterEnd/git/trees/1.20?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/quiqueck/BetterEnd/1.20',
|
||||
prefix: 'src/main/resources/assets/betterend/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
ae2: {
|
||||
api: 'https://api.github.com/repos/AppliedEnergistics/Applied-Energistics-2/git/trees/fabric/1.20.1?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/AppliedEnergistics/Applied-Energistics-2/fabric/1.20.1',
|
||||
prefix: 'src/main/resources/assets/ae2/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
twilightforest: {
|
||||
api: 'https://api.github.com/repos/TeamTwilight/twilightforest/git/trees/1.20.1?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/TeamTwilight/twilightforest/1.20.1',
|
||||
prefix: 'src/main/resources/assets/twilightforest/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Rate-limit-friendly parallel downloader ────────────────────────────────
|
||||
|
||||
const CONCURRENCY = 10;
|
||||
const RETRY_DELAY = 1000;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
async function downloadFile(url, dest, retries = 0) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.status === 404) return false; // genuinely missing
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, buffer);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (retries < MAX_RETRIES) {
|
||||
await new Promise(r => setTimeout(r, RETRY_DELAY * (retries + 1)));
|
||||
return downloadFile(url, dest, retries + 1);
|
||||
}
|
||||
console.error(` ✗ ${url}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runPool(tasks) {
|
||||
let idx = 0;
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
async function worker() {
|
||||
while (idx < tasks.length) {
|
||||
const task = tasks[idx++];
|
||||
const success = await downloadFile(task.url, task.dest);
|
||||
if (success) ok++;
|
||||
else fail++;
|
||||
}
|
||||
}
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return { ok, fail };
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function downloadNamespace(name, repo) {
|
||||
console.log(`\n⬇ ${name}: fetching file list…`);
|
||||
|
||||
const res = await fetch(repo.api, {
|
||||
headers: { 'User-Agent': 'inventory-manager-texture-dl' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(` ✗ GitHub API ${res.status}: ${await res.text()}`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
// Filter to only PNG files in the target folders under the prefix
|
||||
const tasks = [];
|
||||
for (const entry of data.tree || []) {
|
||||
if (entry.type !== 'blob') continue;
|
||||
if (!entry.path.startsWith(repo.prefix)) continue;
|
||||
if (!entry.path.endsWith('.png')) continue;
|
||||
|
||||
const relPath = entry.path.slice(repo.prefix.length); // e.g. "item/diamond.png"
|
||||
const inFolder = repo.folders.some(f => relPath.startsWith(f));
|
||||
if (!inFolder) continue;
|
||||
|
||||
const dest = path.join(CACHE_DIR, name, relPath);
|
||||
if (fs.existsSync(dest)) continue; // already cached
|
||||
|
||||
tasks.push({
|
||||
url: `${repo.raw}/${entry.path}`,
|
||||
dest,
|
||||
});
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log(` ✓ ${name}: all textures already cached`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` ↓ downloading ${tasks.length} textures…`);
|
||||
const { ok, fail } = await runPool(tasks);
|
||||
console.log(` ✓ ${name}: ${ok} downloaded, ${fail} failed`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Texture cache dir: ${CACHE_DIR}`);
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
|
||||
for (const [name, repo] of Object.entries(REPOS)) {
|
||||
await downloadNamespace(name, repo);
|
||||
}
|
||||
|
||||
// Clean up any .miss files so newly-downloaded textures take effect
|
||||
let cleared = 0;
|
||||
function cleanMiss(dir) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) cleanMiss(full);
|
||||
else if (entry.name.endsWith('.miss')) { fs.unlinkSync(full); cleared++; }
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
cleanMiss(CACHE_DIR);
|
||||
if (cleared) console.log(`\n🗑 Cleared ${cleared} negative-cache entries`);
|
||||
|
||||
// Build and print index stats
|
||||
const stats = {};
|
||||
function countPngs(dir, ns) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) countPngs(full, ns);
|
||||
else if (entry.name.endsWith('.png')) stats[ns] = (stats[ns] || 0) + 1;
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
for (const ns of Object.keys(REPOS)) {
|
||||
countPngs(path.join(CACHE_DIR, ns), ns);
|
||||
}
|
||||
console.log('\n📊 Cache totals:');
|
||||
for (const [ns, count] of Object.entries(stats)) {
|
||||
console.log(` ${ns}: ${count} textures`);
|
||||
}
|
||||
console.log('\n✅ Done');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
3346
web/server/package-lock.json
generated
Normal file
3346
web/server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
web/server/package.json
Normal file
25
web/server/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "inventory-manager-server",
|
||||
"version": "1.0.0",
|
||||
"description": "CC:Tweaked Inventory Manager - Web Server",
|
||||
"main": "server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"download-textures": "node download-textures.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cc-platform/server": "file:../../../cc-platform-core/server",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2",
|
||||
"vitest": "^3.2.1"
|
||||
}
|
||||
}
|
||||
1277
web/server/server.js
Normal file
1277
web/server/server.js
Normal file
File diff suppressed because it is too large
Load Diff
11
web/server/vitest.config.js
Normal file
11
web/server/vitest.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
DB_PATH: path.join(os.tmpdir(), `inv-test-${process.pid}.db`),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user