Compare commits
122 Commits
467be0493c
...
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 |
39
.package
39
.package
@@ -1,13 +1,18 @@
|
||||
{
|
||||
title = "Inventory Manager",
|
||||
description = "Automated inventory management system for CC:Tweaked. Tracks items across networked storage, crafting turtles, furnaces, and alerts. Includes web dashboard via bridge computer.",
|
||||
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 pkgDir = fs.combine("packages", "inventory-manager")
|
||||
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 .. "]: ")
|
||||
@@ -26,9 +31,10 @@
|
||||
print(" 1) Inventory Manager (main controller)")
|
||||
print(" 2) Inventory Client (display-only)")
|
||||
print(" 3) Web Bridge (HTTP forwarder)")
|
||||
print(" 4) Skip setup")
|
||||
print(" 4) Mining Turtle (cobble miner)")
|
||||
print(" 5) Skip setup")
|
||||
print("")
|
||||
write("Choice (1/2/3/4): ")
|
||||
write("Choice (1/2/3/4/5): ")
|
||||
local choice = read()
|
||||
|
||||
if choice == "1" then
|
||||
@@ -46,14 +52,14 @@
|
||||
dropperName = dropperName,
|
||||
barrelName = barrelName,
|
||||
}
|
||||
local f = fs.open(fs.combine(pkgDir, ".manager_config"), "w")
|
||||
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(pkgDir, ".webbridge_config"), "w")
|
||||
local bf = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
|
||||
bf.write(textutils.serialiseJSON(bcfg))
|
||||
bf.close()
|
||||
print("Saved web bridge config.")
|
||||
@@ -74,7 +80,7 @@
|
||||
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(pkgDir, ".client_config"), "w")
|
||||
local f = fs.open(fs.combine(cfgDir, ".client_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved client config.")
|
||||
@@ -85,11 +91,26 @@
|
||||
local serverUrl = ask("Web server URL", "http://localhost")
|
||||
|
||||
local cfg = { serverUrl = serverUrl }
|
||||
local f = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
|
||||
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
|
||||
|
||||
|
||||
@@ -8,18 +8,26 @@ 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 fs.exists(fs.combine(BASE, '.manager_config')) then
|
||||
if cfgExists('.manager_config') then
|
||||
role = 'manager'
|
||||
elseif fs.exists(fs.combine(BASE, '.client_config')) then
|
||||
elseif cfgExists('.client_config') then
|
||||
role = 'client'
|
||||
elseif fs.exists(fs.combine(BASE, '.webbridge_config')) then
|
||||
elseif cfgExists('.webbridge_config') then
|
||||
role = 'bridge'
|
||||
elseif cfgExists('.miner_config') then
|
||||
role = 'miner'
|
||||
elseif _G.turtle then
|
||||
role = 'turtle'
|
||||
end
|
||||
@@ -63,6 +71,7 @@ local programs = {
|
||||
client = 'inventoryClient.lua',
|
||||
bridge = 'inventoryWebBridge.lua',
|
||||
turtle = 'craftingTurtle.lua',
|
||||
miner = 'miningTurtle.lua',
|
||||
}
|
||||
|
||||
local program = fs.combine(BASE, programs[role])
|
||||
|
||||
@@ -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)
|
||||
-------------------------------------------------
|
||||
@@ -22,7 +31,17 @@ local CRAFT_SLOTS = {1, 2, 3, 5, 6, 7, 9, 10, 11}
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
local TURTLE_CONFIG_FILE = _path(".turtle_config")
|
||||
-- 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
|
||||
@@ -52,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
|
||||
@@ -78,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)
|
||||
@@ -91,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("")
|
||||
@@ -109,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
|
||||
@@ -170,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
|
||||
@@ -266,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
|
||||
|
||||
19
etc/apps.db
19
etc/apps.db
@@ -1,11 +1,11 @@
|
||||
{
|
||||
[ "im_inventory_manager" ] = {
|
||||
title = "Inventory Manager",
|
||||
title = "Inv Manager",
|
||||
category = "Inventory",
|
||||
run = "inventoryManager.lua",
|
||||
},
|
||||
[ "im_inventory_client" ] = {
|
||||
title = "Inventory Display",
|
||||
title = "Inv Display",
|
||||
category = "Inventory",
|
||||
run = "inventoryClient.lua",
|
||||
},
|
||||
@@ -13,21 +13,16 @@
|
||||
title = "Crafting Turtle",
|
||||
category = "Inventory",
|
||||
run = "craftingTurtle.lua",
|
||||
requires = { turtle = true },
|
||||
},
|
||||
[ "im_dropper_controller" ] = {
|
||||
title = "Dropper Controller",
|
||||
category = "Inventory",
|
||||
run = "dropperController.lua",
|
||||
requires = "turtle",
|
||||
},
|
||||
[ "im_web_bridge" ] = {
|
||||
title = "Inventory Web Bridge",
|
||||
title = "Inv Web Bridge",
|
||||
category = "Inventory",
|
||||
run = "inventoryWebBridge.lua",
|
||||
},
|
||||
[ "im_list_devices" ] = {
|
||||
title = "List Devices",
|
||||
[ "im_dropper" ] = {
|
||||
title = "Dropper Ctrl",
|
||||
category = "Inventory",
|
||||
run = "listDevicesByType.lua",
|
||||
run = "dropperController.lua",
|
||||
},
|
||||
}
|
||||
|
||||
1359
inventoryClient.lua
1359
inventoryClient.lua
File diff suppressed because it is too large
Load Diff
@@ -15,18 +15,49 @@
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path: survives Opus package updates by storing
|
||||
-- user data in usr/config/inventory-manager/ instead of the package dir.
|
||||
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
|
||||
|
||||
-- Write crash info to a file so we can always read it
|
||||
local function _crashLog(err)
|
||||
local f = fs.open(_path(".crash.log"), "w")
|
||||
if f then
|
||||
f.write(tostring(err) .. "\n" .. (debug and debug.traceback and debug.traceback() or ""))
|
||||
f.close()
|
||||
end
|
||||
end
|
||||
|
||||
local ok, err = xpcall(function()
|
||||
|
||||
-- Override dofile to load modules into our _ENV so they inherit
|
||||
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
|
||||
local function dofile(path) -- luacheck: ignore
|
||||
local fn, err = loadfile(path, nil, _ENV)
|
||||
if fn then return fn()
|
||||
else error(err, 2) end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Structured logging & shared UI helpers
|
||||
-------------------------------------------------
|
||||
|
||||
local log = dofile(_path("lib/log.lua"))
|
||||
local ui = dofile(_path("lib/ui.lua"))
|
||||
local log = dofile(_path("lib/log.lua"))
|
||||
local ui = dofile(_path("lib/ui.lua"))
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load modules (factory pattern → shared context)
|
||||
-------------------------------------------------
|
||||
|
||||
local cfg = dofile(_path("manager/config.lua"))(log, _path)
|
||||
cfg.loadConfig()
|
||||
local state = dofile(_path("manager/state.lua"))()
|
||||
|
||||
-- Shared context table (Lua tables are by-reference, so all
|
||||
@@ -48,6 +79,11 @@ ctx.ops = ops
|
||||
local display = dofile(_path("manager/display.lua"))(ctx)
|
||||
ctx.display = display
|
||||
|
||||
-- Recursive crafting engine
|
||||
local craftEngine = dofile(_path("lib/craft.lua"))
|
||||
craftEngine.init(cfg.recipeBook, ops.getItemTotal)
|
||||
ctx.craftEngine = craftEngine
|
||||
|
||||
-- Convenience aliases
|
||||
local cache = state.cache
|
||||
local activity = state.activity
|
||||
@@ -109,6 +145,10 @@ local function broadcastState()
|
||||
craftTurtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName),
|
||||
}
|
||||
|
||||
-- Keep ctx in sync so display.lua can check ctx.craftTurtleOk directly
|
||||
ctx.craftTurtleOk = payload.craftTurtleOk
|
||||
|
||||
-- Only include recipe tables when config has changed (they're large).
|
||||
if state.configDirty then
|
||||
payload.smeltable = cfg.SMELTABLE
|
||||
payload.craftable = cfg.CRAFTABLE
|
||||
@@ -155,24 +195,47 @@ local function main()
|
||||
log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE)
|
||||
end
|
||||
|
||||
-- Find modem for client communication
|
||||
-- Billboard monitor (optional — set billboardMonitorSide in .manager_config)
|
||||
-- Billboard monitor (auto-detects any 3rd monitor, or set billboardMonitor in .manager_config)
|
||||
if display.setupBillboardMonitor() then
|
||||
log.info("INIT", "Billboard monitor: %s", display.billboardMonName)
|
||||
else
|
||||
log.info("INIT", "No billboard monitor found (optional)")
|
||||
end
|
||||
|
||||
-- Find wired modem for client/turtle communication
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "modem" then
|
||||
ctx.networkModem = peripheral.wrap(name)
|
||||
ctx.networkModemName = name
|
||||
ctx.networkModem.open(cfg.ORDER_CHANNEL)
|
||||
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
|
||||
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
|
||||
break
|
||||
local m = peripheral.wrap(name)
|
||||
-- Prefer wired modem (has getNameLocal); skip wireless
|
||||
if m.isWireless and not m.isWireless() then
|
||||
ctx.networkModem = m
|
||||
ctx.networkModemName = name
|
||||
ctx.networkModem.open(cfg.ORDER_CHANNEL)
|
||||
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
|
||||
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
|
||||
break
|
||||
elseif not ctx.networkModem then
|
||||
-- Fallback: use wireless if no wired available
|
||||
ctx.networkModem = m
|
||||
ctx.networkModemName = name
|
||||
end
|
||||
end
|
||||
end
|
||||
if ctx.networkModem then
|
||||
log.info("INIT", "Network modem: %s", ctx.networkModemName)
|
||||
-- Ensure channels are open even on fallback modem
|
||||
ctx.networkModem.open(cfg.ORDER_CHANNEL)
|
||||
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
|
||||
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
|
||||
log.info("INIT", "Network modem: %s (wired=%s)", ctx.networkModemName,
|
||||
tostring(ctx.networkModem.isWireless and not ctx.networkModem.isWireless()))
|
||||
else
|
||||
log.warn("INIT", "No modem found for client sync")
|
||||
end
|
||||
|
||||
-- Detect crafting turtle on network
|
||||
-- The actual craft message goes on CRAFT_CHANNEL which only the crafting
|
||||
-- turtle listens to, so we just need any turtle name for presence checks.
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if name:match("^turtle_") then
|
||||
ctx.craftTurtleName = name
|
||||
@@ -196,7 +259,11 @@ local function main()
|
||||
for k in pairs(cfg.SMELTABLE) do
|
||||
if not state.disabledRecipes[k] then enabledCount = enabledCount + 1 end
|
||||
end
|
||||
log.info("INIT", "%d/%d recipes enabled", enabledCount, totalRecipeCount)
|
||||
log.info("INIT", "%d/%d smelting recipes enabled", enabledCount, totalRecipeCount)
|
||||
do
|
||||
local cc, sc = cfg.recipeBook.count()
|
||||
log.info("INIT", "Recipe book: %d crafting, %d smelting", cc, sc)
|
||||
end
|
||||
|
||||
-- Compost peripherals
|
||||
if peripheral.isPresent(cfg.COMPOST_DROPPER) then
|
||||
@@ -279,6 +346,11 @@ local function main()
|
||||
end
|
||||
|
||||
ops.refreshCache(drawBoot)
|
||||
|
||||
-- Destroy boot window so it doesn't intercept future monitor writes
|
||||
buf.setVisible(true)
|
||||
buf.clear()
|
||||
buf = nil
|
||||
else
|
||||
ops.refreshCache()
|
||||
end
|
||||
@@ -286,13 +358,40 @@ local function main()
|
||||
end
|
||||
print("")
|
||||
|
||||
-----------------------------------------------
|
||||
-- Resilient task wrapper: if a task crashes, it
|
||||
-- restarts after a brief delay instead of killing
|
||||
-- all other parallel tasks.
|
||||
-----------------------------------------------
|
||||
local function resilient(name, fn)
|
||||
return function()
|
||||
while true do
|
||||
local ok, err = pcall(fn)
|
||||
if not ok then
|
||||
log.error("TASK", "%s crashed: %s", name, tostring(err))
|
||||
sleep(5)
|
||||
log.info("TASK", "%s restarting...", name)
|
||||
else
|
||||
-- Task returned normally (shouldn't happen)
|
||||
log.warn("TASK", "%s exited unexpectedly, restarting...", name)
|
||||
sleep(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-----------------------------------------------
|
||||
-- Parallel tasks
|
||||
-----------------------------------------------
|
||||
|
||||
-- Shared queue: capture task writes here, processor task reads.
|
||||
-- This ensures modem_message events are never lost while the
|
||||
-- processor yields for peripheral calls (pushItems, list, etc).
|
||||
local networkQueue = {}
|
||||
|
||||
parallel.waitForAny(
|
||||
-- Task 1: Background inventory scanner
|
||||
function()
|
||||
resilient("Scanner", function()
|
||||
if cacheLoaded then
|
||||
pcall(ops.refreshCache)
|
||||
pcall(ops.checkAlerts)
|
||||
@@ -304,21 +403,22 @@ local function main()
|
||||
sleep(cfg.SCAN_INTERVAL)
|
||||
pcall(ops.refreshCache)
|
||||
pcall(ops.checkAlerts)
|
||||
pcall(function() cfg.recipeBook.flush() end)
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
end
|
||||
end,
|
||||
end),
|
||||
|
||||
-- Task 2: Barrel auto-sort
|
||||
function()
|
||||
resilient("Barrel-sort", function()
|
||||
while true do
|
||||
pcall(ops.sortBarrel)
|
||||
sleep(cfg.POLL_INTERVAL)
|
||||
end
|
||||
end,
|
||||
end),
|
||||
|
||||
-- Task 3: Auto-smelt
|
||||
function()
|
||||
resilient("Auto-smelt", function()
|
||||
while true do
|
||||
local ok, didWork = pcall(ops.autoSmelt)
|
||||
if ok and didWork then
|
||||
@@ -332,10 +432,10 @@ local function main()
|
||||
state.smelterNeedsRedraw = true
|
||||
sleep(cfg.SMELT_INTERVAL)
|
||||
end
|
||||
end,
|
||||
end),
|
||||
|
||||
-- Task 4: Defrag (consolidate partial stacks)
|
||||
function()
|
||||
resilient("Defrag", function()
|
||||
sleep(10)
|
||||
while true do
|
||||
activity.defragging = true
|
||||
@@ -345,10 +445,10 @@ local function main()
|
||||
state.needsRedraw = true
|
||||
sleep(cfg.DEFRAG_INTERVAL)
|
||||
end
|
||||
end,
|
||||
end),
|
||||
|
||||
-- Task 5: Auto-compost
|
||||
function()
|
||||
resilient("Auto-compost", function()
|
||||
while true do
|
||||
activity.composting = true
|
||||
state.needsRedraw = true
|
||||
@@ -358,10 +458,57 @@ local function main()
|
||||
pcall(ops.checkAlerts)
|
||||
sleep(cfg.COMPOST_INTERVAL)
|
||||
end
|
||||
end,
|
||||
end),
|
||||
|
||||
-- Task 5b: Auto-discard excess stock
|
||||
resilient("Auto-discard", function()
|
||||
if #cfg.TRASH_DROPPERS == 0 then
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
sleep(8) -- let initial scan finish first
|
||||
log.info("DISCARD", "Auto-discard active with %d trash dropper(s)", #cfg.TRASH_DROPPERS)
|
||||
while true do
|
||||
activity.discarding = true
|
||||
state.needsRedraw = true
|
||||
pcall(ops.discardExcess)
|
||||
activity.discarding = false
|
||||
state.needsRedraw = true
|
||||
sleep(cfg.DISCARD_INTERVAL)
|
||||
end
|
||||
end),
|
||||
|
||||
-- Task 5c: Auto-craft excess items into target products
|
||||
resilient("Auto-craft", function()
|
||||
sleep(12) -- let initial scan + discard settle first
|
||||
log.info("AUTOCRAFT", "Auto-craft active (%d explicit rule(s), smart=%s)",
|
||||
#cfg.AUTO_CRAFT_RULES, tostring(cfg.AUTO_CRAFT_FROM_EXCESS))
|
||||
while true do
|
||||
if ctx.craftTurtleName then
|
||||
activity.autocrafting = true
|
||||
state.needsRedraw = true
|
||||
pcall(ops.autoCraft)
|
||||
activity.autocrafting = false
|
||||
state.needsRedraw = true
|
||||
end
|
||||
sleep(cfg.AUTO_CRAFT_INTERVAL)
|
||||
end
|
||||
end),
|
||||
|
||||
-- Task 5d: Collection hopper emptying (egg spawner, etc.)
|
||||
resilient("Hopper-collect", function()
|
||||
if #cfg.COLLECTION_HOPPERS == 0 then
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
sleep(3)
|
||||
log.info("COLLECT", "Hopper collection active for %d hopper(s)", #cfg.COLLECTION_HOPPERS)
|
||||
while true do
|
||||
pcall(ops.collectHoppers)
|
||||
sleep(cfg.COLLECTION_INTERVAL)
|
||||
end
|
||||
end),
|
||||
|
||||
-- Task 6: Low-stock alert checker
|
||||
function()
|
||||
resilient("Alert-checker", function()
|
||||
sleep(5)
|
||||
pcall(ops.checkAlerts)
|
||||
state.needsRedraw = true
|
||||
@@ -370,15 +517,16 @@ local function main()
|
||||
pcall(ops.checkAlerts)
|
||||
state.needsRedraw = true
|
||||
end
|
||||
end,
|
||||
end),
|
||||
|
||||
-- Task 7: Main dashboard redraw (event-driven, polls 0.1s)
|
||||
function()
|
||||
resilient("Dashboard", function()
|
||||
state.needsRedraw = true
|
||||
while true do
|
||||
if state.needsRedraw then
|
||||
state.needsRedraw = false
|
||||
pcall(display.drawDashboard)
|
||||
local dok, derr = pcall(display.drawDashboard)
|
||||
if not dok then log.error("DRAW", "Dashboard: %s", tostring(derr)) end
|
||||
end
|
||||
if state.statusTimer > 0 then
|
||||
state.statusTimer = state.statusTimer - 0.1
|
||||
@@ -392,22 +540,40 @@ local function main()
|
||||
end
|
||||
sleep(0.1)
|
||||
end
|
||||
end,
|
||||
end),
|
||||
|
||||
-- Task 8: Smelter dashboard redraw
|
||||
function()
|
||||
resilient("Smelter-dashboard", function()
|
||||
state.smelterNeedsRedraw = true
|
||||
while true do
|
||||
if state.smelterNeedsRedraw then
|
||||
state.smelterNeedsRedraw = false
|
||||
pcall(display.drawSmelterDashboard)
|
||||
local sok, serr = pcall(display.drawSmelterDashboard)
|
||||
if not sok then log.error("DRAW", "Smelter: %s", tostring(serr)) end
|
||||
end
|
||||
sleep(0.1)
|
||||
end
|
||||
end,
|
||||
end),
|
||||
|
||||
-- Task 8b: Billboard dashboard redraw (goals monitor)
|
||||
resilient("Billboard", function()
|
||||
if not display.billboardMon then
|
||||
-- No billboard configured, sleep forever
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
state.billboardNeedsRedraw = true
|
||||
while true do
|
||||
if state.billboardNeedsRedraw then
|
||||
state.billboardNeedsRedraw = false
|
||||
local bok, berr = pcall(display.drawBillboard)
|
||||
if not bok then log.error("DRAW", "Billboard: %s", tostring(berr)) end
|
||||
end
|
||||
sleep(0.5)
|
||||
end
|
||||
end),
|
||||
|
||||
-- Task 9: Touch event listener (both monitors)
|
||||
function()
|
||||
resilient("Touch-listener", function()
|
||||
while true do
|
||||
local event, side, x, y = os.pullEvent("monitor_touch")
|
||||
if display.smelterMonName and side == display.smelterMonName then
|
||||
@@ -418,38 +584,104 @@ local function main()
|
||||
display.handleTouch(x, y)
|
||||
end
|
||||
end
|
||||
end,
|
||||
end),
|
||||
|
||||
-- Task 10: Network state broadcast (skips if nothing changed)
|
||||
function()
|
||||
resilient("Broadcast", function()
|
||||
while true do
|
||||
if state.stateVersion ~= state.lastBroadcastVersion then
|
||||
pcall(broadcastState)
|
||||
end
|
||||
sleep(cfg.BROADCAST_INTERVAL)
|
||||
end
|
||||
end,
|
||||
end),
|
||||
|
||||
-- Task 11: Peripheral detach handler
|
||||
function()
|
||||
resilient("Detach-handler", function()
|
||||
while true do
|
||||
local event, name = os.pullEvent("peripheral_detach")
|
||||
if name then
|
||||
ops.invalidateWrapCache(name)
|
||||
ops.invalidatePeripheralCaches()
|
||||
log.info("DETACH", "%s", name)
|
||||
if name == ctx.craftTurtleName then
|
||||
ctx.craftTurtleName = nil
|
||||
log.warn("DETACH", "Crafting turtle disconnected")
|
||||
end
|
||||
end
|
||||
end
|
||||
end,
|
||||
end),
|
||||
|
||||
-- Task 12: Network order/command listener
|
||||
function()
|
||||
if not ctx.networkModem then return end
|
||||
-- Task 11b: Peripheral attach handler (auto-detect crafting turtle)
|
||||
resilient("Attach-handler", function()
|
||||
while true do
|
||||
local event, name = os.pullEvent("peripheral_attach")
|
||||
if name and name:match("^turtle_") and not ctx.craftTurtleName then
|
||||
ctx.craftTurtleName = name
|
||||
log.info("ATTACH", "Crafting turtle detected: %s", name)
|
||||
pcall(broadcastState)
|
||||
end
|
||||
end
|
||||
end),
|
||||
|
||||
-- Task 12: Supply chest (builder / manifest-based stocking)
|
||||
resilient("Supply-chest", function()
|
||||
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
log.info("SUPPLY", "Stocking %s with %d item types", cfg.SUPPLY_CHEST, #cfg.SUPPLY_MANIFEST)
|
||||
while true do
|
||||
pcall(ops.supplyChest)
|
||||
sleep(cfg.SUPPLY_INTERVAL)
|
||||
end
|
||||
end),
|
||||
|
||||
-- Task 13a: Network message capture (fast — never yields to peripheral calls)
|
||||
-- This coroutine's filter is ALWAYS "modem_message", so it can never
|
||||
-- miss events while other tasks yield for "task_complete" etc.
|
||||
resilient("Network-capture", function()
|
||||
if not ctx.networkModem then
|
||||
log.warn("NET-CAP", "No modem — capture task idle")
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
log.info("NET-CAP", "Capture task started, listening on ch %d", cfg.ORDER_CHANNEL)
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||
if channel == cfg.ORDER_CHANNEL and type(message) == "table" then
|
||||
table.insert(networkQueue, { replyChannel = replyChannel, message = message })
|
||||
log.debug("NET-CAP", "Queued: type=%s queue=%d", tostring(message.type), #networkQueue)
|
||||
end
|
||||
end
|
||||
end),
|
||||
|
||||
-- Task 13b: Network message processor (drains queue — safe to yield)
|
||||
resilient("Network-processor", function()
|
||||
if not ctx.networkModem then
|
||||
log.warn("NET-PROC", "No modem — processor task idle")
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
log.info("NET-PROC", "Processor task started")
|
||||
while true do
|
||||
if #networkQueue == 0 then
|
||||
os.pullEvent()
|
||||
end
|
||||
while #networkQueue > 0 do
|
||||
local entry = table.remove(networkQueue, 1)
|
||||
local message = entry.message
|
||||
local replyChannel = entry.replyChannel
|
||||
log.debug("NET-PROC", "Processing: type=%s id=%s queue=%d",
|
||||
tostring(message.type), tostring(message.commandId), #networkQueue)
|
||||
|
||||
if isCommandDuplicate(message.commandId) then
|
||||
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId))
|
||||
-- Still ACK so the sender stops retrying
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
else
|
||||
recordCommandId(message.commandId)
|
||||
cleanupCommandIds()
|
||||
@@ -485,6 +717,13 @@ local function main()
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "toggle_pause" then
|
||||
state.smeltingPaused = not state.smeltingPaused
|
||||
@@ -494,6 +733,13 @@ local function main()
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "toggle_recipe" and message.recipe then
|
||||
if state.disabledRecipes[message.recipe] then
|
||||
@@ -507,6 +753,13 @@ local function main()
|
||||
state.bumpStateVersion()
|
||||
state.smelterNeedsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "enable_all" then
|
||||
state.disabledRecipes = {}
|
||||
@@ -516,6 +769,13 @@ local function main()
|
||||
state.bumpStateVersion()
|
||||
state.smelterNeedsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "disable_all" then
|
||||
for inputName in pairs(cfg.SMELTABLE) do
|
||||
@@ -527,11 +787,25 @@ local function main()
|
||||
state.bumpStateVersion()
|
||||
state.smelterNeedsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "sort_barrel" and message.barrelName then
|
||||
log.info("NET", "Sort barrel: %s", message.barrelName)
|
||||
pcall(ops.sortBarrel, message.barrelName)
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "register_droppers" and message.clientId and message.droppers then
|
||||
local cid = tostring(message.clientId)
|
||||
@@ -589,6 +863,136 @@ local function main()
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
pcall(broadcastState)
|
||||
|
||||
elseif message.type == "recursive_craft" and message.itemName and message.count then
|
||||
log.info("NET", "Recursive craft: %s x%d", message.itemName, message.count)
|
||||
local pok, ok, craftErr = pcall(ops.recursiveCraft, message.itemName, message.count)
|
||||
if not pok then
|
||||
log.error("NET", "recursiveCraft crashed: %s", tostring(ok))
|
||||
craftErr = tostring(ok)
|
||||
ok = false
|
||||
end
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "recursive_craft_result",
|
||||
commandId = message.commandId,
|
||||
success = ok,
|
||||
error = craftErr,
|
||||
})
|
||||
end)
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
pcall(broadcastState)
|
||||
|
||||
elseif message.type == "sync_disabled_recipes" then
|
||||
if message.disabledRecipes then
|
||||
state.disabledRecipes = message.disabledRecipes
|
||||
end
|
||||
if message.smeltingPaused ~= nil then
|
||||
state.smeltingPaused = message.smeltingPaused
|
||||
end
|
||||
ops.saveDisabledRecipes()
|
||||
log.info("NET", "Synced smelting state from client")
|
||||
state.configDirty = true
|
||||
state.bumpStateVersion()
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "learn_crafting_recipe" and message.output and message.count and message.grid then
|
||||
cfg.recipeBook.learnCraftingRecipe(message.output, message.count, message.grid)
|
||||
cfg.refreshRecipes()
|
||||
cfg.recipeBook.flush()
|
||||
log.info("NET", "Learned crafting recipe: %s", message.output)
|
||||
state.configDirty = true
|
||||
state.bumpStateVersion()
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "learn_smelting_recipe" and message.input and message.result then
|
||||
cfg.recipeBook.learnSmeltingRecipe(message.input, message.result, message.furnaces)
|
||||
cfg.refreshRecipes()
|
||||
cfg.recipeBook.flush()
|
||||
log.info("NET", "Learned smelting recipe: %s -> %s", message.input, message.result)
|
||||
state.configDirty = true
|
||||
state.bumpStateVersion()
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "forget_recipe" and message.recipe then
|
||||
local forgot = cfg.recipeBook.forgetCraftingRecipe(message.recipe) or
|
||||
cfg.recipeBook.forgetSmeltingRecipe(message.recipe)
|
||||
if forgot then
|
||||
cfg.refreshRecipes()
|
||||
cfg.recipeBook.flush()
|
||||
log.info("NET", "Forgot recipe: %s", message.recipe)
|
||||
state.configDirty = true
|
||||
state.bumpStateVersion()
|
||||
end
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "find_item" and message.items then
|
||||
-- Return chest+slot locations for the first matching item
|
||||
-- message.items = list of item names to search (in priority order)
|
||||
-- message.limit = max items to return info for (default 64)
|
||||
local limit = message.limit or 64
|
||||
local results = {}
|
||||
for _, itemName in ipairs(message.items) do
|
||||
if cache.catalogue[itemName] then
|
||||
for _, source in ipairs(cache.catalogue[itemName]) do
|
||||
local chest = ops.wrapCached(source.chest)
|
||||
if chest then
|
||||
for slot, slotItem in pairs(chest.list()) do
|
||||
if slotItem.name == itemName then
|
||||
table.insert(results, {
|
||||
chest = source.chest,
|
||||
slot = slot,
|
||||
name = itemName,
|
||||
count = slotItem.count,
|
||||
})
|
||||
if #results >= limit then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #results >= limit then break end
|
||||
end
|
||||
end
|
||||
if #results > 0 then break end -- found fuel, stop searching
|
||||
end
|
||||
log.info("NET", "find_item: found %d source(s)", #results)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "find_item_result",
|
||||
commandId = message.commandId,
|
||||
results = results,
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
end) -- pcall handler
|
||||
@@ -596,10 +1000,22 @@ local function main()
|
||||
log.error("NET", "Handler error: %s", tostring(handlerErr))
|
||||
end
|
||||
end -- idempotency else
|
||||
end
|
||||
end -- queue loop
|
||||
end
|
||||
end
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
main()
|
||||
|
||||
end, function(e) return tostring(e) .. "\n" .. debug.traceback() end)
|
||||
|
||||
if not ok then
|
||||
_crashLog(err)
|
||||
printError(tostring(err))
|
||||
print("")
|
||||
print("Crash log saved to: " .. _path(".crash.log"))
|
||||
print("")
|
||||
print("Press any key to exit...")
|
||||
os.pullEvent("char")
|
||||
end
|
||||
@@ -3,46 +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
|
||||
-------------------------------------------------
|
||||
|
||||
-- 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
|
||||
|
||||
-- Modem channels (must match inventoryManager.lua)
|
||||
local BROADCAST_CHANNEL = 4200
|
||||
local ORDER_CHANNEL = 4201
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load config from file if present
|
||||
-- Configuration (via platform)
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
local CONFIG_FILE = _path(".webbridge_config")
|
||||
local API_KEY = nil -- optional API key for server auth
|
||||
local config, configSource = WebBridge.loadConfig({
|
||||
serverUrl = "http://localhost",
|
||||
pollInterval = 0.5,
|
||||
stateInterval = 1,
|
||||
apiKey = nil,
|
||||
}, {
|
||||
"usr/config/inventory-manager/.webbridge_config",
|
||||
fs.combine(_baseDir, ".webbridge_config"),
|
||||
})
|
||||
|
||||
local 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
|
||||
local SERVER_URL = config.serverUrl
|
||||
local POLL_INTERVAL = config.pollInterval
|
||||
local STATE_INTERVAL = config.stateInterval
|
||||
local API_KEY = config.apiKey
|
||||
|
||||
if configSource then
|
||||
print("[CONFIG] Loaded from " .. configSource)
|
||||
end
|
||||
|
||||
-- Channels from platform registry (matches inventoryManager.lua)
|
||||
local BROADCAST_CHANNEL = Channels.get('inventory.broadcast')
|
||||
local ORDER_CHANNEL = Channels.get('inventory.order')
|
||||
local BRIDGE_REPLY_CHANNEL = Channels.get('inventory.bridge')
|
||||
|
||||
-------------------------------------------------
|
||||
-- State
|
||||
-------------------------------------------------
|
||||
@@ -52,64 +58,42 @@ local modem = nil
|
||||
local modemName = nil
|
||||
local running = true
|
||||
|
||||
-------------------------------------------------
|
||||
-- Find modem
|
||||
-------------------------------------------------
|
||||
-- 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
|
||||
|
||||
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
|
||||
-- Reliable modem delivery: pending commands awaiting manager acknowledgment.
|
||||
-- processCommand() inserts here; modemListener removes on result receipt.
|
||||
-- A retry task periodically re-transmits unacknowledged commands.
|
||||
local pendingModem = {} -- commandId -> { payload, channel, replyChannel, sent, retries }
|
||||
local MODEM_RETRY_INTERVAL = 2 -- seconds between retry sweeps
|
||||
local MODEM_RETRY_MAX = 5 -- max retransmissions before giving up
|
||||
local MODEM_RETRY_DELAY = 2 -- seconds before first retry (per command)
|
||||
|
||||
-------------------------------------------------
|
||||
-- HTTP helpers
|
||||
-- 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, result = pcall(function()
|
||||
local response = http.post(url, data, headers)
|
||||
if response then
|
||||
local responseData = response.readAll()
|
||||
response.close()
|
||||
return responseData
|
||||
end
|
||||
end)
|
||||
|
||||
if ok then
|
||||
return result
|
||||
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 nil
|
||||
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
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -121,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
|
||||
-------------------------------------------------
|
||||
@@ -133,60 +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
|
||||
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
|
||||
type = "reboot",
|
||||
commandId = cmd.commandId,
|
||||
target = cmd.target or "all",
|
||||
})
|
||||
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
|
||||
|
||||
@@ -202,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
|
||||
@@ -265,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
|
||||
-------------------------------------------------
|
||||
@@ -275,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.")
|
||||
@@ -293,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)
|
||||
@@ -302,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
|
||||
@@ -6,6 +6,16 @@ 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 = {}
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -22,9 +32,12 @@ C.SMELT_RESERVE = 128
|
||||
C.DEFRAG_INTERVAL = 600
|
||||
C.COMPOST_INTERVAL = 3
|
||||
C.ALERT_INTERVAL = 15
|
||||
C.CACHE_FILE = _path(".inventory_cache")
|
||||
C.CACHE_FILE = _configPath(".inventory_cache")
|
||||
C.SMELTER_MONITOR_SIDE = "top"
|
||||
C.DISABLED_RECIPES_FILE = _path(".disabled_recipes")
|
||||
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
|
||||
@@ -43,9 +56,40 @@ 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",
|
||||
@@ -60,7 +104,7 @@ C.SLOT_OUTPUT = 3
|
||||
-- Config file loader
|
||||
-------------------------------------------------
|
||||
|
||||
local CONFIG_FILE = _path(".manager_config")
|
||||
local CONFIG_FILE = _configPath(".manager_config")
|
||||
|
||||
function C.loadConfig()
|
||||
if not fs.exists(CONFIG_FILE) then return end
|
||||
@@ -76,7 +120,10 @@ function C.loadConfig()
|
||||
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.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
|
||||
@@ -92,6 +139,23 @@ function C.loadConfig()
|
||||
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
|
||||
@@ -100,31 +164,38 @@ end
|
||||
-- Data tables
|
||||
-------------------------------------------------
|
||||
|
||||
C.SMELTABLE = dofile(_path("data/smeltable.lua"))
|
||||
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.CRAFTABLE = dofile(_path("data/craftable.lua"))
|
||||
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"))
|
||||
|
||||
-- Pre-build furnace compatibility sets for O(1) lookup
|
||||
for _, recipe in pairs(C.SMELTABLE) do
|
||||
recipe.furnaceSet = {}
|
||||
for _, ft in ipairs(recipe.furnaces) do
|
||||
recipe.furnaceSet[ft] = true
|
||||
end
|
||||
end
|
||||
-- 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()
|
||||
|
||||
-- Pre-built smelt candidate lists per furnace type
|
||||
C.smeltCandidatesByType = {}
|
||||
do
|
||||
-- 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
|
||||
for ft in pairs(recipe.furnaceSet) do
|
||||
table.insert(C.smeltCandidatesByType[ft], { name = itemName, recipe = recipe, food = isFood })
|
||||
end
|
||||
end
|
||||
@@ -136,6 +207,15 @@ do
|
||||
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
|
||||
@@ -144,6 +224,10 @@ for _, f in ipairs(C.FUEL_LIST) do C.FUEL_SET[f.name] = true end
|
||||
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
|
||||
|
||||
1034
manager/display.lua
1034
manager/display.lua
File diff suppressed because it is too large
Load Diff
@@ -148,13 +148,39 @@ function O.refreshCache(onProgress)
|
||||
local totalSlots = 0
|
||||
local usedSlots = 0
|
||||
|
||||
for ci, chest in ipairs(chests) do
|
||||
if onProgress then onProgress(ci, #chests, chest) end
|
||||
local inv = O.wrapCached(chest)
|
||||
if inv then
|
||||
totalSlots = totalSlots + inv.size()
|
||||
local contents = inv.list()
|
||||
for slot, item in pairs(contents) do
|
||||
-- Parallel inventory scanning (chunked)
|
||||
local CHUNK = cfg.PARALLEL_SCAN_CHUNKS or 8
|
||||
local scanData = {}
|
||||
local scanFns = {}
|
||||
for _, chest in ipairs(chests) do
|
||||
table.insert(scanFns, function()
|
||||
local inv = O.wrapCached(chest)
|
||||
if inv then
|
||||
scanData[chest] = {
|
||||
size = inv.size(),
|
||||
contents = inv.list(),
|
||||
}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
for i = 1, #scanFns, CHUNK do
|
||||
local chunk = {}
|
||||
local chunkEnd = math.min(i + CHUNK - 1, #scanFns)
|
||||
for j = i, chunkEnd do
|
||||
chunk[#chunk + 1] = scanFns[j]
|
||||
end
|
||||
if onProgress then
|
||||
onProgress(chunkEnd, #chests, chests[chunkEnd] or "scanning...")
|
||||
end
|
||||
parallel.waitForAll(table.unpack(chunk))
|
||||
end
|
||||
|
||||
for _, chest in ipairs(chests) do
|
||||
local data = scanData[chest]
|
||||
if data then
|
||||
totalSlots = totalSlots + data.size
|
||||
for slot, item in pairs(data.contents) do
|
||||
usedSlots = usedSlots + 1
|
||||
if not catalogue[item.name] then
|
||||
catalogue[item.name] = {}
|
||||
@@ -293,6 +319,22 @@ end
|
||||
-- Barrel auto-sort
|
||||
-------------------------------------------------
|
||||
|
||||
--- Get chests sorted by priority (highest first).
|
||||
-- Falls back to name order if no priority is configured.
|
||||
function O.getChestsByPriority()
|
||||
local chests = O.getChests()
|
||||
local priority = cfg.CHEST_PRIORITY
|
||||
if not priority or not next(priority) then return chests end
|
||||
local sorted = { table.unpack(chests) }
|
||||
table.sort(sorted, function(a, b)
|
||||
local pa = priority[a] or 0
|
||||
local pb = priority[b] or 0
|
||||
if pa ~= pb then return pa > pb end
|
||||
return a < b
|
||||
end)
|
||||
return sorted
|
||||
end
|
||||
|
||||
function O.sortBarrel(barrelOverride)
|
||||
local barrelTarget = (barrelOverride and barrelOverride ~= "") and barrelOverride or cfg.BARREL_NAME
|
||||
local barrel = O.wrapCached(barrelTarget)
|
||||
@@ -305,7 +347,7 @@ function O.sortBarrel(barrelOverride)
|
||||
state.needsRedraw = true
|
||||
|
||||
local catalogue = cache.catalogue
|
||||
local chests = O.getChests()
|
||||
local chests = O.getChestsByPriority()
|
||||
|
||||
for slot, item in pairs(contents) do
|
||||
local moved = 0
|
||||
@@ -723,6 +765,279 @@ function O.autoCompost()
|
||||
return didWork
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Collection hopper emptying (egg spawner, mob farm, etc.)
|
||||
-------------------------------------------------
|
||||
|
||||
function O.collectHoppers()
|
||||
if #cfg.COLLECTION_HOPPERS == 0 then return false end
|
||||
|
||||
local didWork = false
|
||||
local chests = O.getChestsByPriority()
|
||||
local catalogue = cache.catalogue
|
||||
|
||||
for _, hopperName in ipairs(cfg.COLLECTION_HOPPERS) do
|
||||
local hopper = O.wrapCached(hopperName)
|
||||
if hopper then
|
||||
local contents = hopper.list()
|
||||
if contents and next(contents) then
|
||||
for slot, item in pairs(contents) do
|
||||
local moved = 0
|
||||
local triedChests = {}
|
||||
|
||||
-- Prefer chests where item already exists
|
||||
if catalogue[item.name] then
|
||||
for _, entry in ipairs(catalogue[item.name]) do
|
||||
triedChests[entry.chest] = true
|
||||
local n = hopper.pushItems(entry.chest, slot)
|
||||
if n and n > 0 then
|
||||
moved = moved + n
|
||||
state.adjustCache(item.name, entry.chest, n)
|
||||
didWork = true
|
||||
log.info("COLLECT", "%s x%d -> %s (from %s)", item.name, n, entry.chest, hopperName)
|
||||
end
|
||||
if moved >= item.count then break end
|
||||
end
|
||||
end
|
||||
|
||||
-- Overflow to any chest with space
|
||||
if moved < item.count then
|
||||
for _, chest in ipairs(chests) do
|
||||
if not triedChests[chest] then
|
||||
local n = hopper.pushItems(chest, slot)
|
||||
if n and n > 0 then
|
||||
moved = moved + n
|
||||
state.adjustCache(item.name, chest, n)
|
||||
didWork = true
|
||||
log.info("COLLECT", "%s x%d -> %s (from %s)", item.name, n, chest, hopperName)
|
||||
end
|
||||
if moved >= item.count then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return didWork
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Auto-discard excess stock
|
||||
-------------------------------------------------
|
||||
|
||||
function O.discardExcess()
|
||||
if #cfg.TRASH_DROPPERS == 0 then return false end
|
||||
|
||||
local catalogue = cache.catalogue
|
||||
local didWork = false
|
||||
|
||||
-- Build a flat list of dropper handles + free capacity
|
||||
local droppers = {}
|
||||
for _, dName in ipairs(cfg.TRASH_DROPPERS) do
|
||||
local d = O.wrapCached(dName)
|
||||
if d then
|
||||
local used = 0
|
||||
local contents = d.list()
|
||||
if contents then
|
||||
for _, item in pairs(contents) do used = used + item.count end
|
||||
end
|
||||
local free = (d.size() * 64) - used
|
||||
if free > 0 then
|
||||
table.insert(droppers, { name = dName, handle = d, free = free })
|
||||
else
|
||||
log.debug("DISCARD", "Dropper %s is full, skipping", dName)
|
||||
end
|
||||
end
|
||||
end
|
||||
if #droppers == 0 then
|
||||
log.debug("DISCARD", "All trash droppers full or offline, cannot discard")
|
||||
return false
|
||||
end
|
||||
|
||||
-- Round-robin index across droppers
|
||||
local dIdx = 1
|
||||
|
||||
for itemName, maxCount in pairs(cfg.STOCK_LIMITS) do
|
||||
if catalogue[itemName] then
|
||||
local totalInStorage = 0
|
||||
for _, src in ipairs(catalogue[itemName]) do
|
||||
totalInStorage = totalInStorage + src.total
|
||||
end
|
||||
|
||||
local excess = totalInStorage - maxCount
|
||||
if excess > 0 then
|
||||
log.info("DISCARD", "%s: %d in stock, limit %d, discarding %d",
|
||||
itemName, totalInStorage, maxCount, excess)
|
||||
local discarded = 0
|
||||
local srcSnapshot = { table.unpack(catalogue[itemName]) }
|
||||
for _, source in ipairs(srcSnapshot) do
|
||||
if discarded >= excess then break end
|
||||
if source.total > 0 then
|
||||
local chest = O.wrapCached(source.chest)
|
||||
if chest then
|
||||
for slot, slotItem in pairs(chest.list()) do
|
||||
if slotItem.name == itemName then
|
||||
-- Find a dropper with free space (round-robin)
|
||||
local startIdx = dIdx
|
||||
local dropper = nil
|
||||
repeat
|
||||
if droppers[dIdx].free > 0 then
|
||||
dropper = droppers[dIdx]
|
||||
end
|
||||
dIdx = (dIdx % #droppers) + 1
|
||||
until dropper or dIdx == startIdx
|
||||
if not dropper then break end -- all droppers full
|
||||
|
||||
local batch = math.min(slotItem.count, excess - discarded, dropper.free)
|
||||
local n = chest.pushItems(dropper.name, slot, batch)
|
||||
if n and n > 0 then
|
||||
state.adjustCache(itemName, source.chest, -n)
|
||||
dropper.free = dropper.free - n
|
||||
discarded = discarded + n
|
||||
didWork = true
|
||||
end
|
||||
if discarded >= excess then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if discarded > 0 then
|
||||
log.info("DISCARD", "Discarded %s x%d", itemName, discarded)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return didWork
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Auto-craft excess stock into target items
|
||||
-------------------------------------------------
|
||||
|
||||
function O.autoCraft()
|
||||
if not ctx.craftEngine then return false end
|
||||
if not ctx.craftTurtleName then return false end
|
||||
|
||||
local catalogue = cache.catalogue
|
||||
local didWork = false
|
||||
|
||||
-- Phase 1: Explicit rules from auto_craft.lua
|
||||
for _, rule in ipairs(cfg.AUTO_CRAFT_RULES) do
|
||||
local inputName = rule.input
|
||||
local reserve = rule.reserve or 0
|
||||
local outputName = rule.output
|
||||
|
||||
if catalogue[inputName] then
|
||||
local totalInStorage = 0
|
||||
for _, src in ipairs(catalogue[inputName]) do
|
||||
totalInStorage = totalInStorage + src.total
|
||||
end
|
||||
|
||||
local excess = totalInStorage - reserve
|
||||
if excess > 0 then
|
||||
local recipe = cfg.recipeBook.getCraftingRecipe(outputName)
|
||||
if recipe then
|
||||
local ingredients = cfg.recipeBook.getIngredients(recipe)
|
||||
local inputPerCraft = ingredients[inputName] or 0
|
||||
if inputPerCraft > 0 then
|
||||
local batches = math.floor(excess / inputPerCraft)
|
||||
batches = math.min(batches, 64)
|
||||
if batches > 0 then
|
||||
local craftCount = batches * recipe.count
|
||||
log.info("AUTOCRAFT", "%s: %d excess (reserve %d), crafting %d x %s",
|
||||
inputName, excess, reserve, craftCount, outputName)
|
||||
local ok, err = O.recursiveCraft(outputName, craftCount)
|
||||
if ok then
|
||||
didWork = true
|
||||
log.info("AUTOCRAFT", "Crafted %s x%d", outputName, craftCount)
|
||||
else
|
||||
log.warn("AUTOCRAFT", "Failed to craft %s: %s", outputName, tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
log.warn("AUTOCRAFT", "No recipe found for output: %s", outputName)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Phase 2: Smart excess-to-craft — auto-discover recipes for over-stocked items
|
||||
if cfg.AUTO_CRAFT_FROM_EXCESS then
|
||||
-- Track outputs we've already handled via explicit rules to avoid duplicates
|
||||
local handledOutputs = {}
|
||||
for _, rule in ipairs(cfg.AUTO_CRAFT_RULES) do
|
||||
handledOutputs[rule.output] = true
|
||||
end
|
||||
|
||||
for itemName, maxCount in pairs(cfg.STOCK_LIMITS) do
|
||||
if catalogue[itemName] then
|
||||
local totalInStorage = 0
|
||||
for _, src in ipairs(catalogue[itemName]) do
|
||||
totalInStorage = totalInStorage + src.total
|
||||
end
|
||||
|
||||
local excess = totalInStorage - maxCount
|
||||
if excess > 0 then
|
||||
-- Find all crafting recipes that use this item
|
||||
local usingRecipes = cfg.recipeBook.findRecipesUsing(itemName)
|
||||
for _, recipe in ipairs(usingRecipes) do
|
||||
if not handledOutputs[recipe.output] then
|
||||
-- Check how much of the output we already have
|
||||
local outputTotal = O.getItemTotal(recipe.output)
|
||||
if outputTotal < cfg.AUTO_CRAFT_OUTPUT_CAP then
|
||||
local ingredients = cfg.recipeBook.getIngredients(recipe)
|
||||
local inputPerCraft = ingredients[itemName] or 0
|
||||
if inputPerCraft > 0 then
|
||||
-- Only craft up to the output cap
|
||||
local outputRoom = cfg.AUTO_CRAFT_OUTPUT_CAP - outputTotal
|
||||
local maxBatches = math.floor(excess / inputPerCraft)
|
||||
local batchesByRoom = math.ceil(outputRoom / recipe.count)
|
||||
local batches = math.min(maxBatches, batchesByRoom, 64)
|
||||
if batches > 0 then
|
||||
-- Check all other ingredients are available
|
||||
local canCraft = true
|
||||
for ingr, needed in pairs(ingredients) do
|
||||
if ingr ~= itemName then
|
||||
local have = O.getItemTotal(ingr)
|
||||
if have < needed * batches then
|
||||
canCraft = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if canCraft then
|
||||
local craftCount = batches * recipe.count
|
||||
log.info("AUTOCRAFT", "Smart: %s over limit, crafting %d x %s",
|
||||
itemName, craftCount, recipe.output)
|
||||
local ok, err = O.recursiveCraft(recipe.output, craftCount)
|
||||
if ok then
|
||||
didWork = true
|
||||
handledOutputs[recipe.output] = true
|
||||
log.info("AUTOCRAFT", "Smart-crafted %s x%d", recipe.output, craftCount)
|
||||
-- Recalculate excess after crafting
|
||||
break
|
||||
else
|
||||
log.warn("AUTOCRAFT", "Smart craft failed %s: %s", recipe.output, tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return didWork
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Low-stock alert checker
|
||||
-------------------------------------------------
|
||||
@@ -751,6 +1066,63 @@ function O.checkAlerts()
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Supply chest (builder / manifest-based stocking)
|
||||
-------------------------------------------------
|
||||
|
||||
function O.supplyChest()
|
||||
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then return false end
|
||||
|
||||
local supply = O.wrapCached(cfg.SUPPLY_CHEST)
|
||||
if not supply then return false end
|
||||
|
||||
-- Scan what's already in the supply chest
|
||||
local existing = {}
|
||||
local contents = supply.list()
|
||||
if contents then
|
||||
for _, item in pairs(contents) do
|
||||
existing[item.name] = (existing[item.name] or 0) + item.count
|
||||
end
|
||||
end
|
||||
|
||||
local catalogue = cache.catalogue
|
||||
local didWork = false
|
||||
|
||||
for _, manifest in ipairs(cfg.SUPPLY_MANIFEST) do
|
||||
local have = existing[manifest.name] or 0
|
||||
local want = manifest.count or 0
|
||||
local deficit = want - have
|
||||
|
||||
if deficit > 0 and catalogue[manifest.name] then
|
||||
local remaining = deficit
|
||||
local srcSnapshot = { table.unpack(catalogue[manifest.name]) }
|
||||
for _, source in ipairs(srcSnapshot) do
|
||||
if source.total > 0 then
|
||||
local chest = O.wrapCached(source.chest)
|
||||
if chest then
|
||||
for slot, slotItem in pairs(chest.list()) do
|
||||
if slotItem.name == manifest.name then
|
||||
local toMove = math.min(slotItem.count, remaining)
|
||||
local n = chest.pushItems(cfg.SUPPLY_CHEST, slot, toMove)
|
||||
if n and n > 0 then
|
||||
state.adjustCache(manifest.name, source.chest, -n)
|
||||
remaining = remaining - n
|
||||
didWork = true
|
||||
log.info("SUPPLY", "%s x%d -> %s", manifest.name, n, cfg.SUPPLY_CHEST)
|
||||
if remaining <= 0 then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if remaining <= 0 then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return didWork
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Order / Dispense
|
||||
-------------------------------------------------
|
||||
@@ -853,7 +1225,8 @@ function O.getMissingIngredients(recipe)
|
||||
return ui.getMissingIngredients(recipe, O.getItemTotal)
|
||||
end
|
||||
|
||||
function O.craftItem(recipeIdx)
|
||||
function O.craftItem(recipeIdx, batches)
|
||||
batches = batches or 1
|
||||
local recipe = cfg.CRAFTABLE[recipeIdx]
|
||||
if not recipe then
|
||||
log.error("CRAFT", "Invalid recipe index: %s", tostring(recipeIdx))
|
||||
@@ -872,7 +1245,11 @@ function O.craftItem(recipeIdx)
|
||||
return false, "Turtle offline"
|
||||
end
|
||||
|
||||
log.info("CRAFT", "Starting craft: %s (turtle: %s)", recipe.output, ctx.craftTurtleName)
|
||||
-- Clamp batches to 64 (max stack size per slot)
|
||||
batches = math.min(batches, 64)
|
||||
|
||||
log.info("CRAFT", "Starting craft: %s x%d (%d batches, turtle: %s)",
|
||||
recipe.output, batches * recipe.count, batches, ctx.craftTurtleName)
|
||||
|
||||
activity.crafting = true
|
||||
state.needsRedraw = true
|
||||
@@ -882,6 +1259,27 @@ function O.craftItem(recipeIdx)
|
||||
local slotMap = {}
|
||||
local reservedSlots = {}
|
||||
|
||||
-- Sum how many of each ingredient we need total
|
||||
local ingredientTotals = {}
|
||||
for gridPos = 1, 9 do
|
||||
local itemName = recipe.grid[gridPos]
|
||||
if itemName then
|
||||
ingredientTotals[itemName] = (ingredientTotals[itemName] or 0) + batches
|
||||
end
|
||||
end
|
||||
|
||||
-- Check we have enough of each ingredient
|
||||
for itemName, needed in pairs(ingredientTotals) do
|
||||
local have = O.getItemTotal(itemName)
|
||||
if have < needed then
|
||||
log.error("CRAFT", "Not enough %s: have %d, need %d", itemName, have, needed)
|
||||
activity.crafting = false
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
return false, string.format("Need %d %s, have %d", needed, itemName, have)
|
||||
end
|
||||
end
|
||||
|
||||
for gridPos = 1, 9 do
|
||||
local itemName = recipe.grid[gridPos]
|
||||
if itemName then
|
||||
@@ -895,11 +1293,12 @@ function O.craftItem(recipeIdx)
|
||||
for slot, slotItem in pairs(chest.list()) do
|
||||
local key = source.chest .. ":" .. slot
|
||||
if slotItem.name == itemName and not reservedSlots[key] then
|
||||
local pullCount = math.min(batches, slotItem.count)
|
||||
slotMap[tostring(turtleSlot)] = {
|
||||
chestName = source.chest,
|
||||
chestSlot = slot,
|
||||
itemName = itemName,
|
||||
count = 1,
|
||||
count = pullCount,
|
||||
}
|
||||
reservedSlots[key] = true
|
||||
found = true
|
||||
@@ -939,7 +1338,6 @@ function O.craftItem(recipeIdx)
|
||||
log.info("CRAFT", "Waiting for turtle reply (timeout: %ds)...", cfg.CRAFT_TIMEOUT)
|
||||
local deadline = os.clock() + cfg.CRAFT_TIMEOUT
|
||||
local result = nil
|
||||
local bufferedMessages = {}
|
||||
|
||||
while os.clock() < deadline do
|
||||
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
|
||||
@@ -951,18 +1349,13 @@ function O.craftItem(recipeIdx)
|
||||
if channel == cfg.CRAFT_REPLY_CHANNEL and type(message) == "table" and message.type == "craft_result" then
|
||||
result = message
|
||||
break
|
||||
elseif channel == cfg.ORDER_CHANNEL then
|
||||
table.insert(bufferedMessages, {event, p1, p2, p3, p4, p5})
|
||||
end
|
||||
-- ORDER_CHANNEL messages are captured by the Network-capture task
|
||||
elseif event == "timer" and p1 == timerId then
|
||||
-- Timeout tick
|
||||
end
|
||||
end
|
||||
|
||||
for _, msg in ipairs(bufferedMessages) do
|
||||
os.queueEvent(table.unpack(msg))
|
||||
end
|
||||
|
||||
activity.crafting = false
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
@@ -1012,6 +1405,24 @@ function O.craftItem(recipeIdx)
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Recursive crafting (multi-step chains)
|
||||
-------------------------------------------------
|
||||
|
||||
function O.recursiveCraft(outputName, count)
|
||||
if not ctx.craftEngine then
|
||||
return false, "Craft engine not initialized"
|
||||
end
|
||||
activity.crafting = true
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
local ok, err = ctx.craftEngine.executeChain(outputName, count, ctx)
|
||||
activity.crafting = false
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
return ok, err
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Smelting recipe persistence
|
||||
-------------------------------------------------
|
||||
|
||||
@@ -41,6 +41,8 @@ S.activity = {
|
||||
defragging = false,
|
||||
composting = false,
|
||||
crafting = false,
|
||||
discarding = false,
|
||||
autocrafting = false,
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -53,6 +55,7 @@ S.configDirty = true
|
||||
|
||||
function S.bumpStateVersion()
|
||||
S.stateVersion = S.stateVersion + 1
|
||||
S.billboardNeedsRedraw = true
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -61,6 +64,7 @@ end
|
||||
|
||||
S.needsRedraw = true
|
||||
S.smelterNeedsRedraw = true
|
||||
S.billboardNeedsRedraw = true
|
||||
S.statusMessage = ""
|
||||
S.statusColor = colors.white
|
||||
S.statusTimer = 0
|
||||
|
||||
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
|
||||
@@ -3,6 +3,13 @@
|
||||
|
||||
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",
|
||||
@@ -65,8 +72,103 @@ 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("")
|
||||
print("Starting inventoryClient + dropperController...")
|
||||
if dropperEnabled then
|
||||
print("Starting inventoryClient + dropperController...")
|
||||
else
|
||||
print("Starting inventoryClient (no dropper)...")
|
||||
end
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this computer on remote command
|
||||
@@ -90,8 +192,12 @@ local function rebootListener()
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(
|
||||
local tasks = {
|
||||
function() shell.run("inventoryClient.lua") end,
|
||||
function() shell.run("dropperController.lua") end,
|
||||
rebootListener
|
||||
)
|
||||
rebootListener,
|
||||
}
|
||||
if dropperEnabled then
|
||||
table.insert(tasks, function() shell.run("dropperController.lua") end)
|
||||
end
|
||||
|
||||
parallel.waitForAny(table.unpack(tasks))
|
||||
|
||||
@@ -12,11 +12,16 @@ local FILES = {
|
||||
["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",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
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
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build the React app
|
||||
FROM node:18-alpine AS build
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}:4444`;
|
||||
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || 'https://turtles.spatulaa.com';
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,6 @@ services:
|
||||
- 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:
|
||||
@@ -27,7 +21,7 @@ services:
|
||||
- inventory-network
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
|
||||
@@ -1,30 +1,50 @@
|
||||
# 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 . .
|
||||
|
||||
# Create data directory for SQLite with proper ownership
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /data
|
||||
VOLUME /data
|
||||
|
||||
# Run as non-root user for security
|
||||
USER node
|
||||
# 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"]
|
||||
|
||||
@@ -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(`
|
||||
|
||||
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);
|
||||
});
|
||||
18
web/server/package-lock.json
generated
18
web/server/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "inventory-manager-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@cc-platform/server": "file:../../../cc-platform-core/server",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
@@ -18,6 +19,23 @@
|
||||
"vitest": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"../../../cc-platform-core/server": {
|
||||
"name": "@cc-platform/server",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"express": "^4.21.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cc-platform/server": {
|
||||
"resolved": "../../../cc-platform-core/server",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"download-textures": "node download-textures.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cc-platform/server": "file:../../../cc-platform-core/server",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
|
||||
@@ -1,91 +1,152 @@
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
import {
|
||||
loadFullState, saveFullState, recordItemHistory,
|
||||
saveItems, saveFurnaces, saveAlerts, saveState, loadState,
|
||||
getHistory, getHistorySummary, closeDb, flushPendingSave,
|
||||
} from './db.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
createPlatformServer,
|
||||
createWebSocketManager,
|
||||
setupGracefulShutdown,
|
||||
createRateLimiter,
|
||||
createProxyEndpoint,
|
||||
} = require('@cc-platform/server');
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
// CORS intentionally omitted — nginx reverse-proxy makes all requests same-origin.
|
||||
// If you need direct server access during dev, add: app.use(require('cors')())
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
// ========== API Key Authentication ==========
|
||||
|
||||
const API_KEY = process.env.API_KEY || '';
|
||||
|
||||
// Validate bearer token from Authorization header or ?key= query param
|
||||
function extractApiKey(req) {
|
||||
const auth = req.headers.authorization || '';
|
||||
if (auth.startsWith('Bearer ')) return auth.slice(7);
|
||||
return req.query.key || '';
|
||||
}
|
||||
|
||||
// Middleware: require API key on mutating endpoints
|
||||
function requireAuth(req, res, next) {
|
||||
if (!API_KEY) return next(); // Auth disabled when no key configured
|
||||
const token = extractApiKey(req);
|
||||
if (token === API_KEY) return next();
|
||||
return res.status(401).json({ error: 'Unauthorized — invalid or missing API key' });
|
||||
}
|
||||
|
||||
// Apply auth to all POST/PUT/DELETE routes
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
|
||||
return next(); // Read-only endpoints stay open
|
||||
}
|
||||
return requireAuth(req, res, next);
|
||||
// ========== Platform Server Setup ==========
|
||||
// Provides Express app, HTTP server, auth middleware, and health endpoint.
|
||||
// CORS intentionally disabled — nginx reverse-proxy makes all requests same-origin.
|
||||
const { app, server, auth, start, port } = createPlatformServer({
|
||||
serviceName: 'inventory-manager',
|
||||
cors: false,
|
||||
rateLimit: false, // custom rate limiting below (excludes bridge endpoints)
|
||||
healthExtras: () => ({
|
||||
lastUpdate,
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
webClients: webClients.size,
|
||||
}),
|
||||
});
|
||||
|
||||
// ========== Rate Limiting (in-memory, no external dependencies) ==========
|
||||
const { requireAuth } = auth;
|
||||
|
||||
function createRateLimiter(windowMs, max) {
|
||||
const hits = new Map();
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - windowMs;
|
||||
for (const [key, entry] of hits) {
|
||||
if (entry.start < cutoff) hits.delete(key);
|
||||
}
|
||||
}, windowMs);
|
||||
return (req, res, next) => {
|
||||
const key = req.ip || req.socket.remoteAddress || '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();
|
||||
};
|
||||
}
|
||||
// API key reference needed for WebSocket upgrade authentication
|
||||
const API_KEY = process.env.API_KEY || '';
|
||||
|
||||
// 30 mutating requests per minute per IP (excludes bridge state updates)
|
||||
const commandLimiter = createRateLimiter(60_000, 30);
|
||||
// Custom rate limiting: 30 mutating requests/min per IP, excluding bridge endpoints
|
||||
const commandLimiter = createRateLimiter({ windowMs: 60000, maxRequests: 30 });
|
||||
app.use((req, res, next) => {
|
||||
if (req.method !== 'POST' || req.path.startsWith('/api/bridge/')) return next();
|
||||
if (req.path.startsWith('/api/bridge/')) return next();
|
||||
return commandLimiter(req, res, next);
|
||||
});
|
||||
|
||||
// ========== WebSocket Manager (platform-managed) ==========
|
||||
// Replaces hand-rolled WebSocketServer with createWebSocketManager() from
|
||||
// @cc-platform/server. Handles bridge/client URL routing, API key auth on
|
||||
// upgrade, ping/pong keepalive with stale-connection cleanup.
|
||||
//
|
||||
// Bridge path: /ws/bridge — receives state/results, pushes commands real-time
|
||||
// Client path: /ws — receives initial_state on connect, sends commands
|
||||
//
|
||||
// HTTP polling fallback preserved via /api/bridge/* endpoints until WS
|
||||
// adoption is fully verified.
|
||||
const wsManager = createWebSocketManager(server, {
|
||||
apiKey: API_KEY,
|
||||
|
||||
// ---- Bridge lifecycle ----
|
||||
onBridgeConnect: (ws) => {
|
||||
console.log('\u{1F309} CC:Tweaked bridge connected via WebSocket');
|
||||
wsManager.broadcastToClients({ type: 'state_update', bridgeConnected: true });
|
||||
|
||||
// Flush any pending commands queued while no WS bridge was connected.
|
||||
// Replaces the implicit sync that HTTP polling previously provided.
|
||||
const pending = getPendingCommands();
|
||||
if (pending.length > 0) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'command_batch', commands: pending }));
|
||||
console.log(`[Bridge] Flushed ${pending.length} pending command(s) via WS`);
|
||||
} catch (e) {
|
||||
console.error('[Bridge] Failed to flush pending commands:', e.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onBridgeMessage: (ws, data) => {
|
||||
if (data.type === 'state') {
|
||||
// Full state update from bridge (same path as POST /api/bridge/state)
|
||||
updateStateFromBridge(data);
|
||||
} else if (data.type === 'command_result') {
|
||||
// Command result from bridge — forward to all web clients
|
||||
wsManager.broadcastToClients({
|
||||
type: 'command_result',
|
||||
commandId: data.commandId,
|
||||
action: data.action,
|
||||
success: data.success,
|
||||
message: data.message,
|
||||
error: data.error,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onBridgeDisconnect: () => {
|
||||
console.log('\u{1F309} CC:Tweaked bridge disconnected');
|
||||
wsManager.broadcastToClients({
|
||||
type: 'state_update',
|
||||
bridgeConnected: wsManager.bridgeClients.size > 0,
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Web client lifecycle ----
|
||||
onClientConnect: (ws) => {
|
||||
console.log('\u{1F310} New web client connected');
|
||||
// Send full current state to newly connected dashboard
|
||||
ws.send(JSON.stringify({
|
||||
type: 'initial_state',
|
||||
inventory: inventoryState,
|
||||
activity: activityState,
|
||||
alerts: alertsState,
|
||||
smeltingPaused,
|
||||
disabledRecipes,
|
||||
smeltable: smeltableRecipes,
|
||||
craftable: craftableRecipes,
|
||||
craftTurtleOk,
|
||||
lastUpdate,
|
||||
bridgeConnected: wsManager.bridgeClients.size > 0,
|
||||
dropperNicknames,
|
||||
}));
|
||||
},
|
||||
|
||||
onClientMessage: (ws, data) => {
|
||||
if (data.type === 'command') {
|
||||
// Idempotency check for WS commands
|
||||
const cached = checkIdempotent(data.commandId);
|
||||
if (cached) {
|
||||
ws.send(JSON.stringify({ type: 'command_result', commandId: data.commandId, ...cached }));
|
||||
return;
|
||||
}
|
||||
// Forward command to bridge (WS push or HTTP poll queue)
|
||||
pushCommandToBridge(data);
|
||||
if (data.commandId) {
|
||||
recordCommand(data.commandId, { success: true, commandId: data.commandId });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onClientDisconnect: () => {
|
||||
console.log('\u{1F44B} Web client disconnected');
|
||||
},
|
||||
});
|
||||
|
||||
// Aliases — backward compatibility for code referencing these Sets directly
|
||||
const { bridgeClients, webClients } = wsManager;
|
||||
|
||||
// ========== State ==========
|
||||
const webClients = new Set();
|
||||
const bridgeClients = new Set();
|
||||
|
||||
// Load persisted state from SQLite on startup
|
||||
console.log('💾 Loading persisted state from database...');
|
||||
@@ -138,31 +199,25 @@ function recordCommand(commandId, result) {
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
// Broadcasts data to all connected web dashboard clients.
|
||||
// Delegates to platform WS manager (handles JSON serialization, error handling).
|
||||
function broadcastToClients(data) {
|
||||
const message = JSON.stringify(data);
|
||||
webClients.forEach((client) => {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(message);
|
||||
} catch (err) {
|
||||
console.error('❌ WS send error (client):', err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
wsManager.broadcastToClients(data);
|
||||
}
|
||||
|
||||
// Returns a filtered copy of pending commands (clears expired entries).
|
||||
// Used by both the HTTP polling endpoint and WS initial sync.
|
||||
function getPendingCommands() {
|
||||
const now = Date.now();
|
||||
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
|
||||
return [...pendingCommands];
|
||||
}
|
||||
|
||||
// Pushes a command to the CC:Tweaked bridge.
|
||||
// Primary: real-time push via WebSocket (wsManager.sendToBridge).
|
||||
// Fallback: queues for HTTP polling if no WS bridge is connected.
|
||||
function pushCommandToBridge(command) {
|
||||
let sent = false;
|
||||
for (const bridge of bridgeClients) {
|
||||
if (bridge.readyState === 1) {
|
||||
try {
|
||||
bridge.send(JSON.stringify(command));
|
||||
sent = true;
|
||||
} catch (err) {
|
||||
console.error('❌ WS send error (bridge):', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sent = wsManager.sendToBridge(command);
|
||||
if (!sent) {
|
||||
// Fallback: queue for HTTP polling with monotonic ID
|
||||
const id = nextCommandId++;
|
||||
@@ -171,27 +226,266 @@ function pushCommandToBridge(command) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== HTTP Server ==========
|
||||
const server = createServer(app);
|
||||
|
||||
// ========== Texture Proxy / Cache ==========
|
||||
const TEXTURE_CACHE_DIR = process.env.TEXTURE_CACHE_DIR || path.join('/data', 'texture-cache');
|
||||
const NEGATIVE_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days for 404s
|
||||
|
||||
// Upstream CDN mapping
|
||||
// Upstream CDN mapping (Prominence Hasturian Era II modpack coverage)
|
||||
const TEXTURE_UPSTREAMS = {
|
||||
minecraft: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4/assets/minecraft/textures',
|
||||
computercraft: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x/projects/common/src/main/resources/assets/computercraft/textures',
|
||||
create: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev/src/main/resources/assets/create/textures',
|
||||
mythicmetals: 'https://raw.githubusercontent.com/Noaaan/MythicMetals/1.20/src/main/resources/assets/mythicmetals/textures',
|
||||
farmersdelight: 'https://raw.githubusercontent.com/vectorwing/FarmersDelight/1.20/src/main/resources/assets/farmersdelight/textures',
|
||||
ad_astra: 'https://raw.githubusercontent.com/terrarium-earth/Ad-Astra/1.20.1/common/src/main/resources/assets/ad_astra/textures',
|
||||
betterend: 'https://raw.githubusercontent.com/quiqueck/BetterEnd/1.20/src/main/resources/assets/betterend/textures',
|
||||
ae2: 'https://raw.githubusercontent.com/AppliedEnergistics/Applied-Energistics-2/fabric/1.20.1/src/main/resources/assets/ae2/textures',
|
||||
twilightforest: 'https://raw.githubusercontent.com/TeamTwilight/twilightforest/1.20.1/src/main/resources/assets/twilightforest/textures',
|
||||
};
|
||||
|
||||
// Ensure cache directory exists
|
||||
fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true });
|
||||
|
||||
// ── Texture index: maps base_name → relative path for smart resolution ────
|
||||
// Built on startup by scanning the texture cache. Key = short name (no ext),
|
||||
// Value = array of relative paths (e.g. ["item/diamond.png", "block/diamond_ore.png"])
|
||||
const textureIndex = {}; // { namespace: { baseName: [relPath, ...] } }
|
||||
|
||||
function buildTextureIndex() {
|
||||
for (const ns of Object.keys(TEXTURE_UPSTREAMS)) {
|
||||
textureIndex[ns] = {};
|
||||
const nsDir = path.join(TEXTURE_CACHE_DIR, ns);
|
||||
if (!fs.existsSync(nsDir)) continue;
|
||||
(function walk(dir, rel) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) walk(path.join(dir, entry.name), childRel);
|
||||
else if (entry.name.endsWith('.png') && !entry.name.endsWith('.miss')) {
|
||||
const baseName = entry.name.slice(0, -4); // strip .png
|
||||
if (!textureIndex[ns][baseName]) textureIndex[ns][baseName] = [];
|
||||
textureIndex[ns][baseName].push(childRel);
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore unreadable dirs */ }
|
||||
})(nsDir, '');
|
||||
}
|
||||
const total = Object.values(textureIndex).reduce(
|
||||
(sum, idx) => sum + Object.keys(idx).length, 0);
|
||||
console.log(`[Texture Index] Indexed ${total} unique texture names`);
|
||||
}
|
||||
|
||||
buildTextureIndex();
|
||||
|
||||
// ── Smart texture resolution ──────────────────────────────────────────────
|
||||
// Given a Minecraft item/block registry name, find the best matching texture.
|
||||
// Returns the relative cache path or null.
|
||||
|
||||
// Patterns for items whose textures use numbered animation frames
|
||||
const ANIMATED_ITEMS = new Set([
|
||||
'compass', 'recovery_compass', 'clock',
|
||||
'crossbow', // crossbow_standby, crossbow_pulling_0, etc.
|
||||
'light', // light_0 .. light_15
|
||||
]);
|
||||
|
||||
// Maps a registry short name to the actual texture base name
|
||||
const SERVER_ALIASES = {
|
||||
// Registry name differs from texture filename
|
||||
magma_block: 'magma',
|
||||
grass: 'short_grass',
|
||||
scute: 'turtle_scute',
|
||||
enchanted_golden_apple: 'golden_apple',
|
||||
};
|
||||
|
||||
// Preferred animation frame for animated items
|
||||
const ANIMATED_FRAME = {
|
||||
compass: 'compass_16',
|
||||
recovery_compass: 'recovery_compass_16',
|
||||
clock: 'clock_22',
|
||||
crossbow: 'crossbow_standby',
|
||||
light: 'light_15',
|
||||
};
|
||||
|
||||
// Dye color set for carpet/bed/banner resolution
|
||||
const DYE_COLORS = new Set([
|
||||
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime',
|
||||
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue',
|
||||
'brown', 'green', 'red', 'black',
|
||||
]);
|
||||
|
||||
const WOOD_TYPES = new Set([
|
||||
'oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak',
|
||||
'mangrove', 'cherry', 'bamboo', 'crimson', 'warped', 'pale_oak',
|
||||
]);
|
||||
|
||||
// Blocks whose texture uses a suffix: name_front, name_top, etc.
|
||||
const BLOCK_SUFFIXES = {
|
||||
furnace: '_front_on', blast_furnace: '_front_on', smoker: '_front_on',
|
||||
dispenser: '_front', dropper: '_front', observer: '_front',
|
||||
barrel: '_top', crafting_table: '_top', grass_block: '_top',
|
||||
mycelium: '_top', podzol: '_top', dirt_path: '_top',
|
||||
quartz_block: '_side',
|
||||
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',
|
||||
tnt: '_side', composter: '_side', enchanting_table: '_top',
|
||||
cartography_table: '_top', fletching_table: '_top', smithing_table: '_top',
|
||||
};
|
||||
|
||||
// Derivative block suffixes → resolve to parent block
|
||||
const DERIVATIVE_SUFFIXES = [
|
||||
'_stairs', '_slab', '_fence_gate', '_fence', '_wall',
|
||||
'_button', '_pressure_plate',
|
||||
];
|
||||
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',
|
||||
};
|
||||
// "smooth_" blocks use the parent's top/bottom face
|
||||
const SMOOTH_ALIASES = {
|
||||
smooth_sandstone: 'sandstone_top',
|
||||
smooth_red_sandstone: 'red_sandstone_top',
|
||||
smooth_quartz: 'quartz_block_bottom',
|
||||
};
|
||||
|
||||
function resolveTexturePath(ns, itemName) {
|
||||
const idx = textureIndex[ns];
|
||||
if (!idx) return null;
|
||||
|
||||
// Helper: check if a baseName exists and return preferred path
|
||||
function find(baseName, preferFolder) {
|
||||
const paths = idx[baseName];
|
||||
if (!paths || paths.length === 0) return null;
|
||||
if (preferFolder) {
|
||||
const match = paths.find(p => p.startsWith(preferFolder + '/'));
|
||||
if (match) return match;
|
||||
}
|
||||
return paths[0];
|
||||
}
|
||||
|
||||
// Helper: check if a baseName with a suffix exists in block/
|
||||
function findBlock(baseName) {
|
||||
const suffix = BLOCK_SUFFIXES[baseName];
|
||||
if (suffix) {
|
||||
const hit = find(baseName + suffix, 'block') || find(baseName, 'block');
|
||||
if (hit) return hit;
|
||||
}
|
||||
return find(baseName, 'block');
|
||||
}
|
||||
|
||||
// 1. Direct lookup — try item/ then block/
|
||||
let hit = find(itemName, 'item') || findBlock(itemName);
|
||||
if (hit) return hit;
|
||||
|
||||
// 2. Server-side alias
|
||||
if (SERVER_ALIASES[itemName]) {
|
||||
const alias = SERVER_ALIASES[itemName];
|
||||
hit = find(alias, 'item') || findBlock(alias);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// 3. Smooth blocks
|
||||
if (SMOOTH_ALIASES[itemName]) {
|
||||
hit = find(SMOOTH_ALIASES[itemName], 'block');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// 4. Animated items — pick a representative frame
|
||||
if (ANIMATED_FRAME[itemName]) {
|
||||
hit = find(ANIMATED_FRAME[itemName], 'item');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// 5. Carpets → wool texture
|
||||
if (itemName.endsWith('_carpet')) {
|
||||
const color = itemName.slice(0, -'_carpet'.length);
|
||||
if (DYE_COLORS.has(color)) {
|
||||
hit = find(color + '_wool', 'block');
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Wood → log texture
|
||||
if (itemName.endsWith('_wood')) {
|
||||
const base = itemName.slice(0, -'_wood'.length);
|
||||
const stripped = base.startsWith('stripped_') ? base.slice('stripped_'.length) : null;
|
||||
const woodType = stripped || base;
|
||||
if (WOOD_TYPES.has(woodType)) {
|
||||
const logName = stripped ? `stripped_${woodType}_log` : `${woodType}_log`;
|
||||
hit = find(logName, 'block');
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Derivative blocks (stairs, slabs, fences, walls…) → parent block
|
||||
for (const suffix of DERIVATIVE_SUFFIXES) {
|
||||
if (!itemName.endsWith(suffix)) continue;
|
||||
const base = itemName.slice(0, -suffix.length);
|
||||
// Try direct parent
|
||||
hit = findBlock(base);
|
||||
if (hit) return hit;
|
||||
// Wood → planks
|
||||
if (WOOD_TYPES.has(base)) {
|
||||
hit = findBlock(base + '_planks');
|
||||
if (hit) return hit;
|
||||
}
|
||||
// Stone aliases (brick → bricks, etc.)
|
||||
if (STONE_ALIASES[base]) {
|
||||
hit = findBlock(STONE_ALIASES[base]);
|
||||
if (hit) return hit;
|
||||
}
|
||||
break; // only one derivative suffix can match
|
||||
}
|
||||
|
||||
// 8. Prefix match — for items like "compass" try "compass_*"
|
||||
const prefixMatches = Object.keys(idx).filter(k => k.startsWith(itemName + '_'));
|
||||
if (prefixMatches.length > 0) {
|
||||
// Sort for deterministic result, prefer item/ folder
|
||||
prefixMatches.sort();
|
||||
for (const m of prefixMatches) {
|
||||
hit = find(m, 'item');
|
||||
if (hit) return hit;
|
||||
}
|
||||
hit = find(prefixMatches[0]);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Smart resolution endpoint ─────────────────────────────────────────────
|
||||
// GET /api/texture/resolve/:namespace/:itemName
|
||||
// Returns the texture PNG for a Minecraft registry item name, using smart
|
||||
// matching (aliases, animation frames, carpet→wool, etc.)
|
||||
app.get('/api/texture/resolve/:namespace/:itemName', (req, res) => {
|
||||
const { namespace, itemName } = req.params;
|
||||
const name = itemName.replace(/\.png$/i, '');
|
||||
|
||||
const resolved = resolveTexturePath(namespace, name);
|
||||
if (!resolved) {
|
||||
return res.status(404).send('No texture found');
|
||||
}
|
||||
|
||||
const fullPath = path.join(TEXTURE_CACHE_DIR, namespace, resolved);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return res.status(404).send('Texture file missing');
|
||||
}
|
||||
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('Cache-Control', 'public, max-age=604800');
|
||||
res.set('X-Texture-Resolved', resolved);
|
||||
return res.sendFile(fullPath);
|
||||
});
|
||||
|
||||
// ── Legacy texture proxy (fallback for cache misses) ──────────────────────
|
||||
app.get('/api/texture/:namespace/*', async (req, res) => {
|
||||
const { namespace } = req.params;
|
||||
const texturePath = req.params[0]; // e.g. "item/diamond.png"
|
||||
|
||||
const texturePath = req.params[0];
|
||||
const upstream = TEXTURE_UPSTREAMS[namespace];
|
||||
if (!upstream) {
|
||||
return res.status(404).send('Unknown namespace');
|
||||
@@ -282,16 +576,7 @@ app.delete('/api/texture-cache/negative', (req, res) => {
|
||||
res.json({ cleared });
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
lastUpdate,
|
||||
uptime: process.uptime(),
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
webClients: webClients.size,
|
||||
});
|
||||
});
|
||||
// Health endpoint provided by platform (createPlatformServer) with custom extras
|
||||
|
||||
// Get current inventory state
|
||||
app.get('/api/inventory', (req, res) => {
|
||||
@@ -588,6 +873,122 @@ app.post('/api/craft', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Recursive craft (multi-step crafting chain)
|
||||
app.post('/api/recursive-craft', (req, res) => {
|
||||
try {
|
||||
const { itemName, count, commandId } = req.body;
|
||||
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
if (!itemName || typeof itemName !== 'string' || itemName.length > 200) {
|
||||
return res.status(400).json({ error: 'Missing or invalid itemName' });
|
||||
}
|
||||
const parsedCount = parseInt(count);
|
||||
if (!Number.isFinite(parsedCount) || parsedCount < 1 || parsedCount > 100000) {
|
||||
return res.status(400).json({ error: 'Invalid count (1–100000)' });
|
||||
}
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'recursive_craft', commandId, itemName, count: parsedCount });
|
||||
const result = { success: true, commandId, message: `Recursive craft sent: ${itemName} x${count}` };
|
||||
recordCommand(commandId, result);
|
||||
console.log(`🔨 Recursive craft: ${itemName} x${count}`);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Learn a crafting recipe
|
||||
app.post('/api/recipes/learn-crafting', (req, res) => {
|
||||
try {
|
||||
const { output, count, grid, commandId } = req.body;
|
||||
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
if (!output || typeof output !== 'string' || output.length > 200) {
|
||||
return res.status(400).json({ error: 'Missing or invalid output' });
|
||||
}
|
||||
if (!grid || !Array.isArray(grid) || grid.length !== 9) {
|
||||
return res.status(400).json({ error: 'grid must be an array of 9 items' });
|
||||
}
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'learn_crafting_recipe', commandId, output, count: count || 1, grid });
|
||||
const result = { success: true, commandId, message: `Learned crafting recipe: ${output}` };
|
||||
recordCommand(commandId, result);
|
||||
console.log(`📖 Learn crafting recipe: ${output}`);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Learn a smelting recipe
|
||||
app.post('/api/recipes/learn-smelting', (req, res) => {
|
||||
try {
|
||||
const { input, result: recipeResult, furnaces, commandId } = req.body;
|
||||
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
if (!input || typeof input !== 'string' || input.length > 200) {
|
||||
return res.status(400).json({ error: 'Missing or invalid input' });
|
||||
}
|
||||
if (!recipeResult || typeof recipeResult !== 'string' || recipeResult.length > 200) {
|
||||
return res.status(400).json({ error: 'Missing or invalid result' });
|
||||
}
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'learn_smelting_recipe', commandId, input, result: recipeResult, furnaces });
|
||||
const apiResult = { success: true, commandId, message: `Learned smelting recipe: ${input} → ${recipeResult}` };
|
||||
recordCommand(commandId, apiResult);
|
||||
console.log(`📖 Learn smelting recipe: ${input} → ${recipeResult}`);
|
||||
res.json(apiResult);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Forget a recipe
|
||||
app.post('/api/recipes/forget', (req, res) => {
|
||||
try {
|
||||
const { recipe, commandId } = req.body;
|
||||
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
if (!recipe || typeof recipe !== 'string' || recipe.length > 200) {
|
||||
return res.status(400).json({ error: 'Missing or invalid recipe name' });
|
||||
}
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'forget_recipe', commandId, recipe });
|
||||
const result = { success: true, commandId, message: `Forget recipe: ${recipe}` };
|
||||
recordCommand(commandId, result);
|
||||
console.log(`🗑️ Forget recipe: ${recipe}`);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Sync disabled recipes state
|
||||
app.post('/api/recipes/sync', (req, res) => {
|
||||
try {
|
||||
const { disabledRecipes, smeltingPaused, commandId } = req.body;
|
||||
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'sync_disabled_recipes', commandId, disabledRecipes, smeltingPaused });
|
||||
const result = { success: true, commandId, message: 'Synced recipe state' };
|
||||
recordCommand(commandId, result);
|
||||
console.log('🔄 Sync disabled recipes');
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Bridge Endpoints (for HTTP polling fallback) ==========
|
||||
|
||||
// Bridge sends inventory state
|
||||
@@ -605,11 +1006,7 @@ app.post('/api/bridge/state', (req, res) => {
|
||||
// Bridge polls for pending commands (auth required — contains operational data)
|
||||
app.get('/api/bridge/commands', requireAuth, (req, res) => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
// Clear old commands (>30s)
|
||||
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
|
||||
|
||||
const commands = [...pendingCommands];
|
||||
const commands = getPendingCommands();
|
||||
res.json({ commands });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -785,187 +1182,19 @@ function updateStateFromBridge(data) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== WebSocket Server ==========
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 /* 1 MB */ });
|
||||
// ========== Server Startup Info ==========
|
||||
// WebSocket management is handled by createWebSocketManager() (initialized above).
|
||||
// Bridge: /ws/bridge | Client: /ws | Keepalive: 25s ping/pong (platform default)
|
||||
|
||||
console.log(`🚀 Inventory Manager Web Server starting...`);
|
||||
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
|
||||
console.log(`🔌 WebSocket Server: ws://localhost:${PORT}/ws`);
|
||||
console.log(`📡 HTTP Server: http://localhost:${port}`);
|
||||
console.log(`🔌 WebSocket Server: ws://localhost:${port}/ws`);
|
||||
if (API_KEY) {
|
||||
console.log('🔒 API key authentication enabled');
|
||||
} else {
|
||||
console.log('⚠️ No API_KEY set \u2014 authentication disabled (open access)');
|
||||
}
|
||||
|
||||
// Authenticate WebSocket upgrades
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
if (API_KEY) {
|
||||
// Extract key from query string: /ws?key=... or /ws/bridge?key=...
|
||||
const urlObj = new URL(req.url, `http://${req.headers.host}`);
|
||||
const token = urlObj.searchParams.get('key') || '';
|
||||
if (token !== API_KEY) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = req.url || '';
|
||||
|
||||
// ---- Bridge WebSocket connection ----
|
||||
if (url.startsWith('/ws/bridge')) {
|
||||
console.log('🌉 CC:Tweaked bridge connected via WebSocket');
|
||||
bridgeClients.add(ws);
|
||||
ws.isAlive = true;
|
||||
|
||||
// Notify web clients that the bridge is now connected
|
||||
broadcastToClients({
|
||||
type: 'state_update',
|
||||
bridgeConnected: true,
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
if (data.type === 'ping') {
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'state') {
|
||||
// Full state update from bridge
|
||||
updateStateFromBridge(data);
|
||||
} else if (data.type === 'command_result') {
|
||||
// Command result from bridge — include commandId
|
||||
broadcastToClients({
|
||||
type: 'command_result',
|
||||
commandId: data.commandId,
|
||||
action: data.action,
|
||||
success: data.success,
|
||||
message: data.message,
|
||||
error: data.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Bridge WS message error:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🌉 CC:Tweaked bridge disconnected');
|
||||
bridgeClients.delete(ws);
|
||||
|
||||
// Notify web clients that the bridge may be disconnected
|
||||
broadcastToClients({
|
||||
type: 'state_update',
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ Bridge WS error:', error);
|
||||
bridgeClients.delete(ws);
|
||||
|
||||
broadcastToClients({
|
||||
type: 'state_update',
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Web client WebSocket connection ----
|
||||
console.log('🌐 New web client connected');
|
||||
webClients.add(ws);
|
||||
ws.isAlive = true;
|
||||
|
||||
// Send current state to new client
|
||||
ws.send(JSON.stringify({
|
||||
type: 'initial_state',
|
||||
inventory: inventoryState,
|
||||
activity: activityState,
|
||||
alerts: alertsState,
|
||||
smeltingPaused,
|
||||
disabledRecipes,
|
||||
smeltable: smeltableRecipes,
|
||||
craftable: craftableRecipes,
|
||||
craftTurtleOk,
|
||||
lastUpdate,
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
dropperNicknames,
|
||||
}));
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
if (data.type === 'command') {
|
||||
// Idempotency check for WS commands
|
||||
const cached = checkIdempotent(data.commandId);
|
||||
if (cached) {
|
||||
ws.send(JSON.stringify({ type: 'command_result', commandId: data.commandId, ...cached }));
|
||||
return;
|
||||
}
|
||||
// Forward command to bridge
|
||||
pushCommandToBridge(data);
|
||||
if (data.commandId) {
|
||||
recordCommand(data.commandId, { success: true, commandId: data.commandId });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error processing web client message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('👋 Web client disconnected');
|
||||
webClients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== WebSocket Keep-Alive ==========
|
||||
// Ping all web clients and bridge connections every 25s to keep connections alive
|
||||
const WS_PING_INTERVAL = setInterval(() => {
|
||||
webClients.forEach((ws) => {
|
||||
if (!ws.isAlive) {
|
||||
webClients.delete(ws);
|
||||
return ws.terminate();
|
||||
}
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
bridgeClients.forEach((ws) => {
|
||||
if (!ws.isAlive) {
|
||||
console.log('🌉 Bridge connection stale — terminating');
|
||||
bridgeClients.delete(ws);
|
||||
broadcastToClients({ type: 'state_update', bridgeConnected: bridgeClients.size > 0 });
|
||||
return ws.terminate();
|
||||
}
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, 25000);
|
||||
|
||||
wss.on('close', () => {
|
||||
clearInterval(WS_PING_INTERVAL);
|
||||
});
|
||||
|
||||
// ========== Cross-Project Integration API ==========
|
||||
// These endpoints allow the RemoteTurtle system to query inventory state
|
||||
|
||||
@@ -1025,53 +1254,24 @@ app.get('/api/integration/low-stock', (req, res) => {
|
||||
});
|
||||
|
||||
// Proxy to turtle server for combined dashboard info
|
||||
app.get('/api/integration/turtle-status', async (req, res) => {
|
||||
if (!TURTLE_SERVER_URL) {
|
||||
return res.json({ configured: false, message: 'TURTLE_SERVER_URL not configured' });
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${TURTLE_SERVER_URL}/api/turtles`);
|
||||
const data = await resp.json();
|
||||
res.json({ configured: true, ...data });
|
||||
} catch (err) {
|
||||
res.status(502).json({ configured: true, error: `Cannot reach turtle server: ${err.message}` });
|
||||
}
|
||||
});
|
||||
createProxyEndpoint(app, '/api/integration/turtle-status', 'TURTLE_SERVER_URL', '/api/turtles');
|
||||
|
||||
// ========== Start Server ==========
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`✅ Inventory Manager Web Server ready on ${HOST}:${PORT}`);
|
||||
console.log(`\nBridge HTTP endpoint: http://localhost:${PORT}/api/bridge/state`);
|
||||
console.log(`Bridge WebSocket: ws://localhost:${PORT}/ws/bridge`);
|
||||
console.log(`Web client WebSocket: ws://localhost:${PORT}/ws`);
|
||||
setupGracefulShutdown({
|
||||
serviceName: 'inventory-manager',
|
||||
cleanup: [
|
||||
() => wsManager.close(),
|
||||
() => server.close(),
|
||||
() => { closeDb(); console.log('💾 Database closed'); },
|
||||
],
|
||||
});
|
||||
|
||||
start(() => {
|
||||
console.log(`\nBridge HTTP endpoint: http://localhost:${port}/api/bridge/state`);
|
||||
console.log(`Bridge WebSocket: ws://localhost:${port}/ws/bridge`);
|
||||
console.log(`Web client WebSocket: ws://localhost:${port}/ws`);
|
||||
if (TURTLE_SERVER_URL) {
|
||||
console.log(`🐢 Turtle server integration: ${TURTLE_SERVER_URL}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown() {
|
||||
console.log('\n🛑 Shutting down server...');
|
||||
try {
|
||||
wss.close();
|
||||
server.close();
|
||||
closeDb();
|
||||
console.log('💾 Database closed');
|
||||
} catch (err) {
|
||||
console.error('❌ Error during shutdown:', err.message);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// Catch unhandled errors to prevent silent crashes
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('❌ Unhandled rejection:', reason);
|
||||
});
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('❌ Uncaught exception:', err);
|
||||
shutdown();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user