792 lines
34 KiB
Lua
792 lines
34 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 _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 itemDB = dofile(_path("lib/itemDB.lua"))
|
|
itemDB.init(_configPath(".item_names.db"))
|
|
|
|
-------------------------------------------------
|
|
-- 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
|
|
ctx.itemDB = itemDB
|
|
|
|
-- 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),
|
|
}
|
|
|
|
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
|
|
|
|
-- 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 (must have craft() method)
|
|
for _, name in ipairs(peripheral.getNames()) do
|
|
if name:match("^turtle_") then
|
|
local methods = peripheral.getMethods(name)
|
|
if methods then
|
|
local hasCraft = false
|
|
for _, m in ipairs(methods) do
|
|
if m == "craft" then hasCraft = true; break end
|
|
end
|
|
if hasCraft then
|
|
ctx.craftTurtleName = name
|
|
break
|
|
end
|
|
end
|
|
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("")
|
|
|
|
-----------------------------------------------
|
|
-- Parallel tasks
|
|
-----------------------------------------------
|
|
|
|
parallel.waitForAny(
|
|
-- Task 1: Background inventory 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() itemDB.flush() end)
|
|
pcall(function() cfg.recipeBook.flush() end)
|
|
state.needsRedraw = true
|
|
state.smelterNeedsRedraw = true
|
|
end
|
|
end,
|
|
|
|
-- Task 2: Barrel auto-sort
|
|
function()
|
|
while true do
|
|
pcall(ops.sortBarrel)
|
|
sleep(cfg.POLL_INTERVAL)
|
|
end
|
|
end,
|
|
|
|
-- Task 3: 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)
|
|
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
|
|
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 6: Low-stock 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)
|
|
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
|
|
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 9: Touch event listener (both monitors)
|
|
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)
|
|
function()
|
|
while true do
|
|
if state.stateVersion ~= state.lastBroadcastVersion then
|
|
pcall(broadcastState)
|
|
end
|
|
sleep(cfg.BROADCAST_INTERVAL)
|
|
end
|
|
end,
|
|
|
|
-- Task 11: Peripheral 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)
|
|
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
|
|
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
|
|
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 == "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
|
|
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 |