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.
935 lines
40 KiB
Lua
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 |