Compare commits
121 Commits
465efbeb0e
...
stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9835556e6f | ||
|
|
033da0933c | ||
|
|
1336590241 | ||
|
|
78e7c92893 | ||
|
|
e59b6c1832 | ||
|
|
e343ab8b4e | ||
|
|
b9b69a4966 | ||
|
|
bdc9b3f291 | ||
|
|
8b8279878a | ||
|
|
f095e18e95 | ||
|
|
904d4ec7f7 | ||
|
|
baaa724595 | ||
|
|
8ff4203152 | ||
|
|
72c978ee75 | ||
|
|
d25f25ee52 | ||
|
|
2adfa7f7a3 | ||
|
|
96afe8dcb7 | ||
|
|
1b0b1c570d | ||
|
|
73e157cc13 | ||
|
|
18bdac03b1 | ||
|
|
173a0a9f95 | ||
|
|
01eef4eead | ||
|
|
a499446366 | ||
|
|
a6bf84d6b8 | ||
|
|
aaaf25350c | ||
|
|
432a9feff9 | ||
|
|
ee76a61240 | ||
|
|
85228b134b | ||
|
|
d97167b21c | ||
|
|
69e24e7d79 | ||
|
|
02248ccc38 | ||
|
|
e67fded321 | ||
|
|
621902b3e8 | ||
|
|
380289d484 | ||
|
|
d67a2fde88 | ||
|
|
215652d47c | ||
|
|
b49574f39b | ||
|
|
d4a9441b54 | ||
|
|
5518161adf | ||
|
|
4c329bbfb3 | ||
|
|
381951bd91 | ||
|
|
fdc3c36cd7 | ||
|
|
64b3e4b069 | ||
|
|
c4acc2159e | ||
|
|
a0740b81f5 | ||
|
|
82d74a01b5 | ||
|
|
bb139b4afd | ||
|
|
40e6eab42d | ||
|
|
d9b7bd32b7 | ||
|
|
76772140aa | ||
|
|
314fec5c47 | ||
|
|
e261113134 | ||
|
|
7693265b62 | ||
|
|
2d8d2b360f | ||
|
|
37e9b89057 | ||
|
|
467be0493c | ||
|
|
6f3a650e69 | ||
|
|
38727c5eeb | ||
|
|
d4a3b1cce9 | ||
|
|
356e84b262 | ||
|
|
57fd3e88bc | ||
|
|
4d8229ab46 | ||
|
|
d43662b2dc | ||
|
|
3095fc7387 | ||
|
|
63e28d9126 | ||
|
|
761127650f | ||
|
|
67273d6a80 | ||
|
|
15362edc87 | ||
|
|
40df13e756 | ||
|
|
f2634328ec | ||
|
|
53863657d7 | ||
|
|
ba0cbfbbb6 | ||
|
|
a8513b339c | ||
|
|
21f12501ca | ||
|
|
6760ca5e5d | ||
|
|
82ab5cd9f5 | ||
|
|
5a99543fd6 | ||
|
|
79fdee1e29 | ||
|
|
acfaad67f7 | ||
|
|
e664102807 | ||
|
|
026d0c8d6b | ||
|
|
33845c70d7 | ||
|
|
fa18c72cf7 | ||
|
|
c5aa4b5332 | ||
|
|
6adbd88b22 | ||
|
|
c65fd67b8e | ||
|
|
62f67b7893 | ||
|
|
58ff05c5e6 | ||
|
|
899606b5c0 | ||
|
|
67a1148e85 | ||
|
|
00206ef794 | ||
|
|
e1186ab532 | ||
|
|
01ca8ca127 | ||
|
|
2ac11350e6 | ||
|
|
891fb2a10c | ||
|
|
8da6d8bc0e | ||
|
|
8c98546fbf | ||
|
|
9a7d4b3175 | ||
|
|
479e1918f5 | ||
|
|
9d5fceee5c | ||
|
|
efc3a88052 | ||
|
|
deeb70ba0d | ||
|
|
187b0276d1 | ||
|
|
48d34a5eeb | ||
|
|
78674714b1 | ||
|
|
1a6d32c16b | ||
|
|
7a277a86eb | ||
|
|
8c02dc15c0 | ||
|
|
4045def0a7 | ||
|
|
460cf34252 | ||
|
|
ea75c1eabc | ||
|
|
0fb57d7c94 | ||
|
|
edcc19e5a4 | ||
|
|
8ad05cdeb9 | ||
|
|
e7e605ea00 | ||
|
|
6fa7ae5e8f | ||
|
|
b3c3faa06d | ||
|
|
5162a71be4 | ||
|
|
0d3de9dc48 | ||
|
|
2a68ffcb90 | ||
|
|
10dc27a2c4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
bin/
|
||||
.env
|
||||
*.bak
|
||||
|
||||
115
.package
Normal file
115
.package
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
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.",
|
||||
repository = "gitea://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/stable/",
|
||||
exclude = {
|
||||
"^web/", "^__tests__/", "^startup/",
|
||||
"%.md$", "%.json$", "^%.git", "^LICENSE$", "^node_modules/",
|
||||
"%.bak$", "^listDevicesByType%.lua$",
|
||||
},
|
||||
install = [[
|
||||
local cfgDir = "usr/config/inventory-manager"
|
||||
if not fs.isDir(cfgDir) then fs.makeDir(cfgDir) end
|
||||
local function ask(prompt, default)
|
||||
if default and #default > 0 then
|
||||
write(prompt .. " [" .. default .. "]: ")
|
||||
else
|
||||
write(prompt .. ": ")
|
||||
end
|
||||
local val = read()
|
||||
if not val or #val == 0 then return default end
|
||||
return val
|
||||
end
|
||||
|
||||
print("")
|
||||
print("-- Inventory Manager Setup --")
|
||||
print("")
|
||||
print("Which role(s) will this computer run?")
|
||||
print(" 1) Inventory Manager (main controller)")
|
||||
print(" 2) Inventory Client (display-only)")
|
||||
print(" 3) Web Bridge (HTTP forwarder)")
|
||||
print(" 4) Mining Turtle (cobble miner)")
|
||||
print(" 5) Skip setup")
|
||||
print("")
|
||||
write("Choice (1/2/3/4/5): ")
|
||||
local choice = read()
|
||||
|
||||
if choice == "1" then
|
||||
print("")
|
||||
print("-- Manager Configuration --")
|
||||
local monitorSide = ask("Monitor side", "left")
|
||||
local smelterMonitorSide = ask("Smelter monitor side", "top")
|
||||
local dropperName = ask("Dropper peripheral name", "minecraft:dropper_9")
|
||||
local barrelName = ask("Barrel peripheral name", "minecraft:barrel_0")
|
||||
local serverUrl = ask("Web server URL (blank to skip)", "")
|
||||
|
||||
local cfg = {
|
||||
monitorSide = monitorSide,
|
||||
smelterMonitorSide = smelterMonitorSide,
|
||||
dropperName = dropperName,
|
||||
barrelName = barrelName,
|
||||
}
|
||||
local f = fs.open(fs.combine(cfgDir, ".manager_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved manager config.")
|
||||
|
||||
if serverUrl and #serverUrl > 0 then
|
||||
local bcfg = { serverUrl = serverUrl }
|
||||
local bf = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
|
||||
bf.write(textutils.serialiseJSON(bcfg))
|
||||
bf.close()
|
||||
print("Saved web bridge config.")
|
||||
end
|
||||
|
||||
elseif choice == "2" then
|
||||
print("")
|
||||
print("-- Client Configuration --")
|
||||
local monitorSide = ask("Monitor side", "left")
|
||||
local smelterMonitorSide = ask("Smelter monitor side", "top")
|
||||
local dropperName = ask("Dropper peripheral name (blank if none)", "")
|
||||
local barrelName = ask("Barrel peripheral name (blank if none)", "")
|
||||
|
||||
local cfg = {
|
||||
monitorSide = monitorSide,
|
||||
smelterMonitorSide = smelterMonitorSide,
|
||||
}
|
||||
if dropperName and #dropperName > 0 then cfg.dropperName = dropperName end
|
||||
if barrelName and #barrelName > 0 then cfg.barrelName = barrelName end
|
||||
|
||||
local f = fs.open(fs.combine(cfgDir, ".client_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved client config.")
|
||||
|
||||
elseif choice == "3" then
|
||||
print("")
|
||||
print("-- Web Bridge Configuration --")
|
||||
local serverUrl = ask("Web server URL", "http://localhost")
|
||||
|
||||
local cfg = { serverUrl = serverUrl }
|
||||
local f = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved web bridge config.")
|
||||
|
||||
elseif choice == "4" then
|
||||
print("")
|
||||
print("-- Mining Turtle Configuration --")
|
||||
local mineInterval = ask("Mine interval in seconds", "0.5")
|
||||
local dumpThreshold = ask("Dump when N slots full", "14")
|
||||
|
||||
local cfg = {
|
||||
mineInterval = tonumber(mineInterval) or 0.5,
|
||||
dumpThreshold = tonumber(dumpThreshold) or 14,
|
||||
}
|
||||
local f = fs.open(fs.combine(cfgDir, ".miner_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved miner config.")
|
||||
|
||||
else
|
||||
print("Skipped — edit config files manually later.")
|
||||
end
|
||||
]],
|
||||
}
|
||||
92
autorun/startup.lua
Normal file
92
autorun/startup.lua
Normal file
@@ -0,0 +1,92 @@
|
||||
-- Inventory Manager - Opus autorun script
|
||||
-- Detects configured role and launches the appropriate program at boot
|
||||
|
||||
local fs = _G.fs
|
||||
local kernel = _G.kernel
|
||||
local os = _G.os
|
||||
local peripheral = _G.peripheral
|
||||
local shell = _ENV.shell
|
||||
|
||||
local BASE = 'packages/inventory-manager'
|
||||
local CFG = 'usr/config/inventory-manager'
|
||||
|
||||
-------------------------------------------------
|
||||
-- Determine role from config files written during install
|
||||
-------------------------------------------------
|
||||
|
||||
local function cfgExists(name)
|
||||
return fs.exists(fs.combine(CFG, name))
|
||||
or fs.exists(fs.combine(BASE, name))
|
||||
end
|
||||
|
||||
local role
|
||||
if cfgExists('.manager_config') then
|
||||
role = 'manager'
|
||||
elseif cfgExists('.client_config') then
|
||||
role = 'client'
|
||||
elseif cfgExists('.webbridge_config') then
|
||||
role = 'bridge'
|
||||
elseif cfgExists('.miner_config') then
|
||||
role = 'miner'
|
||||
elseif _G.turtle then
|
||||
role = 'turtle'
|
||||
end
|
||||
|
||||
if not role then return end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Register reboot listener as a kernel hook
|
||||
-- Allows remote reboot via modem (channel 4205)
|
||||
-------------------------------------------------
|
||||
|
||||
local SYSTEM_CHANNEL = 4205
|
||||
|
||||
local modem = peripheral.find('modem')
|
||||
if modem then
|
||||
modem.open(SYSTEM_CHANNEL)
|
||||
|
||||
kernel.hook('modem_message', function(_, data)
|
||||
-- data = { side, channel, replyChannel, message, distance }
|
||||
if data[2] == SYSTEM_CHANNEL
|
||||
and type(data[4]) == 'table'
|
||||
and data[4].type == 'reboot'
|
||||
then
|
||||
local target = data[4].target or 'all'
|
||||
if target == 'all'
|
||||
or target == role
|
||||
or target == tostring(os.getComputerID())
|
||||
then
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Launch the program for this role
|
||||
-------------------------------------------------
|
||||
|
||||
local programs = {
|
||||
manager = 'inventoryManager.lua',
|
||||
client = 'inventoryClient.lua',
|
||||
bridge = 'inventoryWebBridge.lua',
|
||||
turtle = 'craftingTurtle.lua',
|
||||
miner = 'miningTurtle.lua',
|
||||
}
|
||||
|
||||
local program = fs.combine(BASE, programs[role])
|
||||
|
||||
if shell.openForegroundTab then
|
||||
shell.openForegroundTab(program)
|
||||
|
||||
-- Client role also runs the dropper controller
|
||||
if role == 'client' then
|
||||
local dropper = fs.combine(BASE, 'dropperController.lua')
|
||||
if fs.exists(dropper) then
|
||||
shell.openTab(dropper)
|
||||
end
|
||||
end
|
||||
else
|
||||
-- No multishell — run directly (blocks boot)
|
||||
shell.run(program)
|
||||
end
|
||||
@@ -4,6 +4,15 @@
|
||||
-- Pulls ingredients from chests, crafts, and pushes results back.
|
||||
-- Requires a wired modem attached to the turtle.
|
||||
|
||||
local shell = _ENV.shell
|
||||
|
||||
local function fatal(msg)
|
||||
printError(msg)
|
||||
print("\nPress any key to exit...")
|
||||
os.pullEvent("key")
|
||||
return
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Default configuration (overridden by .turtle_config)
|
||||
-------------------------------------------------
|
||||
@@ -19,7 +28,20 @@ local CRAFT_SLOTS = {1, 2, 3, 5, 6, 7, 9, 10, 11}
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
|
||||
local TURTLE_CONFIG_FILE = ".turtle_config"
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path (survives Opus package updates)
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local TURTLE_CONFIG_FILE = _configPath(".turtle_config")
|
||||
|
||||
local function loadConfig()
|
||||
if not fs.exists(TURTLE_CONFIG_FILE) then return end
|
||||
@@ -49,9 +71,7 @@ print("")
|
||||
|
||||
-- Verify this is a crafting turtle
|
||||
if not turtle or not turtle.craft then
|
||||
print("[ERR] No turtle.craft() available!")
|
||||
print(" This must be a Crafting Turtle.")
|
||||
return
|
||||
return fatal("[ERR] No turtle.craft() available!\n This must be a Crafting Turtle.")
|
||||
end
|
||||
|
||||
-- Find wired modem and get our network name
|
||||
@@ -75,10 +95,7 @@ for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
|
||||
end
|
||||
|
||||
if not modem or not selfName then
|
||||
print("[ERR] No wired modem found!")
|
||||
print(" Attach a wired modem to the turtle")
|
||||
print(" and connect it to the network.")
|
||||
return
|
||||
return fatal("[ERR] No wired modem found!\n Attach a wired modem to the turtle\n and connect it to the network.")
|
||||
end
|
||||
|
||||
print("[OK] Modem: " .. modemSide)
|
||||
@@ -88,15 +105,6 @@ print("[OK] Network name: " .. selfName)
|
||||
modem.open(CRAFT_CHANNEL)
|
||||
print("[OK] Listening on channel " .. CRAFT_CHANNEL)
|
||||
|
||||
-- Wrap our own inventory peripheral for pullItems/pushItems
|
||||
local selfInv = peripheral.wrap(selfName)
|
||||
if not selfInv then
|
||||
print("[ERR] Cannot wrap own peripheral: " .. selfName)
|
||||
print(" Make sure the wired modem is connected.")
|
||||
return
|
||||
end
|
||||
|
||||
print("[OK] Self-inventory peripheral ready")
|
||||
print("")
|
||||
print("Waiting for craft commands from master...")
|
||||
print("")
|
||||
@@ -106,15 +114,20 @@ print("")
|
||||
-------------------------------------------------
|
||||
|
||||
local function clearInventory(chests)
|
||||
-- Push all items from all 16 turtle slots back to chests
|
||||
-- Pull all items from turtle slots into chests (using chest.pullItems)
|
||||
local cleared = 0
|
||||
for slot = 1, 16 do
|
||||
if turtle.getItemCount(slot) > 0 then
|
||||
for _, chestName in ipairs(chests) do
|
||||
local ok, n = pcall(selfInv.pushItems, chestName, slot)
|
||||
if ok and n and n > 0 then
|
||||
cleared = cleared + n
|
||||
break
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.pullItems then
|
||||
local ok, n = pcall(chest.pullItems, selfName, slot)
|
||||
if ok and n and n > 0 then
|
||||
cleared = cleared + n
|
||||
if turtle.getItemCount(slot) == 0 then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -175,7 +188,13 @@ local function handleCraftCommand(message)
|
||||
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)
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if not chest then
|
||||
print(string.format("[CRAFT] Cannot wrap chest: %s", chestName))
|
||||
allPlaced = false
|
||||
break
|
||||
end
|
||||
local ok, n = pcall(chest.pushItems, selfName, 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))
|
||||
@@ -263,20 +282,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
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
-- Dropper Controller: Runs on Computer 1 (next to dropper_0)
|
||||
-- Watches the dropper and pulses redstone until it's empty.
|
||||
-- Dropper Controller
|
||||
-- Watches a dropper peripheral and pulses redstone until it's empty.
|
||||
|
||||
local REDSTONE_SIDE = "back" -- side facing dropper_0
|
||||
local DROPPER_SIDE = "back" -- side where the dropper peripheral is
|
||||
local REDSTONE_SIDE = "back" -- side facing dropper (for redstone output)
|
||||
local PULSE_TIME = 0.3 -- redstone pulse duration in seconds
|
||||
local POLL_INTERVAL = 0.5 -- how often to check the dropper
|
||||
local DROPPER_SIDE = "back" -- side where the dropper is (as peripheral)
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return fs.combine(_baseDir, rel)
|
||||
end
|
||||
|
||||
local CONFIG_FILE = _configPath(".dropper_config")
|
||||
|
||||
if fs.exists(CONFIG_FILE) then
|
||||
local f = fs.open(CONFIG_FILE, "r")
|
||||
local raw = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, raw)
|
||||
if ok and cfg then
|
||||
if cfg.dropperSide then DROPPER_SIDE = cfg.dropperSide end
|
||||
if cfg.redstoneSide then REDSTONE_SIDE = cfg.redstoneSide end
|
||||
if cfg.pulseTime then PULSE_TIME = cfg.pulseTime end
|
||||
if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Helpers
|
||||
|
||||
28
etc/apps.db
Normal file
28
etc/apps.db
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
[ "im_inventory_manager" ] = {
|
||||
title = "Inv Manager",
|
||||
category = "Inventory",
|
||||
run = "inventoryManager.lua",
|
||||
},
|
||||
[ "im_inventory_client" ] = {
|
||||
title = "Inv Display",
|
||||
category = "Inventory",
|
||||
run = "inventoryClient.lua",
|
||||
},
|
||||
[ "im_crafting_turtle" ] = {
|
||||
title = "Crafting Turtle",
|
||||
category = "Inventory",
|
||||
run = "craftingTurtle.lua",
|
||||
requires = "turtle",
|
||||
},
|
||||
[ "im_web_bridge" ] = {
|
||||
title = "Inv Web Bridge",
|
||||
category = "Inventory",
|
||||
run = "inventoryWebBridge.lua",
|
||||
},
|
||||
[ "im_dropper" ] = {
|
||||
title = "Dropper Ctrl",
|
||||
category = "Inventory",
|
||||
run = "dropperController.lua",
|
||||
},
|
||||
}
|
||||
1357
inventoryClient.lua
1357
inventoryClient.lua
File diff suppressed because it is too large
Load Diff
3244
inventoryManager.lua
3244
inventoryManager.lua
File diff suppressed because it is too large
Load Diff
3019
inventoryManager.lua.bak
Normal file
3019
inventoryManager.lua.bak
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,21 @@ local ORDER_CHANNEL = 4201
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
|
||||
local CONFIG_FILE = ".webbridge_config"
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local CONFIG_FILE = _configPath(".webbridge_config")
|
||||
local API_KEY = nil -- optional API key for server auth
|
||||
|
||||
local function loadConfig()
|
||||
if fs.exists(CONFIG_FILE) then
|
||||
@@ -33,6 +47,7 @@ local function loadConfig()
|
||||
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
|
||||
@@ -71,8 +86,9 @@ local function httpPost(path, body)
|
||||
local url = SERVER_URL .. path
|
||||
local data = textutils.serialiseJSON(body)
|
||||
local headers = { ["Content-Type"] = "application/json" }
|
||||
if API_KEY then headers["Authorization"] = "Bearer " .. API_KEY end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
local ok, result = pcall(function()
|
||||
local response = http.post(url, data, headers)
|
||||
if response then
|
||||
local responseData = response.readAll()
|
||||
@@ -81,16 +97,18 @@ local function httpPost(path, body)
|
||||
end
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
-- Silent fail, will retry
|
||||
return nil
|
||||
if ok then
|
||||
return result
|
||||
end
|
||||
return nil
|
||||
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)
|
||||
local response = http.get(url, headers)
|
||||
if response then
|
||||
local data = response.readAll()
|
||||
response.close()
|
||||
@@ -171,6 +189,12 @@ local function processCommand(cmd)
|
||||
commandId = cmd.commandId,
|
||||
recipeIdx = cmd.recipeIdx,
|
||||
})
|
||||
elseif action == "reboot" then
|
||||
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
|
||||
type = "reboot",
|
||||
commandId = cmd.commandId,
|
||||
target = cmd.target or "all",
|
||||
})
|
||||
else
|
||||
print("[CMD] Unknown action: " .. tostring(action))
|
||||
end
|
||||
@@ -274,6 +298,11 @@ local function main()
|
||||
print("[OK] Server URL: " .. SERVER_URL)
|
||||
print("[OK] Poll interval: " .. POLL_INTERVAL .. "s")
|
||||
print("[OK] State interval: " .. STATE_INTERVAL .. "s")
|
||||
if API_KEY then
|
||||
print("[OK] API key configured")
|
||||
else
|
||||
print("[WARN] No API key set (open access)")
|
||||
end
|
||||
print("")
|
||||
print("Bridge is running. Press Ctrl+T to stop.")
|
||||
print("Listening for master broadcasts on ch " .. BROADCAST_CHANNEL)
|
||||
|
||||
325
lib/craft.lua
Normal file
325
lib/craft.lua
Normal file
@@ -0,0 +1,325 @@
|
||||
-- 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)
|
||||
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
|
||||
|
||||
local chests = ops.getChests()
|
||||
local slotMap = {}
|
||||
local reserved = {}
|
||||
|
||||
-- Map each grid position to a chest 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
|
||||
slotMap[tostring(tSlot)] = {
|
||||
chestName = src.chest,
|
||||
chestSlot = slot,
|
||||
itemName = itemName,
|
||||
count = 1,
|
||||
}
|
||||
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
|
||||
|
||||
for batch = 1, step.count do
|
||||
local ok, batchErr = craft.executeSingleCraft(step.recipe, ctx)
|
||||
if not ok then
|
||||
ctx.state.activity.crafting = false
|
||||
ctx.state.needsRedraw = true
|
||||
ctx.log.error("CRAFT", "Chain failed at step %d batch %d: %s", i, batch, batchErr)
|
||||
return false, string.format("Step %d/%d failed: %s", i, #steps, batchErr)
|
||||
end
|
||||
-- Brief pause between batches to let turtle finish
|
||||
if batch < step.count 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
|
||||
248
lib/recipeBook.lua
Normal file
248
lib/recipeBook.lua
Normal file
@@ -0,0 +1,248 @@
|
||||
-- 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
|
||||
|
||||
return recipeBook
|
||||
189
manager/config.lua
Normal file
189
manager/config.lua
Normal file
@@ -0,0 +1,189 @@
|
||||
-- manager/config.lua — Configuration constants, data tables, and lookup structures
|
||||
-- Usage: local cfg = dofile(_path("manager/config.lua"))(log, _path)
|
||||
|
||||
return function(log, _path)
|
||||
|
||||
-- Fall back to CWD-relative if _path not provided (standalone use)
|
||||
if not _path then _path = function(p) return p end end
|
||||
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local C = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Default configuration (overridden by .manager_config)
|
||||
-------------------------------------------------
|
||||
|
||||
C.DROPPER_NAME = "minecraft:dropper_9"
|
||||
C.BARREL_NAME = "minecraft:barrel_0"
|
||||
C.POLL_INTERVAL = 2
|
||||
C.MONITOR_SIDE = "left"
|
||||
C.SCAN_INTERVAL = 120
|
||||
C.SMELT_INTERVAL = 3
|
||||
C.SMELT_RESERVE = 128
|
||||
C.DEFRAG_INTERVAL = 600
|
||||
C.COMPOST_INTERVAL = 3
|
||||
C.ALERT_INTERVAL = 15
|
||||
C.CACHE_FILE = _configPath(".inventory_cache")
|
||||
C.SMELTER_MONITOR_SIDE = "top"
|
||||
C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes")
|
||||
|
||||
-- Network
|
||||
C.BROADCAST_CHANNEL = 4200
|
||||
C.ORDER_CHANNEL = 4201
|
||||
C.BROADCAST_INTERVAL = 1
|
||||
C.CRAFT_CHANNEL = 4203
|
||||
C.CRAFT_REPLY_CHANNEL = 4204
|
||||
C.SYSTEM_CHANNEL = 4205
|
||||
|
||||
-- Crafting
|
||||
C.CRAFT_TIMEOUT = 15
|
||||
C.GRID_TO_SLOT = {1, 2, 3, 5, 6, 7, 9, 10, 11}
|
||||
|
||||
-- Compost (overridable via config file)
|
||||
C.COMPOST_RESERVE = 128
|
||||
C.COMPOST_DROPPER = "minecraft:dropper_10"
|
||||
C.COMPOST_HOPPER = "minecraft:hopper_0"
|
||||
|
||||
-- Peripheral
|
||||
C.PERIPHERAL_CACHE_TTL = 5
|
||||
|
||||
-- Parallel scanning
|
||||
C.PARALLEL_SCAN_CHUNKS = 8
|
||||
|
||||
-- Storage priority (higher = preferred; keyed by chest peripheral name)
|
||||
C.CHEST_PRIORITY = {}
|
||||
|
||||
-- Builder / supply manifest
|
||||
C.SUPPLY_CHEST = "" -- peripheral name of supply chest (empty = disabled)
|
||||
C.SUPPLY_INTERVAL = 10 -- seconds between supply checks
|
||||
C.SUPPLY_MANIFEST = {} -- { { name = "mod:item", count = N }, ... }
|
||||
|
||||
-- Furnace types
|
||||
C.FURNACE_TYPES = {
|
||||
"minecraft:furnace",
|
||||
"minecraft:smoker",
|
||||
"minecraft:blast_furnace",
|
||||
}
|
||||
C.SLOT_INPUT = 1
|
||||
C.SLOT_FUEL = 2
|
||||
C.SLOT_OUTPUT = 3
|
||||
|
||||
-------------------------------------------------
|
||||
-- Config file loader
|
||||
-------------------------------------------------
|
||||
|
||||
local CONFIG_FILE = _configPath(".manager_config")
|
||||
|
||||
function C.loadConfig()
|
||||
if not fs.exists(CONFIG_FILE) then return end
|
||||
local f = fs.open(CONFIG_FILE, "r")
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, data)
|
||||
if not ok or not cfg then
|
||||
log.warn("CONFIG", "Failed to parse %s", CONFIG_FILE)
|
||||
return
|
||||
end
|
||||
C._raw = cfg
|
||||
if cfg.dropperName then C.DROPPER_NAME = cfg.dropperName end
|
||||
if cfg.barrelName then C.BARREL_NAME = cfg.barrelName end
|
||||
if cfg.monitorSide then C.MONITOR_SIDE = cfg.monitorSide end
|
||||
if cfg.smelterMonitorSide then C.SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end
|
||||
if cfg.pollInterval then C.POLL_INTERVAL = cfg.pollInterval end
|
||||
if cfg.scanInterval then C.SCAN_INTERVAL = cfg.scanInterval end
|
||||
if cfg.smeltInterval then C.SMELT_INTERVAL = cfg.smeltInterval end
|
||||
if cfg.defragInterval then C.DEFRAG_INTERVAL = cfg.defragInterval end
|
||||
if cfg.compostInterval then C.COMPOST_INTERVAL = cfg.compostInterval end
|
||||
if cfg.alertInterval then C.ALERT_INTERVAL = cfg.alertInterval end
|
||||
if cfg.broadcastInterval then C.BROADCAST_INTERVAL = cfg.broadcastInterval end
|
||||
if cfg.smeltReserve then C.SMELT_RESERVE = cfg.smeltReserve end
|
||||
if cfg.broadcastChannel then C.BROADCAST_CHANNEL = cfg.broadcastChannel end
|
||||
if cfg.orderChannel then C.ORDER_CHANNEL = cfg.orderChannel end
|
||||
if cfg.craftChannel then C.CRAFT_CHANNEL = cfg.craftChannel end
|
||||
if cfg.craftReplyChannel then C.CRAFT_REPLY_CHANNEL = cfg.craftReplyChannel end
|
||||
if cfg.compostReserve then C.COMPOST_RESERVE = cfg.compostReserve end
|
||||
if cfg.compostDropper then C.COMPOST_DROPPER = cfg.compostDropper end
|
||||
if cfg.compostHopper then C.COMPOST_HOPPER = cfg.compostHopper end
|
||||
if cfg.parallelScanChunks then C.PARALLEL_SCAN_CHUNKS = cfg.parallelScanChunks end
|
||||
if cfg.chestPriority then C.CHEST_PRIORITY = cfg.chestPriority end
|
||||
if cfg.supplyChest then C.SUPPLY_CHEST = cfg.supplyChest end
|
||||
if cfg.supplyInterval then C.SUPPLY_INTERVAL = cfg.supplyInterval end
|
||||
if cfg.supplyManifest then C.SUPPLY_MANIFEST = cfg.supplyManifest end
|
||||
if cfg.logLevel then log.setLevel(cfg.logLevel) end
|
||||
log.info("CONFIG", "Loaded from %s", CONFIG_FILE)
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Data tables
|
||||
-------------------------------------------------
|
||||
|
||||
C.FUEL_LIST = dofile(_path("data/fuel.lua"))
|
||||
local _compostData = dofile(_path("data/compostable.lua"))
|
||||
C.COMPOSTABLE = _compostData.items
|
||||
C.COMPOST_TRASH = _compostData.trash
|
||||
C.LOW_STOCK_ALERTS = dofile(_path("data/alerts.lua"))
|
||||
|
||||
-- Recipe book: merges built-in recipes + user-learned recipes
|
||||
local recipeBook = dofile(_path("lib/recipeBook.lua"))
|
||||
recipeBook.init(_configPath(".recipes.db"))
|
||||
recipeBook.loadLegacyCrafting(dofile(_path("data/craftable.lua")))
|
||||
recipeBook.loadLegacySmelting(dofile(_path("data/smeltable.lua")))
|
||||
C.recipeBook = recipeBook
|
||||
C.SMELTABLE = recipeBook.getSmeltingTable()
|
||||
C.CRAFTABLE = recipeBook.getCraftingList()
|
||||
|
||||
-- Rebuild furnace/smelt indices from current recipe data
|
||||
function C.rebuildIndices()
|
||||
for _, recipe in pairs(C.SMELTABLE) do
|
||||
recipe.furnaceSet = {}
|
||||
for _, ft in ipairs(recipe.furnaces) do
|
||||
recipe.furnaceSet[ft] = true
|
||||
end
|
||||
end
|
||||
C.smeltCandidatesByType = {}
|
||||
for _, ftype in ipairs(C.FURNACE_TYPES) do
|
||||
C.smeltCandidatesByType[ftype] = {}
|
||||
end
|
||||
for itemName, recipe in pairs(C.SMELTABLE) do
|
||||
local isFood = recipe.furnaceSet["minecraft:smoker"] or false
|
||||
for ft in pairs(recipe.furnaceSet) do
|
||||
table.insert(C.smeltCandidatesByType[ft], { name = itemName, recipe = recipe, food = isFood })
|
||||
end
|
||||
end
|
||||
for _, list in pairs(C.smeltCandidatesByType) do
|
||||
table.sort(list, function(a, b)
|
||||
if a.food ~= b.food then return a.food end
|
||||
return a.name < b.name
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- Refresh recipes from recipeBook and rebuild all indices
|
||||
function C.refreshRecipes()
|
||||
C.SMELTABLE = C.recipeBook.getSmeltingTable()
|
||||
C.CRAFTABLE = C.recipeBook.getCraftingList()
|
||||
C.rebuildIndices()
|
||||
end
|
||||
|
||||
C.rebuildIndices()
|
||||
|
||||
-- Build fuel set for quick lookup
|
||||
C.FUEL_SET = {}
|
||||
for _, f in ipairs(C.FUEL_LIST) do C.FUEL_SET[f.name] = true end
|
||||
|
||||
-- Build compostable set for quick lookup
|
||||
C.COMPOSTABLE_SET = {}
|
||||
for _, name in ipairs(C.COMPOSTABLE) do C.COMPOSTABLE_SET[name] = true end
|
||||
|
||||
return C
|
||||
|
||||
end
|
||||
1417
manager/display.lua
Normal file
1417
manager/display.lua
Normal file
File diff suppressed because it is too large
Load Diff
998
manager/display.lua.bak
Normal file
998
manager/display.lua.bak
Normal file
@@ -0,0 +1,998 @@
|
||||
-- manager/display.lua — Dashboard rendering and touch handlers
|
||||
-- Usage: local display = dofile("manager/display.lua")(ctx)
|
||||
|
||||
return function(ctx)
|
||||
|
||||
local cfg = ctx.cfg
|
||||
local state = ctx.state
|
||||
local log = ctx.log
|
||||
local ui = ctx.ui
|
||||
local ops = ctx.ops
|
||||
|
||||
local cache = state.cache
|
||||
local activity = state.activity
|
||||
|
||||
local D = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Monitor handles (set during init)
|
||||
-------------------------------------------------
|
||||
|
||||
D.mon = nil
|
||||
D.monName = nil
|
||||
D.smelterMon = nil
|
||||
D.smelterMonName = nil
|
||||
|
||||
function D.setupMonitor()
|
||||
D.mon, D.monName = ui.setupMonitor(cfg.MONITOR_SIDE, cfg.SMELTER_MONITOR_SIDE)
|
||||
return D.mon ~= nil
|
||||
end
|
||||
|
||||
function D.setupSmelterMonitor()
|
||||
D.smelterMon, D.smelterMonName = ui.setupSmelterMonitor(cfg.SMELTER_MONITOR_SIDE, D.monName)
|
||||
return D.smelterMon ~= nil
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Main dashboard UI state
|
||||
-------------------------------------------------
|
||||
|
||||
local selectedAmount = 1
|
||||
local amountOptions = {1, 4, 8, 16, 32, 64}
|
||||
|
||||
local touchZones = {}
|
||||
local pendingZones = {}
|
||||
|
||||
local currentPage = 1
|
||||
local totalPages = 1
|
||||
local searchQuery = ""
|
||||
local showKeyboard = false
|
||||
|
||||
local kbRows = {
|
||||
{"Q","W","E","R","T","Y","U","I","O","P"},
|
||||
{"A","S","D","F","G","H","J","K","L"},
|
||||
{"Z","X","C","V","B","N","M"},
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Smelter dashboard UI state
|
||||
-------------------------------------------------
|
||||
|
||||
local smelterView = "status"
|
||||
local smelterPage = 1
|
||||
local smelterTotalPages = 1
|
||||
local smelterTouchZones = {}
|
||||
local smelterPendingZones = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Drawing helpers (delegated to shared ui module)
|
||||
-------------------------------------------------
|
||||
|
||||
local draw = nil
|
||||
|
||||
local function setDrawTarget(target)
|
||||
draw = target
|
||||
ui.draw = target
|
||||
end
|
||||
|
||||
local function monWrite(x, y, text, fg, bg) ui.monWrite(x, y, text, fg, bg) end
|
||||
local function monFill(y, color) ui.monFill(y, color) end
|
||||
local function monCenter(y, text, fg, bg) ui.monCenter(y, text, fg, bg) end
|
||||
local function monBar(x, y, w, r, bc, bgc) ui.monBar(x, y, w, r, bc, bgc) end
|
||||
local function drawButton(x, y, t, fg, bg, pl, pr) return ui.drawButton(x, y, t, fg, bg, pl, pr) end
|
||||
|
||||
local function addZone(x1, y1, x2, y2, action, data)
|
||||
ui.addZone(pendingZones, x1, y1, x2, y2, action, data)
|
||||
end
|
||||
|
||||
local function hitTest(x, y)
|
||||
return ui.hitTest(touchZones, x, y)
|
||||
end
|
||||
|
||||
local function addSmelterZone(x1, y1, x2, y2, action, data)
|
||||
ui.addZone(smelterPendingZones, x1, y1, x2, y2, action, data)
|
||||
end
|
||||
|
||||
local function smelterHitTest(x, y)
|
||||
return ui.hitTest(smelterTouchZones, x, y)
|
||||
end
|
||||
|
||||
local function getFilteredItems()
|
||||
state.ensureItemList()
|
||||
return ui.getFilteredItems(cache.itemList, searchQuery)
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Main dashboard drawing
|
||||
-------------------------------------------------
|
||||
|
||||
function D.drawDashboard()
|
||||
if not D.mon then return end
|
||||
|
||||
local w, h = D.mon.getSize()
|
||||
pendingZones = {}
|
||||
|
||||
setDrawTarget(window.create(D.mon, 1, 1, w, h, false))
|
||||
draw.setBackgroundColor(colors.black)
|
||||
draw.clear()
|
||||
|
||||
-- Title bar
|
||||
monFill(1, colors.blue)
|
||||
monCenter(1, " ** INVENTORY MANAGER ** ", colors.white, colors.blue)
|
||||
|
||||
-- Status bar
|
||||
monFill(2, colors.gray)
|
||||
local statusParts = {}
|
||||
table.insert(statusParts, string.format(" Chests: %d", cache.chestCount))
|
||||
table.insert(statusParts, cache.dropperOk and "Dropper: OK" or "Dropper: --")
|
||||
table.insert(statusParts, cache.barrelOk and "Barrel: OK" or "Barrel: --")
|
||||
if cache.furnaceCount and cache.furnaceCount > 0 then
|
||||
table.insert(statusParts, string.format("Furnaces: %d", cache.furnaceCount))
|
||||
end
|
||||
|
||||
local actParts = {}
|
||||
if activity.sorting then table.insert(actParts, "SORTING") end
|
||||
if activity.dispensing then table.insert(actParts, "DISPENSING") end
|
||||
if activity.smelting then table.insert(actParts, "SMELTING") end
|
||||
if activity.scanning then table.insert(actParts, "SCANNING") end
|
||||
if activity.defragging then table.insert(actParts, "DEFRAG") end
|
||||
if activity.composting then table.insert(actParts, "COMPOST") end
|
||||
|
||||
monWrite(2, 2, table.concat(statusParts, " | "), colors.white, colors.gray)
|
||||
|
||||
if #actParts > 0 then
|
||||
local actStr = " " .. table.concat(actParts, " | ") .. " "
|
||||
monWrite(w - #actStr, 2, actStr, colors.white, colors.orange)
|
||||
end
|
||||
|
||||
-- Divider
|
||||
monFill(3, colors.lightBlue)
|
||||
monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.cyan, colors.lightBlue)
|
||||
|
||||
-- Storage capacity
|
||||
monFill(4, colors.black)
|
||||
local capLabel = string.format(" Storage: %d/%d slots (%d free)",
|
||||
cache.usedSlots, cache.totalSlots, cache.freeSlots)
|
||||
monWrite(2, 4, capLabel, colors.lightGray, colors.black)
|
||||
|
||||
local barStart = #capLabel + 4
|
||||
local barWidth = w - barStart - 2
|
||||
if barWidth > 4 then
|
||||
local barColor = colors.lime
|
||||
if cache.usedRatio > 0.9 then barColor = colors.red
|
||||
elseif cache.usedRatio > 0.7 then barColor = colors.orange
|
||||
elseif cache.usedRatio > 0.5 then barColor = colors.yellow
|
||||
end
|
||||
monBar(barStart, 4, barWidth, cache.usedRatio, barColor, colors.gray)
|
||||
local pctStr = string.format(" %d%% ", math.floor(cache.usedRatio * 100))
|
||||
local pctX = barStart + math.floor(barWidth / 2) - math.floor(#pctStr / 2)
|
||||
monWrite(pctX, 4, pctStr, colors.white, barColor)
|
||||
end
|
||||
|
||||
-- Amount selector (row 5)
|
||||
monFill(5, colors.black)
|
||||
monWrite(2, 5, "Qty:", colors.lightGray, colors.black)
|
||||
local btnX = 7
|
||||
for _, amt in ipairs(amountOptions) do
|
||||
local label = tostring(amt)
|
||||
local bg = (amt == selectedAmount) and colors.cyan or colors.gray
|
||||
local fg = (amt == selectedAmount) and colors.white or colors.lightGray
|
||||
local x1, y1, x2, y2 = drawButton(btnX, 5, label, fg, bg)
|
||||
addZone(x1, y1, x2, y2, "amount", amt)
|
||||
btnX = x2 + 2
|
||||
end
|
||||
|
||||
local refreshBg = activity.scanning and colors.yellow or colors.green
|
||||
local refreshFg = activity.scanning and colors.black or colors.white
|
||||
local refreshTxt = activity.scanning and "Scanning" or "Refresh"
|
||||
local scanX = w - #refreshTxt - 3
|
||||
local sx1, sy1, sx2, sy2 = drawButton(scanX, 5, refreshTxt, refreshFg, refreshBg, 1, 1)
|
||||
addZone(sx1, sy1, sx2, sy2, "scan", nil)
|
||||
|
||||
-- Search bar + Pagination (row 6)
|
||||
monFill(6, colors.black)
|
||||
|
||||
local kbLabel = showKeyboard and " X " or " ? "
|
||||
local kbBg = showKeyboard and colors.red or colors.purple
|
||||
monWrite(2, 6, kbLabel, colors.white, kbBg)
|
||||
addZone(2, 6, 4, 6, "kb_toggle", nil)
|
||||
|
||||
local queryDisplay = searchQuery
|
||||
if showKeyboard then
|
||||
queryDisplay = queryDisplay .. "|"
|
||||
elseif queryDisplay == "" then
|
||||
queryDisplay = "search..."
|
||||
end
|
||||
local fieldW = math.floor(w * 0.4)
|
||||
if fieldW < 10 then fieldW = 10 end
|
||||
local displayText = queryDisplay:sub(1, fieldW)
|
||||
displayText = displayText .. string.rep("_", math.max(0, fieldW - #displayText))
|
||||
monWrite(6, 6, displayText,
|
||||
(searchQuery == "" and not showKeyboard) and colors.gray or colors.white,
|
||||
colors.black)
|
||||
addZone(6, 6, 5 + fieldW, 6, "kb_toggle", nil)
|
||||
|
||||
local filteredItems = getFilteredItems()
|
||||
|
||||
local maxRows = h - 10
|
||||
if maxRows < 1 then maxRows = 1 end
|
||||
totalPages = math.max(1, math.ceil(#filteredItems / maxRows))
|
||||
if currentPage > totalPages then currentPage = totalPages end
|
||||
if currentPage < 1 then currentPage = 1 end
|
||||
|
||||
local pageStr = string.format("Pg %d/%d", currentPage, totalPages)
|
||||
local navW = 3 + 1 + #pageStr + 1 + 3
|
||||
local navX = w - navW
|
||||
|
||||
if currentPage > 1 then
|
||||
monWrite(navX, 6, " < ", colors.white, colors.gray)
|
||||
addZone(navX, 6, navX + 2, 6, "page_prev", nil)
|
||||
else
|
||||
monWrite(navX, 6, " < ", colors.lightGray, colors.black)
|
||||
end
|
||||
|
||||
monWrite(navX + 4, 6, pageStr, colors.lightGray, colors.black)
|
||||
|
||||
local nextX = navX + 4 + #pageStr + 1
|
||||
if currentPage < totalPages then
|
||||
monWrite(nextX, 6, " > ", colors.white, colors.gray)
|
||||
addZone(nextX, 6, nextX + 2, 6, "page_next", nil)
|
||||
else
|
||||
monWrite(nextX, 6, " > ", colors.lightGray, colors.black)
|
||||
end
|
||||
|
||||
-- Column headers (row 7)
|
||||
local row = 7
|
||||
monFill(row, colors.gray)
|
||||
monWrite(2, row, "#", colors.lightGray, colors.gray)
|
||||
monWrite(5, row, "Item", colors.lightGray, colors.gray)
|
||||
monWrite(w - 22, row, "Qty", colors.lightGray, colors.gray)
|
||||
monWrite(w - 14, row, "Stock", colors.lightGray, colors.gray)
|
||||
monWrite(w - 1, row, ">", colors.lightGray, colors.gray)
|
||||
row = row + 1
|
||||
|
||||
-- Item rows
|
||||
local maxCount = 0
|
||||
for _, item in ipairs(filteredItems) do
|
||||
if item.total > maxCount then maxCount = item.total end
|
||||
end
|
||||
if maxCount == 0 then maxCount = 1 end
|
||||
|
||||
local startIdx = (currentPage - 1) * maxRows + 1
|
||||
local endIdx = math.min(startIdx + maxRows - 1, #filteredItems)
|
||||
|
||||
if #filteredItems == 0 then
|
||||
monFill(8, colors.black)
|
||||
monFill(9, colors.black)
|
||||
if searchQuery ~= "" then
|
||||
monCenter(9, "No items match \"" .. searchQuery .. "\"", colors.gray, colors.black)
|
||||
else
|
||||
monCenter(9, "No items in storage", colors.gray, colors.black)
|
||||
end
|
||||
row = 10
|
||||
else
|
||||
for i = startIdx, endIdx do
|
||||
local item = filteredItems[i]
|
||||
local y = row
|
||||
local short = item.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
short = short:sub(1,1):upper() .. short:sub(2)
|
||||
|
||||
local maxNameLen = w - 30
|
||||
if #short > maxNameLen then
|
||||
short = short:sub(1, maxNameLen - 2) .. ".."
|
||||
end
|
||||
|
||||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||||
monFill(y, rowBg)
|
||||
|
||||
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
|
||||
monWrite(5, y, short, colors.white, rowBg)
|
||||
monWrite(w - 22, y, tostring(item.total), colors.yellow, rowBg)
|
||||
|
||||
local ratio = item.total / maxCount
|
||||
local barColor = colors.lime
|
||||
if ratio < 0.25 then barColor = colors.red
|
||||
elseif ratio < 0.5 then barColor = colors.orange
|
||||
end
|
||||
monBar(w - 14, y, 12, ratio, barColor, rowBg == colors.gray and colors.lightGray or colors.gray)
|
||||
|
||||
monWrite(w - 1, y, ">", colors.orange, rowBg)
|
||||
addZone(1, y, w, y, "order", item.name)
|
||||
|
||||
row = row + 1
|
||||
end
|
||||
end
|
||||
|
||||
local lastItemRow = h - 3
|
||||
while row <= lastItemRow do
|
||||
monFill(row, colors.black)
|
||||
row = row + 1
|
||||
end
|
||||
|
||||
if showKeyboard then
|
||||
local keyW = 3
|
||||
local keyGap = 1
|
||||
|
||||
local kbDefs = {
|
||||
{ keys = kbRows[1], specials = {{ label = " Bksp ", action = "kb_bksp", bg = colors.red }} },
|
||||
{ keys = kbRows[2], specials = {{ label = " Done ", action = "kb_done", bg = colors.green }} },
|
||||
{ keys = kbRows[3], specials = {
|
||||
{ label = " Space ", action = "kb_space", bg = colors.lightGray },
|
||||
{ label = " Clr ", action = "kb_clear", bg = colors.orange },
|
||||
}},
|
||||
}
|
||||
|
||||
for rowIdx, def in ipairs(kbDefs) do
|
||||
local y = h - 3 + rowIdx
|
||||
monFill(y, colors.black)
|
||||
|
||||
local keysW = #def.keys * keyW + math.max(0, #def.keys - 1) * keyGap
|
||||
local specialsW = 0
|
||||
for _, sp in ipairs(def.specials) do
|
||||
specialsW = specialsW + keyGap + #sp.label
|
||||
end
|
||||
local rowW = keysW + specialsW
|
||||
local x = math.floor((w - rowW) / 2) + 1
|
||||
|
||||
for ki, key in ipairs(def.keys) do
|
||||
monWrite(x, y, " " .. key .. " ", colors.white, colors.gray)
|
||||
addZone(x, y, x + keyW - 1, y, "kb_key", key:lower())
|
||||
x = x + keyW
|
||||
if ki < #def.keys then x = x + keyGap end
|
||||
end
|
||||
|
||||
for _, sp in ipairs(def.specials) do
|
||||
x = x + keyGap
|
||||
monWrite(x, y, sp.label, colors.white, sp.bg)
|
||||
addZone(x, y, x + #sp.label - 1, y, sp.action, nil)
|
||||
x = x + #sp.label
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Status message
|
||||
monFill(h - 2, colors.black)
|
||||
if #state.activeAlerts > 0 then
|
||||
local alertIdx = math.floor(os.epoch("utc") / 2000) % #state.activeAlerts + 1
|
||||
local a = state.activeAlerts[alertIdx]
|
||||
local alertMsg = string.format(" LOW STOCK: %s (%d/%d) ", a.label, a.current, a.min)
|
||||
monCenter(h - 2, alertMsg, colors.white, colors.red)
|
||||
elseif state.statusTimer > 0 and #state.statusMessage > 0 then
|
||||
monCenter(h - 2, state.statusMessage, state.statusColor, colors.black)
|
||||
end
|
||||
|
||||
-- Footer
|
||||
state.ensureItemList()
|
||||
monFill(h - 1, colors.gray)
|
||||
local footerLeft = string.format(" Total: %d items | %d types ",
|
||||
cache.grandTotal, #cache.itemList)
|
||||
monWrite(2, h - 1, footerLeft, colors.white, colors.gray)
|
||||
|
||||
if searchQuery ~= "" then
|
||||
local filterNote = string.format("| Showing %d ", #filteredItems)
|
||||
monWrite(2 + #footerLeft + 1, h - 1, filterNote, colors.yellow, colors.gray)
|
||||
end
|
||||
|
||||
local timeStr = textutils.formatTime(os.time(), true)
|
||||
monWrite(w - #timeStr - 1, h - 1, timeStr, colors.lightGray, colors.gray)
|
||||
|
||||
-- Bottom accent
|
||||
monFill(h, colors.blue)
|
||||
local bottomMsg = " Tap item to order "
|
||||
if activity.dispensing then
|
||||
bottomMsg = " DISPENSING... "
|
||||
elseif activity.smelting then
|
||||
bottomMsg = " SMELTING... "
|
||||
elseif activity.sorting then
|
||||
bottomMsg = " SORTING BARREL... "
|
||||
elseif activity.defragging then
|
||||
bottomMsg = " DEFRAGMENTING... "
|
||||
elseif activity.composting then
|
||||
bottomMsg = " COMPOSTING... "
|
||||
end
|
||||
monCenter(h, bottomMsg, colors.lightBlue, colors.blue)
|
||||
end
|
||||
|
||||
draw.setVisible(true)
|
||||
touchZones = pendingZones
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Smelter dashboard drawing
|
||||
-------------------------------------------------
|
||||
|
||||
function D.drawSmelterDashboard()
|
||||
if not D.smelterMon then return end
|
||||
|
||||
local w, h = D.smelterMon.getSize()
|
||||
smelterPendingZones = {}
|
||||
|
||||
setDrawTarget(window.create(D.smelterMon, 1, 1, w, h, false))
|
||||
draw.setBackgroundColor(colors.black)
|
||||
draw.clear()
|
||||
|
||||
-- Title bar
|
||||
monFill(1, colors.purple)
|
||||
monCenter(1, " ** SMELTER DASHBOARD ** ", colors.white, colors.purple)
|
||||
|
||||
-- Status bar
|
||||
monFill(2, colors.gray)
|
||||
local activeCount = 0
|
||||
for _, fs in ipairs(cache.furnaceStatus or {}) do
|
||||
if fs.active then activeCount = activeCount + 1 end
|
||||
end
|
||||
local statusStr = string.format(" Furnaces: %d Active: %d",
|
||||
cache.furnaceCount or 0, activeCount)
|
||||
monWrite(2, 2, statusStr, colors.white, colors.gray)
|
||||
|
||||
local pauseLabel = state.smeltingPaused and " PAUSED " or " ACTIVE "
|
||||
local pauseBg = state.smeltingPaused and colors.red or colors.lime
|
||||
local pauseFg = state.smeltingPaused and colors.white or colors.black
|
||||
monWrite(w - #pauseLabel, 2, pauseLabel, pauseFg, pauseBg)
|
||||
addSmelterZone(w - #pauseLabel, 2, w - 1, 2, "toggle_pause", nil)
|
||||
|
||||
-- Divider
|
||||
monFill(3, colors.magenta)
|
||||
monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.pink, colors.magenta)
|
||||
|
||||
-- Tab row
|
||||
monFill(4, colors.black)
|
||||
local tabStatusBg = smelterView == "status" and colors.purple or colors.gray
|
||||
local tabSmeltBg = smelterView == "smelt" and colors.purple or colors.gray
|
||||
local tabCraftBg = smelterView == "craft" and colors.purple or colors.gray
|
||||
local tabMissingBg = smelterView == "missing" and colors.purple or colors.gray
|
||||
local bx1, by1, bx2, by2
|
||||
bx1, by1, bx2, by2 = drawButton(2, 4, "Status", colors.white, tabStatusBg)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "tab", "status")
|
||||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Smelt", colors.white, tabSmeltBg)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "tab", "smelt")
|
||||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Craft", colors.white, tabCraftBg)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "tab", "craft")
|
||||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Missing", colors.white, tabMissingBg)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "tab", "missing")
|
||||
|
||||
local craftAvailCount = nil
|
||||
|
||||
if smelterView == "status" then
|
||||
-- Furnace Status View
|
||||
monFill(5, colors.gray)
|
||||
local outCol = math.floor(w * 0.40)
|
||||
local fuelCol = math.floor(w * 0.65)
|
||||
local statCol = w - 6
|
||||
monWrite(2, 5, "#", colors.lightGray, colors.gray)
|
||||
monWrite(4, 5, "T", colors.lightGray, colors.gray)
|
||||
monWrite(6, 5, "Input", colors.lightGray, colors.gray)
|
||||
monWrite(outCol, 5, "Output", colors.lightGray, colors.gray)
|
||||
monWrite(fuelCol, 5, "Fuel", colors.lightGray, colors.gray)
|
||||
monWrite(statCol, 5, "State", colors.lightGray, colors.gray)
|
||||
|
||||
local furnaceList = cache.furnaceStatus or {}
|
||||
local maxRows = h - 8
|
||||
if maxRows < 1 then maxRows = 1 end
|
||||
smelterTotalPages = math.max(1, math.ceil(#furnaceList / maxRows))
|
||||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||||
if smelterPage < 1 then smelterPage = 1 end
|
||||
|
||||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||||
local endIdx = math.min(startIdx + maxRows - 1, #furnaceList)
|
||||
|
||||
local row = 6
|
||||
if #furnaceList == 0 then
|
||||
monFill(7, colors.black)
|
||||
monCenter(7, "No furnaces found on network", colors.gray, colors.black)
|
||||
row = 8
|
||||
else
|
||||
for i = startIdx, endIdx do
|
||||
local fs = furnaceList[i]
|
||||
local y = row
|
||||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||||
monFill(y, rowBg)
|
||||
|
||||
monWrite(2, y, string.format("%d", i), colors.lightBlue, rowBg)
|
||||
|
||||
local typeAbbr = "F"
|
||||
local typeColor = colors.orange
|
||||
if fs.type == "minecraft:smoker" then
|
||||
typeAbbr = "S"
|
||||
typeColor = colors.green
|
||||
elseif fs.type == "minecraft:blast_furnace" then
|
||||
typeAbbr = "B"
|
||||
typeColor = colors.cyan
|
||||
end
|
||||
monWrite(4, y, typeAbbr, typeColor, rowBg)
|
||||
|
||||
if fs.input then
|
||||
local inName = fs.input.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
local maxIn = outCol - 8
|
||||
if #inName > maxIn then inName = inName:sub(1, maxIn - 2) .. ".." end
|
||||
monWrite(6, y, inName, colors.white, rowBg)
|
||||
monWrite(outCol - 4, y, "x" .. fs.input.count, colors.yellow, rowBg)
|
||||
else
|
||||
monWrite(6, y, "(empty)", colors.lightGray, rowBg)
|
||||
end
|
||||
|
||||
if fs.output then
|
||||
local outName = fs.output.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
local maxOut = fuelCol - outCol - 5
|
||||
if #outName > maxOut then outName = outName:sub(1, maxOut - 2) .. ".." end
|
||||
monWrite(outCol, y, outName, colors.white, rowBg)
|
||||
monWrite(fuelCol - 4, y, "x" .. fs.output.count, colors.yellow, rowBg)
|
||||
else
|
||||
monWrite(outCol, y, "-", colors.lightGray, rowBg)
|
||||
end
|
||||
|
||||
if fs.fuel then
|
||||
local fuelName = fs.fuel.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
local maxFuel = statCol - fuelCol - 4
|
||||
if #fuelName > maxFuel then fuelName = fuelName:sub(1, maxFuel - 2) .. ".." end
|
||||
monWrite(fuelCol, y, fuelName, colors.white, rowBg)
|
||||
monWrite(statCol - 4, y, "x" .. fs.fuel.count, colors.yellow, rowBg)
|
||||
else
|
||||
monWrite(fuelCol, y, "-", colors.lightGray, rowBg)
|
||||
end
|
||||
|
||||
if state.smeltingPaused then
|
||||
monWrite(statCol, y, "PAUSE", colors.red, rowBg)
|
||||
elseif fs.active then
|
||||
monWrite(statCol, y, " COOK", colors.lime, rowBg)
|
||||
elseif fs.input and not fs.fuel then
|
||||
monWrite(statCol, y, "FUEL?", colors.orange, rowBg)
|
||||
else
|
||||
monWrite(statCol, y, " IDLE", colors.lightGray, rowBg)
|
||||
end
|
||||
|
||||
row = row + 1
|
||||
end
|
||||
end
|
||||
|
||||
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
|
||||
|
||||
elseif smelterView == "smelt" then
|
||||
-- Smelt Recipe Manager View
|
||||
local recipeList = {}
|
||||
for inputName, recipe in pairs(cfg.SMELTABLE) do
|
||||
local short = inputName:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
short = short:sub(1,1):upper() .. short:sub(2)
|
||||
local resultShort = recipe.result:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
resultShort = resultShort:sub(1,1):upper() .. resultShort:sub(2)
|
||||
local types = ""
|
||||
if recipe.furnaceSet["minecraft:furnace"] then types = types .. "F" end
|
||||
if recipe.furnaceSet["minecraft:smoker"] then types = types .. "S" end
|
||||
if recipe.furnaceSet["minecraft:blast_furnace"] then types = types .. "B" end
|
||||
local enabled = not state.disabledRecipes[inputName]
|
||||
local inStorage = 0
|
||||
if cache.catalogue[inputName] then
|
||||
for _, s in ipairs(cache.catalogue[inputName]) do
|
||||
inStorage = inStorage + s.total
|
||||
end
|
||||
end
|
||||
table.insert(recipeList, {
|
||||
inputName = inputName,
|
||||
inputShort = short,
|
||||
resultShort = resultShort,
|
||||
types = types,
|
||||
enabled = enabled,
|
||||
inStorage = inStorage,
|
||||
})
|
||||
end
|
||||
table.sort(recipeList, function(a, b) return a.inputShort < b.inputShort end)
|
||||
|
||||
local arrowCol = math.floor(w * 0.30)
|
||||
local typeCol = math.floor(w * 0.60)
|
||||
local stockCol = math.floor(w * 0.72)
|
||||
local toggleCol = w - 5
|
||||
|
||||
monFill(5, colors.gray)
|
||||
monWrite(2, 5, "Input", colors.lightGray, colors.gray)
|
||||
monWrite(arrowCol, 5, "Output", colors.lightGray, colors.gray)
|
||||
monWrite(typeCol, 5, "Type", colors.lightGray, colors.gray)
|
||||
monWrite(stockCol, 5, "Stock", colors.lightGray, colors.gray)
|
||||
monWrite(toggleCol, 5, "On?", colors.lightGray, colors.gray)
|
||||
|
||||
local bulkX = w - 22
|
||||
bx1, by1, bx2, by2 = drawButton(bulkX, 4, "All On", colors.white, colors.green)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "enable_all", nil)
|
||||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "All Off", colors.white, colors.red)
|
||||
addSmelterZone(bx1, by1, bx2, by2, "disable_all", nil)
|
||||
|
||||
local maxRows = h - 8
|
||||
if maxRows < 1 then maxRows = 1 end
|
||||
smelterTotalPages = math.max(1, math.ceil(#recipeList / maxRows))
|
||||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||||
if smelterPage < 1 then smelterPage = 1 end
|
||||
|
||||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||||
local endIdx = math.min(startIdx + maxRows - 1, #recipeList)
|
||||
|
||||
local row = 6
|
||||
for i = startIdx, endIdx do
|
||||
local r = recipeList[i]
|
||||
local y = row
|
||||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||||
monFill(y, rowBg)
|
||||
|
||||
local maxInputLen = arrowCol - 3
|
||||
local inputDisplay = r.inputShort
|
||||
if #inputDisplay > maxInputLen then
|
||||
inputDisplay = inputDisplay:sub(1, maxInputLen - 2) .. ".."
|
||||
end
|
||||
monWrite(2, y, inputDisplay, colors.white, rowBg)
|
||||
|
||||
local maxOutLen = typeCol - arrowCol - 2
|
||||
local outDisplay = r.resultShort
|
||||
if #outDisplay > maxOutLen then
|
||||
outDisplay = outDisplay:sub(1, maxOutLen - 2) .. ".."
|
||||
end
|
||||
monWrite(arrowCol, y, outDisplay, colors.lightBlue, rowBg)
|
||||
|
||||
monWrite(typeCol, y, r.types, colors.orange, rowBg)
|
||||
monWrite(stockCol, y, tostring(r.inStorage), colors.yellow, rowBg)
|
||||
|
||||
if r.enabled then
|
||||
monWrite(toggleCol, y, " ON ", colors.white, colors.green)
|
||||
else
|
||||
monWrite(toggleCol, y, " OFF", colors.white, colors.red)
|
||||
end
|
||||
addSmelterZone(1, y, w, y, "toggle_recipe", r.inputName)
|
||||
|
||||
row = row + 1
|
||||
end
|
||||
|
||||
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
|
||||
|
||||
elseif smelterView == "craft" then
|
||||
-- Available Crafting Recipes
|
||||
local turtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName)
|
||||
local tLabel = turtleOk and " Turtle OK " or " No Turtle "
|
||||
local tBg = turtleOk and colors.lime or colors.red
|
||||
local tFg = turtleOk and colors.black or colors.white
|
||||
monWrite(w - #tLabel, 4, tLabel, tFg, tBg)
|
||||
|
||||
local availList = {}
|
||||
for idx, recipe in ipairs(cfg.CRAFTABLE) do
|
||||
if ops.canCraftRecipe(recipe) then
|
||||
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
short = short:sub(1,1):upper() .. short:sub(2)
|
||||
local batches = ops.maxCraftBatches(recipe)
|
||||
table.insert(availList, {
|
||||
idx = idx,
|
||||
short = short,
|
||||
count = recipe.count,
|
||||
batches = batches,
|
||||
})
|
||||
end
|
||||
end
|
||||
craftAvailCount = #availList
|
||||
|
||||
monFill(5, colors.gray)
|
||||
local makeCol = w - 6
|
||||
monWrite(2, 5, "#", colors.lightGray, colors.gray)
|
||||
monWrite(4, 5, "Output", colors.lightGray, colors.gray)
|
||||
monWrite(math.floor(w * 0.45), 5, "Yield", colors.lightGray, colors.gray)
|
||||
monWrite(math.floor(w * 0.60), 5, "Can Make", colors.lightGray, colors.gray)
|
||||
monWrite(makeCol, 5, "Go", colors.lightGray, colors.gray)
|
||||
|
||||
local maxRows = h - 8
|
||||
if maxRows < 1 then maxRows = 1 end
|
||||
smelterTotalPages = math.max(1, math.ceil(#availList / maxRows))
|
||||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||||
if smelterPage < 1 then smelterPage = 1 end
|
||||
|
||||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||||
local endIdx = math.min(startIdx + maxRows - 1, #availList)
|
||||
|
||||
local row = 6
|
||||
if #availList == 0 then
|
||||
monFill(7, colors.black)
|
||||
monCenter(7, "No recipes available to craft", colors.gray, colors.black)
|
||||
row = 8
|
||||
else
|
||||
for i = startIdx, endIdx do
|
||||
local r = availList[i]
|
||||
local y = row
|
||||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||||
monFill(y, rowBg)
|
||||
|
||||
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
|
||||
|
||||
local maxNameLen = math.floor(w * 0.40)
|
||||
local nameDisplay = r.short
|
||||
if #nameDisplay > maxNameLen then
|
||||
nameDisplay = nameDisplay:sub(1, maxNameLen - 2) .. ".."
|
||||
end
|
||||
monWrite(4, y, nameDisplay, colors.white, rowBg)
|
||||
|
||||
monWrite(math.floor(w * 0.45), y, "x" .. r.count, colors.yellow, rowBg)
|
||||
monWrite(math.floor(w * 0.60), y,
|
||||
string.format("x%d", r.batches), colors.lime, rowBg)
|
||||
|
||||
if turtleOk then
|
||||
monWrite(makeCol, y, " MAKE ", colors.white, colors.green)
|
||||
addSmelterZone(makeCol, y, makeCol + 5, y, "craft", r.idx)
|
||||
else
|
||||
monWrite(makeCol, y, " ---- ", colors.gray, colors.black)
|
||||
end
|
||||
|
||||
row = row + 1
|
||||
end
|
||||
end
|
||||
|
||||
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
|
||||
|
||||
elseif smelterView == "missing" then
|
||||
-- Unavailable Crafting Recipes
|
||||
local missList = {}
|
||||
for idx, recipe in ipairs(cfg.CRAFTABLE) do
|
||||
if not ops.canCraftRecipe(recipe) then
|
||||
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
short = short:sub(1,1):upper() .. short:sub(2)
|
||||
local missing = ops.getMissingIngredients(recipe)
|
||||
local parts = {}
|
||||
for _, m in ipairs(missing) do
|
||||
local mShort = m.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
table.insert(parts, string.format("%s %d/%d", mShort, m.have, m.need))
|
||||
end
|
||||
table.insert(missList, {
|
||||
idx = idx,
|
||||
short = short,
|
||||
count = recipe.count,
|
||||
summary = table.concat(parts, ", "),
|
||||
})
|
||||
end
|
||||
end
|
||||
craftAvailCount = #cfg.CRAFTABLE - #missList
|
||||
|
||||
monFill(5, colors.gray)
|
||||
monWrite(2, 5, "#", colors.lightGray, colors.gray)
|
||||
monWrite(4, 5, "Output", colors.lightGray, colors.gray)
|
||||
monWrite(math.floor(w * 0.35), 5, "Missing (have/need)", colors.lightGray, colors.gray)
|
||||
|
||||
local maxRows = h - 8
|
||||
if maxRows < 1 then maxRows = 1 end
|
||||
smelterTotalPages = math.max(1, math.ceil(#missList / maxRows))
|
||||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||||
if smelterPage < 1 then smelterPage = 1 end
|
||||
|
||||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||||
local endIdx = math.min(startIdx + maxRows - 1, #missList)
|
||||
|
||||
local row = 6
|
||||
if #missList == 0 then
|
||||
monFill(7, colors.black)
|
||||
monCenter(7, "All recipes can be crafted!", colors.lime, colors.black)
|
||||
row = 8
|
||||
else
|
||||
for i = startIdx, endIdx do
|
||||
local r = missList[i]
|
||||
local y = row
|
||||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||||
monFill(y, rowBg)
|
||||
|
||||
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
|
||||
|
||||
local nameCol = math.floor(w * 0.35) - 5
|
||||
local nameDisplay = r.short .. " x" .. r.count
|
||||
if #nameDisplay > nameCol then
|
||||
nameDisplay = nameDisplay:sub(1, nameCol - 2) .. ".."
|
||||
end
|
||||
monWrite(4, y, nameDisplay, colors.white, rowBg)
|
||||
|
||||
local missCol = math.floor(w * 0.35)
|
||||
local missW = w - missCol - 1
|
||||
local summaryDisplay = r.summary
|
||||
if #summaryDisplay > missW then
|
||||
summaryDisplay = summaryDisplay:sub(1, missW - 2) .. ".."
|
||||
end
|
||||
monWrite(missCol, y, summaryDisplay, colors.red, rowBg)
|
||||
|
||||
row = row + 1
|
||||
end
|
||||
end
|
||||
|
||||
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
|
||||
end
|
||||
|
||||
-- Pagination (h - 1)
|
||||
monFill(h - 1, colors.gray)
|
||||
local pageStr = string.format("Pg %d/%d", smelterPage, smelterTotalPages)
|
||||
monCenter(h - 1, pageStr, colors.white, colors.gray)
|
||||
|
||||
if smelterPage > 1 then
|
||||
monWrite(2, h - 1, " < ", colors.white, colors.lightGray)
|
||||
addSmelterZone(2, h - 1, 4, h - 1, "page_prev", nil)
|
||||
end
|
||||
if smelterPage < smelterTotalPages then
|
||||
monWrite(w - 3, h - 1, " > ", colors.white, colors.lightGray)
|
||||
addSmelterZone(w - 3, h - 1, w - 1, h - 1, "page_next", nil)
|
||||
end
|
||||
|
||||
-- Bottom accent
|
||||
monFill(h, colors.purple)
|
||||
local bottomMsg = ""
|
||||
if smelterView == "status" or smelterView == "smelt" then
|
||||
local enabledCount = 0
|
||||
local totalRecipes = 0
|
||||
for _ in pairs(cfg.SMELTABLE) do totalRecipes = totalRecipes + 1 end
|
||||
for inputName in pairs(cfg.SMELTABLE) do
|
||||
if not state.disabledRecipes[inputName] then enabledCount = enabledCount + 1 end
|
||||
end
|
||||
bottomMsg = string.format(" Smelt: %d/%d enabled ", enabledCount, totalRecipes)
|
||||
if activity.smelting then bottomMsg = " SMELTING... " end
|
||||
elseif smelterView == "craft" then
|
||||
bottomMsg = " Tap MAKE to craft "
|
||||
if activity.crafting then bottomMsg = " CRAFTING... " end
|
||||
elseif smelterView == "missing" then
|
||||
local availC = craftAvailCount or 0
|
||||
bottomMsg = string.format(" Available: %d/%d recipes ", availC, #cfg.CRAFTABLE)
|
||||
end
|
||||
monCenter(h, bottomMsg, colors.pink, colors.purple)
|
||||
|
||||
draw.setVisible(true)
|
||||
smelterTouchZones = smelterPendingZones
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Touch handlers
|
||||
-------------------------------------------------
|
||||
|
||||
function D.handleTouch(x, y)
|
||||
local action, data = hitTest(x, y)
|
||||
if not action then
|
||||
log.debug("TOUCH", "No zone hit")
|
||||
return
|
||||
end
|
||||
|
||||
if action == "amount" then
|
||||
selectedAmount = data
|
||||
log.debug("UI", "Amount set to %s", data)
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "order" then
|
||||
local itemName = data
|
||||
if itemName then
|
||||
local short = itemName:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
state.statusMessage = string.format("Ordering %s x%d...", short, selectedAmount)
|
||||
state.statusColor = colors.cyan
|
||||
state.statusTimer = 10
|
||||
activity.dispensing = true
|
||||
state.needsRedraw = true
|
||||
ops.orderItem(itemName, selectedAmount)
|
||||
end
|
||||
|
||||
elseif action == "scan" then
|
||||
state.statusMessage = "Refreshing..."
|
||||
state.statusColor = colors.cyan
|
||||
state.statusTimer = 3
|
||||
state.needsRedraw = true
|
||||
log.debug("UI", "Manual refresh")
|
||||
|
||||
elseif action == "kb_toggle" then
|
||||
showKeyboard = not showKeyboard
|
||||
log.debug("UI", "Keyboard %s", showKeyboard and "open" or "closed")
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "kb_key" then
|
||||
if #searchQuery < 30 then
|
||||
searchQuery = searchQuery .. data
|
||||
end
|
||||
currentPage = 1
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "kb_bksp" then
|
||||
if #searchQuery > 0 then
|
||||
searchQuery = searchQuery:sub(1, -2)
|
||||
end
|
||||
currentPage = 1
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "kb_space" then
|
||||
if #searchQuery < 30 then
|
||||
searchQuery = searchQuery .. " "
|
||||
end
|
||||
currentPage = 1
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "kb_done" then
|
||||
showKeyboard = false
|
||||
log.debug("UI", "Keyboard closed")
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "kb_clear" then
|
||||
searchQuery = ""
|
||||
currentPage = 1
|
||||
log.debug("UI", "Search cleared")
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "page_prev" then
|
||||
if currentPage > 1 then
|
||||
currentPage = currentPage - 1
|
||||
log.debug("UI", "Page %d", currentPage)
|
||||
end
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "page_next" then
|
||||
if currentPage < totalPages then
|
||||
currentPage = currentPage + 1
|
||||
log.debug("UI", "Page %d", currentPage)
|
||||
end
|
||||
state.needsRedraw = true
|
||||
end
|
||||
end
|
||||
|
||||
function D.handleSmelterTouch(x, y)
|
||||
local action, data = smelterHitTest(x, y)
|
||||
if not action then return end
|
||||
|
||||
if action == "tab" then
|
||||
smelterView = data
|
||||
smelterPage = 1
|
||||
log.debug("UI", "Tab: %s", data)
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "toggle_pause" then
|
||||
state.smeltingPaused = not state.smeltingPaused
|
||||
log.debug("UI", "Smelting %s", state.smeltingPaused and "PAUSED" or "RESUMED")
|
||||
ops.saveDisabledRecipes()
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
|
||||
elseif action == "toggle_recipe" then
|
||||
if state.disabledRecipes[data] then
|
||||
state.disabledRecipes[data] = nil
|
||||
else
|
||||
state.disabledRecipes[data] = true
|
||||
end
|
||||
local short = data:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
log.debug("UI", "Recipe %s: %s", short, state.disabledRecipes[data] and "OFF" or "ON")
|
||||
ops.saveDisabledRecipes()
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "enable_all" then
|
||||
state.disabledRecipes = {}
|
||||
log.debug("UI", "All recipes enabled")
|
||||
ops.saveDisabledRecipes()
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "disable_all" then
|
||||
for inputName in pairs(cfg.SMELTABLE) do
|
||||
state.disabledRecipes[inputName] = true
|
||||
end
|
||||
log.debug("UI", "All recipes disabled")
|
||||
ops.saveDisabledRecipes()
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "page_prev" then
|
||||
if smelterPage > 1 then
|
||||
smelterPage = smelterPage - 1
|
||||
end
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "page_next" then
|
||||
if smelterPage < smelterTotalPages then
|
||||
smelterPage = smelterPage + 1
|
||||
end
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
elseif action == "craft" then
|
||||
local recipeIdx = data
|
||||
local recipe = cfg.CRAFTABLE[recipeIdx]
|
||||
if recipe then
|
||||
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
|
||||
log.info("CRAFT", "Craft request: %s (#%d)", short, recipeIdx)
|
||||
local ok, err = ops.craftItem(recipeIdx)
|
||||
if ok then
|
||||
state.statusMessage = "Crafted " .. short .. " x" .. recipe.count
|
||||
state.statusColor = colors.lime
|
||||
else
|
||||
state.statusMessage = "Craft failed: " .. (err or "unknown")
|
||||
state.statusColor = colors.red
|
||||
end
|
||||
state.statusTimer = 5
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return D
|
||||
|
||||
end
|
||||
1160
manager/operations.lua
Normal file
1160
manager/operations.lua
Normal file
File diff suppressed because it is too large
Load Diff
137
manager/state.lua
Normal file
137
manager/state.lua
Normal file
@@ -0,0 +1,137 @@
|
||||
-- manager/state.lua — Shared mutable state, cache, and coordination flags
|
||||
-- Usage: local state = dofile("manager/state.lua")()
|
||||
|
||||
return function()
|
||||
|
||||
local S = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Cached data (updated by background scanner)
|
||||
-------------------------------------------------
|
||||
|
||||
S.cache = {
|
||||
catalogue = {},
|
||||
itemList = {},
|
||||
itemListDirty = false,
|
||||
grandTotal = 0,
|
||||
chestCount = 0,
|
||||
totalSlots = 0,
|
||||
usedSlots = 0,
|
||||
freeSlots = 0,
|
||||
usedRatio = 0,
|
||||
dropperOk = false,
|
||||
barrelOk = false,
|
||||
furnaceCount = 0,
|
||||
furnaceStatus = {},
|
||||
droppers = {},
|
||||
}
|
||||
|
||||
-- Client-registered droppers, keyed by clientId
|
||||
S.clientDroppers = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Activity flags (shown on monitor)
|
||||
-------------------------------------------------
|
||||
|
||||
S.activity = {
|
||||
sorting = false,
|
||||
dispensing = false,
|
||||
scanning = false,
|
||||
smelting = false,
|
||||
defragging = false,
|
||||
composting = false,
|
||||
crafting = false,
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
-- State version tracking (for delta broadcasting)
|
||||
-------------------------------------------------
|
||||
|
||||
S.stateVersion = 0
|
||||
S.lastBroadcastVersion = -1
|
||||
S.configDirty = true
|
||||
|
||||
function S.bumpStateVersion()
|
||||
S.stateVersion = S.stateVersion + 1
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- UI coordination flags
|
||||
-------------------------------------------------
|
||||
|
||||
S.needsRedraw = true
|
||||
S.smelterNeedsRedraw = true
|
||||
S.statusMessage = ""
|
||||
S.statusColor = colors.white
|
||||
S.statusTimer = 0
|
||||
|
||||
-------------------------------------------------
|
||||
-- Alerts and smelting control
|
||||
-------------------------------------------------
|
||||
|
||||
S.activeAlerts = {}
|
||||
S.smeltingPaused = false
|
||||
S.disabledRecipes = {}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Instant cache adjustment (no scan needed)
|
||||
-------------------------------------------------
|
||||
|
||||
function S.adjustCache(itemName, chestName, delta)
|
||||
if delta == 0 then return end
|
||||
|
||||
local cat = S.cache.catalogue
|
||||
if delta > 0 then
|
||||
if not cat[itemName] then cat[itemName] = {} end
|
||||
local found = false
|
||||
for _, entry in ipairs(cat[itemName]) do
|
||||
if entry.chest == chestName then
|
||||
entry.total = entry.total + delta
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
table.insert(cat[itemName], { chest = chestName, total = delta })
|
||||
end
|
||||
else
|
||||
if cat[itemName] then
|
||||
for idx, entry in ipairs(cat[itemName]) do
|
||||
if entry.chest == chestName then
|
||||
entry.total = entry.total + delta
|
||||
if entry.total <= 0 then
|
||||
table.remove(cat[itemName], idx)
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
if #cat[itemName] == 0 then
|
||||
cat[itemName] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
S.cache.itemListDirty = true
|
||||
S.cache.grandTotal = S.cache.grandTotal + delta
|
||||
S.bumpStateVersion()
|
||||
end
|
||||
|
||||
function S.ensureItemList()
|
||||
if not S.cache.itemListDirty then return end
|
||||
local itemList = {}
|
||||
local grandTotal = 0
|
||||
for name, sources in pairs(S.cache.catalogue) do
|
||||
local total = 0
|
||||
for _, s in ipairs(sources) do total = total + s.total end
|
||||
grandTotal = grandTotal + total
|
||||
table.insert(itemList, { name = name, total = total })
|
||||
end
|
||||
table.sort(itemList, function(a, b) return a.total > b.total end)
|
||||
S.cache.itemList = itemList
|
||||
S.cache.grandTotal = grandTotal
|
||||
S.cache.itemListDirty = false
|
||||
end
|
||||
|
||||
return S
|
||||
|
||||
end
|
||||
414
miningTurtle.lua
Normal file
414
miningTurtle.lua
Normal file
@@ -0,0 +1,414 @@
|
||||
-- Mining Turtle Script
|
||||
-- Sits on top of an infinite cobblestone generator.
|
||||
-- Continuously mines the block below and pushes items into
|
||||
-- networked storage via wired modem.
|
||||
-- Requires a wired modem attached to the turtle.
|
||||
|
||||
local shell = _ENV.shell
|
||||
|
||||
local function fatal(msg)
|
||||
printError(msg)
|
||||
print("\nPress any key to exit...")
|
||||
os.pullEvent("key")
|
||||
return
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Default configuration (overridden by .miner_config)
|
||||
-------------------------------------------------
|
||||
|
||||
local MINE_INTERVAL = 0.5 -- seconds between dig attempts
|
||||
local DUMP_THRESHOLD = 14 -- dump when this many slots are occupied
|
||||
local DUMP_INTERVAL = 30 -- force dump every N seconds even if not full
|
||||
local FUEL_SLOT = 16 -- slot used for pulling/burning fuel
|
||||
local FUEL_THRESHOLD = 200 -- pull fuel when below this level
|
||||
local SYSTEM_CHANNEL = 4205 -- remote reboot channel
|
||||
local ORDER_CHANNEL = 4201 -- channel to talk to manager
|
||||
local FUEL_REQUEST_TIMEOUT = 5 -- seconds to wait for manager reply
|
||||
|
||||
-- Fuel items the turtle will look for in storage (best first)
|
||||
local FUEL_ITEMS = {
|
||||
"minecraft:coal",
|
||||
"minecraft:charcoal",
|
||||
"minecraft:coal_block",
|
||||
"minecraft:blaze_rod",
|
||||
"minecraft:dried_kelp_block",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path (survives Opus package updates)
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local CONFIG_FILE = _configPath(".miner_config")
|
||||
|
||||
local function loadConfig()
|
||||
if not fs.exists(CONFIG_FILE) then return end
|
||||
local f = fs.open(CONFIG_FILE, "r")
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, data)
|
||||
if not ok or not cfg then
|
||||
print("[WARN] Failed to parse " .. CONFIG_FILE)
|
||||
return
|
||||
end
|
||||
if cfg.mineInterval then MINE_INTERVAL = cfg.mineInterval end
|
||||
if cfg.dumpThreshold then DUMP_THRESHOLD = cfg.dumpThreshold end
|
||||
if cfg.dumpInterval then DUMP_INTERVAL = cfg.dumpInterval end
|
||||
if cfg.fuelSlot then FUEL_SLOT = cfg.fuelSlot end
|
||||
print("[CONFIG] Loaded from " .. CONFIG_FILE)
|
||||
end
|
||||
|
||||
loadConfig()
|
||||
|
||||
-------------------------------------------------
|
||||
-- Setup
|
||||
-------------------------------------------------
|
||||
|
||||
print("=================================")
|
||||
print(" Mining Turtle (Cobble Miner)")
|
||||
print("=================================")
|
||||
print("")
|
||||
|
||||
if not turtle then
|
||||
return fatal("[ERR] Not a turtle!")
|
||||
end
|
||||
|
||||
-- Find wired modem and get our network name
|
||||
local modem = nil
|
||||
local modemSide = nil
|
||||
local selfName = nil
|
||||
|
||||
for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
|
||||
if peripheral.getType(side) == "modem" then
|
||||
local m = peripheral.wrap(side)
|
||||
if m.getNameLocal then
|
||||
local name = m.getNameLocal()
|
||||
if name then
|
||||
modem = m
|
||||
modemSide = side
|
||||
selfName = name
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not modem or not selfName then
|
||||
return fatal("[ERR] No wired modem found!\n Attach a wired modem to the turtle\n and connect it to the network.")
|
||||
end
|
||||
|
||||
print("[OK] Modem: " .. modemSide)
|
||||
print("[OK] Network name: " .. selfName)
|
||||
print("[OK] Mine interval: " .. MINE_INTERVAL .. "s")
|
||||
print("[OK] Dump threshold: " .. DUMP_THRESHOLD .. " slots")
|
||||
print("")
|
||||
|
||||
-------------------------------------------------
|
||||
-- Find chests on the network to dump into
|
||||
-------------------------------------------------
|
||||
|
||||
local function getChests()
|
||||
local chests = {}
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
local ptype = peripheral.getType(name)
|
||||
if ptype == "minecraft:chest" or ptype == "minecraft:barrel" or
|
||||
ptype == "minecraft:shulker_box" then
|
||||
table.insert(chests, name)
|
||||
end
|
||||
end
|
||||
return chests
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Dump inventory into networked storage
|
||||
-- Uses pull approach: wrap the remote chest and call
|
||||
-- chest.pullItems(selfName, slot) — avoids needing
|
||||
-- to wrap the turtle's own peripheral.
|
||||
-------------------------------------------------
|
||||
|
||||
local function dumpInventory()
|
||||
local chests = getChests()
|
||||
if #chests == 0 then
|
||||
print("[DUMP] No chests on network!")
|
||||
return 0
|
||||
end
|
||||
|
||||
local totalPushed = 0
|
||||
for slot = 1, 16 do
|
||||
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
|
||||
for _, chestName in ipairs(chests) do
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.pullItems then
|
||||
local ok, n = pcall(chest.pullItems, selfName, slot)
|
||||
if ok and n and n > 0 then
|
||||
totalPushed = totalPushed + n
|
||||
if turtle.getItemCount(slot) == 0 then
|
||||
break -- slot empty, move to next
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Notify manager to refresh its cache so counts update immediately
|
||||
if totalPushed > 0 then
|
||||
pcall(function()
|
||||
modem.transmit(ORDER_CHANNEL, ORDER_CHANNEL, { type = "scan" })
|
||||
end)
|
||||
end
|
||||
|
||||
return totalPushed
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Count occupied inventory slots
|
||||
-------------------------------------------------
|
||||
|
||||
local function occupiedSlots()
|
||||
local count = 0
|
||||
for slot = 1, 16 do
|
||||
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Auto-refuel: ask manager for fuel, pull directly
|
||||
-------------------------------------------------
|
||||
|
||||
--- Try to burn whatever is already in FUEL_SLOT
|
||||
local function burnFuelSlot()
|
||||
if FUEL_SLOT <= 0 then return false end
|
||||
local prev = turtle.getSelectedSlot()
|
||||
turtle.select(FUEL_SLOT)
|
||||
if turtle.getItemCount() > 0 then
|
||||
local ok = turtle.refuel()
|
||||
turtle.select(prev)
|
||||
return ok
|
||||
end
|
||||
turtle.select(prev)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Ask the inventory manager where fuel items are
|
||||
local function askManagerForFuel()
|
||||
local replyChannel = ORDER_CHANNEL + 100 + os.getComputerID()
|
||||
modem.open(replyChannel)
|
||||
|
||||
modem.transmit(ORDER_CHANNEL, replyChannel, {
|
||||
type = "find_item",
|
||||
items = FUEL_ITEMS,
|
||||
limit = 1,
|
||||
})
|
||||
|
||||
local deadline = os.clock() + FUEL_REQUEST_TIMEOUT
|
||||
local result = nil
|
||||
|
||||
while os.clock() < deadline do
|
||||
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
|
||||
local event, p1, p2, p3, p4 = os.pullEvent()
|
||||
if event == "modem_message" and p2 == replyChannel then
|
||||
if type(p4) == "table" and p4.type == "find_item_result" then
|
||||
result = p4.results
|
||||
break
|
||||
end
|
||||
elseif event == "timer" and p1 == timerId then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
modem.close(replyChannel)
|
||||
return result
|
||||
end
|
||||
|
||||
--- Pull fuel from a specific chest+slot (told by manager)
|
||||
local function pullFuelFromSource(source)
|
||||
if FUEL_SLOT <= 0 then return false end
|
||||
local chest = peripheral.wrap(source.chest)
|
||||
if not chest then return false end
|
||||
local ok, n = pcall(chest.pushItems, selfName, source.slot, 64, FUEL_SLOT)
|
||||
if ok and n and n > 0 then
|
||||
print(string.format("[FUEL] Pulled %s x%d from %s", source.name, n, source.chest))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function pullFuelFromStorage()
|
||||
-- Ask manager first (it knows exactly where fuel is)
|
||||
local sources = askManagerForFuel()
|
||||
if sources and #sources > 0 then
|
||||
for _, source in ipairs(sources) do
|
||||
if pullFuelFromSource(source) then return true end
|
||||
end
|
||||
end
|
||||
|
||||
-- Fallback: scan chests directly (in case manager is offline)
|
||||
local fuelSet = {}
|
||||
for _, name in ipairs(FUEL_ITEMS) do fuelSet[name] = true end
|
||||
local chests = getChests()
|
||||
for _, chestName in ipairs(chests) do
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.list then
|
||||
for slot, item in pairs(chest.list() or {}) do
|
||||
if fuelSet[item.name] then
|
||||
local ok, n = pcall(chest.pushItems, selfName, slot, 64, FUEL_SLOT)
|
||||
if ok and n and n > 0 then
|
||||
print(string.format("[FUEL] Pulled %s x%d (fallback)", item.name, n))
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function autoRefuel()
|
||||
if turtle.getFuelLevel() == "unlimited" then return end
|
||||
if turtle.getFuelLevel() >= FUEL_THRESHOLD then return end
|
||||
|
||||
-- First try burning whatever is already in the fuel slot
|
||||
if burnFuelSlot() then
|
||||
print("[FUEL] Burned local fuel. Level: " .. turtle.getFuelLevel())
|
||||
return
|
||||
end
|
||||
|
||||
-- Pull fresh fuel from networked storage
|
||||
if pullFuelFromStorage() then
|
||||
burnFuelSlot()
|
||||
print("[FUEL] Refueled from storage. Level: " .. turtle.getFuelLevel())
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Stats
|
||||
-------------------------------------------------
|
||||
|
||||
local totalMined = 0
|
||||
local totalDumped = 0
|
||||
local startTime = os.clock()
|
||||
|
||||
local function printStats()
|
||||
local elapsed = os.clock() - startTime
|
||||
local mins = math.floor(elapsed / 60)
|
||||
local secs = math.floor(elapsed % 60)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("=================================")
|
||||
print(" Mining Turtle - Running")
|
||||
print("=================================")
|
||||
print(string.format(" Mined: %d blocks", totalMined))
|
||||
print(string.format(" Dumped: %d items", totalDumped))
|
||||
print(string.format(" Uptime: %dm %ds", mins, secs))
|
||||
if turtle.getFuelLevel() ~= "unlimited" then
|
||||
print(string.format(" Fuel: %d", turtle.getFuelLevel()))
|
||||
end
|
||||
print(string.format(" Inv used: %d/16 slots", occupiedSlots()))
|
||||
print("=================================")
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Main mining loop
|
||||
-------------------------------------------------
|
||||
|
||||
local function mineLoop()
|
||||
local lastDump = os.clock()
|
||||
|
||||
while true do
|
||||
-- Auto-refuel if low
|
||||
autoRefuel()
|
||||
|
||||
-- Check fuel — keep trying to pull from storage
|
||||
if turtle.getFuelLevel() ~= "unlimited" and turtle.getFuelLevel() < 1 then
|
||||
print("[WARN] Out of fuel! Searching storage...")
|
||||
while turtle.getFuelLevel() < 1 do
|
||||
if pullFuelFromStorage() then
|
||||
burnFuelSlot()
|
||||
end
|
||||
if turtle.getFuelLevel() < 1 then
|
||||
printStats()
|
||||
sleep(10)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Mine the block below
|
||||
if turtle.detectDown() then
|
||||
local ok, err = turtle.digDown()
|
||||
if ok then
|
||||
totalMined = totalMined + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Dump inventory if threshold reached or timer expired
|
||||
local now = os.clock()
|
||||
if occupiedSlots() >= DUMP_THRESHOLD or (now - lastDump) >= DUMP_INTERVAL then
|
||||
if occupiedSlots() > 0 then
|
||||
local pushed = dumpInventory()
|
||||
totalDumped = totalDumped + pushed
|
||||
if pushed > 0 then
|
||||
print(string.format("[DUMP] Pushed %d items to storage", pushed))
|
||||
end
|
||||
end
|
||||
lastDump = os.clock()
|
||||
end
|
||||
|
||||
-- Refresh display periodically
|
||||
printStats()
|
||||
|
||||
sleep(MINE_INTERVAL)
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Reboot listener (remote reboot support)
|
||||
-------------------------------------------------
|
||||
|
||||
local function rebootListener()
|
||||
modem.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == "miner" or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received.")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Entry point
|
||||
-------------------------------------------------
|
||||
|
||||
print("Starting mining loop...")
|
||||
print("Press Ctrl+T to stop.")
|
||||
print("")
|
||||
sleep(1)
|
||||
|
||||
local ok, err = pcall(function()
|
||||
parallel.waitForAny(mineLoop, rebootListener)
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
fatal("[ERR] Mining turtle crashed:\n" .. tostring(err))
|
||||
end
|
||||
82
startup/bridge.lua
Normal file
82
startup/bridge.lua
Normal file
@@ -0,0 +1,82 @@
|
||||
-- startup.lua for Web Bridge computer
|
||||
-- Auto-updates from git then launches inventoryWebBridge.lua
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
local FILES = {
|
||||
["inventoryWebBridge.lua"] = "inventoryWebBridge.lua",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
local function download(remotePath, localPath)
|
||||
local url = REPO_RAW .. "/" .. remotePath
|
||||
local response = http.get(url)
|
||||
if response then
|
||||
local f = fs.open(localPath, "w")
|
||||
f.write(response.readAll())
|
||||
f.close()
|
||||
response.close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("==================================")
|
||||
print(" Web Bridge - Startup")
|
||||
print(" Computer ID: " .. os.getComputerID())
|
||||
print("==================================")
|
||||
print("")
|
||||
|
||||
local updated, failed = 0, 0
|
||||
for localPath, remotePath in pairs(FILES) do
|
||||
write(" " .. localPath .. " ... ")
|
||||
if download(remotePath, localPath) then
|
||||
print("OK")
|
||||
updated = updated + 1
|
||||
else
|
||||
print("FAIL")
|
||||
failed = failed + 1
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if failed > 0 then
|
||||
print(string.format("Updated %d files, %d failed.", updated, failed))
|
||||
else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
print("")
|
||||
print("Starting inventoryWebBridge...")
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this computer on remote command
|
||||
local SYSTEM_CHANNEL = 4205
|
||||
local ROLE = "bridge"
|
||||
|
||||
local function rebootListener()
|
||||
local m = peripheral.find("modem")
|
||||
if not m then return end
|
||||
m.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == ROLE or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received. Rebooting...")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(
|
||||
function() shell.run("inventoryWebBridge.lua") end,
|
||||
rebootListener
|
||||
)
|
||||
203
startup/client.lua
Normal file
203
startup/client.lua
Normal file
@@ -0,0 +1,203 @@
|
||||
-- startup.lua for Inventory Client computer
|
||||
-- Auto-updates from git then launches inventoryClient + dropperController in parallel
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
-- Persistent config directory (survives Opus package updates)
|
||||
local PERSIST_DIR = "usr/config/inventory-manager"
|
||||
if not fs.isDir(PERSIST_DIR) then fs.makeDir(PERSIST_DIR) end
|
||||
|
||||
local SETUP_FILE = fs.combine(PERSIST_DIR, ".client_setup")
|
||||
local DROPPER_CONFIG = fs.combine(PERSIST_DIR, ".dropper_config")
|
||||
|
||||
-- Files to download (destination -> repo path)
|
||||
local FILES = {
|
||||
["inventoryClient.lua"] = "inventoryClient.lua",
|
||||
["dropperController.lua"] = "dropperController.lua",
|
||||
["lib/log.lua"] = "lib/log.lua",
|
||||
["lib/ui.lua"] = "lib/ui.lua",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
local function ensureDir(filePath)
|
||||
local dir = filePath:match("^(.+)/")
|
||||
if dir and not fs.isDir(dir) then
|
||||
fs.makeDir(dir)
|
||||
end
|
||||
end
|
||||
|
||||
local function download(remotePath, localPath)
|
||||
local url = REPO_RAW .. "/" .. remotePath
|
||||
local response = http.get(url)
|
||||
if response then
|
||||
ensureDir(localPath)
|
||||
local f = fs.open(localPath, "w")
|
||||
f.write(response.readAll())
|
||||
f.close()
|
||||
response.close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("==================================")
|
||||
print(" Inventory Client - Startup")
|
||||
print(" Computer ID: " .. os.getComputerID())
|
||||
print("==================================")
|
||||
print("")
|
||||
|
||||
-- Update files
|
||||
local updated, failed = 0, 0
|
||||
for localPath, remotePath in pairs(FILES) do
|
||||
write(" " .. localPath .. " ... ")
|
||||
if download(remotePath, localPath) then
|
||||
print("OK")
|
||||
updated = updated + 1
|
||||
else
|
||||
print("FAIL")
|
||||
failed = failed + 1
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if failed > 0 then
|
||||
print(string.format("Updated %d files, %d failed.", updated, failed))
|
||||
print("Continuing with existing files...")
|
||||
else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- First-run setup
|
||||
-------------------------------------------------
|
||||
|
||||
local VALID_SIDES = { "top", "bottom", "left", "right", "front", "back" }
|
||||
local function isValidSide(s)
|
||||
for _, v in ipairs(VALID_SIDES) do
|
||||
if v == s then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function firstRunSetup()
|
||||
if fs.exists(SETUP_FILE) then return end
|
||||
|
||||
print("")
|
||||
print("========== First-Run Setup ==========")
|
||||
print("")
|
||||
write("Is there a dropper next to this computer? (y/n): ")
|
||||
local answer = read():lower():sub(1, 1)
|
||||
|
||||
if answer == "y" then
|
||||
-- Ask which side the dropper is on
|
||||
local side
|
||||
while true do
|
||||
print("")
|
||||
print("Valid sides: top, bottom, left, right, front, back")
|
||||
write("Which side is the dropper on? ")
|
||||
side = read():lower():gsub("%s+", "")
|
||||
if isValidSide(side) then
|
||||
break
|
||||
end
|
||||
print("Invalid side. Please try again.")
|
||||
end
|
||||
|
||||
-- Ask which side to pulse redstone from (defaults to same side)
|
||||
print("")
|
||||
write("Redstone output side [" .. side .. "]: ")
|
||||
local rsSide = read():lower():gsub("%s+", "")
|
||||
if rsSide == "" then rsSide = side end
|
||||
if not isValidSide(rsSide) then
|
||||
print("Invalid side, defaulting to " .. side)
|
||||
rsSide = side
|
||||
end
|
||||
|
||||
-- Save dropper config
|
||||
local cfg = textutils.serialiseJSON({
|
||||
dropperSide = side,
|
||||
redstoneSide = rsSide,
|
||||
enabled = true,
|
||||
})
|
||||
local f = fs.open(DROPPER_CONFIG, "w")
|
||||
f.write(cfg)
|
||||
f.close()
|
||||
print("")
|
||||
print("Dropper configured: peripheral=" .. side .. ", redstone=" .. rsSide)
|
||||
else
|
||||
-- No dropper - save config with enabled=false
|
||||
local f = fs.open(DROPPER_CONFIG, "w")
|
||||
f.write(textutils.serialiseJSON({ enabled = false }))
|
||||
f.close()
|
||||
print("No dropper configured.")
|
||||
end
|
||||
|
||||
-- Mark setup as complete
|
||||
local f = fs.open(SETUP_FILE, "w")
|
||||
f.write("done")
|
||||
f.close()
|
||||
|
||||
print("")
|
||||
print("Setup complete!")
|
||||
print("(Delete " .. SETUP_FILE .. " to re-run setup)")
|
||||
sleep(2)
|
||||
end
|
||||
|
||||
firstRunSetup()
|
||||
|
||||
-------------------------------------------------
|
||||
-- Determine if dropper controller should run
|
||||
-------------------------------------------------
|
||||
|
||||
local dropperEnabled = false
|
||||
if fs.exists(DROPPER_CONFIG) then
|
||||
local f = fs.open(DROPPER_CONFIG, "r")
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, f.readAll())
|
||||
f.close()
|
||||
if ok and cfg and cfg.enabled then
|
||||
dropperEnabled = true
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if dropperEnabled then
|
||||
print("Starting inventoryClient + dropperController...")
|
||||
else
|
||||
print("Starting inventoryClient (no dropper)...")
|
||||
end
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this computer on remote command
|
||||
local SYSTEM_CHANNEL = 4205
|
||||
local ROLE = "client"
|
||||
|
||||
local function rebootListener()
|
||||
local m = peripheral.find("modem")
|
||||
if not m then return end
|
||||
m.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == ROLE or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received. Rebooting...")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local tasks = {
|
||||
function() shell.run("inventoryClient.lua") end,
|
||||
rebootListener,
|
||||
}
|
||||
if dropperEnabled then
|
||||
table.insert(tasks, function() shell.run("dropperController.lua") end)
|
||||
end
|
||||
|
||||
parallel.waitForAny(table.unpack(tasks))
|
||||
105
startup/manager.lua
Normal file
105
startup/manager.lua
Normal file
@@ -0,0 +1,105 @@
|
||||
-- startup.lua for Inventory Manager computer
|
||||
-- Auto-updates from git then launches inventoryManager.lua
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
-- Files to download (destination -> repo path)
|
||||
local FILES = {
|
||||
["inventoryManager.lua"] = "inventoryManager.lua",
|
||||
["manager/config.lua"] = "manager/config.lua",
|
||||
["manager/state.lua"] = "manager/state.lua",
|
||||
["manager/operations.lua"] = "manager/operations.lua",
|
||||
["manager/display.lua"] = "manager/display.lua",
|
||||
["lib/log.lua"] = "lib/log.lua",
|
||||
["lib/ui.lua"] = "lib/ui.lua",
|
||||
["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",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
local function ensureDir(filePath)
|
||||
local dir = filePath:match("^(.+)/")
|
||||
if dir and not fs.isDir(dir) then
|
||||
fs.makeDir(dir)
|
||||
end
|
||||
end
|
||||
|
||||
local function download(remotePath, localPath)
|
||||
local url = REPO_RAW .. "/" .. remotePath
|
||||
local response = http.get(url)
|
||||
if response then
|
||||
ensureDir(localPath)
|
||||
local f = fs.open(localPath, "w")
|
||||
f.write(response.readAll())
|
||||
f.close()
|
||||
response.close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("==================================")
|
||||
print(" Inventory Manager - Startup")
|
||||
print(" Computer ID: " .. os.getComputerID())
|
||||
print("==================================")
|
||||
print("")
|
||||
|
||||
-- Update files
|
||||
local updated, failed = 0, 0
|
||||
for localPath, remotePath in pairs(FILES) do
|
||||
write(" " .. localPath .. " ... ")
|
||||
if download(remotePath, localPath) then
|
||||
print("OK")
|
||||
updated = updated + 1
|
||||
else
|
||||
print("FAIL")
|
||||
failed = failed + 1
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if failed > 0 then
|
||||
print(string.format("Updated %d files, %d failed.", updated, failed))
|
||||
print("Continuing with existing files...")
|
||||
else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
print("")
|
||||
print("Starting inventoryManager...")
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this computer when the manager sends
|
||||
-- a reboot command to itself (target = "all" or "manager")
|
||||
local SYSTEM_CHANNEL = 4205
|
||||
local ROLE = "manager"
|
||||
|
||||
local function rebootListener()
|
||||
local m = peripheral.find("modem")
|
||||
if not m then return end
|
||||
m.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == ROLE or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received. Rebooting...")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(
|
||||
function() shell.run("inventoryManager.lua") end,
|
||||
rebootListener
|
||||
)
|
||||
58
startup/miner.lua
Normal file
58
startup/miner.lua
Normal file
@@ -0,0 +1,58 @@
|
||||
-- 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)
|
||||
|
||||
shell.run("miningTurtle.lua")
|
||||
82
startup/turtle.lua
Normal file
82
startup/turtle.lua
Normal file
@@ -0,0 +1,82 @@
|
||||
-- startup.lua for Crafting Turtle
|
||||
-- Auto-updates from git then launches craftingTurtle.lua
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
local FILES = {
|
||||
["craftingTurtle.lua"] = "craftingTurtle.lua",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
local function download(remotePath, localPath)
|
||||
local url = REPO_RAW .. "/" .. remotePath
|
||||
local response = http.get(url)
|
||||
if response then
|
||||
local f = fs.open(localPath, "w")
|
||||
f.write(response.readAll())
|
||||
f.close()
|
||||
response.close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("==================================")
|
||||
print(" Crafting Turtle - Startup")
|
||||
print(" Computer ID: " .. os.getComputerID())
|
||||
print("==================================")
|
||||
print("")
|
||||
|
||||
local updated, failed = 0, 0
|
||||
for localPath, remotePath in pairs(FILES) do
|
||||
write(" " .. localPath .. " ... ")
|
||||
if download(remotePath, localPath) then
|
||||
print("OK")
|
||||
updated = updated + 1
|
||||
else
|
||||
print("FAIL")
|
||||
failed = failed + 1
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if failed > 0 then
|
||||
print(string.format("Updated %d files, %d failed.", updated, failed))
|
||||
else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
print("")
|
||||
print("Starting craftingTurtle...")
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this turtle on remote command
|
||||
local SYSTEM_CHANNEL = 4205
|
||||
local ROLE = "turtle"
|
||||
|
||||
local function rebootListener()
|
||||
local m = peripheral.find("modem")
|
||||
if not m then return end
|
||||
m.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == ROLE or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received. Rebooting...")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(
|
||||
function() shell.run("craftingTurtle.lua") end,
|
||||
rebootListener
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build the React app
|
||||
FROM node:18-alpine AS build
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -9,6 +9,10 @@ RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
# Accept API key at build time so Vite can inline it
|
||||
ARG VITE_API_KEY=""
|
||||
ENV VITE_API_KEY=${VITE_API_KEY}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Texture proxy cache zone (100MB, inactive entries purged after 7 days)
|
||||
proxy_cache_path /tmp/nginx_texture_cache levels=1:2 keys_zone=textures:10m
|
||||
max_size=100m inactive=7d use_temp_path=off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -5,6 +9,15 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
server_tokens off;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "0" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Serve static files, fallback to index.html for SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -27,6 +40,7 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Let the server set Cache-Control, but also cache at nginx level
|
||||
proxy_cache textures;
|
||||
proxy_cache_valid 200 7d;
|
||||
proxy_cache_valid 404 1d;
|
||||
expires 7d;
|
||||
|
||||
@@ -4,6 +4,31 @@
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&display=swap');
|
||||
|
||||
/* === Cross-link Button === */
|
||||
.cross-link-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 2px solid var(--mc-dark);
|
||||
background: #2e4a8b;
|
||||
color: var(--mc-text-aqua);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 1px 0 #4466bb, inset 0 -1px 0 #1a2a66;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cross-link-btn:hover {
|
||||
background: #3e5a9b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
:root {
|
||||
--mc-dark: #1a1a1a;
|
||||
--mc-darker: #0e0e0e;
|
||||
|
||||
@@ -22,6 +22,8 @@ function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || 'https://turtles.spatulaa.com';
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
}, [connect]);
|
||||
@@ -34,10 +36,6 @@ function App() {
|
||||
|
||||
const staleSecs = lastUpdate ? Math.floor((Date.now() - lastUpdate) / 1000) : null;
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
const renderPanelContent = () => {
|
||||
switch (panelTab) {
|
||||
case 'inventory':
|
||||
@@ -60,6 +58,16 @@ function App() {
|
||||
<div className="app-header">
|
||||
<h1>⛏️ Inventory Manager</h1>
|
||||
<div className="header-right">
|
||||
{/* Cross-link to Turtle Dashboard */}
|
||||
<a
|
||||
href={turtleDashboardUrl}
|
||||
className="cross-link-btn"
|
||||
title="Open Turtle Control Dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🐢 Turtles
|
||||
</a>
|
||||
{/* Settings gear button */}
|
||||
<button
|
||||
className="settings-gear"
|
||||
|
||||
@@ -200,6 +200,81 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* === Remote Reboot Section === */
|
||||
|
||||
.reboot-status {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
margin-bottom: 0.6rem;
|
||||
background: #1a1f2e;
|
||||
border: 2px solid #3a4a6a;
|
||||
color: var(--mc-text-aqua);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reboot-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.reboot-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: #222;
|
||||
border: 2px solid #3a3a3a;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.reboot-target:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.reboot-target-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reboot-target-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--mc-text-white);
|
||||
}
|
||||
|
||||
.reboot-target-desc {
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-gray);
|
||||
}
|
||||
|
||||
.reboot-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reboot-confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-btn.red {
|
||||
border-color: #aa3333;
|
||||
color: var(--mc-text-red);
|
||||
background: linear-gradient(180deg, #5a2020, #3a1010);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #7a3030,
|
||||
inset -1px -1px 0 #2a0808;
|
||||
}
|
||||
|
||||
.mc-btn.red:hover {
|
||||
background: linear-gradient(180deg, #7a3030, #4a1818);
|
||||
color: #ff6666;
|
||||
}
|
||||
|
||||
/* === Settings gear button (in header) === */
|
||||
|
||||
.settings-gear {
|
||||
|
||||
@@ -2,14 +2,25 @@ import React, { useState, useCallback } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import './SettingsPanel.css';
|
||||
|
||||
const REBOOT_TARGETS = [
|
||||
{ value: 'all', label: 'All Devices', icon: '🔄', desc: 'Reboot everything (manager + clients + turtles + bridge)' },
|
||||
{ value: 'client', label: 'Clients', icon: '💻', desc: 'Reboot all inventory client displays' },
|
||||
{ value: 'turtle', label: 'Crafting Turtles', icon: '🐢', desc: 'Reboot all crafting turtles' },
|
||||
{ value: 'bridge', label: 'Web Bridge', icon: '🌉', desc: 'Reboot the HTTP/WS bridge computer' },
|
||||
{ value: 'manager', label: 'Manager', icon: '📦', desc: 'Reboot the inventory manager (will disconnect briefly)' },
|
||||
];
|
||||
|
||||
function SettingsPanel({ isOpen, onClose }) {
|
||||
const droppers = useInventoryStore((state) => state.inventory.droppers) || [];
|
||||
const dropperNicknames = useInventoryStore((state) => state.dropperNicknames) || {};
|
||||
const setDropperNickname = useInventoryStore((state) => state.setDropperNickname);
|
||||
const rebootComputers = useInventoryStore((state) => state.rebootComputers);
|
||||
|
||||
const [editingDropper, setEditingDropper] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [rebootConfirm, setRebootConfirm] = useState(null);
|
||||
const [rebootStatus, setRebootStatus] = useState(null);
|
||||
|
||||
const startEditing = useCallback((dropperName) => {
|
||||
setEditingDropper(dropperName);
|
||||
@@ -39,6 +50,18 @@ function SettingsPanel({ isOpen, onClose }) {
|
||||
}
|
||||
}, [saveNickname]);
|
||||
|
||||
const handleReboot = useCallback(async (target) => {
|
||||
setRebootConfirm(null);
|
||||
setRebootStatus(`Sending reboot to ${target}...`);
|
||||
const result = await rebootComputers(target);
|
||||
if (result?.success) {
|
||||
setRebootStatus(`✅ Reboot sent to: ${target}`);
|
||||
} else {
|
||||
setRebootStatus(`❌ Failed: ${result?.error || 'Unknown error'}`);
|
||||
}
|
||||
setTimeout(() => setRebootStatus(null), 4000);
|
||||
}, [rebootComputers]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -136,6 +159,51 @@ function SettingsPanel({ isOpen, onClose }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>🔄 Remote Reboot</h3>
|
||||
<p className="settings-hint">
|
||||
Reboot CC:Tweaked computers remotely. They will auto-update from git and restart.
|
||||
</p>
|
||||
|
||||
{rebootStatus && (
|
||||
<div className="reboot-status">{rebootStatus}</div>
|
||||
)}
|
||||
|
||||
<div className="reboot-grid">
|
||||
{REBOOT_TARGETS.map((t) => (
|
||||
<div key={t.value} className="reboot-target">
|
||||
<div className="reboot-target-info">
|
||||
<span className="reboot-target-label">{t.icon} {t.label}</span>
|
||||
<span className="reboot-target-desc">{t.desc}</span>
|
||||
</div>
|
||||
{rebootConfirm === t.value ? (
|
||||
<div className="reboot-confirm-actions">
|
||||
<button
|
||||
className="mc-btn red"
|
||||
onClick={() => handleReboot(t.value)}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
className="mc-btn"
|
||||
onClick={() => setRebootConfirm(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="mc-btn reboot-btn"
|
||||
onClick={() => setRebootConfirm(t.value)}
|
||||
>
|
||||
Reboot
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL ||
|
||||
const _baseWsUrl = import.meta.env.VITE_WS_URL ||
|
||||
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||
const API_URL = import.meta.env.VITE_API_URL ||
|
||||
`${window.location.protocol}//${window.location.host}/api`;
|
||||
const API_KEY = import.meta.env.VITE_API_KEY || '';
|
||||
|
||||
console.log('🔌 WebSocket URL:', WS_URL);
|
||||
// Append API key to WebSocket URL as query param
|
||||
const WS_URL = API_KEY ? `${_baseWsUrl}?key=${encodeURIComponent(API_KEY)}` : _baseWsUrl;
|
||||
|
||||
// Build common headers for authenticated requests
|
||||
function authHeaders(extra = {}) {
|
||||
const headers = { 'Content-Type': 'application/json', ...extra };
|
||||
if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
console.log('🔌 WebSocket URL:', _baseWsUrl);
|
||||
console.log('📡 API URL:', API_URL);
|
||||
if (API_KEY) console.log('🔒 API key configured');
|
||||
|
||||
// Generate a unique command ID for idempotent requests
|
||||
function newCommandId() {
|
||||
@@ -17,6 +29,7 @@ function newCommandId() {
|
||||
let _httpPollTimer = null;
|
||||
let _wsHealthTimer = null;
|
||||
let _lastWsMessage = 0;
|
||||
let _reconnectDelay = 1000;
|
||||
|
||||
const HTTP_POLL_INTERVAL = 10_000; // Poll HTTP every 10 s as fallback
|
||||
const WS_STALE_TIMEOUT = 35_000; // If no WS message for 35 s → reconnect
|
||||
@@ -33,7 +46,7 @@ function _applyStateData(data, current) {
|
||||
craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk,
|
||||
bridgeConnected: data.bridgeConnected !== undefined ? data.bridgeConnected : current.bridgeConnected,
|
||||
dropperNicknames: data.dropperNicknames || current.dropperNicknames,
|
||||
lastUpdate: data.lastUpdate || Date.now(),
|
||||
lastUpdate: data.lastUpdate != null ? data.lastUpdate : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +141,7 @@ export const useInventoryStore = create((set, get) => ({
|
||||
ws.onopen = () => {
|
||||
console.log('✅ Connected to server');
|
||||
_lastWsMessage = Date.now();
|
||||
_reconnectDelay = 1000;
|
||||
set({ connected: true, ws });
|
||||
};
|
||||
|
||||
@@ -158,9 +172,11 @@ export const useInventoryStore = create((set, get) => ({
|
||||
ws.onclose = () => {
|
||||
console.log('❌ Disconnected from server');
|
||||
set({ connected: false, ws: null });
|
||||
const delay = _reconnectDelay;
|
||||
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
|
||||
setTimeout(() => {
|
||||
get().connect();
|
||||
}, 3000);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
@@ -187,7 +203,7 @@ export const useInventoryStore = create((set, get) => ({
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/order`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ itemName, amount, dropperName, commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
@@ -201,7 +217,7 @@ export const useInventoryStore = create((set, get) => ({
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/scan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
@@ -215,7 +231,7 @@ export const useInventoryStore = create((set, get) => ({
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/smelting/toggle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
@@ -229,7 +245,7 @@ export const useInventoryStore = create((set, get) => ({
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/recipes/toggle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ recipe, commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
@@ -241,25 +257,29 @@ export const useInventoryStore = create((set, get) => ({
|
||||
|
||||
enableAllRecipes: async () => {
|
||||
try {
|
||||
await fetch(`${API_URL}/recipes/enable-all`, {
|
||||
const response = await fetch(`${API_URL}/recipes/enable-all`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error enabling all recipes:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
disableAllRecipes: async () => {
|
||||
try {
|
||||
await fetch(`${API_URL}/recipes/disable-all`, {
|
||||
const response = await fetch(`${API_URL}/recipes/disable-all`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error disabling all recipes:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
@@ -267,7 +287,7 @@ export const useInventoryStore = create((set, get) => ({
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/craft`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ recipeIdx, commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
@@ -277,11 +297,25 @@ export const useInventoryStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
rebootComputers: async (target = 'all') => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/reboot`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ target, commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('❌ Error sending reboot:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
sortBarrel: async (barrelName) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/sort-barrel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ barrelName, commandId: newCommandId() }),
|
||||
});
|
||||
return await response.json();
|
||||
@@ -296,8 +330,8 @@ export const useInventoryStore = create((set, get) => ({
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/dropper-nicknames`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dropperName, nickname }),
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ dropperName, nickname, commandId: newCommandId() }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.nicknames) {
|
||||
|
||||
@@ -5,23 +5,23 @@ services:
|
||||
- inventory-network
|
||||
volumes:
|
||||
- server-data:/data
|
||||
environment:
|
||||
- API_KEY=${API_KEY:-}
|
||||
- TURTLE_SERVER_URL=${TURTLE_SERVER_URL:-}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
start_period: 5s
|
||||
retries: 3
|
||||
|
||||
client:
|
||||
build: ./client
|
||||
build:
|
||||
context: ./client
|
||||
args:
|
||||
VITE_API_KEY: ${API_KEY:-}
|
||||
ports:
|
||||
- "80:80"
|
||||
networks:
|
||||
- inventory-network
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Node.js backend
|
||||
FROM node:18-alpine
|
||||
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
|
||||
|
||||
@@ -11,6 +13,7 @@ COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Remove build tools after install to keep image small
|
||||
# libstdc++ and su-exec are kept for runtime
|
||||
RUN apk del python3 make g++
|
||||
|
||||
COPY . .
|
||||
@@ -19,9 +22,14 @@ COPY . .
|
||||
RUN mkdir -p /data
|
||||
VOLUME /data
|
||||
|
||||
# Entrypoint fixes /data permissions then drops to node user
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "require('http').get('http://127.0.0.1:3001/api/health', r => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
207
web/server/__tests__/db.test.js
Normal file
207
web/server/__tests__/db.test.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Tests for the Inventory Manager database layer (db.js).
|
||||
*
|
||||
* Sets DB_PATH to a temp file BEFORE importing the module so all
|
||||
* operations hit a throwaway SQLite database. The underlying
|
||||
* better-sqlite3 `Database` instance is also imported so we can
|
||||
* DELETE rows between tests without reopening the file.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// DB_PATH is set via vitest.config.js env before this module loads.
|
||||
const TMP_DB = process.env.DB_PATH;
|
||||
import db from '../db.js';
|
||||
import {
|
||||
saveItems, loadItems,
|
||||
saveState, loadState,
|
||||
saveFurnaces, loadFurnaces,
|
||||
saveAlerts, loadAlerts,
|
||||
recordItemHistory, getHistory, getHistorySummary,
|
||||
saveFullState, flushPendingSave, loadFullState,
|
||||
closeDb,
|
||||
} from '../db.js';
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Wipe all rows from every table so each test starts fresh */
|
||||
function clearAllTables() {
|
||||
db.exec(`
|
||||
DELETE FROM items;
|
||||
DELETE FROM furnaces;
|
||||
DELETE FROM alerts;
|
||||
DELETE FROM state;
|
||||
DELETE FROM item_history;
|
||||
`);
|
||||
}
|
||||
|
||||
// ── lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { closeDb(); } catch { /* already closed */ }
|
||||
try { fs.unlinkSync(TMP_DB); } catch { /* ignore */ }
|
||||
try { fs.unlinkSync(TMP_DB + '-wal'); } catch { /* ignore */ }
|
||||
try { fs.unlinkSync(TMP_DB + '-shm'); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// ── tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('db – item persistence', () => {
|
||||
it('saveItems + loadItems round-trips item data', () => {
|
||||
const items = [
|
||||
{ name: 'minecraft:diamond', count: 64, displayName: 'Diamond' },
|
||||
{ name: 'minecraft:iron_ingot', count: 128, displayName: 'Iron Ingot' },
|
||||
];
|
||||
saveItems(items);
|
||||
|
||||
const loaded = loadItems();
|
||||
expect(loaded).toHaveLength(2);
|
||||
|
||||
// loadItems returns ORDER BY count DESC
|
||||
expect(loaded[0].name).toBe('minecraft:iron_ingot');
|
||||
expect(loaded[0].count).toBe(128);
|
||||
expect(loaded[1].name).toBe('minecraft:diamond');
|
||||
expect(loaded[1].count).toBe(64);
|
||||
});
|
||||
|
||||
it('saveItems upserts — updates existing rows instead of duplicating', () => {
|
||||
saveItems([{ name: 'minecraft:diamond', count: 10, displayName: 'Diamond' }]);
|
||||
saveItems([{ name: 'minecraft:diamond', count: 99, displayName: 'Diamond' }]);
|
||||
|
||||
const loaded = loadItems();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].count).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('db – state key-value store', () => {
|
||||
it('saveState + loadState round-trips JSON', () => {
|
||||
saveState('smeltingPaused', true);
|
||||
expect(loadState('smeltingPaused')).toBe(true);
|
||||
|
||||
saveState('disabledRecipes', { 'minecraft:iron_ingot': true });
|
||||
expect(loadState('disabledRecipes')).toEqual({ 'minecraft:iron_ingot': true });
|
||||
});
|
||||
|
||||
it('loadState returns defaultValue when key missing', () => {
|
||||
expect(loadState('nonexistent', 42)).toBe(42);
|
||||
expect(loadState('nonexistent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('db – furnace persistence', () => {
|
||||
it('saveFurnaces + loadFurnaces round-trips furnace status', () => {
|
||||
const furnaces = {
|
||||
'minecraft:furnace_0': {
|
||||
active: true,
|
||||
type: 'minecraft:furnace',
|
||||
input: { name: 'minecraft:raw_iron', count: 8 },
|
||||
fuel: { name: 'minecraft:coal', count: 3 },
|
||||
output: null,
|
||||
},
|
||||
};
|
||||
|
||||
saveFurnaces(furnaces);
|
||||
const loaded = loadFurnaces();
|
||||
|
||||
expect(loaded['minecraft:furnace_0']).toBeDefined();
|
||||
expect(loaded['minecraft:furnace_0'].active).toBe(true);
|
||||
expect(loaded['minecraft:furnace_0'].input.name).toBe('minecraft:raw_iron');
|
||||
});
|
||||
});
|
||||
|
||||
describe('db – alert persistence', () => {
|
||||
it('saveAlerts + loadAlerts round-trips triggered alerts', () => {
|
||||
saveAlerts([
|
||||
{ item: 'minecraft:diamond', triggered: true, current: 5, threshold: 10 },
|
||||
{ item: 'minecraft:coal', triggered: false, current: 200, threshold: 50 },
|
||||
]);
|
||||
|
||||
const loaded = loadAlerts();
|
||||
// loadAlerts only returns triggered=1
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].item).toBe('minecraft:diamond');
|
||||
expect(loaded[0].triggered).toBe(true);
|
||||
expect(loaded[0].current).toBe(5);
|
||||
expect(loaded[0].threshold).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('db – item history', () => {
|
||||
it('getHistory returns recorded snapshots ordered newest-first', () => {
|
||||
// recordItemHistory is throttled (5-min internal cooldown).
|
||||
// First call after module load will go through.
|
||||
recordItemHistory([
|
||||
{ name: 'minecraft:diamond', count: 64 },
|
||||
{ name: 'minecraft:iron_ingot', count: 128 },
|
||||
]);
|
||||
|
||||
const history = getHistory('minecraft:diamond', 10);
|
||||
expect(history.length).toBeGreaterThanOrEqual(1);
|
||||
expect(history[0].count).toBe(64);
|
||||
});
|
||||
|
||||
it('getHistorySummary groups by timestamp', () => {
|
||||
// May be throttled if previous test already recorded in this module load.
|
||||
// Insert directly via the underlying db for determinism.
|
||||
const now = Date.now();
|
||||
db.prepare('INSERT INTO item_history (name, count, recorded_at) VALUES (?, ?, ?)').run('a', 10, now);
|
||||
db.prepare('INSERT INTO item_history (name, count, recorded_at) VALUES (?, ?, ?)').run('b', 20, now);
|
||||
|
||||
const summary = getHistorySummary();
|
||||
expect(summary.length).toBeGreaterThanOrEqual(1);
|
||||
expect(summary[0].total).toBe(30); // 10 + 20
|
||||
});
|
||||
});
|
||||
|
||||
describe('db – full state round-trip', () => {
|
||||
it('saveFullState + flushPendingSave + loadFullState restores everything', () => {
|
||||
const state = {
|
||||
inventoryState: {
|
||||
itemList: [
|
||||
{ name: 'minecraft:diamond', count: 64, displayName: 'Diamond' },
|
||||
],
|
||||
grandTotal: 64,
|
||||
chestCount: 2,
|
||||
totalSlots: 54,
|
||||
usedSlots: 1,
|
||||
freeSlots: 53,
|
||||
usedRatio: 0.02,
|
||||
dropperOk: true,
|
||||
barrelOk: false,
|
||||
furnaceCount: 1,
|
||||
furnaceStatus: {
|
||||
'furnace_0': { active: false, type: 'minecraft:furnace', input: null, fuel: null, output: null },
|
||||
},
|
||||
droppers: ['dropper_0'],
|
||||
},
|
||||
activityState: { lastScan: 12345 },
|
||||
alertsState: [{ item: 'minecraft:coal', triggered: true, current: 3, threshold: 10 }],
|
||||
smeltingPaused: false,
|
||||
disabledRecipes: { 'minecraft:charcoal': true },
|
||||
smeltableRecipes: { 'minecraft:raw_iron': 'minecraft:iron_ingot' },
|
||||
craftableRecipes: [{ output: 'minecraft:chest', count: 1, slots: { 1: 'minecraft:planks' } }],
|
||||
craftTurtleOk: true,
|
||||
};
|
||||
|
||||
saveFullState(state);
|
||||
flushPendingSave(); // force the debounced write
|
||||
|
||||
const restored = loadFullState();
|
||||
expect(restored.inventoryState.itemList).toHaveLength(1);
|
||||
expect(restored.inventoryState.itemList[0].name).toBe('minecraft:diamond');
|
||||
expect(restored.inventoryState.grandTotal).toBe(64);
|
||||
expect(restored.smeltingPaused).toBe(false);
|
||||
expect(restored.disabledRecipes).toEqual({ 'minecraft:charcoal': true });
|
||||
expect(restored.craftTurtleOk).toBe(true);
|
||||
expect(restored.alertsState).toHaveLength(1);
|
||||
expect(restored.alertsState[0].item).toBe('minecraft:coal');
|
||||
});
|
||||
});
|
||||
308
web/server/__tests__/server.test.js
Normal file
308
web/server/__tests__/server.test.js
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Tests for Inventory Manager server logic.
|
||||
*
|
||||
* server.js is a monolith that starts listening on import, so we can't
|
||||
* import it directly in a test. Instead we extract and test the pure
|
||||
* helper logic that lives inside it:
|
||||
*
|
||||
* • Rate limiter algorithm
|
||||
* • Input validation rules (order, craft, reboot targets)
|
||||
* • Lua -> Frontend state normalization (itemList, furnaces, alerts, recipes)
|
||||
* • Idempotent command tracking
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Extracted: createRateLimiter ───────────────────────────────────────
|
||||
|
||||
function createRateLimiter(windowMs, max) {
|
||||
const hits = new Map();
|
||||
// Note: in tests we skip the setInterval cleanup
|
||||
return (req, res, next) => {
|
||||
const key = req.ip || 'unknown';
|
||||
const now = Date.now();
|
||||
const entry = hits.get(key);
|
||||
if (!entry || now - entry.start > windowMs) {
|
||||
hits.set(key, { start: now, count: 1 });
|
||||
return next();
|
||||
}
|
||||
entry.count++;
|
||||
if (entry.count > max) {
|
||||
res.set('Retry-After', String(Math.ceil((entry.start + windowMs - now) / 1000)));
|
||||
return res.status(429).json({ error: 'Too many requests — try again later' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
// ── Extracted: normalization helpers ───────────────────────────────────
|
||||
|
||||
function normalizeItemList(rawItems) {
|
||||
return Array.isArray(rawItems)
|
||||
? rawItems.map(item => ({
|
||||
name: item.name || '',
|
||||
count: item.total !== undefined ? item.total : (item.count || 0),
|
||||
displayName: item.displayName || item.name || '',
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeFurnaces(rawFurnaces) {
|
||||
let normalizedFurnaces = {};
|
||||
if (Array.isArray(rawFurnaces)) {
|
||||
rawFurnaces.forEach(f => {
|
||||
if (f && f.name) {
|
||||
normalizedFurnaces[f.name] = {
|
||||
active: f.active || false,
|
||||
type: f.type || 'minecraft:furnace',
|
||||
input: f.input || null,
|
||||
fuel: f.fuel || null,
|
||||
output: f.output || null,
|
||||
};
|
||||
}
|
||||
});
|
||||
} else if (typeof rawFurnaces === 'object') {
|
||||
normalizedFurnaces = rawFurnaces;
|
||||
}
|
||||
return normalizedFurnaces;
|
||||
}
|
||||
|
||||
function normalizeAlerts(rawAlerts) {
|
||||
return Array.isArray(rawAlerts)
|
||||
? rawAlerts.map(a => ({
|
||||
item: a.name || a.item || a.label || '',
|
||||
triggered: true,
|
||||
current: a.current || 0,
|
||||
threshold: a.min || a.threshold || 0,
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeCraftable(rawCraftable) {
|
||||
return Array.isArray(rawCraftable)
|
||||
? rawCraftable.map(recipe => {
|
||||
const slots = {};
|
||||
if (recipe.grid && Array.isArray(recipe.grid)) {
|
||||
recipe.grid.forEach((item, idx) => {
|
||||
if (item) slots[idx + 1] = item;
|
||||
});
|
||||
} else if (recipe.slots) {
|
||||
Object.assign(slots, recipe.slots);
|
||||
}
|
||||
return { output: recipe.output || '', count: recipe.count || 1, slots };
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
// ── Extracted: idempotent command tracking ─────────────────────────────
|
||||
|
||||
function createCommandTracker(ttl = 5 * 60 * 1000) {
|
||||
const processedCommands = new Map();
|
||||
return {
|
||||
check(commandId) {
|
||||
if (!commandId) return null;
|
||||
return processedCommands.get(commandId)?.result || null;
|
||||
},
|
||||
record(commandId, result) {
|
||||
if (!commandId) return;
|
||||
processedCommands.set(commandId, { result, timestamp: Date.now() });
|
||||
},
|
||||
size() { return processedCommands.size; },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createRateLimiter', () => {
|
||||
it('allows requests under the limit', () => {
|
||||
const limiter = createRateLimiter(60_000, 3);
|
||||
const req = { ip: '127.0.0.1' };
|
||||
const next = vi.fn();
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
|
||||
|
||||
limiter(req, res, next);
|
||||
limiter(req, res, next);
|
||||
limiter(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(3);
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks requests over the limit with 429', () => {
|
||||
const limiter = createRateLimiter(60_000, 2);
|
||||
const req = { ip: '10.0.0.1' };
|
||||
const next = vi.fn();
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
|
||||
|
||||
limiter(req, res, next); // 1 — allowed
|
||||
limiter(req, res, next); // 2 — allowed
|
||||
limiter(req, res, next); // 3 — blocked
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
expect(res.set).toHaveBeenCalledWith('Retry-After', expect.any(String));
|
||||
});
|
||||
|
||||
it('tracks different IPs independently', () => {
|
||||
const limiter = createRateLimiter(60_000, 1);
|
||||
const next = vi.fn();
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
|
||||
|
||||
limiter({ ip: 'a' }, res, next); // a:1 — allowed
|
||||
limiter({ ip: 'b' }, res, next); // b:1 — allowed
|
||||
limiter({ ip: 'a' }, res, next); // a:2 — blocked
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeItemList', () => {
|
||||
it('converts Lua-style { name, total } to { name, count }', () => {
|
||||
const raw = [
|
||||
{ name: 'minecraft:diamond', total: 64 },
|
||||
{ name: 'minecraft:coal', total: 200 },
|
||||
];
|
||||
const result = normalizeItemList(raw);
|
||||
expect(result[0]).toEqual({ name: 'minecraft:diamond', count: 64, displayName: 'minecraft:diamond' });
|
||||
expect(result[1].count).toBe(200);
|
||||
});
|
||||
|
||||
it('falls back to count if total is absent', () => {
|
||||
const raw = [{ name: 'minecraft:iron_ingot', count: 32, displayName: 'Iron Ingot' }];
|
||||
const result = normalizeItemList(raw);
|
||||
expect(result[0].count).toBe(32);
|
||||
expect(result[0].displayName).toBe('Iron Ingot');
|
||||
});
|
||||
|
||||
it('returns empty array for non-array input', () => {
|
||||
expect(normalizeItemList(null)).toEqual([]);
|
||||
expect(normalizeItemList('bad')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFurnaces', () => {
|
||||
it('converts Lua array-of-objects to keyed map', () => {
|
||||
const raw = [
|
||||
{ name: 'furnace_0', active: true, type: 'minecraft:blast_furnace', input: { name: 'iron' }, fuel: null, output: null },
|
||||
];
|
||||
const result = normalizeFurnaces(raw);
|
||||
expect(result['furnace_0'].active).toBe(true);
|
||||
expect(result['furnace_0'].type).toBe('minecraft:blast_furnace');
|
||||
});
|
||||
|
||||
it('passes through pre-keyed objects as-is', () => {
|
||||
const raw = { furnace_0: { active: false } };
|
||||
const result = normalizeFurnaces(raw);
|
||||
expect(result).toBe(raw); // same reference
|
||||
});
|
||||
|
||||
it('skips entries without a name', () => {
|
||||
const raw = [{ active: true }, { name: 'f1', active: false }];
|
||||
const result = normalizeFurnaces(raw);
|
||||
expect(Object.keys(result)).toEqual(['f1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAlerts', () => {
|
||||
it('normalizes Lua-style {label, current, min} fields', () => {
|
||||
const raw = [{ label: 'Diamond', current: 2, min: 10 }];
|
||||
const result = normalizeAlerts(raw);
|
||||
expect(result[0]).toEqual({ item: 'Diamond', triggered: true, current: 2, threshold: 10 });
|
||||
});
|
||||
|
||||
it('accepts name or item field', () => {
|
||||
expect(normalizeAlerts([{ name: 'A' }])[0].item).toBe('A');
|
||||
expect(normalizeAlerts([{ item: 'B' }])[0].item).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCraftable', () => {
|
||||
it('converts Lua grid (flat array of 9) to slots object', () => {
|
||||
const raw = [{
|
||||
output: 'minecraft:chest',
|
||||
count: 1,
|
||||
grid: [null, 'minecraft:planks', null, 'minecraft:planks', null, 'minecraft:planks', null, 'minecraft:planks', null],
|
||||
}];
|
||||
const result = normalizeCraftable(raw);
|
||||
expect(result[0].output).toBe('minecraft:chest');
|
||||
// grid indices are 0-based, slots keys are 1-based
|
||||
expect(result[0].slots).toEqual({ 2: 'minecraft:planks', 4: 'minecraft:planks', 6: 'minecraft:planks', 8: 'minecraft:planks' });
|
||||
});
|
||||
|
||||
it('passes through pre-slotted recipes', () => {
|
||||
const raw = [{ output: 'x', count: 2, slots: { 1: 'a', 5: 'b' } }];
|
||||
const result = normalizeCraftable(raw);
|
||||
expect(result[0].slots).toEqual({ 1: 'a', 5: 'b' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotent command tracking', () => {
|
||||
let tracker;
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = createCommandTracker();
|
||||
});
|
||||
|
||||
it('returns null for unseen commandId', () => {
|
||||
expect(tracker.check('cmd-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns cached result for repeated commandId', () => {
|
||||
const result = { success: true, message: 'Order sent' };
|
||||
tracker.record('cmd-1', result);
|
||||
expect(tracker.check('cmd-1')).toEqual(result);
|
||||
});
|
||||
|
||||
it('ignores null/undefined commandId', () => {
|
||||
tracker.record(null, { x: 1 });
|
||||
tracker.record(undefined, { x: 2 });
|
||||
expect(tracker.size()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not cross-contaminate different command IDs', () => {
|
||||
tracker.record('a', { val: 1 });
|
||||
tracker.record('b', { val: 2 });
|
||||
expect(tracker.check('a')).toEqual({ val: 1 });
|
||||
expect(tracker.check('b')).toEqual({ val: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation rules', () => {
|
||||
const VALID_REBOOT_TARGETS = ['all', 'manager', 'client', 'turtle', 'bridge'];
|
||||
|
||||
it('accepts valid reboot targets', () => {
|
||||
for (const t of VALID_REBOOT_TARGETS) {
|
||||
expect(VALID_REBOOT_TARGETS.includes(t) || /^\d+$/.test(t)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts numeric computer IDs', () => {
|
||||
expect(/^\d+$/.test('42')).toBe(true);
|
||||
expect(/^\d+$/.test('0')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid reboot targets', () => {
|
||||
for (const t of ['admin', 'drop ; rm -rf /', '', 'ALL']) {
|
||||
expect(VALID_REBOOT_TARGETS.includes(t) || /^\d+$/.test(t)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates order amount range (1–100000)', () => {
|
||||
const valid = (n) => Number.isFinite(n) && n >= 1 && n <= 100000;
|
||||
expect(valid(1)).toBe(true);
|
||||
expect(valid(100000)).toBe(true);
|
||||
expect(valid(0)).toBe(false);
|
||||
expect(valid(-1)).toBe(false);
|
||||
expect(valid(100001)).toBe(false);
|
||||
expect(valid(NaN)).toBe(false);
|
||||
expect(valid(Infinity)).toBe(false);
|
||||
});
|
||||
|
||||
it('validates craft recipeIdx range (1–1000)', () => {
|
||||
const valid = (n) => Number.isFinite(n) && n >= 1 && n <= 1000;
|
||||
expect(valid(1)).toBe(true);
|
||||
expect(valid(500)).toBe(true);
|
||||
expect(valid(0)).toBe(false);
|
||||
expect(valid(1001)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,32 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { existsSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || '/data/inventory.db';
|
||||
|
||||
console.log(`[db] Opening database at ${DB_PATH} (uid=${process.getuid()})`);
|
||||
|
||||
// Ensure the directory exists
|
||||
const dbDir = dirname(DB_PATH);
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
// Verify directory is writable before opening SQLite
|
||||
try {
|
||||
accessSync(dbDir, fsConstants.W_OK);
|
||||
} catch {
|
||||
console.error(`[db] FATAL: directory ${dbDir} is not writable by uid ${process.getuid()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(DB_PATH);
|
||||
} catch (err) {
|
||||
console.error(`[db] FATAL: failed to open database: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Performance pragmas
|
||||
db.pragma('journal_mode = WAL');
|
||||
@@ -19,6 +35,8 @@ db.pragma('foreign_keys = ON');
|
||||
db.pragma('cache_size = -8000'); // 8MB cache
|
||||
db.pragma('temp_store = MEMORY');
|
||||
|
||||
console.log('[db] Database ready');
|
||||
|
||||
// ========== Schema ==========
|
||||
|
||||
db.exec(`
|
||||
@@ -475,7 +493,9 @@ export function flushPendingSave() {
|
||||
*/
|
||||
export function closeDb() {
|
||||
flushPendingSave();
|
||||
db.close();
|
||||
if (db.open) {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
13
web/server/docker-entrypoint.sh
Executable file
13
web/server/docker-entrypoint.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/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"
|
||||
|
||||
# Drop privileges and exec the CMD
|
||||
echo "[entrypoint] Dropping to user 'node', running: $*"
|
||||
exec su-exec node "$@"
|
||||
2079
web/server/package-lock.json
generated
2079
web/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"dev": "nodemon server.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.7.0",
|
||||
@@ -15,6 +17,7 @@
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
"nodemon": "^3.0.2",
|
||||
"vitest": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import cors from 'cors';
|
||||
import { createServer } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -18,7 +17,9 @@ const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
app.use(cors());
|
||||
// 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 ==========
|
||||
@@ -48,6 +49,40 @@ app.use((req, res, next) => {
|
||||
return requireAuth(req, res, next);
|
||||
});
|
||||
|
||||
// ========== Rate Limiting (in-memory, no external dependencies) ==========
|
||||
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
// 30 mutating requests per minute per IP (excludes bridge state updates)
|
||||
const commandLimiter = createRateLimiter(60_000, 30);
|
||||
app.use((req, res, next) => {
|
||||
if (req.method !== 'POST' || req.path.startsWith('/api/bridge/')) return next();
|
||||
return commandLimiter(req, res, next);
|
||||
});
|
||||
|
||||
// ========== State ==========
|
||||
const webClients = new Set();
|
||||
const bridgeClients = new Set();
|
||||
@@ -107,7 +142,11 @@ function broadcastToClients(data) {
|
||||
const message = JSON.stringify(data);
|
||||
webClients.forEach((client) => {
|
||||
if (client.readyState === 1) {
|
||||
client.send(message);
|
||||
try {
|
||||
client.send(message);
|
||||
} catch (err) {
|
||||
console.error('❌ WS send error (client):', err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -116,8 +155,12 @@ function pushCommandToBridge(command) {
|
||||
let sent = false;
|
||||
for (const bridge of bridgeClients) {
|
||||
if (bridge.readyState === 1) {
|
||||
bridge.send(JSON.stringify(command));
|
||||
sent = true;
|
||||
try {
|
||||
bridge.send(JSON.stringify(command));
|
||||
sent = true;
|
||||
} catch (err) {
|
||||
console.error('❌ WS send error (bridge):', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sent) {
|
||||
@@ -322,13 +365,20 @@ app.post('/api/order', (req, res) => {
|
||||
if (!itemName || !amount) {
|
||||
return res.status(400).json({ error: 'Missing itemName or amount' });
|
||||
}
|
||||
if (typeof itemName !== 'string' || itemName.length > 200) {
|
||||
return res.status(400).json({ error: 'Invalid itemName' });
|
||||
}
|
||||
const parsedAmount = parseInt(amount);
|
||||
if (!Number.isFinite(parsedAmount) || parsedAmount < 1 || parsedAmount > 100000) {
|
||||
return res.status(400).json({ error: 'Invalid amount (1–100000)' });
|
||||
}
|
||||
|
||||
const command = {
|
||||
type: 'command',
|
||||
action: 'order',
|
||||
commandId,
|
||||
itemName,
|
||||
amount: parseInt(amount),
|
||||
amount: parsedAmount,
|
||||
dropperName: dropperName || null,
|
||||
};
|
||||
|
||||
@@ -352,8 +402,17 @@ app.get('/api/dropper-nicknames', (req, res) => {
|
||||
// Set a single dropper nickname
|
||||
app.post('/api/dropper-nicknames', (req, res) => {
|
||||
try {
|
||||
const { dropperName, nickname } = req.body;
|
||||
if (!dropperName) return res.status(400).json({ error: 'Missing dropperName' });
|
||||
const { dropperName, nickname, commandId } = req.body;
|
||||
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
if (!dropperName || typeof dropperName !== 'string' || dropperName.length > 200) {
|
||||
return res.status(400).json({ error: 'Missing or invalid dropperName' });
|
||||
}
|
||||
if (nickname && String(nickname).length > 50) {
|
||||
return res.status(400).json({ error: 'Nickname too long (max 50)' });
|
||||
}
|
||||
|
||||
if (nickname && nickname.trim()) {
|
||||
dropperNicknames[dropperName] = nickname.trim();
|
||||
@@ -363,7 +422,9 @@ app.post('/api/dropper-nicknames', (req, res) => {
|
||||
|
||||
saveState('dropperNicknames', dropperNicknames);
|
||||
console.log(`🏷️ Dropper nickname: ${dropperName} → ${nickname || '(removed)'}`);
|
||||
res.json({ success: true, nicknames: dropperNicknames });
|
||||
const result = { success: true, nicknames: dropperNicknames };
|
||||
recordCommand(commandId, result);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -413,7 +474,9 @@ app.post('/api/recipes/toggle', (req, res) => {
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
if (!recipe) return res.status(400).json({ error: 'Missing recipe name' });
|
||||
if (!recipe || typeof recipe !== 'string' || recipe.length > 200) {
|
||||
return res.status(400).json({ error: 'Missing or invalid recipe name' });
|
||||
}
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'toggle_recipe', commandId, recipe });
|
||||
const result = { success: true, commandId };
|
||||
@@ -427,25 +490,58 @@ app.post('/api/recipes/toggle', (req, res) => {
|
||||
|
||||
// Enable/disable all recipes
|
||||
app.post('/api/recipes/enable-all', (req, res) => {
|
||||
const { commandId } = req.body || {};
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
try {
|
||||
const { commandId } = req.body || {};
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'enable_all', commandId });
|
||||
const result = { success: true, commandId };
|
||||
recordCommand(commandId, result);
|
||||
res.json(result);
|
||||
pushCommandToBridge({ type: 'command', action: 'enable_all', commandId });
|
||||
const result = { success: true, commandId };
|
||||
recordCommand(commandId, result);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/recipes/disable-all', (req, res) => {
|
||||
const { commandId } = req.body || {};
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
try {
|
||||
const { commandId } = req.body || {};
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'disable_all', commandId });
|
||||
const result = { success: true, commandId };
|
||||
recordCommand(commandId, result);
|
||||
res.json(result);
|
||||
pushCommandToBridge({ type: 'command', action: 'disable_all', commandId });
|
||||
const result = { success: true, commandId };
|
||||
recordCommand(commandId, result);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Remote reboot CC:Tweaked computers
|
||||
app.post('/api/reboot', (req, res) => {
|
||||
try {
|
||||
const { target, commandId } = req.body || {};
|
||||
|
||||
const cached = checkIdempotent(commandId);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
const validTargets = ['all', 'manager', 'client', 'turtle', 'bridge'];
|
||||
const t = target || 'all';
|
||||
// Allow valid role names or numeric computer IDs
|
||||
if (!validTargets.includes(t) && !/^\d+$/.test(t)) {
|
||||
return res.status(400).json({ error: 'Invalid target. Use: all, manager, client, turtle, bridge, or a computer ID' });
|
||||
}
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'reboot', commandId, target: t });
|
||||
const result = { success: true, commandId, message: `Reboot sent to: ${t}` };
|
||||
recordCommand(commandId, result);
|
||||
console.log(`🔄 Reboot requested: target=${t}`);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Sort barrel
|
||||
@@ -477,8 +573,12 @@ app.post('/api/craft', (req, res) => {
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
if (recipeIdx === undefined) return res.status(400).json({ error: 'Missing recipeIdx' });
|
||||
const parsedIdx = parseInt(recipeIdx);
|
||||
if (!Number.isFinite(parsedIdx) || parsedIdx < 1 || parsedIdx > 1000) {
|
||||
return res.status(400).json({ error: 'Invalid recipeIdx' });
|
||||
}
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'craft', commandId, recipeIdx: parseInt(recipeIdx) });
|
||||
pushCommandToBridge({ type: 'command', action: 'craft', commandId, recipeIdx: parsedIdx });
|
||||
const result = { success: true, commandId };
|
||||
recordCommand(commandId, result);
|
||||
console.log(`🔨 Craft request: recipe #${recipeIdx}`);
|
||||
@@ -502,8 +602,8 @@ app.post('/api/bridge/state', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Bridge polls for pending commands
|
||||
app.get('/api/bridge/commands', (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)
|
||||
@@ -662,6 +762,7 @@ function updateStateFromBridge(data) {
|
||||
smeltable: smeltableRecipes,
|
||||
craftable: craftableRecipes,
|
||||
craftTurtleOk,
|
||||
dropperNicknames,
|
||||
lastUpdate,
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
});
|
||||
@@ -686,7 +787,7 @@ function updateStateFromBridge(data) {
|
||||
|
||||
// ========== WebSocket Server ==========
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 /* 1 MB */ });
|
||||
|
||||
console.log(`🚀 Inventory Manager Web Server starting...`);
|
||||
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
|
||||
@@ -721,6 +822,7 @@ wss.on('connection', (ws, req) => {
|
||||
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({
|
||||
@@ -766,6 +868,8 @@ wss.on('connection', (ws, req) => {
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ Bridge WS error:', error);
|
||||
@@ -836,7 +940,7 @@ wss.on('connection', (ws, req) => {
|
||||
});
|
||||
|
||||
// ========== WebSocket Keep-Alive ==========
|
||||
// Ping all web clients every 25s to keep connections alive through reverse proxies
|
||||
// Ping all web clients and bridge connections every 25s to keep connections alive
|
||||
const WS_PING_INTERVAL = setInterval(() => {
|
||||
webClients.forEach((ws) => {
|
||||
if (!ws.isAlive) {
|
||||
@@ -846,12 +950,94 @@ const WS_PING_INTERVAL = setInterval(() => {
|
||||
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
|
||||
|
||||
const TURTLE_SERVER_URL = process.env.TURTLE_SERVER_URL || ''; // e.g. http://turtle-server:3001
|
||||
|
||||
// Find where a specific item is stored (for turtles to pick up items)
|
||||
app.get('/api/integration/locate-item', (req, res) => {
|
||||
const { name, minCount } = req.query;
|
||||
if (!name) return res.status(400).json({ error: 'Item name required (?name=minecraft:diamond)' });
|
||||
|
||||
const items = inventoryState.itemList || [];
|
||||
const match = items.find(i => i.name === name);
|
||||
if (!match || match.count < (parseInt(minCount) || 1)) {
|
||||
return res.json({ found: false, available: match ? match.count : 0 });
|
||||
}
|
||||
res.json({ found: true, name: match.name, count: match.count, displayName: match.displayName });
|
||||
});
|
||||
|
||||
// Search items by partial name (for turtle autocomplete/fuzzy matching)
|
||||
app.get('/api/integration/search-items', (req, res) => {
|
||||
const { q, limit } = req.query;
|
||||
if (!q) return res.status(400).json({ error: 'Search query required (?q=diamond)' });
|
||||
|
||||
const items = inventoryState.itemList || [];
|
||||
const query = q.toLowerCase();
|
||||
const results = items
|
||||
.filter(i => i.name.toLowerCase().includes(query) || (i.displayName || '').toLowerCase().includes(query))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, parseInt(limit) || 20);
|
||||
|
||||
res.json({ results });
|
||||
});
|
||||
|
||||
// Get storage summary (available space, total items) for turtle decision-making
|
||||
app.get('/api/integration/storage-status', (req, res) => {
|
||||
res.json({
|
||||
grandTotal: inventoryState.grandTotal || 0,
|
||||
chestCount: inventoryState.chestCount || 0,
|
||||
totalSlots: inventoryState.totalSlots || 0,
|
||||
usedSlots: inventoryState.usedSlots || 0,
|
||||
freeSlots: (inventoryState.totalSlots || 0) - (inventoryState.usedSlots || 0),
|
||||
lastUpdate,
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Get full item list (for turtle dump target selection)
|
||||
app.get('/api/integration/items', (req, res) => {
|
||||
const items = inventoryState.itemList || [];
|
||||
res.json({ items: items.map(i => ({ name: i.name, count: i.count })) });
|
||||
});
|
||||
|
||||
// Get alerts (so turtles know what items are running low)
|
||||
app.get('/api/integration/low-stock', (req, res) => {
|
||||
const triggered = (alertsState || []).filter(a => a.triggered !== false);
|
||||
res.json({ alerts: triggered });
|
||||
});
|
||||
|
||||
// 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}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Start Server ==========
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
@@ -859,19 +1045,33 @@ server.listen(PORT, HOST, () => {
|
||||
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 closing database:', err.message);
|
||||
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();
|
||||
});
|
||||
|
||||
11
web/server/vitest.config.js
Normal file
11
web/server/vitest.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
DB_PATH: path.join(os.tmpdir(), `inv-test-${process.pid}.db`),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user