Files
Inventory-Manager-CC/inventoryManager.lua
MayaTheShy ec1a681924 fix: replace custom event wake-up with polling, remove craftItem double-capture
Two robustness improvements to the Network capture/processor split:

1. Processor wake-up: replaced os.queueEvent('network_queued') /
   os.pullEvent('network_queued') with sleep(0) polling. Custom events
   can be consumed by other coroutines (e.g. craftItem's unfiltered
   os.pullEvent()) or swallowed by the OS event layer. Polling the
   shared queue every tick is simpler and guaranteed reliable.

2. craftItem: removed ORDER_CHANNEL message buffering and re-queuing.
   With the dedicated Network-capture task, ORDER_CHANNEL messages are
   already safely captured into networkQueue. The old buffering caused
   double-capture: capture task adds to queue, craftItem also buffers,
   then re-queues via os.queueEvent -> capture captures again -> dup.
   The commandId dedup caught these, but removing the source is cleaner.
2026-03-28 23:40:09 -04:00

935 lines
40 KiB
Lua

-- Inventory Manager: Touch UI on monitor
-- Main computer (networked). Computer 1 sits next to dropper_0 and auto-dispenses.
--
-- Modular architecture:
-- manager/config.lua — configuration constants & data tables
-- manager/state.lua — shared mutable state, cache, flags
-- manager/operations.lua — peripheral helpers & all inventory operations
-- manager/display.lua — dashboard rendering & touch handlers
-- inventoryManager.lua — this file: orchestrator, main(), network handler
-------------------------------------------------
-- Resolve base directory for portable path resolution
-------------------------------------------------
local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
-- Persistent config path: survives Opus package updates by storing
-- user data in usr/config/inventory-manager/ instead of the package dir.
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
-- Write crash info to a file so we can always read it
local function _crashLog(err)
local f = fs.open(_path(".crash.log"), "w")
if f then
f.write(tostring(err) .. "\n" .. (debug and debug.traceback and debug.traceback() or ""))
f.close()
end
end
local ok, err = xpcall(function()
-- Override dofile to load modules into our _ENV so they inherit
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
local function dofile(path) -- luacheck: ignore
local fn, err = loadfile(path, nil, _ENV)
if fn then return fn()
else error(err, 2) end
end
-------------------------------------------------
-- Structured logging & shared UI helpers
-------------------------------------------------
local log = dofile(_path("lib/log.lua"))
local ui = dofile(_path("lib/ui.lua"))
-------------------------------------------------
-- Load modules (factory pattern → shared context)
-------------------------------------------------
local cfg = dofile(_path("manager/config.lua"))(log, _path)
local state = dofile(_path("manager/state.lua"))()
-- Shared context table (Lua tables are by-reference, so all
-- modules see the same mutable cache/activity/etc)
local ctx = {
log = log,
ui = ui,
cfg = cfg,
state = state,
-- Filled during init:
networkModem = nil,
networkModemName = nil,
craftTurtleName = nil,
}
local ops = dofile(_path("manager/operations.lua"))(ctx)
ctx.ops = ops
local display = dofile(_path("manager/display.lua"))(ctx)
ctx.display = display
-- Recursive crafting engine
local craftEngine = dofile(_path("lib/craft.lua"))
craftEngine.init(cfg.recipeBook, ops.getItemTotal)
ctx.craftEngine = craftEngine
-- Convenience aliases
local cache = state.cache
local activity = state.activity
-------------------------------------------------
-- Idempotent command tracking
-------------------------------------------------
local processedCmdIds = {}
local CMD_TTL_MS = 300000 -- 5 minutes
local function isCommandDuplicate(commandId)
if not commandId then return false end
local entry = processedCmdIds[commandId]
return entry ~= nil and (os.epoch("utc") - entry) < CMD_TTL_MS
end
local function recordCommandId(commandId)
if not commandId then return end
processedCmdIds[commandId] = os.epoch("utc")
end
local function cleanupCommandIds()
local cutoff = os.epoch("utc") - CMD_TTL_MS
for id, ts in pairs(processedCmdIds) do
if ts < cutoff then processedCmdIds[id] = nil end
end
end
-------------------------------------------------
-- Network broadcast (sends state to client displays)
-------------------------------------------------
local function broadcastState()
if not ctx.networkModem then return end
state.ensureItemList()
local payload = {
type = "state",
stateVersion = state.stateVersion,
cache = {
itemList = cache.itemList,
grandTotal = cache.grandTotal,
chestCount = cache.chestCount,
totalSlots = cache.totalSlots,
usedSlots = cache.usedSlots,
freeSlots = cache.freeSlots,
usedRatio = cache.usedRatio,
dropperOk = cache.dropperOk,
barrelOk = cache.barrelOk,
furnaceCount = cache.furnaceCount,
furnaceStatus = cache.furnaceStatus,
droppers = cache.droppers,
},
activity = activity,
alerts = state.activeAlerts,
smeltingPaused = state.smeltingPaused,
disabledRecipes = state.disabledRecipes,
craftTurtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName),
}
-- Keep ctx in sync so display.lua can check ctx.craftTurtleOk directly
ctx.craftTurtleOk = payload.craftTurtleOk
-- Only include recipe tables when config has changed (they're large).
if state.configDirty then
payload.smeltable = cfg.SMELTABLE
payload.craftable = cfg.CRAFTABLE
state.configDirty = false
end
ctx.networkModem.transmit(cfg.BROADCAST_CHANNEL, cfg.ORDER_CHANNEL, payload)
state.lastBroadcastVersion = state.stateVersion
end
-------------------------------------------------
-- Main
-------------------------------------------------
local function main()
print("=================================")
print(" Inventory Manager v3 (Modular)")
print("=================================")
print("")
-- Peripheral detection
if peripheral.isPresent(cfg.DROPPER_NAME) then
log.info("INIT", "Dropper: %s", cfg.DROPPER_NAME)
else
log.warn("INIT", "Dropper not found: %s", cfg.DROPPER_NAME)
end
if peripheral.isPresent(cfg.BARREL_NAME) then
log.info("INIT", "Barrel: %s", cfg.BARREL_NAME)
else
log.warn("INIT", "Barrel not found: %s", cfg.BARREL_NAME)
end
-- Monitors
if display.setupMonitor() then
log.info("INIT", "Monitor: %s", display.monName or cfg.MONITOR_SIDE)
else
log.warn("INIT", "No monitor on %s", cfg.MONITOR_SIDE)
end
if display.setupSmelterMonitor() then
log.info("INIT", "Smelter monitor: %s", display.smelterMonName or cfg.SMELTER_MONITOR_SIDE)
else
log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE)
end
-- Billboard monitor (optional — set billboardMonitorSide in .manager_config)
-- Billboard monitor (auto-detects any 3rd monitor, or set billboardMonitor in .manager_config)
if display.setupBillboardMonitor() then
log.info("INIT", "Billboard monitor: %s", display.billboardMonName)
else
log.info("INIT", "No billboard monitor found (optional)")
end
-- Find wired modem for client/turtle communication
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" then
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
-- 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
break
end
end
if ctx.craftTurtleName then
log.info("INIT", "Crafting turtle: %s", ctx.craftTurtleName)
else
log.warn("INIT", "No crafting turtle found")
end
-- Load recipe toggles from disk
ops.loadDisabledRecipes()
if state.smeltingPaused then
log.info("INIT", "Smelting is PAUSED (toggle on smelter monitor)")
end
local enabledCount = 0
local totalRecipeCount = 0
for _ in pairs(cfg.SMELTABLE) do totalRecipeCount = totalRecipeCount + 1 end
for k in pairs(cfg.SMELTABLE) do
if not state.disabledRecipes[k] then enabledCount = enabledCount + 1 end
end
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
log.info("INIT", "Compost dropper: %s", cfg.COMPOST_DROPPER)
else
log.warn("INIT", "Compost dropper not found: %s", cfg.COMPOST_DROPPER)
end
if peripheral.isPresent(cfg.COMPOST_HOPPER) then
log.info("INIT", "Compost hopper: %s", cfg.COMPOST_HOPPER)
else
log.warn("INIT", "Compost hopper not found: %s", cfg.COMPOST_HOPPER)
end
log.info("INIT", "Tracking %d low-stock alerts", #cfg.LOW_STOCK_ALERTS)
print("")
print("Console shows log. Use the monitors to interact.")
print("")
-- Try loading cached inventory from disk for instant startup
local cacheLoaded = ops.loadCacheFromDisk()
if cacheLoaded then
log.info("INIT", "Loaded cached inventory (%d types)", #cache.itemList)
log.info("INIT", "Background refresh starting...")
else
-- No cache: do full scan with boot progress bar
log.info("INIT", "No cache found. Scanning inventories...")
if display.mon then
local w, h = display.mon.getSize()
local buf = window.create(display.mon, 1, 1, w, h, false)
local function drawBoot(current, total, chestName)
buf.setBackgroundColor(colors.black)
buf.clear()
buf.setBackgroundColor(colors.blue)
buf.setCursorPos(1, 1)
buf.write(string.rep(" ", w))
local title = " INVENTORY MANAGER "
buf.setCursorPos(math.floor((w - #title) / 2) + 1, 1)
buf.setTextColor(colors.white)
buf.write(title)
local midY = math.floor(h / 2)
buf.setBackgroundColor(colors.black)
buf.setTextColor(colors.lightGray)
local label = "Scanning inventories..."
buf.setCursorPos(math.floor((w - #label) / 2) + 1, midY - 2)
buf.write(label)
local short = chestName or ""
if #short > w - 4 then short = ".." .. short:sub(-(w - 6)) end
buf.setTextColor(colors.gray)
buf.setCursorPos(math.floor((w - #short) / 2) + 1, midY - 1)
buf.write(short)
local barW = math.min(w - 8, 40)
local barX = math.floor((w - barW) / 2) + 1
local ratio = total > 0 and (current / total) or 0
local filled = math.floor(ratio * barW)
buf.setCursorPos(barX, midY + 1)
buf.setBackgroundColor(colors.lime)
buf.write(string.rep(" ", filled))
buf.setBackgroundColor(colors.gray)
buf.write(string.rep(" ", barW - filled))
buf.setBackgroundColor(colors.black)
buf.setTextColor(colors.white)
local pct = string.format("%d/%d (%d%%)", current, total, math.floor(ratio * 100))
buf.setCursorPos(math.floor((w - #pct) / 2) + 1, midY + 3)
buf.write(pct)
buf.setCursorPos(1, h)
buf.setBackgroundColor(colors.blue)
buf.write(string.rep(" ", w))
buf.setVisible(true)
buf.setVisible(false)
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
log.info("INIT", "Done. Found %d item types.", #cache.itemList)
end
print("")
-----------------------------------------------
-- Resilient task wrapper: if a task crashes, it
-- restarts after a brief delay instead of killing
-- all other parallel tasks.
-----------------------------------------------
local function resilient(name, fn)
return function()
while true do
local ok, err = pcall(fn)
if not ok then
log.error("TASK", "%s crashed: %s", name, tostring(err))
sleep(5)
log.info("TASK", "%s restarting...", name)
else
-- Task returned normally (shouldn't happen)
log.warn("TASK", "%s exited unexpectedly, restarting...", name)
sleep(1)
end
end
end
end
-----------------------------------------------
-- Parallel tasks
-----------------------------------------------
-- Shared queue: capture task writes here, processor task reads.
-- This ensures modem_message events are never lost while the
-- processor yields for peripheral calls (pushItems, list, etc).
local networkQueue = {}
parallel.waitForAny(
-- Task 1: Background inventory scanner
resilient("Scanner", function()
if cacheLoaded then
pcall(ops.refreshCache)
pcall(ops.checkAlerts)
state.needsRedraw = true
state.smelterNeedsRedraw = true
log.info("INIT", "Background refresh complete. %d types.", #cache.itemList)
end
while true do
sleep(cfg.SCAN_INTERVAL)
pcall(ops.refreshCache)
pcall(ops.checkAlerts)
pcall(function() cfg.recipeBook.flush() end)
state.needsRedraw = true
state.smelterNeedsRedraw = true
end
end),
-- Task 2: Barrel auto-sort
resilient("Barrel-sort", function()
while true do
pcall(ops.sortBarrel)
sleep(cfg.POLL_INTERVAL)
end
end),
-- Task 3: Auto-smelt
resilient("Auto-smelt", function()
while true do
local ok, didWork = pcall(ops.autoSmelt)
if ok and didWork then
activity.smelting = true
state.needsRedraw = true
state.smelterNeedsRedraw = true
end
activity.smelting = false
pcall(ops.refreshFurnaceStatus)
state.needsRedraw = true
state.smelterNeedsRedraw = true
sleep(cfg.SMELT_INTERVAL)
end
end),
-- Task 4: Defrag (consolidate partial stacks)
resilient("Defrag", function()
sleep(10)
while true do
activity.defragging = true
state.needsRedraw = true
pcall(ops.defragInventory)
activity.defragging = false
state.needsRedraw = true
sleep(cfg.DEFRAG_INTERVAL)
end
end),
-- Task 5: Auto-compost
resilient("Auto-compost", function()
while true do
activity.composting = true
state.needsRedraw = true
pcall(ops.autoCompost)
activity.composting = false
state.needsRedraw = true
pcall(ops.checkAlerts)
sleep(cfg.COMPOST_INTERVAL)
end
end),
-- Task 5b: Auto-discard excess stock
resilient("Auto-discard", function()
if #cfg.TRASH_DROPPERS == 0 then
while true do sleep(3600) end
end
sleep(8) -- let initial scan finish first
log.info("DISCARD", "Auto-discard active with %d trash dropper(s)", #cfg.TRASH_DROPPERS)
while true do
activity.discarding = true
state.needsRedraw = true
pcall(ops.discardExcess)
activity.discarding = false
state.needsRedraw = true
sleep(cfg.DISCARD_INTERVAL)
end
end),
-- Task 5c: Auto-craft excess items into target products
resilient("Auto-craft", function()
sleep(12) -- let initial scan + discard settle first
log.info("AUTOCRAFT", "Auto-craft active (%d explicit rule(s), smart=%s)",
#cfg.AUTO_CRAFT_RULES, tostring(cfg.AUTO_CRAFT_FROM_EXCESS))
while true do
if ctx.craftTurtleName then
activity.autocrafting = true
state.needsRedraw = true
pcall(ops.autoCraft)
activity.autocrafting = false
state.needsRedraw = true
end
sleep(cfg.AUTO_CRAFT_INTERVAL)
end
end),
-- Task 5d: Collection hopper emptying (egg spawner, etc.)
resilient("Hopper-collect", function()
if #cfg.COLLECTION_HOPPERS == 0 then
while true do sleep(3600) end
end
sleep(3)
log.info("COLLECT", "Hopper collection active for %d hopper(s)", #cfg.COLLECTION_HOPPERS)
while true do
pcall(ops.collectHoppers)
sleep(cfg.COLLECTION_INTERVAL)
end
end),
-- Task 6: Low-stock alert checker
resilient("Alert-checker", function()
sleep(5)
pcall(ops.checkAlerts)
state.needsRedraw = true
while true do
sleep(cfg.ALERT_INTERVAL)
pcall(ops.checkAlerts)
state.needsRedraw = true
end
end),
-- Task 7: Main dashboard redraw (event-driven, polls 0.1s)
resilient("Dashboard", function()
state.needsRedraw = true
while true do
if state.needsRedraw then
state.needsRedraw = false
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
if state.statusTimer <= 0 then
state.statusTimer = 0
state.needsRedraw = true
end
end
if #state.activeAlerts > 0 then
state.needsRedraw = true
end
sleep(0.1)
end
end),
-- Task 8: Smelter dashboard redraw
resilient("Smelter-dashboard", function()
state.smelterNeedsRedraw = true
while true do
if state.smelterNeedsRedraw then
state.smelterNeedsRedraw = false
local sok, serr = pcall(display.drawSmelterDashboard)
if not sok then log.error("DRAW", "Smelter: %s", tostring(serr)) end
end
sleep(0.1)
end
end),
-- Task 8b: Billboard dashboard redraw (goals monitor)
resilient("Billboard", function()
if not display.billboardMon then
-- No billboard configured, sleep forever
while true do sleep(3600) end
end
state.billboardNeedsRedraw = true
while true do
if state.billboardNeedsRedraw then
state.billboardNeedsRedraw = false
local bok, berr = pcall(display.drawBillboard)
if not bok then log.error("DRAW", "Billboard: %s", tostring(berr)) end
end
sleep(0.5)
end
end),
-- Task 9: Touch event listener (both monitors)
resilient("Touch-listener", function()
while true do
local event, side, x, y = os.pullEvent("monitor_touch")
if display.smelterMonName and side == display.smelterMonName then
log.debug("TOUCH", "x=%d y=%d", x, y)
display.handleSmelterTouch(x, y)
else
log.debug("TOUCH", "x=%d y=%d", x, y)
display.handleTouch(x, y)
end
end
end),
-- Task 10: Network state broadcast (skips if nothing changed)
resilient("Broadcast", function()
while true do
if state.stateVersion ~= state.lastBroadcastVersion then
pcall(broadcastState)
end
sleep(cfg.BROADCAST_INTERVAL)
end
end),
-- Task 11: Peripheral detach handler
resilient("Detach-handler", function()
while true do
local event, name = os.pullEvent("peripheral_detach")
if name then
ops.invalidateWrapCache(name)
ops.invalidatePeripheralCaches()
log.info("DETACH", "%s", name)
if name == ctx.craftTurtleName then
ctx.craftTurtleName = nil
log.warn("DETACH", "Crafting turtle disconnected")
end
end
end
end),
-- Task 11b: Peripheral attach handler (auto-detect crafting turtle)
resilient("Attach-handler", function()
while true do
local event, name = os.pullEvent("peripheral_attach")
if name and name:match("^turtle_") and not ctx.craftTurtleName then
ctx.craftTurtleName = name
log.info("ATTACH", "Crafting turtle detected: %s", name)
pcall(broadcastState)
end
end
end),
-- Task 12: Supply chest (builder / manifest-based stocking)
resilient("Supply-chest", function()
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then
while true do sleep(3600) end
end
log.info("SUPPLY", "Stocking %s with %d item types", cfg.SUPPLY_CHEST, #cfg.SUPPLY_MANIFEST)
while true do
pcall(ops.supplyChest)
sleep(cfg.SUPPLY_INTERVAL)
end
end),
-- Task 13a: Network message capture (fast — never yields to peripheral calls)
-- This coroutine's filter is ALWAYS "modem_message", so it can never
-- miss events while other tasks yield for "task_complete" etc.
resilient("Network-capture", function()
if not ctx.networkModem then
while true do sleep(3600) end
end
while true do
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
if channel == cfg.ORDER_CHANNEL and type(message) == "table" then
table.insert(networkQueue, { replyChannel = replyChannel, message = message })
end
end
end),
-- Task 13b: Network message processor (drains queue — safe to yield)
resilient("Network-processor", function()
if not ctx.networkModem then
while true do sleep(3600) end
end
while true do
if #networkQueue == 0 then
sleep(0)
end
while #networkQueue > 0 do
local entry = table.remove(networkQueue, 1)
local message = entry.message
local replyChannel = entry.replyChannel
if isCommandDuplicate(message.commandId) then
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId))
else
recordCommandId(message.commandId)
cleanupCommandIds()
local handlerOk, handlerErr = pcall(function()
if message.type == "order" and message.itemName and message.amount then
log.info("NET", "Order: %s x%d", message.itemName, message.amount)
local pok, success = pcall(ops.orderItem, message.itemName, message.amount, message.dropperName)
if not pok then
log.error("NET", "orderItem crashed: %s", tostring(success))
success = false
state.statusMessage = "Order error"
state.statusColor = colors.red
state.statusTimer = 5
state.needsRedraw = true
end
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "order_result",
commandId = message.commandId,
success = success,
message = state.statusMessage,
color = state.statusColor,
})
end)
pcall(broadcastState)
elseif message.type == "scan" then
log.info("NET", "Scan request from client")
state.configDirty = true
pcall(ops.refreshCache)
pcall(ops.checkAlerts)
state.needsRedraw = true
state.smelterNeedsRedraw = true
pcall(broadcastState)
elseif message.type == "toggle_pause" then
state.smeltingPaused = not state.smeltingPaused
log.info("NET", "Smelting %s", state.smeltingPaused and "PAUSED" or "RESUMED")
ops.saveDisabledRecipes()
state.bumpStateVersion()
state.smelterNeedsRedraw = true
state.needsRedraw = true
pcall(broadcastState)
elseif message.type == "toggle_recipe" and message.recipe then
if state.disabledRecipes[message.recipe] then
state.disabledRecipes[message.recipe] = nil
else
state.disabledRecipes[message.recipe] = true
end
log.info("NET", "Recipe toggle: %s", message.recipe)
ops.saveDisabledRecipes()
state.configDirty = true
state.bumpStateVersion()
state.smelterNeedsRedraw = true
pcall(broadcastState)
elseif message.type == "enable_all" then
state.disabledRecipes = {}
log.info("NET", "All recipes enabled")
ops.saveDisabledRecipes()
state.configDirty = true
state.bumpStateVersion()
state.smelterNeedsRedraw = true
pcall(broadcastState)
elseif message.type == "disable_all" then
for inputName in pairs(cfg.SMELTABLE) do
state.disabledRecipes[inputName] = true
end
log.info("NET", "All recipes disabled")
ops.saveDisabledRecipes()
state.configDirty = true
state.bumpStateVersion()
state.smelterNeedsRedraw = true
pcall(broadcastState)
elseif message.type == "sort_barrel" and message.barrelName then
log.info("NET", "Sort barrel: %s", message.barrelName)
pcall(ops.sortBarrel, message.barrelName)
pcall(broadcastState)
elseif message.type == "register_droppers" and message.clientId and message.droppers then
local cid = tostring(message.clientId)
state.clientDroppers[cid] = message.droppers
log.info("NET", "Client %s registered %d dropper(s)", cid, #message.droppers)
local seenNames = {}
local merged = {}
for _, d in ipairs(cache.droppers or {}) do
if not d.clientId then
table.insert(merged, d)
seenNames[d.name] = true
end
end
for clientId, clientList in pairs(state.clientDroppers) do
for _, d in ipairs(clientList) do
if not seenNames[d.name] then
table.insert(merged, { name = d.name, isDefault = false, clientId = clientId })
seenNames[d.name] = true
end
end
end
cache.droppers = merged
state.bumpStateVersion()
pcall(broadcastState)
elseif message.type == "reboot" then
local target = message.target or "all"
log.info("NET", "Reboot command received, target: %s", target)
ctx.networkModem.transmit(cfg.SYSTEM_CHANNEL, cfg.ORDER_CHANNEL, {
type = "reboot",
target = target,
})
if target == "all" or target == "manager" then
log.info("NET", "Manager rebooting in 1s...")
sleep(1)
os.reboot()
end
elseif message.type == "craft" and message.recipeIdx then
log.info("NET", "Craft request: recipe #%d", message.recipeIdx)
local pok, ok, err = pcall(ops.craftItem, message.recipeIdx)
if not pok then
log.error("NET", "craftItem crashed: %s", tostring(ok))
err = tostring(ok)
ok = false
end
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "craft_result",
commandId = message.commandId,
success = ok,
error = err,
})
end)
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))
end
end -- idempotency else
end -- queue loop
end
end)
)
end
main()
end, function(e) return tostring(e) .. "\n" .. debug.traceback() end)
if not ok then
_crashLog(err)
printError(tostring(err))
print("")
print("Crash log saved to: " .. _path(".crash.log"))
print("")
print("Press any key to exit...")
os.pullEvent("char")
end