Compare commits
184 Commits
5162a71be4
...
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ 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
|
||||
]],
|
||||
}
|
||||
110
README.md
110
README.md
@@ -47,7 +47,9 @@ A Minecraft inventory management system built on [CC:Tweaked](https://tweaked.cc
|
||||
| `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)
|
||||
|
||||
@@ -62,54 +64,27 @@ A Minecraft inventory management system built on [CC:Tweaked](https://tweaked.cc
|
||||
### 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 the Master Controller
|
||||
### 1. Install via Opus Package Manager (Recommended)
|
||||
|
||||
On your main CC:Tweaked computer (with the monitor and wired modem), open the terminal and run:
|
||||
On any CC:Tweaked computer running Opus, open the shell and run:
|
||||
|
||||
```
|
||||
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/inventoryManager.lua startup.lua
|
||||
package install inventory-manager
|
||||
```
|
||||
|
||||
This downloads `inventoryManager.lua` and saves it as `startup.lua` so it runs automatically on boot. Reboot the computer or run `startup.lua` to start.
|
||||
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.
|
||||
|
||||
### 2. Install a Display Client (Optional)
|
||||
|
||||
On a separate CC:Tweaked computer with a monitor:
|
||||
To update later:
|
||||
|
||||
```
|
||||
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/inventoryClient.lua startup.lua
|
||||
package update inventory-manager
|
||||
```
|
||||
|
||||
### 3. Install the Crafting Turtle (Optional)
|
||||
|
||||
On a **Crafting Turtle** connected to the wired network:
|
||||
|
||||
```
|
||||
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/craftingTurtle.lua startup.lua
|
||||
```
|
||||
|
||||
### 4. Install the Dropper Controller (Optional)
|
||||
|
||||
On a computer adjacent to a dropper block:
|
||||
|
||||
```
|
||||
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/dropperController.lua startup.lua
|
||||
```
|
||||
|
||||
### 5. Install the Web Bridge (Optional)
|
||||
|
||||
On any CC:Tweaked computer with a wired modem and HTTP access:
|
||||
|
||||
```
|
||||
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/inventoryWebBridge.lua startup.lua
|
||||
```
|
||||
|
||||
Configure the web server URL by editing `.webbridge_config` on the computer, or it will default to `http://localhost`.
|
||||
|
||||
### 6. Start the Web Dashboard (Optional)
|
||||
### 2. Start the Web Dashboard (Optional)
|
||||
|
||||
On the host machine, navigate to the `web/` directory and run:
|
||||
|
||||
@@ -119,14 +94,79 @@ 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
|
||||
|
||||
|
||||
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
|
||||
@@ -4,6 +4,15 @@
|
||||
-- Pulls ingredients from chests, crafts, and pushes results back.
|
||||
-- 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 .turtle_config)
|
||||
-------------------------------------------------
|
||||
@@ -19,7 +28,20 @@ local CRAFT_SLOTS = {1, 2, 3, 5, 6, 7, 9, 10, 11}
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
|
||||
local TURTLE_CONFIG_FILE = ".turtle_config"
|
||||
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
|
||||
@@ -49,9 +71,7 @@ print("")
|
||||
|
||||
-- Verify this is a crafting turtle
|
||||
if not turtle or not turtle.craft then
|
||||
print("[ERR] No turtle.craft() available!")
|
||||
print(" This must be a Crafting Turtle.")
|
||||
return
|
||||
return fatal("[ERR] No turtle.craft() available!\n This must be a Crafting Turtle.")
|
||||
end
|
||||
|
||||
-- Find wired modem and get our network name
|
||||
@@ -75,10 +95,7 @@ for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
|
||||
end
|
||||
|
||||
if not modem or not selfName then
|
||||
print("[ERR] No wired modem found!")
|
||||
print(" Attach a wired modem to the turtle")
|
||||
print(" and connect it to the network.")
|
||||
return
|
||||
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)
|
||||
@@ -88,15 +105,6 @@ print("[OK] Network name: " .. selfName)
|
||||
modem.open(CRAFT_CHANNEL)
|
||||
print("[OK] Listening on channel " .. CRAFT_CHANNEL)
|
||||
|
||||
-- Wrap our own inventory peripheral for pullItems/pushItems
|
||||
local selfInv = peripheral.wrap(selfName)
|
||||
if not selfInv then
|
||||
print("[ERR] Cannot wrap own peripheral: " .. selfName)
|
||||
print(" Make sure the wired modem is connected.")
|
||||
return
|
||||
end
|
||||
|
||||
print("[OK] Self-inventory peripheral ready")
|
||||
print("")
|
||||
print("Waiting for craft commands from master...")
|
||||
print("")
|
||||
@@ -106,15 +114,20 @@ print("")
|
||||
-------------------------------------------------
|
||||
|
||||
local function clearInventory(chests)
|
||||
-- Push all items from all 16 turtle slots back to 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 ok, n = pcall(selfInv.pushItems, chestName, slot)
|
||||
if ok and n and n > 0 then
|
||||
cleared = cleared + n
|
||||
break
|
||||
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
|
||||
@@ -167,24 +180,56 @@ local function handleCraftCommand(message)
|
||||
|
||||
for turtleSlotStr, info in pairs(slots) do
|
||||
local turtleSlot = tonumber(turtleSlotStr)
|
||||
local chestName = info.chestName
|
||||
local chestSlot = info.chestSlot
|
||||
local itemName = info.itemName
|
||||
local count = info.count or 1
|
||||
local placed = 0
|
||||
|
||||
print(string.format("[CRAFT] Pulling %s from %s slot %d -> turtle slot %d",
|
||||
itemName, chestName, chestSlot, turtleSlot))
|
||||
|
||||
local ok, n = pcall(selfInv.pullItems, chestName, chestSlot, count, turtleSlot)
|
||||
if ok and n and n > 0 then
|
||||
placedItems[turtleSlot] = itemName
|
||||
print(string.format("[CRAFT] Placed %s x%d in slot %d", itemName, n, turtleSlot))
|
||||
else
|
||||
if not ok then
|
||||
print(string.format("[CRAFT] pullItems error: %s", tostring(n)))
|
||||
else
|
||||
print(string.format("[CRAFT] pullItems returned %s (expected %d)", tostring(n), count))
|
||||
-- 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
|
||||
@@ -263,20 +308,26 @@ end
|
||||
-- Main loop: listen for modem commands
|
||||
-------------------------------------------------
|
||||
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||
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,
|
||||
})
|
||||
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
|
||||
|
||||
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" },
|
||||
}
|
||||
@@ -31,6 +31,24 @@ return {
|
||||
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,
|
||||
@@ -105,6 +123,114 @@ return {
|
||||
"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,
|
||||
|
||||
@@ -8,7 +8,6 @@ return {
|
||||
{ name = "minecraft:coal_block", burn_time = 80 },
|
||||
{ name = "minecraft:blaze_rod", burn_time = 12 },
|
||||
{ name = "minecraft:dried_kelp_block", burn_time = 20 },
|
||||
{ name = "minecraft:lava_bucket", burn_time = 100 },
|
||||
{ name = "minecraft:oak_planks", burn_time = 1.5 },
|
||||
{ name = "minecraft:spruce_planks",burn_time = 1.5 },
|
||||
{ name = "minecraft:birch_planks", burn_time = 1.5 },
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
1319
inventoryClient.lua
1319
inventoryClient.lua
File diff suppressed because it is too large
Load Diff
3629
inventoryManager.lua
3629
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
@@ -3,42 +3,52 @@
|
||||
-- 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
|
||||
-- Configuration (via platform)
|
||||
-------------------------------------------------
|
||||
|
||||
-- Web server URL (change to your Docker host IP/hostname)
|
||||
local SERVER_URL = "http://localhost"
|
||||
local POLL_INTERVAL = 0.5 -- seconds between command polls
|
||||
local STATE_INTERVAL = 1 -- seconds between state forwards
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
|
||||
-- Modem channels (must match inventoryManager.lua)
|
||||
local BROADCAST_CHANNEL = 4200
|
||||
local ORDER_CHANNEL = 4201
|
||||
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"),
|
||||
})
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
local SERVER_URL = config.serverUrl
|
||||
local POLL_INTERVAL = config.pollInterval
|
||||
local STATE_INTERVAL = config.stateInterval
|
||||
local API_KEY = config.apiKey
|
||||
|
||||
local CONFIG_FILE = ".webbridge_config"
|
||||
|
||||
local function loadConfig()
|
||||
if fs.exists(CONFIG_FILE) then
|
||||
local f = fs.open(CONFIG_FILE, "r")
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, data)
|
||||
if ok and cfg then
|
||||
if cfg.serverUrl then SERVER_URL = cfg.serverUrl end
|
||||
if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end
|
||||
if cfg.stateInterval then STATE_INTERVAL = cfg.stateInterval end
|
||||
if cfg.apiKey then API_KEY = cfg.apiKey end
|
||||
print("[CONFIG] Loaded from " .. CONFIG_FILE)
|
||||
end
|
||||
end
|
||||
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
|
||||
-------------------------------------------------
|
||||
@@ -47,66 +57,43 @@ local latestState = nil -- last broadcast from master
|
||||
local modem = nil
|
||||
local modemName = nil
|
||||
local running = true
|
||||
local API_KEY = nil -- optional API key for server auth
|
||||
|
||||
-- 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)
|
||||
|
||||
-------------------------------------------------
|
||||
-- Find modem
|
||||
-------------------------------------------------
|
||||
|
||||
local function findModem()
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "modem" then
|
||||
modem = peripheral.wrap(name)
|
||||
modemName = name
|
||||
modem.open(BROADCAST_CHANNEL)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- HTTP helpers
|
||||
-- HTTP helpers (thin wrappers around platform)
|
||||
-------------------------------------------------
|
||||
|
||||
local function httpPost(path, body)
|
||||
local url = SERVER_URL .. path
|
||||
local data = textutils.serialiseJSON(body)
|
||||
local headers = { ["Content-Type"] = "application/json" }
|
||||
if API_KEY then headers["Authorization"] = "Bearer " .. API_KEY end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
local response = http.post(url, data, headers)
|
||||
if response then
|
||||
local responseData = response.readAll()
|
||||
response.close()
|
||||
return responseData
|
||||
end
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
-- Silent fail, will retry
|
||||
return nil
|
||||
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 url = SERVER_URL .. path
|
||||
local headers = nil
|
||||
if API_KEY then headers = { ["Authorization"] = "Bearer " .. API_KEY } end
|
||||
local ok, result = pcall(function()
|
||||
local response = http.get(url, headers)
|
||||
if response then
|
||||
local data = response.readAll()
|
||||
response.close()
|
||||
return textutils.unserialiseJSON(data)
|
||||
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
|
||||
end)
|
||||
|
||||
if ok then
|
||||
return result
|
||||
return nil
|
||||
end
|
||||
return nil
|
||||
return textutils.unserialiseJSON(rawBody)
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -118,6 +105,32 @@ local function forwardState()
|
||||
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
|
||||
-------------------------------------------------
|
||||
@@ -130,54 +143,55 @@ local function processCommand(cmd)
|
||||
|
||||
print(string.format("[CMD] %s", action))
|
||||
|
||||
-- Build the modem payload from the server command
|
||||
local payload
|
||||
if action == "order" then
|
||||
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
|
||||
payload = {
|
||||
type = "order",
|
||||
commandId = cmd.commandId,
|
||||
itemName = cmd.itemName,
|
||||
amount = cmd.amount,
|
||||
dropperName = cmd.dropperName,
|
||||
})
|
||||
}
|
||||
elseif action == "scan" then
|
||||
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
|
||||
type = "scan",
|
||||
commandId = cmd.commandId,
|
||||
})
|
||||
payload = { type = "scan", commandId = cmd.commandId }
|
||||
elseif action == "toggle_pause" then
|
||||
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
|
||||
type = "toggle_pause",
|
||||
commandId = cmd.commandId,
|
||||
})
|
||||
payload = { type = "toggle_pause", commandId = cmd.commandId }
|
||||
elseif action == "toggle_recipe" then
|
||||
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
|
||||
type = "toggle_recipe",
|
||||
commandId = cmd.commandId,
|
||||
recipe = cmd.recipe,
|
||||
})
|
||||
payload = { type = "toggle_recipe", commandId = cmd.commandId, recipe = cmd.recipe }
|
||||
elseif action == "enable_all" then
|
||||
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
|
||||
type = "enable_all",
|
||||
commandId = cmd.commandId,
|
||||
})
|
||||
payload = { type = "enable_all", commandId = cmd.commandId }
|
||||
elseif action == "disable_all" then
|
||||
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
|
||||
type = "disable_all",
|
||||
commandId = cmd.commandId,
|
||||
})
|
||||
payload = { type = "disable_all", commandId = cmd.commandId }
|
||||
elseif action == "sort_barrel" then
|
||||
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
|
||||
type = "sort_barrel",
|
||||
commandId = cmd.commandId,
|
||||
barrelName = cmd.barrelName,
|
||||
})
|
||||
payload = { type = "sort_barrel", commandId = cmd.commandId, barrelName = cmd.barrelName }
|
||||
elseif action == "craft" then
|
||||
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
|
||||
type = "craft",
|
||||
commandId = cmd.commandId,
|
||||
recipeIdx = cmd.recipeIdx,
|
||||
})
|
||||
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
|
||||
|
||||
@@ -193,45 +207,95 @@ local function modemListener()
|
||||
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
|
||||
local ok, err = pcall(forwardState)
|
||||
if not ok then
|
||||
-- Connection error, will retry
|
||||
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
|
||||
-- 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
|
||||
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
|
||||
pcall(processCommand, cmd)
|
||||
if cmdId > maxId then maxId = cmdId end
|
||||
-- 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
|
||||
-- Acknowledge up to the highest processed ID
|
||||
if maxId > lastProcessedId then
|
||||
lastProcessedId = maxId
|
||||
httpPost("/api/bridge/commands/ack", { lastProcessedId = lastProcessedId })
|
||||
end
|
||||
end)
|
||||
if not ok then
|
||||
print(string.format("[ERR] Command poll: %s", tostring(err)))
|
||||
end
|
||||
end)
|
||||
end
|
||||
sleep(POLL_INTERVAL)
|
||||
end
|
||||
end
|
||||
@@ -256,6 +320,91 @@ local function heartbeat()
|
||||
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
|
||||
-------------------------------------------------
|
||||
@@ -266,10 +415,16 @@ local function main()
|
||||
print("===================================")
|
||||
print("")
|
||||
|
||||
loadConfig()
|
||||
-- Config already loaded at require-time via WebBridge.loadConfig above
|
||||
|
||||
if findModem() then
|
||||
print("[OK] Modem: " .. modemName)
|
||||
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.")
|
||||
@@ -284,6 +439,7 @@ local function main()
|
||||
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)
|
||||
@@ -293,7 +449,9 @@ local function main()
|
||||
modemListener,
|
||||
stateForwarder,
|
||||
commandPoller,
|
||||
heartbeat
|
||||
heartbeat,
|
||||
wsConnector,
|
||||
modemRetry
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build the React app
|
||||
FROM node:18-alpine AS build
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# 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 _;
|
||||
@@ -5,6 +9,15 @@ server {
|
||||
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;
|
||||
@@ -27,6 +40,7 @@ server {
|
||||
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;
|
||||
|
||||
@@ -4,6 +4,31 @@
|
||||
|
||||
@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;
|
||||
|
||||
@@ -22,6 +22,8 @@ function App() {
|
||||
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]);
|
||||
@@ -34,10 +36,6 @@ function App() {
|
||||
|
||||
const staleSecs = lastUpdate ? Math.floor((Date.now() - lastUpdate) / 1000) : null;
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
const renderPanelContent = () => {
|
||||
switch (panelTab) {
|
||||
case 'inventory':
|
||||
@@ -60,6 +58,16 @@ function 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"
|
||||
|
||||
@@ -2,274 +2,47 @@ import React, { useState } from 'react';
|
||||
import { getItemEmoji } from '../utils/itemUtils';
|
||||
import './ItemIcon.css';
|
||||
|
||||
// All textures are proxied & cached through our server
|
||||
const TEXTURE_PROXY_BASE = '/api/texture';
|
||||
// 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 whose texture file name differs from their registry name
|
||||
const TEXTURE_ALIASES = {
|
||||
// Renamed items in 1.20+
|
||||
grass: 'short_grass',
|
||||
scute: 'turtle_scute',
|
||||
};
|
||||
|
||||
// CC:Tweaked texture paths (registry name → actual file in the CC repo)
|
||||
const CC_TEXTURE_MAP = {
|
||||
turtle_normal: 'block/turtle_normal_front',
|
||||
turtle_advanced: 'block/turtle_advanced_front',
|
||||
computer_normal: 'block/computer_normal_front',
|
||||
computer_advanced: 'block/computer_advanced_front',
|
||||
computer_command: 'block/computer_command_front',
|
||||
monitor_normal: 'block/monitor_normal_0',
|
||||
monitor_advanced: 'block/monitor_advanced_0',
|
||||
wired_modem: 'block/wired_modem_face',
|
||||
wired_modem_full: 'block/wired_modem_face',
|
||||
wireless_modem_normal: 'block/wireless_modem_normal_face',
|
||||
wireless_modem_advanced: 'block/wireless_modem_advanced_face',
|
||||
speaker: 'block/speaker_front',
|
||||
disk_drive: 'block/disk_drive_front',
|
||||
printer: 'block/printer_front_empty',
|
||||
cable: 'block/cable_core',
|
||||
// CC item textures
|
||||
pocket_computer_normal: 'item/pocket_computer_normal',
|
||||
pocket_computer_advanced: 'item/pocket_computer_advanced',
|
||||
disk: 'item/disk_frame',
|
||||
printed_book: 'item/printed_book',
|
||||
printed_page: 'item/printed_page',
|
||||
printed_pages: 'item/printed_pages',
|
||||
};
|
||||
|
||||
// Items whose texture lives in the block/ folder instead of item/
|
||||
const BLOCK_TEXTURES = new Set([
|
||||
'stone', 'granite', 'polished_granite', 'diorite', 'polished_diorite', 'andesite',
|
||||
'polished_andesite', 'cobblestone', 'oak_planks', 'spruce_planks', 'birch_planks',
|
||||
'jungle_planks', 'acacia_planks', 'dark_oak_planks', 'mangrove_planks', 'cherry_planks',
|
||||
'bamboo_planks', 'crimson_planks', 'warped_planks', 'oak_log', 'spruce_log', 'birch_log',
|
||||
'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log', 'bamboo_block',
|
||||
'stripped_oak_log', 'stripped_spruce_log', 'stripped_birch_log', 'stripped_jungle_log',
|
||||
'stripped_acacia_log', 'stripped_dark_oak_log', 'stripped_mangrove_log', 'stripped_cherry_log',
|
||||
'sand', 'red_sand', 'gravel', 'dirt', 'coarse_dirt', 'rooted_dirt', 'mud',
|
||||
'cobblestone', 'mossy_cobblestone', 'obsidian', 'crying_obsidian',
|
||||
'netherrack', 'soul_sand', 'soul_soil', 'basalt', 'polished_basalt', 'smooth_basalt',
|
||||
'glowstone', 'glass', 'tinted_glass',
|
||||
|
||||
'bricks', 'stone_bricks', 'mossy_stone_bricks', 'cracked_stone_bricks',
|
||||
'chiseled_stone_bricks', 'deepslate_bricks', 'nether_bricks', 'red_nether_bricks',
|
||||
'bookshelf', 'clay', 'pumpkin', 'carved_pumpkin', 'jack_o_lantern', 'melon',
|
||||
'sponge', 'wet_sponge', 'sandstone', 'red_sandstone', 'prismarine', 'dark_prismarine',
|
||||
'sea_lantern', 'hay_block', 'terracotta', 'packed_ice', 'blue_ice',
|
||||
'snow_block', 'ice', 'mycelium', 'podzol', 'grass_block', 'moss_block',
|
||||
'deepslate', 'cobbled_deepslate', 'polished_deepslate', 'calcite', 'tuff', 'dripstone_block',
|
||||
'coal_ore', 'iron_ore', 'gold_ore', 'diamond_ore', 'emerald_ore', 'lapis_ore', 'redstone_ore',
|
||||
'copper_ore', 'nether_gold_ore', 'nether_quartz_ore', 'ancient_debris',
|
||||
'deepslate_coal_ore', 'deepslate_iron_ore', 'deepslate_gold_ore', 'deepslate_diamond_ore',
|
||||
'deepslate_emerald_ore', 'deepslate_lapis_ore', 'deepslate_redstone_ore', 'deepslate_copper_ore',
|
||||
'coal_block', 'iron_block', 'gold_block', 'diamond_block', 'emerald_block',
|
||||
'lapis_block', 'redstone_block', 'copper_block', 'raw_iron_block', 'raw_gold_block',
|
||||
'raw_copper_block', 'netherite_block', 'amethyst_block', 'quartz_block',
|
||||
'tnt', 'end_stone', 'end_stone_bricks', 'purpur_block', 'purpur_pillar',
|
||||
'magma_block', 'bone_block', 'dried_kelp_block', 'honeycomb_block',
|
||||
'slime_block', 'honey_block', 'note_block', 'jukebox',
|
||||
'white_wool', 'orange_wool', 'magenta_wool', 'light_blue_wool', 'yellow_wool',
|
||||
'lime_wool', 'pink_wool', 'gray_wool', 'light_gray_wool', 'cyan_wool',
|
||||
'purple_wool', 'blue_wool', 'brown_wool', 'green_wool', 'red_wool', 'black_wool',
|
||||
'white_concrete', 'orange_concrete', 'magenta_concrete', 'light_blue_concrete',
|
||||
'yellow_concrete', 'lime_concrete', 'pink_concrete', 'gray_concrete',
|
||||
'light_gray_concrete', 'cyan_concrete', 'purple_concrete', 'blue_concrete',
|
||||
'brown_concrete', 'green_concrete', 'red_concrete', 'black_concrete',
|
||||
'white_terracotta', 'orange_terracotta', 'magenta_terracotta', 'light_blue_terracotta',
|
||||
'yellow_terracotta', 'lime_terracotta', 'pink_terracotta', 'gray_terracotta',
|
||||
'light_gray_terracotta', 'cyan_terracotta', 'purple_terracotta', 'blue_terracotta',
|
||||
'brown_terracotta', 'green_terracotta', 'red_terracotta', 'black_terracotta',
|
||||
'white_glazed_terracotta', 'orange_glazed_terracotta', 'magenta_glazed_terracotta',
|
||||
'light_blue_glazed_terracotta', 'yellow_glazed_terracotta', 'lime_glazed_terracotta',
|
||||
'pink_glazed_terracotta', 'gray_glazed_terracotta', 'light_gray_glazed_terracotta',
|
||||
'cyan_glazed_terracotta', 'purple_glazed_terracotta', 'blue_glazed_terracotta',
|
||||
'brown_glazed_terracotta', 'green_glazed_terracotta', 'red_glazed_terracotta',
|
||||
'black_glazed_terracotta',
|
||||
'white_stained_glass', 'orange_stained_glass', 'magenta_stained_glass',
|
||||
'light_blue_stained_glass', 'yellow_stained_glass', 'lime_stained_glass',
|
||||
'pink_stained_glass', 'gray_stained_glass', 'light_gray_stained_glass',
|
||||
'cyan_stained_glass', 'purple_stained_glass', 'blue_stained_glass',
|
||||
'brown_stained_glass', 'green_stained_glass', 'red_stained_glass',
|
||||
'black_stained_glass',
|
||||
'crafting_table', 'furnace', 'blast_furnace', 'smoker', 'smithing_table',
|
||||
'fletching_table', 'cartography_table', 'loom', 'stonecutter', 'grindstone',
|
||||
'anvil', 'chipped_anvil', 'damaged_anvil', 'enchanting_table',
|
||||
'brewing_stand', 'cauldron', 'composter', 'barrel',
|
||||
'shulker_box', 'dispenser', 'dropper', 'hopper', 'observer',
|
||||
'piston', 'sticky_piston', 'redstone_lamp', 'target', 'lever',
|
||||
'beacon', 'conduit', 'lodestone', 'respawn_anchor',
|
||||
'cactus', 'sugar_cane', 'bamboo',
|
||||
'mushroom_stem', 'brown_mushroom_block', 'red_mushroom_block',
|
||||
'oak_leaves', 'spruce_leaves', 'birch_leaves', 'jungle_leaves',
|
||||
'acacia_leaves', 'dark_oak_leaves', 'mangrove_leaves', 'cherry_leaves', 'azalea_leaves',
|
||||
// Additional blocks commonly seen as items
|
||||
'smooth_stone', 'smooth_sandstone', 'smooth_red_sandstone', 'smooth_quartz',
|
||||
'chiseled_sandstone', 'cut_sandstone', 'chiseled_red_sandstone', 'cut_red_sandstone',
|
||||
'quartz_pillar', 'chiseled_quartz_block', 'quartz_bricks',
|
||||
'ladder', 'cobweb', 'torch', 'soul_torch', 'lantern', 'soul_lantern',
|
||||
'redstone_torch', 'chain',
|
||||
'rail', 'powered_rail', 'detector_rail', 'activator_rail',
|
||||
'brown_mushroom', 'red_mushroom',
|
||||
'oak_sapling', 'spruce_sapling', 'birch_sapling', 'jungle_sapling',
|
||||
'acacia_sapling', 'dark_oak_sapling', 'cherry_sapling',
|
||||
'dandelion', 'poppy', 'blue_orchid', 'allium', 'azure_bluet',
|
||||
'red_tulip', 'orange_tulip', 'white_tulip', 'pink_tulip',
|
||||
'oxeye_daisy', 'cornflower', 'lily_of_the_valley', 'sunflower',
|
||||
'lilac', 'rose_bush', 'peony', 'wither_rose', 'torchflower',
|
||||
'dead_bush', 'fern', 'short_grass', 'tall_grass', 'large_fern',
|
||||
'vine', 'lily_pad', 'seagrass', 'kelp', 'hanging_roots', 'spore_blossom',
|
||||
'tube_coral', 'brain_coral', 'bubble_coral', 'fire_coral', 'horn_coral',
|
||||
'tube_coral_block', 'brain_coral_block', 'bubble_coral_block', 'fire_coral_block', 'horn_coral_block',
|
||||
'tube_coral_fan', 'brain_coral_fan', 'bubble_coral_fan', 'fire_coral_fan', 'horn_coral_fan',
|
||||
'white_concrete_powder', 'orange_concrete_powder', 'magenta_concrete_powder',
|
||||
'light_blue_concrete_powder', 'yellow_concrete_powder', 'lime_concrete_powder',
|
||||
'pink_concrete_powder', 'gray_concrete_powder', 'light_gray_concrete_powder',
|
||||
'cyan_concrete_powder', 'purple_concrete_powder', 'blue_concrete_powder',
|
||||
'brown_concrete_powder', 'green_concrete_powder', 'red_concrete_powder', 'black_concrete_powder',
|
||||
'sculk', 'sculk_catalyst', 'sculk_shrieker', 'sculk_sensor', 'sculk_vein',
|
||||
'mud_bricks', 'packed_mud', 'muddy_mangrove_roots',
|
||||
'crimson_stem', 'warped_stem', 'stripped_crimson_stem', 'stripped_warped_stem',
|
||||
'crimson_nylium', 'warped_nylium', 'shroomlight', 'nether_wart_block', 'warped_wart_block',
|
||||
'crying_obsidian', 'blackstone', 'polished_blackstone', 'polished_blackstone_bricks',
|
||||
'chiseled_polished_blackstone', 'gilded_blackstone', 'cracked_polished_blackstone_bricks',
|
||||
'lodestone', 'respawn_anchor',
|
||||
'pointed_dripstone', 'moss_carpet', 'azalea', 'flowering_azalea',
|
||||
'powder_snow', 'mangrove_roots',
|
||||
'copper_block', 'exposed_copper', 'weathered_copper', 'oxidized_copper',
|
||||
'cut_copper', 'exposed_cut_copper', 'weathered_cut_copper', 'oxidized_cut_copper',
|
||||
'waxed_copper_block', 'waxed_exposed_copper', 'waxed_weathered_copper', 'waxed_oxidized_copper',
|
||||
// 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',
|
||||
]);
|
||||
|
||||
// Some blocks need a specific texture suffix (e.g. furnace_front, oak_log_top)
|
||||
// We use _front, _top, or _side variants for recognizable look
|
||||
const BLOCK_TEXTURE_SUFFIXES = {
|
||||
furnace: '_front',
|
||||
blast_furnace: '_front',
|
||||
smoker: '_front',
|
||||
dispenser: '_front',
|
||||
dropper: '_front',
|
||||
observer: '_front',
|
||||
piston: '_top',
|
||||
sticky_piston: '_top',
|
||||
barrel: '_top',
|
||||
crafting_table: '_top',
|
||||
cartography_table: '_top',
|
||||
fletching_table: '_top',
|
||||
smithing_table: '_top',
|
||||
grass_block: '_top',
|
||||
mycelium: '_top',
|
||||
podzol: '_top',
|
||||
pumpkin: '_side',
|
||||
carved_pumpkin: '_front',
|
||||
jack_o_lantern: '_front',
|
||||
jukebox: '_top',
|
||||
loom: '_front',
|
||||
bee_nest: '_front',
|
||||
beehive: '_front',
|
||||
respawn_anchor: '_top',
|
||||
bone_block: '_side',
|
||||
basalt: '_side',
|
||||
polished_basalt: '_side',
|
||||
quartz_pillar: '_side',
|
||||
purpur_pillar: '_side',
|
||||
tnt: '_side',
|
||||
composter: '_side',
|
||||
enchanting_table: '_top',
|
||||
};
|
||||
|
||||
// Resolve derivative blocks (stairs, slabs, fences, walls, buttons) to parent block texture
|
||||
const WOOD_TYPES = new Set([
|
||||
'oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak',
|
||||
'mangrove', 'cherry', 'bamboo', 'crimson', 'warped',
|
||||
const DYE_COLORS = new Set([
|
||||
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime',
|
||||
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue',
|
||||
'brown', 'green', 'red', 'black',
|
||||
]);
|
||||
const STONE_ALIASES = {
|
||||
brick: 'bricks', stone_brick: 'stone_bricks', mossy_stone_brick: 'mossy_stone_bricks',
|
||||
nether_brick: 'nether_bricks', red_nether_brick: 'red_nether_bricks',
|
||||
end_stone_brick: 'end_stone_bricks', deepslate_brick: 'deepslate_bricks',
|
||||
deepslate_tile: 'deepslate_tiles', polished_blackstone_brick: 'polished_blackstone_bricks',
|
||||
mud_brick: 'mud_bricks', quartz: 'quartz_block', purpur: 'purpur_block',
|
||||
smooth_stone: 'smooth_stone',
|
||||
};
|
||||
|
||||
function resolveDerivativeTexture(name) {
|
||||
const suffixes = ['_stairs', '_slab', '_fence_gate', '_fence', '_wall', '_button', '_pressure_plate'];
|
||||
for (const suffix of suffixes) {
|
||||
if (!name.endsWith(suffix)) continue;
|
||||
const base = name.slice(0, -suffix.length);
|
||||
if (BLOCK_TEXTURES.has(base)) return base;
|
||||
if (WOOD_TYPES.has(base)) return `${base}_planks`;
|
||||
if (STONE_ALIASES[base]) return STONE_ALIASES[base];
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate texture URLs to try in order.
|
||||
* - CC:Tweaked → curated texture map (1 request)
|
||||
* - Create → item/ then block/ (2 requests max)
|
||||
* - Unknown mods → empty (instant emoji, no wasted requests)
|
||||
* - Vanilla derivatives → parent block texture (1 request)
|
||||
* - Vanilla → block/ or item/ with fallback
|
||||
* Build the single resolve URL for an item.
|
||||
* The server handles all name resolution (aliases, suffixes, derivatives, etc.)
|
||||
*/
|
||||
function getTextureUrls(fullItemName) {
|
||||
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;
|
||||
const urls = [];
|
||||
|
||||
// CC:Tweaked → use curated texture map
|
||||
if (namespace === 'computercraft') {
|
||||
const mapped = CC_TEXTURE_MAP[shortName];
|
||||
if (mapped) {
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/${mapped}.png`);
|
||||
} else {
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/item/${shortName}.png`);
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/block/${shortName}.png`);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
// Entity-only items → instant emoji, no network request
|
||||
if (namespace === 'minecraft' && isEntityOnly(shortName)) return null;
|
||||
|
||||
// Create mod → item/ then block/
|
||||
if (namespace === 'create') {
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/create/item/${shortName}.png`);
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/create/block/${shortName}.png`);
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Unknown mod namespace → no textures available, instant emoji fallback
|
||||
if (namespace !== 'minecraft') {
|
||||
return urls;
|
||||
}
|
||||
|
||||
// === Vanilla (minecraft) ===
|
||||
const alias = TEXTURE_ALIASES[shortName] || shortName;
|
||||
|
||||
// Derivative blocks (stairs, slabs, fences, walls, buttons) → parent texture
|
||||
const parent = resolveDerivativeTexture(alias);
|
||||
if (parent) {
|
||||
const suffix = BLOCK_TEXTURE_SUFFIXES[parent] || '';
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${parent}${suffix}.png`);
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Known block → try block/ first (with suffix if applicable)
|
||||
if (BLOCK_TEXTURES.has(alias)) {
|
||||
const suffix = BLOCK_TEXTURE_SUFFIXES[alias] || '';
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}${suffix}.png`);
|
||||
if (suffix) urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}.png`);
|
||||
}
|
||||
|
||||
// Try item/ texture
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/item/${alias}.png`);
|
||||
|
||||
// If not a known block, also try block/ as last resort
|
||||
if (!BLOCK_TEXTURES.has(alias)) {
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}.png`);
|
||||
}
|
||||
|
||||
return urls;
|
||||
return `${RESOLVE_BASE}/${namespace}/${shortName}.png`;
|
||||
}
|
||||
|
||||
// Cache of resolved icon URLs (avoid re-fetching on every render)
|
||||
@@ -277,21 +50,20 @@ const iconCache = new Map();
|
||||
|
||||
/**
|
||||
* Renders a Minecraft item icon using the official game textures.
|
||||
* Cascading fallback: mod texture → item texture → block texture → emoji
|
||||
* 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 [urlIndex, setUrlIndex] = useState(0);
|
||||
const [allFailed, setAllFailed] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!itemName) {
|
||||
return <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>📦</span>;
|
||||
}
|
||||
|
||||
// Use full item name as cache key (handles mod namespaces correctly)
|
||||
const cacheKey = itemName.replace(/^minecraft:/, '');
|
||||
|
||||
// Check if we already know this item has no texture
|
||||
if (iconCache.get(cacheKey) === 'none' || allFailed) {
|
||||
if (iconCache.get(cacheKey) === 'none' || failed) {
|
||||
return (
|
||||
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||
{getItemEmoji(itemName)}
|
||||
@@ -314,10 +86,9 @@ function ItemIcon({ itemName, size = 32 }) {
|
||||
);
|
||||
}
|
||||
|
||||
const urls = getTextureUrls(itemName);
|
||||
const currentUrl = urls[urlIndex];
|
||||
const url = getTextureUrl(itemName);
|
||||
|
||||
if (!currentUrl) {
|
||||
if (!url) {
|
||||
iconCache.set(cacheKey, 'none');
|
||||
return (
|
||||
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||
@@ -329,21 +100,17 @@ function ItemIcon({ itemName, size = 32 }) {
|
||||
return (
|
||||
<img
|
||||
className="item-icon-img"
|
||||
src={currentUrl}
|
||||
src={url}
|
||||
alt={cacheKey}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
onLoad={() => {
|
||||
iconCache.set(cacheKey, currentUrl);
|
||||
iconCache.set(cacheKey, url);
|
||||
}}
|
||||
onError={() => {
|
||||
if (urlIndex + 1 < urls.length) {
|
||||
setUrlIndex(urlIndex + 1);
|
||||
} else {
|
||||
iconCache.set(cacheKey, 'none');
|
||||
setAllFailed(true);
|
||||
}
|
||||
iconCache.set(cacheKey, 'none');
|
||||
setFailed(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -200,6 +200,81 @@
|
||||
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 {
|
||||
|
||||
@@ -2,14 +2,25 @@ 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);
|
||||
@@ -39,6 +50,18 @@ function SettingsPanel({ isOpen, onClose }) {
|
||||
}
|
||||
}, [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 (
|
||||
@@ -136,6 +159,51 @@ function SettingsPanel({ isOpen, onClose }) {
|
||||
</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>
|
||||
|
||||
@@ -29,6 +29,7 @@ function newCommandId() {
|
||||
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
|
||||
@@ -45,7 +46,7 @@ function _applyStateData(data, current) {
|
||||
craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk,
|
||||
bridgeConnected: data.bridgeConnected !== undefined ? data.bridgeConnected : current.bridgeConnected,
|
||||
dropperNicknames: data.dropperNicknames || current.dropperNicknames,
|
||||
lastUpdate: data.lastUpdate || Date.now(),
|
||||
lastUpdate: data.lastUpdate != null ? data.lastUpdate : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,6 +141,7 @@ export const useInventoryStore = create((set, get) => ({
|
||||
ws.onopen = () => {
|
||||
console.log('✅ Connected to server');
|
||||
_lastWsMessage = Date.now();
|
||||
_reconnectDelay = 1000;
|
||||
set({ connected: true, ws });
|
||||
};
|
||||
|
||||
@@ -170,9 +172,11 @@ export const useInventoryStore = create((set, get) => ({
|
||||
ws.onclose = () => {
|
||||
console.log('❌ Disconnected from server');
|
||||
set({ connected: false, ws: null });
|
||||
const delay = _reconnectDelay;
|
||||
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
|
||||
setTimeout(() => {
|
||||
get().connect();
|
||||
}, 3000);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
@@ -253,25 +257,29 @@ export const useInventoryStore = create((set, get) => ({
|
||||
|
||||
enableAllRecipes: async () => {
|
||||
try {
|
||||
await fetch(`${API_URL}/recipes/enable-all`, {
|
||||
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 {
|
||||
await fetch(`${API_URL}/recipes/disable-all`, {
|
||||
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 };
|
||||
}
|
||||
},
|
||||
|
||||
@@ -289,6 +297,20 @@ export const useInventoryStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
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`, {
|
||||
@@ -309,7 +331,7 @@ export const useInventoryStore = create((set, get) => ({
|
||||
const response = await fetch(`${API_URL}/dropper-nicknames`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ dropperName, nickname }),
|
||||
body: JSON.stringify({ dropperName, nickname, commandId: newCommandId() }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.nicknames) {
|
||||
|
||||
@@ -7,13 +7,8 @@ services:
|
||||
- server-data:/data
|
||||
environment:
|
||||
- API_KEY=${API_KEY:-}
|
||||
- TURTLE_SERVER_URL=${TURTLE_SERVER_URL:-}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
start_period: 5s
|
||||
retries: 3
|
||||
|
||||
client:
|
||||
build:
|
||||
@@ -26,7 +21,7 @@ services:
|
||||
- inventory-network
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
# Node.js backend
|
||||
FROM node:18-alpine
|
||||
# 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
|
||||
RUN apk add --no-cache python3 make g++
|
||||
# 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 . .
|
||||
@@ -19,9 +37,14 @@ COPY . .
|
||||
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=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,32 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
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 });
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
// 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');
|
||||
@@ -19,6 +35,8 @@ 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(`
|
||||
@@ -475,7 +493,9 @@ export function flushPendingSave() {
|
||||
*/
|
||||
export function closeDb() {
|
||||
flushPendingSave();
|
||||
db.close();
|
||||
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);
|
||||
});
|
||||
2097
web/server/package-lock.json
generated
2097
web/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,15 +6,20 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon 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"
|
||||
"nodemon": "^3.0.2",
|
||||
"vitest": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
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