Compare commits
55 Commits
467be0493c
...
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 |
34
.package
34
.package
@@ -1,13 +1,15 @@
|
||||
{
|
||||
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/main/",
|
||||
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 pkgDir = fs.combine("packages", "inventory-manager")
|
||||
local cfgDir = "usr/config/inventory-manager"
|
||||
if not fs.isDir(cfgDir) then fs.makeDir(cfgDir) end
|
||||
local function ask(prompt, default)
|
||||
if default and #default > 0 then
|
||||
write(prompt .. " [" .. default .. "]: ")
|
||||
@@ -26,9 +28,10 @@
|
||||
print(" 1) Inventory Manager (main controller)")
|
||||
print(" 2) Inventory Client (display-only)")
|
||||
print(" 3) Web Bridge (HTTP forwarder)")
|
||||
print(" 4) Skip setup")
|
||||
print(" 4) Mining Turtle (cobble miner)")
|
||||
print(" 5) Skip setup")
|
||||
print("")
|
||||
write("Choice (1/2/3/4): ")
|
||||
write("Choice (1/2/3/4/5): ")
|
||||
local choice = read()
|
||||
|
||||
if choice == "1" then
|
||||
@@ -46,14 +49,14 @@
|
||||
dropperName = dropperName,
|
||||
barrelName = barrelName,
|
||||
}
|
||||
local f = fs.open(fs.combine(pkgDir, ".manager_config"), "w")
|
||||
local f = fs.open(fs.combine(cfgDir, ".manager_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved manager config.")
|
||||
|
||||
if serverUrl and #serverUrl > 0 then
|
||||
local bcfg = { serverUrl = serverUrl }
|
||||
local bf = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
|
||||
local bf = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
|
||||
bf.write(textutils.serialiseJSON(bcfg))
|
||||
bf.close()
|
||||
print("Saved web bridge config.")
|
||||
@@ -74,7 +77,7 @@
|
||||
if dropperName and #dropperName > 0 then cfg.dropperName = dropperName end
|
||||
if barrelName and #barrelName > 0 then cfg.barrelName = barrelName end
|
||||
|
||||
local f = fs.open(fs.combine(pkgDir, ".client_config"), "w")
|
||||
local f = fs.open(fs.combine(cfgDir, ".client_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved client config.")
|
||||
@@ -85,11 +88,26 @@
|
||||
local serverUrl = ask("Web server URL", "http://localhost")
|
||||
|
||||
local cfg = { serverUrl = serverUrl }
|
||||
local f = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
|
||||
local f = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved web bridge config.")
|
||||
|
||||
elseif choice == "4" then
|
||||
print("")
|
||||
print("-- Mining Turtle Configuration --")
|
||||
local mineInterval = ask("Mine interval in seconds", "0.5")
|
||||
local dumpThreshold = ask("Dump when N slots full", "14")
|
||||
|
||||
local cfg = {
|
||||
mineInterval = tonumber(mineInterval) or 0.5,
|
||||
dumpThreshold = tonumber(dumpThreshold) or 14,
|
||||
}
|
||||
local f = fs.open(fs.combine(cfgDir, ".miner_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved miner config.")
|
||||
|
||||
else
|
||||
print("Skipped — edit config files manually later.")
|
||||
end
|
||||
|
||||
@@ -8,18 +8,26 @@ local peripheral = _G.peripheral
|
||||
local shell = _ENV.shell
|
||||
|
||||
local BASE = 'packages/inventory-manager'
|
||||
local CFG = 'usr/config/inventory-manager'
|
||||
|
||||
-------------------------------------------------
|
||||
-- Determine role from config files written during install
|
||||
-------------------------------------------------
|
||||
|
||||
local function cfgExists(name)
|
||||
return fs.exists(fs.combine(CFG, name))
|
||||
or fs.exists(fs.combine(BASE, name))
|
||||
end
|
||||
|
||||
local role
|
||||
if fs.exists(fs.combine(BASE, '.manager_config')) then
|
||||
if cfgExists('.manager_config') then
|
||||
role = 'manager'
|
||||
elseif fs.exists(fs.combine(BASE, '.client_config')) then
|
||||
elseif cfgExists('.client_config') then
|
||||
role = 'client'
|
||||
elseif fs.exists(fs.combine(BASE, '.webbridge_config')) then
|
||||
elseif cfgExists('.webbridge_config') then
|
||||
role = 'bridge'
|
||||
elseif cfgExists('.miner_config') then
|
||||
role = 'miner'
|
||||
elseif _G.turtle then
|
||||
role = 'turtle'
|
||||
end
|
||||
@@ -63,6 +71,7 @@ local programs = {
|
||||
client = 'inventoryClient.lua',
|
||||
bridge = 'inventoryWebBridge.lua',
|
||||
turtle = 'craftingTurtle.lua',
|
||||
miner = 'miningTurtle.lua',
|
||||
}
|
||||
|
||||
local program = fs.combine(BASE, programs[role])
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
-- Pulls ingredients from chests, crafts, and pushes results back.
|
||||
-- Requires a wired modem attached to the turtle.
|
||||
|
||||
local shell = _ENV.shell
|
||||
|
||||
local function fatal(msg)
|
||||
printError(msg)
|
||||
print("\nPress any key to exit...")
|
||||
os.pullEvent("key")
|
||||
return
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Default configuration (overridden by .turtle_config)
|
||||
-------------------------------------------------
|
||||
@@ -22,7 +31,17 @@ local CRAFT_SLOTS = {1, 2, 3, 5, 6, 7, 9, 10, 11}
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
local TURTLE_CONFIG_FILE = _path(".turtle_config")
|
||||
-- Persistent config path (survives Opus package updates)
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local TURTLE_CONFIG_FILE = _configPath(".turtle_config")
|
||||
|
||||
local function loadConfig()
|
||||
if not fs.exists(TURTLE_CONFIG_FILE) then return end
|
||||
@@ -52,9 +71,7 @@ print("")
|
||||
|
||||
-- Verify this is a crafting turtle
|
||||
if not turtle or not turtle.craft then
|
||||
print("[ERR] No turtle.craft() available!")
|
||||
print(" This must be a Crafting Turtle.")
|
||||
return
|
||||
return fatal("[ERR] No turtle.craft() available!\n This must be a Crafting Turtle.")
|
||||
end
|
||||
|
||||
-- Find wired modem and get our network name
|
||||
@@ -78,10 +95,7 @@ for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
|
||||
end
|
||||
|
||||
if not modem or not selfName then
|
||||
print("[ERR] No wired modem found!")
|
||||
print(" Attach a wired modem to the turtle")
|
||||
print(" and connect it to the network.")
|
||||
return
|
||||
return fatal("[ERR] No wired modem found!\n Attach a wired modem to the turtle\n and connect it to the network.")
|
||||
end
|
||||
|
||||
print("[OK] Modem: " .. modemSide)
|
||||
@@ -91,15 +105,6 @@ print("[OK] Network name: " .. selfName)
|
||||
modem.open(CRAFT_CHANNEL)
|
||||
print("[OK] Listening on channel " .. CRAFT_CHANNEL)
|
||||
|
||||
-- Wrap our own inventory peripheral for pullItems/pushItems
|
||||
local selfInv = peripheral.wrap(selfName)
|
||||
if not selfInv then
|
||||
print("[ERR] Cannot wrap own peripheral: " .. selfName)
|
||||
print(" Make sure the wired modem is connected.")
|
||||
return
|
||||
end
|
||||
|
||||
print("[OK] Self-inventory peripheral ready")
|
||||
print("")
|
||||
print("Waiting for craft commands from master...")
|
||||
print("")
|
||||
@@ -109,15 +114,20 @@ print("")
|
||||
-------------------------------------------------
|
||||
|
||||
local function clearInventory(chests)
|
||||
-- Push all items from all 16 turtle slots back to chests
|
||||
-- Pull all items from turtle slots into chests (using chest.pullItems)
|
||||
local cleared = 0
|
||||
for slot = 1, 16 do
|
||||
if turtle.getItemCount(slot) > 0 then
|
||||
for _, chestName in ipairs(chests) do
|
||||
local ok, n = pcall(selfInv.pushItems, chestName, slot)
|
||||
if ok and n and n > 0 then
|
||||
cleared = cleared + n
|
||||
break
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.pullItems then
|
||||
local ok, n = pcall(chest.pullItems, selfName, slot)
|
||||
if ok and n and n > 0 then
|
||||
cleared = cleared + n
|
||||
if turtle.getItemCount(slot) == 0 then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -178,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))
|
||||
@@ -266,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
|
||||
|
||||
19
etc/apps.db
19
etc/apps.db
@@ -1,11 +1,11 @@
|
||||
{
|
||||
[ "im_inventory_manager" ] = {
|
||||
title = "Inventory Manager",
|
||||
title = "Inv Manager",
|
||||
category = "Inventory",
|
||||
run = "inventoryManager.lua",
|
||||
},
|
||||
[ "im_inventory_client" ] = {
|
||||
title = "Inventory Display",
|
||||
title = "Inv Display",
|
||||
category = "Inventory",
|
||||
run = "inventoryClient.lua",
|
||||
},
|
||||
@@ -13,21 +13,16 @@
|
||||
title = "Crafting Turtle",
|
||||
category = "Inventory",
|
||||
run = "craftingTurtle.lua",
|
||||
requires = { turtle = true },
|
||||
},
|
||||
[ "im_dropper_controller" ] = {
|
||||
title = "Dropper Controller",
|
||||
category = "Inventory",
|
||||
run = "dropperController.lua",
|
||||
requires = "turtle",
|
||||
},
|
||||
[ "im_web_bridge" ] = {
|
||||
title = "Inventory Web Bridge",
|
||||
title = "Inv Web Bridge",
|
||||
category = "Inventory",
|
||||
run = "inventoryWebBridge.lua",
|
||||
},
|
||||
[ "im_list_devices" ] = {
|
||||
title = "List Devices",
|
||||
[ "im_dropper" ] = {
|
||||
title = "Dropper Ctrl",
|
||||
category = "Inventory",
|
||||
run = "listDevicesByType.lua",
|
||||
run = "dropperController.lua",
|
||||
},
|
||||
}
|
||||
|
||||
1403
inventoryClient.lua
1403
inventoryClient.lua
File diff suppressed because it is too large
Load Diff
@@ -15,12 +15,45 @@
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path: survives Opus package updates by storing
|
||||
-- user data in usr/config/inventory-manager/ instead of the package dir.
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
-- Write crash info to a file so we can always read it
|
||||
local function _crashLog(err)
|
||||
local f = fs.open(_path(".crash.log"), "w")
|
||||
if f then
|
||||
f.write(tostring(err) .. "\n" .. (debug and debug.traceback and debug.traceback() or ""))
|
||||
f.close()
|
||||
end
|
||||
end
|
||||
|
||||
local ok, err = xpcall(function()
|
||||
|
||||
-- Override dofile to load modules into our _ENV so they inherit
|
||||
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
|
||||
local _ccDofile = dofile
|
||||
local function dofile(path) -- luacheck: ignore
|
||||
local fn, err = loadfile(path, nil, _ENV)
|
||||
if fn then return fn()
|
||||
else error(err, 2) end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Structured logging & shared UI helpers
|
||||
-------------------------------------------------
|
||||
|
||||
local log = dofile(_path("lib/log.lua"))
|
||||
local ui = dofile(_path("lib/ui.lua"))
|
||||
local log = dofile(_path("lib/log.lua"))
|
||||
local ui = dofile(_path("lib/ui.lua"))
|
||||
local itemDB = dofile(_path("lib/itemDB.lua"))
|
||||
itemDB.init(_configPath(".item_names.db"))
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load modules (factory pattern → shared context)
|
||||
@@ -48,6 +81,12 @@ ctx.ops = ops
|
||||
local display = dofile(_path("manager/display.lua"))(ctx)
|
||||
ctx.display = display
|
||||
|
||||
-- Recursive crafting engine
|
||||
local craftEngine = dofile(_path("lib/craft.lua"))
|
||||
craftEngine.init(cfg.recipeBook, ops.getItemTotal)
|
||||
ctx.craftEngine = craftEngine
|
||||
ctx.itemDB = itemDB
|
||||
|
||||
-- Convenience aliases
|
||||
local cache = state.cache
|
||||
local activity = state.activity
|
||||
@@ -109,11 +148,12 @@ local function broadcastState()
|
||||
craftTurtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName),
|
||||
}
|
||||
|
||||
if state.configDirty then
|
||||
payload.smeltable = cfg.SMELTABLE
|
||||
payload.craftable = cfg.CRAFTABLE
|
||||
state.configDirty = false
|
||||
end
|
||||
-- Keep ctx in sync so display.lua can check ctx.craftTurtleOk directly
|
||||
ctx.craftTurtleOk = payload.craftTurtleOk
|
||||
|
||||
payload.smeltable = cfg.SMELTABLE
|
||||
payload.craftable = cfg.CRAFTABLE
|
||||
state.configDirty = false
|
||||
|
||||
ctx.networkModem.transmit(cfg.BROADCAST_CHANNEL, cfg.ORDER_CHANNEL, payload)
|
||||
state.lastBroadcastVersion = state.stateVersion
|
||||
@@ -155,24 +195,39 @@ local function main()
|
||||
log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE)
|
||||
end
|
||||
|
||||
-- Find modem for client communication
|
||||
-- Find wired modem for client/turtle communication
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "modem" then
|
||||
ctx.networkModem = peripheral.wrap(name)
|
||||
ctx.networkModemName = name
|
||||
ctx.networkModem.open(cfg.ORDER_CHANNEL)
|
||||
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
|
||||
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
|
||||
break
|
||||
local m = peripheral.wrap(name)
|
||||
-- Prefer wired modem (has getNameLocal); skip wireless
|
||||
if m.isWireless and not m.isWireless() then
|
||||
ctx.networkModem = m
|
||||
ctx.networkModemName = name
|
||||
ctx.networkModem.open(cfg.ORDER_CHANNEL)
|
||||
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
|
||||
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
|
||||
break
|
||||
elseif not ctx.networkModem then
|
||||
-- Fallback: use wireless if no wired available
|
||||
ctx.networkModem = m
|
||||
ctx.networkModemName = name
|
||||
end
|
||||
end
|
||||
end
|
||||
if ctx.networkModem then
|
||||
log.info("INIT", "Network modem: %s", ctx.networkModemName)
|
||||
-- Ensure channels are open even on fallback modem
|
||||
ctx.networkModem.open(cfg.ORDER_CHANNEL)
|
||||
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
|
||||
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
|
||||
log.info("INIT", "Network modem: %s (wired=%s)", ctx.networkModemName,
|
||||
tostring(ctx.networkModem.isWireless and not ctx.networkModem.isWireless()))
|
||||
else
|
||||
log.warn("INIT", "No modem found for client sync")
|
||||
end
|
||||
|
||||
-- Detect crafting turtle on network
|
||||
-- The actual craft message goes on CRAFT_CHANNEL which only the crafting
|
||||
-- turtle listens to, so we just need any turtle name for presence checks.
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if name:match("^turtle_") then
|
||||
ctx.craftTurtleName = name
|
||||
@@ -196,7 +251,11 @@ local function main()
|
||||
for k in pairs(cfg.SMELTABLE) do
|
||||
if not state.disabledRecipes[k] then enabledCount = enabledCount + 1 end
|
||||
end
|
||||
log.info("INIT", "%d/%d recipes enabled", enabledCount, totalRecipeCount)
|
||||
log.info("INIT", "%d/%d smelting recipes enabled", enabledCount, totalRecipeCount)
|
||||
do
|
||||
local cc, sc = cfg.recipeBook.count()
|
||||
log.info("INIT", "Recipe book: %d crafting, %d smelting", cc, sc)
|
||||
end
|
||||
|
||||
-- Compost peripherals
|
||||
if peripheral.isPresent(cfg.COMPOST_DROPPER) then
|
||||
@@ -279,6 +338,11 @@ local function main()
|
||||
end
|
||||
|
||||
ops.refreshCache(drawBoot)
|
||||
|
||||
-- Destroy boot window so it doesn't intercept future monitor writes
|
||||
buf.setVisible(true)
|
||||
buf.clear()
|
||||
buf = nil
|
||||
else
|
||||
ops.refreshCache()
|
||||
end
|
||||
@@ -304,6 +368,8 @@ local function main()
|
||||
sleep(cfg.SCAN_INTERVAL)
|
||||
pcall(ops.refreshCache)
|
||||
pcall(ops.checkAlerts)
|
||||
pcall(function() itemDB.flush() end)
|
||||
pcall(function() cfg.recipeBook.flush() end)
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
end
|
||||
@@ -378,7 +444,8 @@ local function main()
|
||||
while true do
|
||||
if state.needsRedraw then
|
||||
state.needsRedraw = false
|
||||
pcall(display.drawDashboard)
|
||||
local dok, derr = pcall(display.drawDashboard)
|
||||
if not dok then log.error("DRAW", "Dashboard: %s", tostring(derr)) end
|
||||
end
|
||||
if state.statusTimer > 0 then
|
||||
state.statusTimer = state.statusTimer - 0.1
|
||||
@@ -400,7 +467,8 @@ local function main()
|
||||
while true do
|
||||
if state.smelterNeedsRedraw then
|
||||
state.smelterNeedsRedraw = false
|
||||
pcall(display.drawSmelterDashboard)
|
||||
local sok, serr = pcall(display.drawSmelterDashboard)
|
||||
if not sok then log.error("DRAW", "Smelter: %s", tostring(serr)) end
|
||||
end
|
||||
sleep(0.1)
|
||||
end
|
||||
@@ -438,19 +506,58 @@ local function main()
|
||||
ops.invalidateWrapCache(name)
|
||||
ops.invalidatePeripheralCaches()
|
||||
log.info("DETACH", "%s", name)
|
||||
if name == ctx.craftTurtleName then
|
||||
ctx.craftTurtleName = nil
|
||||
log.warn("DETACH", "Crafting turtle disconnected")
|
||||
end
|
||||
end
|
||||
end
|
||||
end,
|
||||
|
||||
-- Task 12: Network order/command listener
|
||||
-- Task 11b: Peripheral attach handler (auto-detect crafting turtle)
|
||||
function()
|
||||
if not ctx.networkModem then return end
|
||||
while true do
|
||||
local event, name = os.pullEvent("peripheral_attach")
|
||||
if name and name:match("^turtle_") and not ctx.craftTurtleName then
|
||||
ctx.craftTurtleName = name
|
||||
log.info("ATTACH", "Crafting turtle detected: %s", name)
|
||||
pcall(broadcastState)
|
||||
end
|
||||
end
|
||||
end,
|
||||
|
||||
-- Task 12: Supply chest (builder / manifest-based stocking)
|
||||
function()
|
||||
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
log.info("SUPPLY", "Stocking %s with %d item types", cfg.SUPPLY_CHEST, #cfg.SUPPLY_MANIFEST)
|
||||
while true do
|
||||
pcall(ops.supplyChest)
|
||||
sleep(cfg.SUPPLY_INTERVAL)
|
||||
end
|
||||
end,
|
||||
|
||||
-- Task 13: Network order/command listener
|
||||
function()
|
||||
if not ctx.networkModem then
|
||||
log.warn("NET", "No modem — listener disabled")
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
log.info("NET", "Listener started on channel %d (modem: %s)", cfg.ORDER_CHANNEL, ctx.networkModemName or "?")
|
||||
local cmdCount = 0
|
||||
while true do
|
||||
log.info("NET", "Waiting for modem_message... (handled %d so far)", cmdCount)
|
||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||
log.info("NET", "Got modem_message: side=%s ch=%d reply=%d type=%s",
|
||||
tostring(side), channel or -1, replyChannel or -1,
|
||||
type(message) == "table" and tostring(message.type) or type(message))
|
||||
if channel == cfg.ORDER_CHANNEL and type(message) == "table" then
|
||||
if isCommandDuplicate(message.commandId) then
|
||||
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId))
|
||||
else
|
||||
cmdCount = cmdCount + 1
|
||||
log.info("NET", "Command #%d accepted: type=%s cmdId=%s", cmdCount, tostring(message.type), tostring(message.commandId))
|
||||
recordCommandId(message.commandId)
|
||||
cleanupCommandIds()
|
||||
local handlerOk, handlerErr = pcall(function()
|
||||
@@ -589,13 +696,119 @@ local function main()
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
pcall(broadcastState)
|
||||
|
||||
elseif message.type == "recursive_craft" and message.itemName and message.count then
|
||||
log.info("NET", "Recursive craft: %s x%d", message.itemName, message.count)
|
||||
local pok, ok, craftErr = pcall(ops.recursiveCraft, message.itemName, message.count)
|
||||
if not pok then
|
||||
log.error("NET", "recursiveCraft crashed: %s", tostring(ok))
|
||||
craftErr = tostring(ok)
|
||||
ok = false
|
||||
end
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "recursive_craft_result",
|
||||
commandId = message.commandId,
|
||||
success = ok,
|
||||
error = craftErr,
|
||||
})
|
||||
end)
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
pcall(broadcastState)
|
||||
|
||||
elseif message.type == "sync_disabled_recipes" then
|
||||
if message.disabledRecipes then
|
||||
state.disabledRecipes = message.disabledRecipes
|
||||
end
|
||||
if message.smeltingPaused ~= nil then
|
||||
state.smeltingPaused = message.smeltingPaused
|
||||
end
|
||||
ops.saveDisabledRecipes()
|
||||
log.info("NET", "Synced smelting state from client")
|
||||
state.configDirty = true
|
||||
state.bumpStateVersion()
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
pcall(broadcastState)
|
||||
|
||||
elseif message.type == "learn_crafting_recipe" and message.output and message.count and message.grid then
|
||||
cfg.recipeBook.learnCraftingRecipe(message.output, message.count, message.grid)
|
||||
cfg.refreshRecipes()
|
||||
cfg.recipeBook.flush()
|
||||
log.info("NET", "Learned crafting recipe: %s", message.output)
|
||||
state.configDirty = true
|
||||
state.bumpStateVersion()
|
||||
pcall(broadcastState)
|
||||
|
||||
elseif message.type == "learn_smelting_recipe" and message.input and message.result then
|
||||
cfg.recipeBook.learnSmeltingRecipe(message.input, message.result, message.furnaces)
|
||||
cfg.refreshRecipes()
|
||||
cfg.recipeBook.flush()
|
||||
log.info("NET", "Learned smelting recipe: %s -> %s", message.input, message.result)
|
||||
state.configDirty = true
|
||||
state.bumpStateVersion()
|
||||
pcall(broadcastState)
|
||||
|
||||
elseif message.type == "forget_recipe" and message.recipe then
|
||||
local forgot = cfg.recipeBook.forgetCraftingRecipe(message.recipe) or
|
||||
cfg.recipeBook.forgetSmeltingRecipe(message.recipe)
|
||||
if forgot then
|
||||
cfg.refreshRecipes()
|
||||
cfg.recipeBook.flush()
|
||||
log.info("NET", "Forgot recipe: %s", message.recipe)
|
||||
state.configDirty = true
|
||||
state.bumpStateVersion()
|
||||
end
|
||||
pcall(broadcastState)
|
||||
|
||||
elseif message.type == "find_item" and message.items then
|
||||
-- Return chest+slot locations for the first matching item
|
||||
-- message.items = list of item names to search (in priority order)
|
||||
-- message.limit = max items to return info for (default 64)
|
||||
local limit = message.limit or 64
|
||||
local results = {}
|
||||
for _, itemName in ipairs(message.items) do
|
||||
if cache.catalogue[itemName] then
|
||||
for _, source in ipairs(cache.catalogue[itemName]) do
|
||||
local chest = ops.wrapCached(source.chest)
|
||||
if chest then
|
||||
for slot, slotItem in pairs(chest.list()) do
|
||||
if slotItem.name == itemName then
|
||||
table.insert(results, {
|
||||
chest = source.chest,
|
||||
slot = slot,
|
||||
name = itemName,
|
||||
count = slotItem.count,
|
||||
})
|
||||
if #results >= limit then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #results >= limit then break end
|
||||
end
|
||||
end
|
||||
if #results > 0 then break end -- found fuel, stop searching
|
||||
end
|
||||
log.info("NET", "find_item: found %d source(s)", #results)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "find_item_result",
|
||||
commandId = message.commandId,
|
||||
results = results,
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
end) -- pcall handler
|
||||
if not handlerOk then
|
||||
log.error("NET", "Handler error: %s", tostring(handlerErr))
|
||||
else
|
||||
log.info("NET", "Command #%d handled OK", cmdCount)
|
||||
end
|
||||
end -- idempotency else
|
||||
else
|
||||
log.info("NET", "Ignored: ch=%d (want %d) or not table", channel or -1, cfg.ORDER_CHANNEL)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -603,3 +816,15 @@ local function main()
|
||||
end
|
||||
|
||||
main()
|
||||
|
||||
end, function(e) return tostring(e) .. "\n" .. debug.traceback() end)
|
||||
|
||||
if not ok then
|
||||
_crashLog(err)
|
||||
printError(tostring(err))
|
||||
print("")
|
||||
print("Crash log saved to: " .. _path(".crash.log"))
|
||||
print("")
|
||||
print("Press any key to exit...")
|
||||
os.pullEvent("char")
|
||||
end
|
||||
@@ -24,7 +24,17 @@ local ORDER_CHANNEL = 4201
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
local CONFIG_FILE = _path(".webbridge_config")
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local CONFIG_FILE = _configPath(".webbridge_config")
|
||||
local API_KEY = nil -- optional API key for server auth
|
||||
|
||||
local function loadConfig()
|
||||
|
||||
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
|
||||
@@ -6,6 +6,16 @@ return function(log, _path)
|
||||
-- Fall back to CWD-relative if _path not provided (standalone use)
|
||||
if not _path then _path = function(p) return p end end
|
||||
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local C = {}
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -22,9 +32,9 @@ C.SMELT_RESERVE = 128
|
||||
C.DEFRAG_INTERVAL = 600
|
||||
C.COMPOST_INTERVAL = 3
|
||||
C.ALERT_INTERVAL = 15
|
||||
C.CACHE_FILE = _path(".inventory_cache")
|
||||
C.CACHE_FILE = _configPath(".inventory_cache")
|
||||
C.SMELTER_MONITOR_SIDE = "top"
|
||||
C.DISABLED_RECIPES_FILE = _path(".disabled_recipes")
|
||||
C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes")
|
||||
|
||||
-- Network
|
||||
C.BROADCAST_CHANNEL = 4200
|
||||
@@ -46,6 +56,17 @@ 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",
|
||||
@@ -60,7 +81,7 @@ C.SLOT_OUTPUT = 3
|
||||
-- Config file loader
|
||||
-------------------------------------------------
|
||||
|
||||
local CONFIG_FILE = _path(".manager_config")
|
||||
local CONFIG_FILE = _configPath(".manager_config")
|
||||
|
||||
function C.loadConfig()
|
||||
if not fs.exists(CONFIG_FILE) then return end
|
||||
@@ -92,6 +113,11 @@ function C.loadConfig()
|
||||
if cfg.compostReserve then C.COMPOST_RESERVE = cfg.compostReserve end
|
||||
if cfg.compostDropper then C.COMPOST_DROPPER = cfg.compostDropper end
|
||||
if cfg.compostHopper then C.COMPOST_HOPPER = cfg.compostHopper end
|
||||
if cfg.parallelScanChunks then C.PARALLEL_SCAN_CHUNKS = cfg.parallelScanChunks end
|
||||
if cfg.chestPriority then C.CHEST_PRIORITY = cfg.chestPriority end
|
||||
if cfg.supplyChest then C.SUPPLY_CHEST = cfg.supplyChest end
|
||||
if cfg.supplyInterval then C.SUPPLY_INTERVAL = cfg.supplyInterval end
|
||||
if cfg.supplyManifest then C.SUPPLY_MANIFEST = cfg.supplyManifest end
|
||||
if cfg.logLevel then log.setLevel(cfg.logLevel) end
|
||||
log.info("CONFIG", "Loaded from %s", CONFIG_FILE)
|
||||
end
|
||||
@@ -100,31 +126,36 @@ end
|
||||
-- Data tables
|
||||
-------------------------------------------------
|
||||
|
||||
C.SMELTABLE = dofile(_path("data/smeltable.lua"))
|
||||
C.FUEL_LIST = dofile(_path("data/fuel.lua"))
|
||||
local _compostData = dofile(_path("data/compostable.lua"))
|
||||
C.COMPOSTABLE = _compostData.items
|
||||
C.COMPOST_TRASH = _compostData.trash
|
||||
C.CRAFTABLE = dofile(_path("data/craftable.lua"))
|
||||
C.LOW_STOCK_ALERTS = dofile(_path("data/alerts.lua"))
|
||||
|
||||
-- Pre-build furnace compatibility sets for O(1) lookup
|
||||
for _, recipe in pairs(C.SMELTABLE) do
|
||||
recipe.furnaceSet = {}
|
||||
for _, ft in ipairs(recipe.furnaces) do
|
||||
recipe.furnaceSet[ft] = true
|
||||
end
|
||||
end
|
||||
-- Recipe book: merges built-in recipes + user-learned recipes
|
||||
local recipeBook = dofile(_path("lib/recipeBook.lua"))
|
||||
recipeBook.init(_configPath(".recipes.db"))
|
||||
recipeBook.loadLegacyCrafting(dofile(_path("data/craftable.lua")))
|
||||
recipeBook.loadLegacySmelting(dofile(_path("data/smeltable.lua")))
|
||||
C.recipeBook = recipeBook
|
||||
C.SMELTABLE = recipeBook.getSmeltingTable()
|
||||
C.CRAFTABLE = recipeBook.getCraftingList()
|
||||
|
||||
-- Pre-built smelt candidate lists per furnace type
|
||||
C.smeltCandidatesByType = {}
|
||||
do
|
||||
-- Rebuild furnace/smelt indices from current recipe data
|
||||
function C.rebuildIndices()
|
||||
for _, recipe in pairs(C.SMELTABLE) do
|
||||
recipe.furnaceSet = {}
|
||||
for _, ft in ipairs(recipe.furnaces) do
|
||||
recipe.furnaceSet[ft] = true
|
||||
end
|
||||
end
|
||||
C.smeltCandidatesByType = {}
|
||||
for _, ftype in ipairs(C.FURNACE_TYPES) do
|
||||
C.smeltCandidatesByType[ftype] = {}
|
||||
end
|
||||
for itemName, recipe in pairs(C.SMELTABLE) do
|
||||
local isFood = recipe.furnaceSet["minecraft:smoker"] or false
|
||||
for ft, _ in pairs(recipe.furnaceSet) do
|
||||
for ft in pairs(recipe.furnaceSet) do
|
||||
table.insert(C.smeltCandidatesByType[ft], { name = itemName, recipe = recipe, food = isFood })
|
||||
end
|
||||
end
|
||||
@@ -136,6 +167,15 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
-- Refresh recipes from recipeBook and rebuild all indices
|
||||
function C.refreshRecipes()
|
||||
C.SMELTABLE = C.recipeBook.getSmeltingTable()
|
||||
C.CRAFTABLE = C.recipeBook.getCraftingList()
|
||||
C.rebuildIndices()
|
||||
end
|
||||
|
||||
C.rebuildIndices()
|
||||
|
||||
-- Build fuel set for quick lookup
|
||||
C.FUEL_SET = {}
|
||||
for _, f in ipairs(C.FUEL_LIST) do C.FUEL_SET[f.name] = true end
|
||||
|
||||
@@ -40,6 +40,10 @@ local smelterPage = nil
|
||||
local selectedAmount = 1
|
||||
local amountOptions = {1, 4, 8, 16, 32, 64}
|
||||
local searchQuery = ""
|
||||
local showKeyboard = false
|
||||
local orderPopupItem = nil
|
||||
local orderPopupShort = nil
|
||||
local orderPopupQty = 1
|
||||
local smelterView = "status"
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -263,17 +267,46 @@ local function buildMainPage()
|
||||
searchRow = UI.Window {
|
||||
x = 1, y = 6, ex = -1, height = 1,
|
||||
backgroundColor = colors.black,
|
||||
},
|
||||
|
||||
searchEntry = UI.TextEntry {
|
||||
x = 3, y = 6,
|
||||
ex = '45%',
|
||||
shadowText = "search...",
|
||||
backgroundColor = colors.black,
|
||||
backgroundFocusColor = colors.gray,
|
||||
textColor = colors.white,
|
||||
shadowTextColor = colors.gray,
|
||||
limit = 30,
|
||||
draw = function(self)
|
||||
self:clear(colors.black)
|
||||
-- Keyboard toggle button
|
||||
local kbLabel = showKeyboard and " X " or " ? "
|
||||
local kbBg = showKeyboard and colors.red or colors.purple
|
||||
self:write(1, 1, kbLabel, kbBg, colors.white)
|
||||
-- Search query display
|
||||
local fieldW = math.floor(self.width * 0.4)
|
||||
if fieldW < 10 then fieldW = 10 end
|
||||
local queryDisplay = searchQuery
|
||||
if showKeyboard then
|
||||
queryDisplay = queryDisplay .. "|"
|
||||
elseif queryDisplay == "" then
|
||||
queryDisplay = "search..."
|
||||
end
|
||||
local displayText = queryDisplay:sub(1, fieldW)
|
||||
displayText = displayText .. string.rep("_", math.max(0, fieldW - #displayText))
|
||||
local tc = (searchQuery == "" and not showKeyboard) and colors.gray or colors.white
|
||||
self:write(5, 1, displayText, colors.black, tc)
|
||||
end,
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'mouse_click' then
|
||||
showKeyboard = not showKeyboard
|
||||
local page = self.parent
|
||||
if showKeyboard then
|
||||
UI.Window.enable(page.keyboard)
|
||||
page.keyboard:raise()
|
||||
page.keyboard:draw()
|
||||
else
|
||||
page.keyboard:disable()
|
||||
page.alertBar:draw()
|
||||
page.footerBar:draw()
|
||||
page.bottomBar:draw()
|
||||
end
|
||||
self:draw()
|
||||
page:sync()
|
||||
return true
|
||||
end
|
||||
return UI.Window.eventHandler(self, event)
|
||||
end,
|
||||
},
|
||||
|
||||
refreshBtn = UI.Button {
|
||||
@@ -383,15 +416,229 @@ local function buildMainPage()
|
||||
end,
|
||||
},
|
||||
|
||||
-- On-screen keyboard overlay (bottom 3 rows; starts disabled)
|
||||
keyboard = UI.Window {
|
||||
x = 1, ex = -1, ey = -1, height = 3,
|
||||
backgroundColor = colors.black,
|
||||
enable = function() end, -- prevent auto-enable; toggled manually
|
||||
draw = function(self)
|
||||
self:clear(colors.black)
|
||||
local kbDefs = {
|
||||
{ keys = {"Q","W","E","R","T","Y","U","I","O","P"}, specials = {{ label = " Bksp ", bg = colors.red, action = "kb_bksp" }} },
|
||||
{ keys = {"A","S","D","F","G","H","J","K","L"}, specials = {{ label = " Done ", bg = colors.green, action = "kb_done" }} },
|
||||
{ keys = {"Z","X","C","V","B","N","M"}, specials = {
|
||||
{ label = " Space ", bg = colors.lightGray, action = "kb_space" },
|
||||
{ label = " Clr ", bg = colors.orange, action = "kb_clear" },
|
||||
}},
|
||||
}
|
||||
self._zones = {}
|
||||
local keyW = 3
|
||||
local keyGap = 1
|
||||
for rowIdx, def in ipairs(kbDefs) do
|
||||
local y = rowIdx
|
||||
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((self.width - rowW) / 2) + 1
|
||||
-- Draw letter keys
|
||||
for ki, key in ipairs(def.keys) do
|
||||
self:write(x, y, " " .. key .. " ", colors.gray, colors.white)
|
||||
table.insert(self._zones, { x1 = x, y1 = y, x2 = x + keyW - 1, y2 = y, action = "kb_key", data = key:lower() })
|
||||
x = x + keyW
|
||||
if ki < #def.keys then x = x + keyGap end
|
||||
end
|
||||
-- Draw special keys
|
||||
for _, sp in ipairs(def.specials) do
|
||||
x = x + keyGap
|
||||
self:write(x, y, sp.label, sp.bg, colors.white)
|
||||
table.insert(self._zones, { x1 = x, y1 = y, x2 = x + #sp.label - 1, y2 = y, action = sp.action })
|
||||
x = x + #sp.label
|
||||
end
|
||||
end
|
||||
end,
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'mouse_click' then
|
||||
if self._zones then
|
||||
for _, zone in ipairs(self._zones) do
|
||||
if event.x >= zone.x1 and event.x <= zone.x2
|
||||
and event.y >= zone.y1 and event.y <= zone.y2 then
|
||||
self:emit({ type = zone.action, data = zone.data, element = self })
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return true -- consume click even if no zone hit
|
||||
end
|
||||
return UI.Window.eventHandler(self, event)
|
||||
end,
|
||||
},
|
||||
|
||||
-- Order quantity popup (full-screen overlay; starts disabled)
|
||||
orderPopup = UI.Window {
|
||||
x = 1, y = 1, ex = -1, ey = -1,
|
||||
backgroundColor = colors.gray,
|
||||
enable = function() end, -- toggled manually
|
||||
draw = function(self)
|
||||
self:clear(colors.gray)
|
||||
self._zones = {}
|
||||
|
||||
local dw = math.min(self.width - 2, 30)
|
||||
local dh = 8
|
||||
local dx = math.floor((self.width - dw) / 2) + 1
|
||||
local dy = math.floor((self.height - dh) / 2) + 1
|
||||
|
||||
-- Title row (blue background)
|
||||
local title = "Order: " .. (orderPopupShort or "?")
|
||||
if #title > dw - 2 then title = title:sub(1, dw - 2) end
|
||||
self:write(dx, dy, string.rep(" ", dw), colors.blue)
|
||||
local titleX = dx + math.floor((dw - #title) / 2)
|
||||
self:write(titleX, dy, title, colors.blue, colors.white)
|
||||
|
||||
-- Dialog body rows (gray background)
|
||||
for row = 1, dh - 1 do
|
||||
self:write(dx, dy + row, string.rep(" ", dw), colors.gray)
|
||||
end
|
||||
|
||||
-- Quantity display (row 2)
|
||||
local qtyStr = string.format("Quantity: %d", orderPopupQty)
|
||||
local qtyX = dx + math.floor((dw - #qtyStr) / 2)
|
||||
self:write(qtyX, dy + 2, qtyStr, colors.gray, colors.white)
|
||||
|
||||
-- Increment buttons (row 4): [-8] [-1] [+1] [+8]
|
||||
local incBtns = {
|
||||
{ label = " -8 ", delta = -8, bg = colors.red },
|
||||
{ label = " -1 ", delta = -1, bg = colors.red },
|
||||
{ label = " +1 ", delta = 1, bg = colors.green },
|
||||
{ label = " +8 ", delta = 8, bg = colors.green },
|
||||
}
|
||||
local totalIncW = 0
|
||||
for _, b in ipairs(incBtns) do totalIncW = totalIncW + #b.label end
|
||||
totalIncW = totalIncW + (#incBtns - 1) * 2
|
||||
local incX = dx + math.floor((dw - totalIncW) / 2)
|
||||
local incRow = dy + 4
|
||||
for _, b in ipairs(incBtns) do
|
||||
self:write(incX, incRow, b.label, b.bg, colors.white)
|
||||
table.insert(self._zones, {
|
||||
x1 = incX, y1 = incRow,
|
||||
x2 = incX + #b.label - 1, y2 = incRow,
|
||||
action = "order_delta", data = b.delta,
|
||||
})
|
||||
incX = incX + #b.label + 2
|
||||
end
|
||||
|
||||
-- Preset buttons (row 5): [1] [4] [8] [16] [32] [64]
|
||||
local presets = {1, 4, 8, 16, 32, 64}
|
||||
local totalPreW = 0
|
||||
for _, p in ipairs(presets) do totalPreW = totalPreW + #tostring(p) + 2 end
|
||||
totalPreW = totalPreW + (#presets - 1)
|
||||
local preX = dx + math.floor((dw - totalPreW) / 2)
|
||||
local preRow = dy + 5
|
||||
for _, p in ipairs(presets) do
|
||||
local label = " " .. tostring(p) .. " "
|
||||
local bg = (p == orderPopupQty) and colors.cyan or colors.lightGray
|
||||
local fg = (p == orderPopupQty) and colors.white or colors.black
|
||||
self:write(preX, preRow, label, bg, fg)
|
||||
table.insert(self._zones, {
|
||||
x1 = preX, y1 = preRow,
|
||||
x2 = preX + #label - 1, y2 = preRow,
|
||||
action = "order_set", data = p,
|
||||
})
|
||||
preX = preX + #label + 1
|
||||
end
|
||||
|
||||
-- Action buttons (row 7): [Cancel] [Order]
|
||||
local cancelLabel = " Cancel "
|
||||
local orderLabel = " Order "
|
||||
local actRow = dy + 7
|
||||
local cancelX = dx + math.floor(dw / 4) - math.floor(#cancelLabel / 2)
|
||||
local orderX = dx + math.floor(3 * dw / 4) - math.floor(#orderLabel / 2)
|
||||
self:write(cancelX, actRow, cancelLabel, colors.red, colors.white)
|
||||
table.insert(self._zones, {
|
||||
x1 = cancelX, y1 = actRow,
|
||||
x2 = cancelX + #cancelLabel - 1, y2 = actRow,
|
||||
action = "order_cancel",
|
||||
})
|
||||
self:write(orderX, actRow, orderLabel, colors.lime, colors.white)
|
||||
table.insert(self._zones, {
|
||||
x1 = orderX, y1 = actRow,
|
||||
x2 = orderX + #orderLabel - 1, y2 = actRow,
|
||||
action = "order_confirm",
|
||||
})
|
||||
end,
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'mouse_click' then
|
||||
if self._zones then
|
||||
for _, zone in ipairs(self._zones) do
|
||||
if event.x >= zone.x1 and event.x <= zone.x2
|
||||
and event.y >= zone.y1 and event.y <= zone.y2 then
|
||||
self:emit({ type = zone.action, data = zone.data, element = self })
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Click outside dialog dismisses popup
|
||||
self:emit({ type = 'order_cancel', element = self })
|
||||
return true
|
||||
end
|
||||
return UI.Window.eventHandler(self, event)
|
||||
end,
|
||||
},
|
||||
|
||||
-- Notification overlay
|
||||
notification = UI.Notification {
|
||||
anchor = 'bottom',
|
||||
},
|
||||
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'text_change' then
|
||||
searchQuery = event.text or ""
|
||||
if event.type == 'kb_key' then
|
||||
if #searchQuery < 30 then
|
||||
searchQuery = searchQuery .. event.data
|
||||
end
|
||||
D.refreshItemGrid()
|
||||
self.searchRow:draw()
|
||||
self.footerBar:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'kb_bksp' then
|
||||
if #searchQuery > 0 then
|
||||
searchQuery = searchQuery:sub(1, -2)
|
||||
end
|
||||
D.refreshItemGrid()
|
||||
self.searchRow:draw()
|
||||
self.footerBar:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'kb_space' then
|
||||
if #searchQuery < 30 then
|
||||
searchQuery = searchQuery .. " "
|
||||
end
|
||||
D.refreshItemGrid()
|
||||
self.searchRow:draw()
|
||||
self.footerBar:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'kb_done' then
|
||||
showKeyboard = false
|
||||
self.keyboard:disable()
|
||||
self.searchRow:draw()
|
||||
self.alertBar:draw()
|
||||
self.footerBar:draw()
|
||||
self.bottomBar:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'kb_clear' then
|
||||
searchQuery = ""
|
||||
showKeyboard = false
|
||||
self.keyboard:disable()
|
||||
D.refreshItemGrid()
|
||||
self.searchRow:draw()
|
||||
self.alertBar:draw()
|
||||
self.footerBar:draw()
|
||||
self.bottomBar:draw()
|
||||
@@ -401,14 +648,55 @@ local function buildMainPage()
|
||||
elseif event.type == 'grid_select' then
|
||||
local row = event.selected
|
||||
if row and row.name then
|
||||
local short = shortName(row.name)
|
||||
state.statusMessage = string.format("Ordering %s x%d...", short, selectedAmount)
|
||||
-- Hide keyboard if showing
|
||||
if showKeyboard then
|
||||
showKeyboard = false
|
||||
self.keyboard:disable()
|
||||
end
|
||||
-- Show order popup
|
||||
orderPopupItem = row.name
|
||||
orderPopupShort = shortName(row.name)
|
||||
orderPopupQty = selectedAmount
|
||||
UI.Window.enable(self.orderPopup)
|
||||
self.orderPopup:raise()
|
||||
self.orderPopup:draw()
|
||||
self:sync()
|
||||
end
|
||||
return true
|
||||
|
||||
elseif event.type == 'order_delta' then
|
||||
orderPopupQty = math.max(1, math.min(999, orderPopupQty + event.data))
|
||||
self.orderPopup:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'order_set' then
|
||||
orderPopupQty = event.data
|
||||
self.orderPopup:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'order_confirm' then
|
||||
if orderPopupItem then
|
||||
local short = shortName(orderPopupItem)
|
||||
state.statusMessage = string.format("Ordering %s x%d...", short, orderPopupQty)
|
||||
state.statusColor = colors.cyan
|
||||
state.statusTimer = 10
|
||||
activity.dispensing = true
|
||||
state.needsRedraw = true
|
||||
ops.orderItem(row.name, selectedAmount)
|
||||
ops.orderItem(orderPopupItem, orderPopupQty)
|
||||
end
|
||||
self.orderPopup:disable()
|
||||
orderPopupItem = nil
|
||||
self:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'order_cancel' then
|
||||
self.orderPopup:disable()
|
||||
orderPopupItem = nil
|
||||
self:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'amount_select' then
|
||||
@@ -446,10 +734,13 @@ local function buildMainPage()
|
||||
btnX = btnX + #tostring(amt) + 4
|
||||
end
|
||||
|
||||
-- Attach to device
|
||||
-- Attach to device (must resize to recompute all child dimensions
|
||||
-- from the monitor device, since Page:postInit defaulted to UI.term)
|
||||
mainDevice.currentPage = mainPage
|
||||
mainPage.parent = mainDevice
|
||||
mainPage:resize()
|
||||
mainPage:setParent()
|
||||
mainPage:enable()
|
||||
end
|
||||
|
||||
function D.updateAmountButtons()
|
||||
@@ -567,6 +858,25 @@ local function buildSmelterPage()
|
||||
selectedBackgroundColor = colors.purple,
|
||||
unselectedBackgroundColor = colors.gray,
|
||||
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'tab_change' then
|
||||
local titleMap = {
|
||||
Status = 'status', Smelt = 'smelt',
|
||||
Craft = 'craft', Missing = 'missing',
|
||||
}
|
||||
if event.tab and event.tab.text then
|
||||
smelterView = titleMap[event.tab.text] or smelterView
|
||||
end
|
||||
D.refreshSmelterData()
|
||||
local page = self.parent
|
||||
if page then
|
||||
page.smelterFooter:draw()
|
||||
page.bottomBar:draw()
|
||||
end
|
||||
end
|
||||
return UI.Tabs.eventHandler(self, event)
|
||||
end,
|
||||
|
||||
-- Status tab
|
||||
statusTab = UI.Tab {
|
||||
index = 1,
|
||||
@@ -667,8 +977,9 @@ local function buildSmelterPage()
|
||||
turtleStatus = UI.Window {
|
||||
x = -14, y = 0, width = 14, height = 1,
|
||||
draw = function(self)
|
||||
local turtleOk = ctx.craftTurtleName
|
||||
and peripheral.isPresent(ctx.craftTurtleName)
|
||||
local turtleOk = ctx.craftTurtleOk
|
||||
or (ctx.craftTurtleName
|
||||
and peripheral.isPresent(ctx.craftTurtleName))
|
||||
local label = turtleOk and " Turtle OK " or " No Turtle "
|
||||
local bg = turtleOk and colors.lime or colors.red
|
||||
local fg = turtleOk and colors.black or colors.white
|
||||
@@ -802,17 +1113,7 @@ local function buildSmelterPage()
|
||||
},
|
||||
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'tab_change' then
|
||||
local tabMap = { 'status', 'smelt', 'craft', 'missing' }
|
||||
if event.current then
|
||||
smelterView = tabMap[event.current] or smelterView
|
||||
end
|
||||
D.refreshSmelterData()
|
||||
self.smelterFooter:draw()
|
||||
self.bottomBar:draw()
|
||||
-- fall through to default handler for tab switching
|
||||
|
||||
elseif event.type == 'enable_all' then
|
||||
if event.type == 'enable_all' then
|
||||
state.disabledRecipes = {}
|
||||
log.debug("UI", "All recipes enabled")
|
||||
ops.saveDisabledRecipes()
|
||||
@@ -849,9 +1150,22 @@ local function buildSmelterPage()
|
||||
local recipeIdx = event.selected.idx
|
||||
local recipe = cfg.CRAFTABLE[recipeIdx]
|
||||
if recipe then
|
||||
local turtleOk = ctx.craftTurtleName
|
||||
and peripheral.isPresent(ctx.craftTurtleName)
|
||||
-- Re-scan for turtle if none known
|
||||
if not ctx.craftTurtleName then
|
||||
for _, pName in ipairs(peripheral.getNames()) do
|
||||
if pName:match("^turtle_") then
|
||||
ctx.craftTurtleName = pName
|
||||
log.info("CRAFT", "Turtle found on re-scan: %s", pName)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
local turtleOk = ctx.craftTurtleOk
|
||||
or (ctx.craftTurtleName
|
||||
and peripheral.isPresent(ctx.craftTurtleName))
|
||||
if not turtleOk then
|
||||
log.warn("CRAFT", "No crafting turtle! (name=%s)",
|
||||
tostring(ctx.craftTurtleName))
|
||||
self.notification:error("No crafting turtle!")
|
||||
return true
|
||||
end
|
||||
@@ -885,7 +1199,9 @@ local function buildSmelterPage()
|
||||
-- Attach to device
|
||||
smelterDevice.currentPage = smelterPage
|
||||
smelterPage.parent = smelterDevice
|
||||
smelterPage:resize()
|
||||
smelterPage:setParent()
|
||||
smelterPage:enable()
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -939,22 +1255,24 @@ function D.refreshSmelterData()
|
||||
end
|
||||
smelterPage.tabs.statusTab.grid:setValues(statusValues)
|
||||
|
||||
-- Smelt tab
|
||||
-- Smelt tab: build stock lookup from itemList (works for both manager and client)
|
||||
state.ensureItemList()
|
||||
local stockLookup = {}
|
||||
for _, item in ipairs(cache.itemList or {}) do
|
||||
stockLookup[item.name] = item.total
|
||||
end
|
||||
|
||||
local recipeList = {}
|
||||
for inputName, recipe in pairs(cfg.SMELTABLE) do
|
||||
local short = shortName(inputName)
|
||||
local resultShort = shortName(recipe.result)
|
||||
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 fSet = recipe.furnaceSet or {}
|
||||
if fSet["minecraft:furnace"] then types = types .. "F" end
|
||||
if fSet["minecraft:smoker"] then types = types .. "S" end
|
||||
if fSet["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
|
||||
local inStorage = stockLookup[inputName] or 0
|
||||
table.insert(recipeList, {
|
||||
inputName = inputName,
|
||||
inputShort = short,
|
||||
|
||||
@@ -148,13 +148,39 @@ function O.refreshCache(onProgress)
|
||||
local totalSlots = 0
|
||||
local usedSlots = 0
|
||||
|
||||
for ci, chest in ipairs(chests) do
|
||||
if onProgress then onProgress(ci, #chests, chest) end
|
||||
local inv = O.wrapCached(chest)
|
||||
if inv then
|
||||
totalSlots = totalSlots + inv.size()
|
||||
local contents = inv.list()
|
||||
for slot, item in pairs(contents) do
|
||||
-- Parallel inventory scanning (chunked)
|
||||
local CHUNK = cfg.PARALLEL_SCAN_CHUNKS or 8
|
||||
local scanData = {}
|
||||
local scanFns = {}
|
||||
for _, chest in ipairs(chests) do
|
||||
table.insert(scanFns, function()
|
||||
local inv = O.wrapCached(chest)
|
||||
if inv then
|
||||
scanData[chest] = {
|
||||
size = inv.size(),
|
||||
contents = inv.list(),
|
||||
}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
for i = 1, #scanFns, CHUNK do
|
||||
local chunk = {}
|
||||
local chunkEnd = math.min(i + CHUNK - 1, #scanFns)
|
||||
for j = i, chunkEnd do
|
||||
chunk[#chunk + 1] = scanFns[j]
|
||||
end
|
||||
if onProgress then
|
||||
onProgress(chunkEnd, #chests, chests[chunkEnd] or "scanning...")
|
||||
end
|
||||
parallel.waitForAll(table.unpack(chunk))
|
||||
end
|
||||
|
||||
for _, chest in ipairs(chests) do
|
||||
local data = scanData[chest]
|
||||
if data then
|
||||
totalSlots = totalSlots + data.size
|
||||
for slot, item in pairs(data.contents) do
|
||||
usedSlots = usedSlots + 1
|
||||
if not catalogue[item.name] then
|
||||
catalogue[item.name] = {}
|
||||
@@ -293,6 +319,22 @@ end
|
||||
-- Barrel auto-sort
|
||||
-------------------------------------------------
|
||||
|
||||
--- Get chests sorted by priority (highest first).
|
||||
-- Falls back to name order if no priority is configured.
|
||||
function O.getChestsByPriority()
|
||||
local chests = O.getChests()
|
||||
local priority = cfg.CHEST_PRIORITY
|
||||
if not priority or not next(priority) then return chests end
|
||||
local sorted = { table.unpack(chests) }
|
||||
table.sort(sorted, function(a, b)
|
||||
local pa = priority[a] or 0
|
||||
local pb = priority[b] or 0
|
||||
if pa ~= pb then return pa > pb end
|
||||
return a < b
|
||||
end)
|
||||
return sorted
|
||||
end
|
||||
|
||||
function O.sortBarrel(barrelOverride)
|
||||
local barrelTarget = (barrelOverride and barrelOverride ~= "") and barrelOverride or cfg.BARREL_NAME
|
||||
local barrel = O.wrapCached(barrelTarget)
|
||||
@@ -305,7 +347,7 @@ function O.sortBarrel(barrelOverride)
|
||||
state.needsRedraw = true
|
||||
|
||||
local catalogue = cache.catalogue
|
||||
local chests = O.getChests()
|
||||
local chests = O.getChestsByPriority()
|
||||
|
||||
for slot, item in pairs(contents) do
|
||||
local moved = 0
|
||||
@@ -751,6 +793,63 @@ function O.checkAlerts()
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Supply chest (builder / manifest-based stocking)
|
||||
-------------------------------------------------
|
||||
|
||||
function O.supplyChest()
|
||||
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then return false end
|
||||
|
||||
local supply = O.wrapCached(cfg.SUPPLY_CHEST)
|
||||
if not supply then return false end
|
||||
|
||||
-- Scan what's already in the supply chest
|
||||
local existing = {}
|
||||
local contents = supply.list()
|
||||
if contents then
|
||||
for _, item in pairs(contents) do
|
||||
existing[item.name] = (existing[item.name] or 0) + item.count
|
||||
end
|
||||
end
|
||||
|
||||
local catalogue = cache.catalogue
|
||||
local didWork = false
|
||||
|
||||
for _, manifest in ipairs(cfg.SUPPLY_MANIFEST) do
|
||||
local have = existing[manifest.name] or 0
|
||||
local want = manifest.count or 0
|
||||
local deficit = want - have
|
||||
|
||||
if deficit > 0 and catalogue[manifest.name] then
|
||||
local remaining = deficit
|
||||
local srcSnapshot = { table.unpack(catalogue[manifest.name]) }
|
||||
for _, source in ipairs(srcSnapshot) do
|
||||
if source.total > 0 then
|
||||
local chest = O.wrapCached(source.chest)
|
||||
if chest then
|
||||
for slot, slotItem in pairs(chest.list()) do
|
||||
if slotItem.name == manifest.name then
|
||||
local toMove = math.min(slotItem.count, remaining)
|
||||
local n = chest.pushItems(cfg.SUPPLY_CHEST, slot, toMove)
|
||||
if n and n > 0 then
|
||||
state.adjustCache(manifest.name, source.chest, -n)
|
||||
remaining = remaining - n
|
||||
didWork = true
|
||||
log.info("SUPPLY", "%s x%d -> %s", manifest.name, n, cfg.SUPPLY_CHEST)
|
||||
if remaining <= 0 then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if remaining <= 0 then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return didWork
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Order / Dispense
|
||||
-------------------------------------------------
|
||||
@@ -1012,6 +1111,24 @@ function O.craftItem(recipeIdx)
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Recursive crafting (multi-step chains)
|
||||
-------------------------------------------------
|
||||
|
||||
function O.recursiveCraft(outputName, count)
|
||||
if not ctx.craftEngine then
|
||||
return false, "Craft engine not initialized"
|
||||
end
|
||||
activity.crafting = true
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
local ok, err = ctx.craftEngine.executeChain(outputName, count, ctx)
|
||||
activity.crafting = false
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
return ok, err
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Smelting recipe persistence
|
||||
-------------------------------------------------
|
||||
|
||||
414
miningTurtle.lua
Normal file
414
miningTurtle.lua
Normal file
@@ -0,0 +1,414 @@
|
||||
-- Mining Turtle Script
|
||||
-- Sits on top of an infinite cobblestone generator.
|
||||
-- Continuously mines the block below and pushes items into
|
||||
-- networked storage via wired modem.
|
||||
-- Requires a wired modem attached to the turtle.
|
||||
|
||||
local shell = _ENV.shell
|
||||
|
||||
local function fatal(msg)
|
||||
printError(msg)
|
||||
print("\nPress any key to exit...")
|
||||
os.pullEvent("key")
|
||||
return
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Default configuration (overridden by .miner_config)
|
||||
-------------------------------------------------
|
||||
|
||||
local MINE_INTERVAL = 0.5 -- seconds between dig attempts
|
||||
local DUMP_THRESHOLD = 14 -- dump when this many slots are occupied
|
||||
local DUMP_INTERVAL = 30 -- force dump every N seconds even if not full
|
||||
local FUEL_SLOT = 16 -- slot used for pulling/burning fuel
|
||||
local FUEL_THRESHOLD = 200 -- pull fuel when below this level
|
||||
local SYSTEM_CHANNEL = 4205 -- remote reboot channel
|
||||
local ORDER_CHANNEL = 4201 -- channel to talk to manager
|
||||
local FUEL_REQUEST_TIMEOUT = 5 -- seconds to wait for manager reply
|
||||
|
||||
-- Fuel items the turtle will look for in storage (best first)
|
||||
local FUEL_ITEMS = {
|
||||
"minecraft:coal",
|
||||
"minecraft:charcoal",
|
||||
"minecraft:coal_block",
|
||||
"minecraft:blaze_rod",
|
||||
"minecraft:dried_kelp_block",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path (survives Opus package updates)
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local CONFIG_FILE = _configPath(".miner_config")
|
||||
|
||||
local function loadConfig()
|
||||
if not fs.exists(CONFIG_FILE) then return end
|
||||
local f = fs.open(CONFIG_FILE, "r")
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, data)
|
||||
if not ok or not cfg then
|
||||
print("[WARN] Failed to parse " .. CONFIG_FILE)
|
||||
return
|
||||
end
|
||||
if cfg.mineInterval then MINE_INTERVAL = cfg.mineInterval end
|
||||
if cfg.dumpThreshold then DUMP_THRESHOLD = cfg.dumpThreshold end
|
||||
if cfg.dumpInterval then DUMP_INTERVAL = cfg.dumpInterval end
|
||||
if cfg.fuelSlot then FUEL_SLOT = cfg.fuelSlot end
|
||||
print("[CONFIG] Loaded from " .. CONFIG_FILE)
|
||||
end
|
||||
|
||||
loadConfig()
|
||||
|
||||
-------------------------------------------------
|
||||
-- Setup
|
||||
-------------------------------------------------
|
||||
|
||||
print("=================================")
|
||||
print(" Mining Turtle (Cobble Miner)")
|
||||
print("=================================")
|
||||
print("")
|
||||
|
||||
if not turtle then
|
||||
return fatal("[ERR] Not a turtle!")
|
||||
end
|
||||
|
||||
-- Find wired modem and get our network name
|
||||
local modem = nil
|
||||
local modemSide = nil
|
||||
local selfName = nil
|
||||
|
||||
for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
|
||||
if peripheral.getType(side) == "modem" then
|
||||
local m = peripheral.wrap(side)
|
||||
if m.getNameLocal then
|
||||
local name = m.getNameLocal()
|
||||
if name then
|
||||
modem = m
|
||||
modemSide = side
|
||||
selfName = name
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not modem or not selfName then
|
||||
return fatal("[ERR] No wired modem found!\n Attach a wired modem to the turtle\n and connect it to the network.")
|
||||
end
|
||||
|
||||
print("[OK] Modem: " .. modemSide)
|
||||
print("[OK] Network name: " .. selfName)
|
||||
print("[OK] Mine interval: " .. MINE_INTERVAL .. "s")
|
||||
print("[OK] Dump threshold: " .. DUMP_THRESHOLD .. " slots")
|
||||
print("")
|
||||
|
||||
-------------------------------------------------
|
||||
-- Find chests on the network to dump into
|
||||
-------------------------------------------------
|
||||
|
||||
local function getChests()
|
||||
local chests = {}
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
local ptype = peripheral.getType(name)
|
||||
if ptype == "minecraft:chest" or ptype == "minecraft:barrel" or
|
||||
ptype == "minecraft:shulker_box" then
|
||||
table.insert(chests, name)
|
||||
end
|
||||
end
|
||||
return chests
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Dump inventory into networked storage
|
||||
-- Uses pull approach: wrap the remote chest and call
|
||||
-- chest.pullItems(selfName, slot) — avoids needing
|
||||
-- to wrap the turtle's own peripheral.
|
||||
-------------------------------------------------
|
||||
|
||||
local function dumpInventory()
|
||||
local chests = getChests()
|
||||
if #chests == 0 then
|
||||
print("[DUMP] No chests on network!")
|
||||
return 0
|
||||
end
|
||||
|
||||
local totalPushed = 0
|
||||
for slot = 1, 16 do
|
||||
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
|
||||
for _, chestName in ipairs(chests) do
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.pullItems then
|
||||
local ok, n = pcall(chest.pullItems, selfName, slot)
|
||||
if ok and n and n > 0 then
|
||||
totalPushed = totalPushed + n
|
||||
if turtle.getItemCount(slot) == 0 then
|
||||
break -- slot empty, move to next
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Notify manager to refresh its cache so counts update immediately
|
||||
if totalPushed > 0 then
|
||||
pcall(function()
|
||||
modem.transmit(ORDER_CHANNEL, ORDER_CHANNEL, { type = "scan" })
|
||||
end)
|
||||
end
|
||||
|
||||
return totalPushed
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Count occupied inventory slots
|
||||
-------------------------------------------------
|
||||
|
||||
local function occupiedSlots()
|
||||
local count = 0
|
||||
for slot = 1, 16 do
|
||||
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Auto-refuel: ask manager for fuel, pull directly
|
||||
-------------------------------------------------
|
||||
|
||||
--- Try to burn whatever is already in FUEL_SLOT
|
||||
local function burnFuelSlot()
|
||||
if FUEL_SLOT <= 0 then return false end
|
||||
local prev = turtle.getSelectedSlot()
|
||||
turtle.select(FUEL_SLOT)
|
||||
if turtle.getItemCount() > 0 then
|
||||
local ok = turtle.refuel()
|
||||
turtle.select(prev)
|
||||
return ok
|
||||
end
|
||||
turtle.select(prev)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Ask the inventory manager where fuel items are
|
||||
local function askManagerForFuel()
|
||||
local replyChannel = ORDER_CHANNEL + 100 + os.getComputerID()
|
||||
modem.open(replyChannel)
|
||||
|
||||
modem.transmit(ORDER_CHANNEL, replyChannel, {
|
||||
type = "find_item",
|
||||
items = FUEL_ITEMS,
|
||||
limit = 1,
|
||||
})
|
||||
|
||||
local deadline = os.clock() + FUEL_REQUEST_TIMEOUT
|
||||
local result = nil
|
||||
|
||||
while os.clock() < deadline do
|
||||
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
|
||||
local event, p1, p2, p3, p4 = os.pullEvent()
|
||||
if event == "modem_message" and p2 == replyChannel then
|
||||
if type(p4) == "table" and p4.type == "find_item_result" then
|
||||
result = p4.results
|
||||
break
|
||||
end
|
||||
elseif event == "timer" and p1 == timerId then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
modem.close(replyChannel)
|
||||
return result
|
||||
end
|
||||
|
||||
--- Pull fuel from a specific chest+slot (told by manager)
|
||||
local function pullFuelFromSource(source)
|
||||
if FUEL_SLOT <= 0 then return false end
|
||||
local chest = peripheral.wrap(source.chest)
|
||||
if not chest then return false end
|
||||
local ok, n = pcall(chest.pushItems, selfName, source.slot, 64, FUEL_SLOT)
|
||||
if ok and n and n > 0 then
|
||||
print(string.format("[FUEL] Pulled %s x%d from %s", source.name, n, source.chest))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function pullFuelFromStorage()
|
||||
-- Ask manager first (it knows exactly where fuel is)
|
||||
local sources = askManagerForFuel()
|
||||
if sources and #sources > 0 then
|
||||
for _, source in ipairs(sources) do
|
||||
if pullFuelFromSource(source) then return true end
|
||||
end
|
||||
end
|
||||
|
||||
-- Fallback: scan chests directly (in case manager is offline)
|
||||
local fuelSet = {}
|
||||
for _, name in ipairs(FUEL_ITEMS) do fuelSet[name] = true end
|
||||
local chests = getChests()
|
||||
for _, chestName in ipairs(chests) do
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.list then
|
||||
for slot, item in pairs(chest.list() or {}) do
|
||||
if fuelSet[item.name] then
|
||||
local ok, n = pcall(chest.pushItems, selfName, slot, 64, FUEL_SLOT)
|
||||
if ok and n and n > 0 then
|
||||
print(string.format("[FUEL] Pulled %s x%d (fallback)", item.name, n))
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function autoRefuel()
|
||||
if turtle.getFuelLevel() == "unlimited" then return end
|
||||
if turtle.getFuelLevel() >= FUEL_THRESHOLD then return end
|
||||
|
||||
-- First try burning whatever is already in the fuel slot
|
||||
if burnFuelSlot() then
|
||||
print("[FUEL] Burned local fuel. Level: " .. turtle.getFuelLevel())
|
||||
return
|
||||
end
|
||||
|
||||
-- Pull fresh fuel from networked storage
|
||||
if pullFuelFromStorage() then
|
||||
burnFuelSlot()
|
||||
print("[FUEL] Refueled from storage. Level: " .. turtle.getFuelLevel())
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Stats
|
||||
-------------------------------------------------
|
||||
|
||||
local totalMined = 0
|
||||
local totalDumped = 0
|
||||
local startTime = os.clock()
|
||||
|
||||
local function printStats()
|
||||
local elapsed = os.clock() - startTime
|
||||
local mins = math.floor(elapsed / 60)
|
||||
local secs = math.floor(elapsed % 60)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("=================================")
|
||||
print(" Mining Turtle - Running")
|
||||
print("=================================")
|
||||
print(string.format(" Mined: %d blocks", totalMined))
|
||||
print(string.format(" Dumped: %d items", totalDumped))
|
||||
print(string.format(" Uptime: %dm %ds", mins, secs))
|
||||
if turtle.getFuelLevel() ~= "unlimited" then
|
||||
print(string.format(" Fuel: %d", turtle.getFuelLevel()))
|
||||
end
|
||||
print(string.format(" Inv used: %d/16 slots", occupiedSlots()))
|
||||
print("=================================")
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Main mining loop
|
||||
-------------------------------------------------
|
||||
|
||||
local function mineLoop()
|
||||
local lastDump = os.clock()
|
||||
|
||||
while true do
|
||||
-- Auto-refuel if low
|
||||
autoRefuel()
|
||||
|
||||
-- Check fuel — keep trying to pull from storage
|
||||
if turtle.getFuelLevel() ~= "unlimited" and turtle.getFuelLevel() < 1 then
|
||||
print("[WARN] Out of fuel! Searching storage...")
|
||||
while turtle.getFuelLevel() < 1 do
|
||||
if pullFuelFromStorage() then
|
||||
burnFuelSlot()
|
||||
end
|
||||
if turtle.getFuelLevel() < 1 then
|
||||
printStats()
|
||||
sleep(10)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Mine the block below
|
||||
if turtle.detectDown() then
|
||||
local ok, err = turtle.digDown()
|
||||
if ok then
|
||||
totalMined = totalMined + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Dump inventory if threshold reached or timer expired
|
||||
local now = os.clock()
|
||||
if occupiedSlots() >= DUMP_THRESHOLD or (now - lastDump) >= DUMP_INTERVAL then
|
||||
if occupiedSlots() > 0 then
|
||||
local pushed = dumpInventory()
|
||||
totalDumped = totalDumped + pushed
|
||||
if pushed > 0 then
|
||||
print(string.format("[DUMP] Pushed %d items to storage", pushed))
|
||||
end
|
||||
end
|
||||
lastDump = os.clock()
|
||||
end
|
||||
|
||||
-- Refresh display periodically
|
||||
printStats()
|
||||
|
||||
sleep(MINE_INTERVAL)
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Reboot listener (remote reboot support)
|
||||
-------------------------------------------------
|
||||
|
||||
local function rebootListener()
|
||||
modem.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == "miner" or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received.")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Entry point
|
||||
-------------------------------------------------
|
||||
|
||||
print("Starting mining loop...")
|
||||
print("Press Ctrl+T to stop.")
|
||||
print("")
|
||||
sleep(1)
|
||||
|
||||
local ok, err = pcall(function()
|
||||
parallel.waitForAny(mineLoop, rebootListener)
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
fatal("[ERR] Mining turtle crashed:\n" .. tostring(err))
|
||||
end
|
||||
@@ -3,6 +3,13 @@
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
-- Persistent config directory (survives Opus package updates)
|
||||
local PERSIST_DIR = "usr/config/inventory-manager"
|
||||
if not fs.isDir(PERSIST_DIR) then fs.makeDir(PERSIST_DIR) end
|
||||
|
||||
local SETUP_FILE = fs.combine(PERSIST_DIR, ".client_setup")
|
||||
local DROPPER_CONFIG = fs.combine(PERSIST_DIR, ".dropper_config")
|
||||
|
||||
-- Files to download (destination -> repo path)
|
||||
local FILES = {
|
||||
["inventoryClient.lua"] = "inventoryClient.lua",
|
||||
@@ -65,8 +72,103 @@ else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- First-run setup
|
||||
-------------------------------------------------
|
||||
|
||||
local VALID_SIDES = { "top", "bottom", "left", "right", "front", "back" }
|
||||
local function isValidSide(s)
|
||||
for _, v in ipairs(VALID_SIDES) do
|
||||
if v == s then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function firstRunSetup()
|
||||
if fs.exists(SETUP_FILE) then return end
|
||||
|
||||
print("")
|
||||
print("========== First-Run Setup ==========")
|
||||
print("")
|
||||
write("Is there a dropper next to this computer? (y/n): ")
|
||||
local answer = read():lower():sub(1, 1)
|
||||
|
||||
if answer == "y" then
|
||||
-- Ask which side the dropper is on
|
||||
local side
|
||||
while true do
|
||||
print("")
|
||||
print("Valid sides: top, bottom, left, right, front, back")
|
||||
write("Which side is the dropper on? ")
|
||||
side = read():lower():gsub("%s+", "")
|
||||
if isValidSide(side) then
|
||||
break
|
||||
end
|
||||
print("Invalid side. Please try again.")
|
||||
end
|
||||
|
||||
-- Ask which side to pulse redstone from (defaults to same side)
|
||||
print("")
|
||||
write("Redstone output side [" .. side .. "]: ")
|
||||
local rsSide = read():lower():gsub("%s+", "")
|
||||
if rsSide == "" then rsSide = side end
|
||||
if not isValidSide(rsSide) then
|
||||
print("Invalid side, defaulting to " .. side)
|
||||
rsSide = side
|
||||
end
|
||||
|
||||
-- Save dropper config
|
||||
local cfg = textutils.serialiseJSON({
|
||||
dropperSide = side,
|
||||
redstoneSide = rsSide,
|
||||
enabled = true,
|
||||
})
|
||||
local f = fs.open(DROPPER_CONFIG, "w")
|
||||
f.write(cfg)
|
||||
f.close()
|
||||
print("")
|
||||
print("Dropper configured: peripheral=" .. side .. ", redstone=" .. rsSide)
|
||||
else
|
||||
-- No dropper - save config with enabled=false
|
||||
local f = fs.open(DROPPER_CONFIG, "w")
|
||||
f.write(textutils.serialiseJSON({ enabled = false }))
|
||||
f.close()
|
||||
print("No dropper configured.")
|
||||
end
|
||||
|
||||
-- Mark setup as complete
|
||||
local f = fs.open(SETUP_FILE, "w")
|
||||
f.write("done")
|
||||
f.close()
|
||||
|
||||
print("")
|
||||
print("Setup complete!")
|
||||
print("(Delete " .. SETUP_FILE .. " to re-run setup)")
|
||||
sleep(2)
|
||||
end
|
||||
|
||||
firstRunSetup()
|
||||
|
||||
-------------------------------------------------
|
||||
-- Determine if dropper controller should run
|
||||
-------------------------------------------------
|
||||
|
||||
local dropperEnabled = false
|
||||
if fs.exists(DROPPER_CONFIG) then
|
||||
local f = fs.open(DROPPER_CONFIG, "r")
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, f.readAll())
|
||||
f.close()
|
||||
if ok and cfg and cfg.enabled then
|
||||
dropperEnabled = true
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
print("Starting inventoryClient + dropperController...")
|
||||
if dropperEnabled then
|
||||
print("Starting inventoryClient + dropperController...")
|
||||
else
|
||||
print("Starting inventoryClient (no dropper)...")
|
||||
end
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this computer on remote command
|
||||
@@ -90,8 +192,12 @@ local function rebootListener()
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(
|
||||
local tasks = {
|
||||
function() shell.run("inventoryClient.lua") end,
|
||||
function() shell.run("dropperController.lua") end,
|
||||
rebootListener
|
||||
)
|
||||
rebootListener,
|
||||
}
|
||||
if dropperEnabled then
|
||||
table.insert(tasks, function() shell.run("dropperController.lua") end)
|
||||
end
|
||||
|
||||
parallel.waitForAny(table.unpack(tasks))
|
||||
|
||||
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")
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build the React app
|
||||
FROM node:18-alpine AS build
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}:4444`;
|
||||
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || 'https://turtles.spatulaa.com';
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
@@ -9,12 +9,6 @@ services:
|
||||
- API_KEY=${API_KEY:-}
|
||||
- TURTLE_SERVER_URL=${TURTLE_SERVER_URL:-}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
start_period: 5s
|
||||
retries: 3
|
||||
|
||||
client:
|
||||
build:
|
||||
@@ -27,7 +21,7 @@ services:
|
||||
- inventory-network
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
|
||||
@@ -1,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,20 +13,23 @@ 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 . .
|
||||
|
||||
# Create data directory for SQLite with proper ownership
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /data
|
||||
VOLUME /data
|
||||
|
||||
# Run as non-root user for security
|
||||
USER node
|
||||
# Entrypoint fixes /data permissions then drops to node user
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "require('http').get('http://127.0.0.1:3001/api/health', r => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { existsSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || '/data/inventory.db';
|
||||
|
||||
console.log(`[db] Opening database at ${DB_PATH} (uid=${process.getuid()})`);
|
||||
|
||||
// Ensure the directory exists
|
||||
const dbDir = dirname(DB_PATH);
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
// Verify directory is writable before opening SQLite
|
||||
try {
|
||||
accessSync(dbDir, fsConstants.W_OK);
|
||||
} catch {
|
||||
console.error(`[db] FATAL: directory ${dbDir} is not writable by uid ${process.getuid()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(DB_PATH);
|
||||
} catch (err) {
|
||||
console.error(`[db] FATAL: failed to open database: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Performance pragmas
|
||||
db.pragma('journal_mode = WAL');
|
||||
@@ -19,6 +35,8 @@ db.pragma('foreign_keys = ON');
|
||||
db.pragma('cache_size = -8000'); // 8MB cache
|
||||
db.pragma('temp_store = MEMORY');
|
||||
|
||||
console.log('[db] Database ready');
|
||||
|
||||
// ========== Schema ==========
|
||||
|
||||
db.exec(`
|
||||
|
||||
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 "$@"
|
||||
Reference in New Issue
Block a user