Files
Inventory-Manager-CC/inventoryManager.lua
MayaTheShy c4acc2159e fix: prevent early return in parallel tasks from killing waitForAny
Task 12 (supply chest) and Task 13 (network) returned immediately
when not configured, which caused parallel.waitForAny to exit and
the entire program to silently stop after cache build.
2026-03-22 18:49:43 -04:00

715 lines
29 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
-- 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(_path(".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 modem for client 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
end
end
if ctx.networkModem then
log.info("INIT", "Network modem: %s", ctx.networkModemName)
else
log.warn("INIT", "No modem found for client sync")
end
-- Detect crafting turtle on network
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)
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
pcall(display.drawDashboard)
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
pcall(display.drawSmelterDashboard)
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)
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