diff --git a/inventoryManager.lua b/inventoryManager.lua index b87c6ed..f1ca738 100644 --- a/inventoryManager.lua +++ b/inventoryManager.lua @@ -1,50 +1,61 @@ -- Inventory Manager: Touch UI on monitor --- Main computer (networked). Computer 1 sits next to dropper_0 and auto-dispenses. +-- 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 ------------------------------------------------- --- Default configuration (overridden by .manager_config) +-- Structured logging & shared UI helpers ------------------------------------------------- -local DROPPER_NAME = "minecraft:dropper_9" -local BARREL_NAME = "minecraft:barrel_0" -local POLL_INTERVAL = 2 -- seconds between barrel checks -local MONITOR_SIDE = "left" -local SCAN_INTERVAL = 120 -- seconds between full background scans -local SMELT_INTERVAL = 3 -- seconds between furnace checks -local SMELT_RESERVE = 128 -- keep at least 2 stacks of each raw material -local DEFRAG_INTERVAL = 600 -- seconds between defrag passes (10 min) -local COMPOST_INTERVAL = 3 -- seconds between composter checks -local ALERT_INTERVAL = 15 -- seconds between alert re-checks -local CACHE_FILE = ".inventory_cache" -- persistent cache file -local SMELTER_MONITOR_SIDE = "top" -local DISABLED_RECIPES_FILE = ".disabled_recipes" +local log = dofile("lib/log.lua") +local ui = dofile("lib/ui.lua") --- Network sync (for client displays) -local BROADCAST_CHANNEL = 4200 -local ORDER_CHANNEL = 4201 -local BROADCAST_INTERVAL = 1 -- seconds between state broadcasts +------------------------------------------------- +-- Load modules (factory pattern → shared context) +------------------------------------------------- --- Crafting turtle -local CRAFT_CHANNEL = 4203 -local CRAFT_REPLY_CHANNEL = 4204 +local cfg = dofile("manager/config.lua")(log) +local state = dofile("manager/state.lua")() --- Remote reboot system channel (all devices listen on this) -local SYSTEM_CHANNEL = 4205 +-- 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("manager/operations.lua")(ctx) +ctx.ops = ops + +local display = dofile("manager/display.lua")(ctx) +ctx.display = display + +-- Convenience aliases +local cache = state.cache +local activity = state.activity ------------------------------------------------- -- Idempotent command tracking ------------------------------------------------- -local processedCmdIds = {} -- commandId -> os.epoch("utc") ms +local processedCmdIds = {} local CMD_TTL_MS = 300000 -- 5 minutes local function isCommandDuplicate(commandId) if not commandId then return false end local entry = processedCmdIds[commandId] - if entry and (os.epoch("utc") - entry) < CMD_TTL_MS then - return true - end - return false + return entry ~= nil and (os.epoch("utc") - entry) < CMD_TTL_MS end local function recordCommandId(commandId) @@ -59,2448 +70,17 @@ local function cleanupCommandIds() end end -------------------------------------------------- --- Load config from file if present -------------------------------------------------- - -local CONFIG_FILE = ".manager_config" -local _loadedConfig = nil -- stored for deferred overrides (compost settings declared later) - -------------------------------------------------- --- Structured logging -------------------------------------------------- - -local log = dofile("lib/log.lua") - -local function loadConfig() - if not fs.exists(CONFIG_FILE) then return end - local f = fs.open(CONFIG_FILE, "r") - local data = f.readAll() - f.close() - local ok, cfg = pcall(textutils.unserialiseJSON, data) - if not ok or not cfg then - log.warn("CONFIG", "Failed to parse %s", CONFIG_FILE) - return - end - _loadedConfig = cfg - -- Peripheral names - if cfg.dropperName then DROPPER_NAME = cfg.dropperName end - if cfg.barrelName then BARREL_NAME = cfg.barrelName end - if cfg.monitorSide then MONITOR_SIDE = cfg.monitorSide end - if cfg.smelterMonitorSide then SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end - -- Timing intervals - if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end - if cfg.scanInterval then SCAN_INTERVAL = cfg.scanInterval end - if cfg.smeltInterval then SMELT_INTERVAL = cfg.smeltInterval end - if cfg.defragInterval then DEFRAG_INTERVAL = cfg.defragInterval end - if cfg.compostInterval then COMPOST_INTERVAL = cfg.compostInterval end - if cfg.alertInterval then ALERT_INTERVAL = cfg.alertInterval end - if cfg.broadcastInterval then BROADCAST_INTERVAL = cfg.broadcastInterval end - -- Reserves - if cfg.smeltReserve then SMELT_RESERVE = cfg.smeltReserve end - -- Modem channels - if cfg.broadcastChannel then BROADCAST_CHANNEL = cfg.broadcastChannel end - if cfg.orderChannel then ORDER_CHANNEL = cfg.orderChannel end - if cfg.craftChannel then CRAFT_CHANNEL = cfg.craftChannel end - if cfg.craftReplyChannel then CRAFT_REPLY_CHANNEL = cfg.craftReplyChannel end - -- Log level - if cfg.logLevel then log.setLevel(cfg.logLevel) end - log.info("CONFIG", "Loaded from %s", CONFIG_FILE) -end - -loadConfig() - -------------------------------------------------- --- Furnace types to manage -------------------------------------------------- - -local FURNACE_TYPES = { - "minecraft:furnace", - "minecraft:smoker", - "minecraft:blast_furnace", -} - --- Furnace slots: 1 = input, 2 = fuel, 3 = output (standard Minecraft) -local SLOT_INPUT = 1 -local SLOT_FUEL = 2 -local SLOT_OUTPUT = 3 - -------------------------------------------------- --- Load data tables from external files (data/) -------------------------------------------------- - -local SMELTABLE = dofile("data/smeltable.lua") -local FUEL_LIST = dofile("data/fuel.lua") -local _compostData = dofile("data/compostable.lua") -local COMPOSTABLE = _compostData.items -local COMPOST_TRASH = _compostData.trash -local CRAFTABLE = dofile("data/craftable.lua") -local LOW_STOCK_ALERTS = dofile("data/alerts.lua") - --- Pre-build furnace compatibility sets for O(1) lookup -for _, recipe in pairs(SMELTABLE) do - recipe.furnaceSet = {} - for _, ft in ipairs(recipe.furnaces) do - recipe.furnaceSet[ft] = true - end -end - --- Pre-built smelt candidate lists per furnace type (sorted: food first, then alpha) -local smeltCandidatesByType = {} -do - for _, ftype in ipairs(FURNACE_TYPES) do - smeltCandidatesByType[ftype] = {} - end - for itemName, recipe in pairs(SMELTABLE) do - local isFood = recipe.furnaceSet["minecraft:smoker"] or false - for ft, _ in pairs(recipe.furnaceSet) do - table.insert(smeltCandidatesByType[ft], { name = itemName, recipe = recipe, food = isFood }) - end - end - for _, list in pairs(smeltCandidatesByType) do - table.sort(list, function(a, b) - if a.food ~= b.food then return a.food end - return a.name < b.name - end) - end -end - --- Build fuel set for quick lookup -local FUEL_SET = {} -for _, f in ipairs(FUEL_LIST) do FUEL_SET[f.name] = true end - --- Build compostable set for quick lookup -local COMPOSTABLE_SET = {} -for _, name in ipairs(COMPOSTABLE) do COMPOSTABLE_SET[name] = true end - --- Compost settings (overridable via config) -local COMPOST_RESERVE = 128 -- 2 stacks -local COMPOST_DROPPER = "minecraft:dropper_10" -local COMPOST_HOPPER = "minecraft:hopper_0" - -if _loadedConfig then - if _loadedConfig.compostReserve then COMPOST_RESERVE = _loadedConfig.compostReserve end - if _loadedConfig.compostDropper then COMPOST_DROPPER = _loadedConfig.compostDropper end - if _loadedConfig.compostHopper then COMPOST_HOPPER = _loadedConfig.compostHopper end -end - --- Crafting grid-to-slot mapping -local GRID_TO_SLOT = {1, 2, 3, 5, 6, 7, 9, 10, 11} - -------------------------------------------------- --- Shared UI helpers (drawing, zones, craft math) -------------------------------------------------- - -local ui = dofile("lib/ui.lua") - --- Active alerts (populated by checkAlerts) -local activeAlerts = {} - -------------------------------------------------- --- State version tracking (for delta broadcasting) -------------------------------------------------- - -local stateVersion = 0 -local lastBroadcastVersion = -1 -local configDirty = true - -local function bumpStateVersion() - stateVersion = stateVersion + 1 -end - -------------------------------------------------- --- Cached data (updated by background scanner) -------------------------------------------------- - -local cache = { - catalogue = {}, -- itemName -> { {chest=name, total=N}, ... } - itemList = {}, -- sorted list of { name, total } - itemListDirty = false, -- lazy rebuild flag for itemList - grandTotal = 0, - chestCount = 0, - totalSlots = 0, - usedSlots = 0, - freeSlots = 0, - usedRatio = 0, - dropperOk = false, - barrelOk = false, - furnaceCount = 0, - furnaceStatus = {}, -- per-furnace { name, type, input, fuel, output, active } - droppers = {}, -- list of available dropper peripherals for dispensing -} - --- Client-registered droppers, keyed by clientId -local clientDroppers = {} - -------------------------------------------------- --- Activity state (shown on monitor) -------------------------------------------------- - -local activity = { - sorting = false, -- barrel sort in progress - dispensing = false, -- order in progress - scanning = false, -- background scan in progress - smelting = false, -- auto-smelt in progress - defragging = false, -- defrag in progress - composting = false, -- auto-compost in progress - crafting = false, -- crafting in progress -} - -------------------------------------------------- --- Instant cache adjustment (no scan needed) -------------------------------------------------- - ---- Adjust cached counts for a single item move. --- delta > 0 means items arrived in chestName; --- delta < 0 means items left chestName. -local function adjustCache(itemName, chestName, delta) - if delta == 0 then return end - - -- 1) catalogue: per-chest totals - local cat = cache.catalogue - if delta > 0 then - -- Items added to a chest - if not cat[itemName] then cat[itemName] = {} end - local found = false - for _, entry in ipairs(cat[itemName]) do - if entry.chest == chestName then - entry.total = entry.total + delta - found = true - break - end - end - if not found then - table.insert(cat[itemName], { chest = chestName, total = delta }) - end - else - -- Items removed from a chest - if cat[itemName] then - for idx, entry in ipairs(cat[itemName]) do - if entry.chest == chestName then - entry.total = entry.total + delta -- delta is negative - if entry.total <= 0 then - table.remove(cat[itemName], idx) - end - break - end - end - -- Remove item from catalogue entirely if no sources left - if #cat[itemName] == 0 then - cat[itemName] = nil - end - end - end - - -- 2) Mark itemList as needing rebuild (deferred until actually read) - cache.itemListDirty = true - - -- 3) Update grandTotal incrementally - cache.grandTotal = cache.grandTotal + delta - - -- 4) Bump state version for delta broadcasting - bumpStateVersion() -end - ---- Rebuild itemList from catalogue if dirty (lazy rebuild) -local function ensureItemList() - if not cache.itemListDirty then return end - local itemList = {} - local grandTotal = 0 - for name, sources in pairs(cache.catalogue) do - local total = 0 - for _, s in ipairs(sources) do total = total + s.total end - grandTotal = grandTotal + total - table.insert(itemList, { name = name, total = total }) - end - table.sort(itemList, function(a, b) return a.total > b.total end) - cache.itemList = itemList - cache.grandTotal = grandTotal - cache.itemListDirty = false -end - -------------------------------------------------- --- Inventory helpers (cached peripheral lists) -------------------------------------------------- - -local PERIPHERAL_CACHE_TTL = 5 -- seconds -local cachedChests = nil -local cachedChestsTime = 0 -local cachedFurnaces = nil -local cachedFurnacesTime = 0 - -local function invalidatePeripheralCaches() - cachedChests = nil - cachedFurnaces = nil -end - --- Peripheral handle cache (avoids re-creating proxy tables on every call) --- Handles are cleared on peripheral_detach events and during full scans. -local wrapCache = {} - -local function wrapCached(name) - local handle = wrapCache[name] - if handle then return handle end - handle = peripheral.wrap(name) - if handle then - wrapCache[name] = handle - end - return handle -end - -local function invalidateWrapCache(name) - if name then - wrapCache[name] = nil - else - wrapCache = {} - end -end - -local function getChests() - local now = os.clock() - if cachedChests and (now - cachedChestsTime) < PERIPHERAL_CACHE_TTL then - return cachedChests - end - local chests = {} - for _, name in ipairs(peripheral.getNames()) do - if peripheral.getType(name) == "minecraft:chest" then - table.insert(chests, name) - end - end - cachedChests = chests - cachedChestsTime = now - return chests -end - -local function getFurnaces() - local now = os.clock() - if cachedFurnaces and (now - cachedFurnacesTime) < PERIPHERAL_CACHE_TTL then - return cachedFurnaces - end - local furnaces = {} - for _, ftype in ipairs(FURNACE_TYPES) do - for _, name in ipairs(peripheral.getNames()) do - if peripheral.getType(name) == ftype then - table.insert(furnaces, name) - end - end - end - cachedFurnaces = furnaces - cachedFurnacesTime = now - return furnaces -end - -local function refreshFurnaceStatus() - local furnaces = getFurnaces() - local status = {} - for _, fname in ipairs(furnaces) do - local furnace = wrapCached(fname) - if furnace then - local contents = furnace.list() - local entry = { - name = fname, - type = peripheral.getType(fname), - input = contents[SLOT_INPUT] or nil, - fuel = contents[SLOT_FUEL] or nil, - output = contents[SLOT_OUTPUT] or nil, - active = (contents[SLOT_INPUT] ~= nil and contents[SLOT_FUEL] ~= nil), - } - table.insert(status, entry) - end - end - cache.furnaceStatus = status - bumpStateVersion() -end - -local function scanInventory(deviceName) - local inv = wrapCached(deviceName) - if not inv then return {} end - local result = {} - for slot, item in pairs(inv.list()) do - if not result[item.name] then - result[item.name] = { total = 0, slots = {} } - end - result[item.name].total = result[item.name].total + item.count - result[item.name].slots[slot] = { name = item.name, count = item.count } - end - return result -end - --- Full scan: updates the global cache --- onProgress(current, total, chestName) is called per chest if provided -local function refreshCache(onProgress) - activity.scanning = true - - -- Clear handle cache so we pick up any changes - invalidateWrapCache() - - -- Single pass over peripherals: classify into chests and furnaces - local chests = {} - local furnaces = {} - local furnaceTypeSet = {} - for _, ft in ipairs(FURNACE_TYPES) do furnaceTypeSet[ft] = true end - - for _, name in ipairs(peripheral.getNames()) do - local ptype = peripheral.getType(name) - if ptype == "minecraft:chest" then - table.insert(chests, name) - elseif furnaceTypeSet[ptype] then - table.insert(furnaces, name) - end - end - - -- Update peripheral caches so getChests()/getFurnaces() stay fresh - local now = os.clock() - cachedChests = chests - cachedChestsTime = now - cachedFurnaces = furnaces - cachedFurnacesTime = now - - local catalogue = {} - local totalSlots = 0 - local usedSlots = 0 - - for ci, chest in ipairs(chests) do - if onProgress then onProgress(ci, #chests, chest) end - local inv = wrapCached(chest) - if inv then - totalSlots = totalSlots + inv.size() - local contents = inv.list() - for slot, item in pairs(contents) do - usedSlots = usedSlots + 1 - if not catalogue[item.name] then - catalogue[item.name] = {} - end - -- Accumulate per-chest totals - local found = false - for _, entry in ipairs(catalogue[item.name]) do - if entry.chest == chest then - entry.total = entry.total + item.count - found = true - break - end - end - if not found then - table.insert(catalogue[item.name], { chest = chest, total = item.count }) - end - end - end - end - - -- Build sorted item list - local itemList = {} - local grandTotal = 0 - for itemName, sources in pairs(catalogue) do - local total = 0 - for _, s in ipairs(sources) do total = total + s.total end - grandTotal = grandTotal + total - table.insert(itemList, { name = itemName, total = total }) - end - table.sort(itemList, function(a, b) return a.total > b.total end) - - -- Update cache atomically - cache.catalogue = catalogue - cache.itemList = itemList - cache.grandTotal = grandTotal - cache.chestCount = #chests - cache.totalSlots = totalSlots - cache.usedSlots = usedSlots - cache.freeSlots = totalSlots - usedSlots - cache.usedRatio = totalSlots > 0 and (usedSlots / totalSlots) or 0 - cache.dropperOk = peripheral.isPresent(DROPPER_NAME) - cache.barrelOk = peripheral.isPresent(BARREL_NAME) - - -- Discover all droppers on the network (for location-based dispensing) - -- Use name-based matching as peripheral.getType() may return "inventory" - -- as the primary type in some CC:Tweaked versions (1.99+) - local droppers = {} - for _, name in ipairs(peripheral.getNames()) do - local isDropper = name:match("^minecraft:dropper_") - if not isDropper and peripheral.hasType then - isDropper = peripheral.hasType(name, "minecraft:dropper") - end - if not isDropper then - isDropper = peripheral.getType(name) == "minecraft:dropper" - end - if isDropper and name ~= COMPOST_DROPPER then - local isDefault = (name == DROPPER_NAME) - table.insert(droppers, { name = name, isDefault = isDefault }) - end - end - -- Sort so default dropper comes first - table.sort(droppers, function(a, b) - if a.isDefault ~= b.isDefault then return a.isDefault end - return a.name < b.name - end) - - -- Merge in droppers registered by remote clients - local seenNames = {} - for _, d in ipairs(droppers) do seenNames[d.name] = true end - for clientId, clientList in pairs(clientDroppers) do - for _, d in ipairs(clientList) do - if not seenNames[d.name] then - table.insert(droppers, { name = d.name, isDefault = false, clientId = clientId }) - seenNames[d.name] = true - end - end - end - - cache.droppers = droppers - - -- Furnace count already computed from single-pass above - cache.furnaceCount = #furnaces - - -- Scan furnace contents for smelter dashboard - refreshFurnaceStatus() - - activity.scanning = false - bumpStateVersion() - - -- Persist cache to disk - pcall(function() - local data = { - catalogue = cache.catalogue, - 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, - savedAt = os.epoch("utc"), - } - local f = fs.open(CACHE_FILE, "w") - f.write(textutils.serialise(data)) - f.close() - end) -end - --- Load cache from disk (returns true if loaded) -local function loadCacheFromDisk() - if not fs.exists(CACHE_FILE) then return false end - local ok, err = pcall(function() - local f = fs.open(CACHE_FILE, "r") - local raw = f.readAll() - f.close() - local data = textutils.unserialise(raw) - if data and data.catalogue and data.itemList then - cache.catalogue = data.catalogue - cache.itemList = data.itemList - cache.grandTotal = data.grandTotal or 0 - cache.chestCount = data.chestCount or 0 - cache.totalSlots = data.totalSlots or 0 - cache.usedSlots = data.usedSlots or 0 - cache.freeSlots = data.freeSlots or 0 - cache.usedRatio = data.usedRatio or 0 - cache.dropperOk = data.dropperOk or false - cache.barrelOk = data.barrelOk or false - cache.furnaceCount = data.furnaceCount or 0 - cache.furnaceStatus = data.furnaceStatus or {} - cache.droppers = data.droppers or {} - else - error("invalid cache data") - end - end) - if not ok then - log.warn("INIT", "Could not load cache: %s", tostring(err)) - return false - end - return true -end - -------------------------------------------------- --- Monitor setup -------------------------------------------------- - -local mon = nil -local monName = nil -local smelterMon = nil -local smelterMonName = nil -local networkModem = nil -local networkModemName = nil -local craftTurtleName = nil - -local function setupMonitor() - mon, monName = ui.setupMonitor(MONITOR_SIDE, SMELTER_MONITOR_SIDE) - return mon ~= nil -end - -local function setupSmelterMonitor() - smelterMon, smelterMonName = ui.setupSmelterMonitor(SMELTER_MONITOR_SIDE, monName) - return smelterMon ~= nil -end - -------------------------------------------------- --- UI State -------------------------------------------------- - -local selectedAmount = 1 -local amountOptions = {1, 4, 8, 16, 32, 64} -local statusMessage = "" -local statusColor = colors.white -local statusTimer = 0 - -local touchZones = {} -local pendingZones = {} -local needsRedraw = true - -local currentPage = 1 -local totalPages = 1 -local searchQuery = "" -local showKeyboard = false - --- Keyboard layout -local kbRows = { - {"Q","W","E","R","T","Y","U","I","O","P"}, - {"A","S","D","F","G","H","J","K","L"}, - {"Z","X","C","V","B","N","M"}, -} - -------------------------------------------------- --- Smelter dashboard state -------------------------------------------------- - -local smelterView = "status" -- "status" or "recipes" -local smelterPage = 1 -local smelterTotalPages = 1 -local smelterTouchZones = {} -local smelterPendingZones = {} -local smelterNeedsRedraw = true -local smeltingPaused = false -local disabledRecipes = {} -- { ["minecraft:raw_iron"] = true } - -local function loadDisabledRecipes() - if not fs.exists(DISABLED_RECIPES_FILE) then return end - pcall(function() - local f = fs.open(DISABLED_RECIPES_FILE, "r") - local raw = f.readAll() - f.close() - local data = textutils.unserialise(raw) - if type(data) == "table" then - if data.disabled then disabledRecipes = data.disabled end - if data.paused ~= nil then smeltingPaused = data.paused end - end - end) -end - -local function saveDisabledRecipes() - pcall(function() - local f = fs.open(DISABLED_RECIPES_FILE, "w") - f.write(textutils.serialise({ disabled = disabledRecipes, paused = smeltingPaused })) - f.close() - end) -end - --- Get items filtered by search query -local function getFilteredItems() - ensureItemList() - return ui.getFilteredItems(cache.itemList, searchQuery) -end - -local function addZone(x1, y1, x2, y2, action, data) - ui.addZone(pendingZones, x1, y1, x2, y2, action, data) -end - -local function hitTest(x, y) - return ui.hitTest(touchZones, x, y) -end - -local function addSmelterZone(x1, y1, x2, y2, action, data) - ui.addZone(smelterPendingZones, x1, y1, x2, y2, action, data) -end - -local function smelterHitTest(x, y) - return ui.hitTest(smelterTouchZones, x, y) -end - -------------------------------------------------- --- Drawing helpers (delegated to shared ui module) -------------------------------------------------- - -local draw = nil -- set to window target before each draw cycle - -local function setDrawTarget(target) - draw = target - ui.draw = target -end - -local function monWrite(x, y, text, fg, bg) ui.monWrite(x, y, text, fg, bg) end -local function monFill(y, color) ui.monFill(y, color) end -local function monCenter(y, text, fg, bg) ui.monCenter(y, text, fg, bg) end -local function monBar(x, y, w, r, bc, bgc) ui.monBar(x, y, w, r, bc, bgc) end -local function drawButton(x, y, t, fg, bg, pl, pr) return ui.drawButton(x, y, t, fg, bg, pl, pr) end - -------------------------------------------------- --- Dashboard (reads ONLY from cache — no peripheral calls — instant) -------------------------------------------------- - -local function drawDashboard() - if not mon then return end - - local w, h = mon.getSize() - pendingZones = {} - - -- Offscreen buffer - setDrawTarget(window.create(mon, 1, 1, w, h, false)) - draw.setBackgroundColor(colors.black) - draw.clear() - - -- ===== Title bar ===== - monFill(1, colors.blue) - monCenter(1, " ** INVENTORY MANAGER ** ", colors.white, colors.blue) - - -- ===== Status bar ===== - monFill(2, colors.gray) - local statusParts = {} - table.insert(statusParts, string.format(" Chests: %d", cache.chestCount)) - table.insert(statusParts, cache.dropperOk and "Dropper: OK" or "Dropper: --") - table.insert(statusParts, cache.barrelOk and "Barrel: OK" or "Barrel: --") - if cache.furnaceCount and cache.furnaceCount > 0 then - table.insert(statusParts, string.format("Furnaces: %d", cache.furnaceCount)) - end - - -- Activity indicators - local actParts = {} - if activity.sorting then table.insert(actParts, "SORTING") end - if activity.dispensing then table.insert(actParts, "DISPENSING") end - if activity.smelting then table.insert(actParts, "SMELTING") end - if activity.scanning then table.insert(actParts, "SCANNING") end - if activity.defragging then table.insert(actParts, "DEFRAG") end - if activity.composting then table.insert(actParts, "COMPOST") end - - monWrite(2, 2, table.concat(statusParts, " | "), colors.white, colors.gray) - - if #actParts > 0 then - local actStr = " " .. table.concat(actParts, " | ") .. " " - monWrite(w - #actStr, 2, actStr, colors.white, colors.orange) - end - - -- ===== Divider ===== - monFill(3, colors.lightBlue) - monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.cyan, colors.lightBlue) - - -- ===== Storage capacity ===== - monFill(4, colors.black) - local capLabel = string.format(" Storage: %d/%d slots (%d free)", - cache.usedSlots, cache.totalSlots, cache.freeSlots) - monWrite(2, 4, capLabel, colors.lightGray, colors.black) - - local barStart = #capLabel + 4 - local barWidth = w - barStart - 2 - if barWidth > 4 then - local barColor = colors.lime - if cache.usedRatio > 0.9 then barColor = colors.red - elseif cache.usedRatio > 0.7 then barColor = colors.orange - elseif cache.usedRatio > 0.5 then barColor = colors.yellow - end - monBar(barStart, 4, barWidth, cache.usedRatio, barColor, colors.gray) - local pctStr = string.format(" %d%% ", math.floor(cache.usedRatio * 100)) - local pctX = barStart + math.floor(barWidth / 2) - math.floor(#pctStr / 2) - monWrite(pctX, 4, pctStr, colors.white, barColor) - end - - -- ===== Amount selector (row 5) ===== - monFill(5, colors.black) - monWrite(2, 5, "Qty:", colors.lightGray, colors.black) - local btnX = 7 - for _, amt in ipairs(amountOptions) do - local label = tostring(amt) - local bg = (amt == selectedAmount) and colors.cyan or colors.gray - local fg = (amt == selectedAmount) and colors.white or colors.lightGray - local x1, y1, x2, y2 = drawButton(btnX, 5, label, fg, bg) - addZone(x1, y1, x2, y2, "amount", amt) - btnX = x2 + 2 - end - - -- Refresh button - local refreshBg = activity.scanning and colors.yellow or colors.green - local refreshFg = activity.scanning and colors.black or colors.white - local refreshTxt = activity.scanning and "Scanning" or "Refresh" - local scanX = w - #refreshTxt - 3 - local sx1, sy1, sx2, sy2 = drawButton(scanX, 5, refreshTxt, refreshFg, refreshBg, 1, 1) - addZone(sx1, sy1, sx2, sy2, "scan", nil) - - -- ===== Search bar + Pagination (row 6) ===== - monFill(6, colors.black) - - -- Keyboard toggle button - local kbLabel = showKeyboard and " X " or " ? " - local kbBg = showKeyboard and colors.red or colors.purple - monWrite(2, 6, kbLabel, colors.white, kbBg) - addZone(2, 6, 4, 6, "kb_toggle", nil) - - -- Search query display - local queryDisplay = searchQuery - if showKeyboard then - queryDisplay = queryDisplay .. "|" - elseif queryDisplay == "" then - queryDisplay = "search..." - end - local fieldW = math.floor(w * 0.4) - if fieldW < 10 then fieldW = 10 end - local displayText = queryDisplay:sub(1, fieldW) - displayText = displayText .. string.rep("_", math.max(0, fieldW - #displayText)) - monWrite(6, 6, displayText, - (searchQuery == "" and not showKeyboard) and colors.gray or colors.white, - colors.black) - addZone(6, 6, 5 + fieldW, 6, "kb_toggle", nil) - - -- Filter items - local filteredItems = getFilteredItems() - - -- Pagination - local maxRows = h - 10 - if maxRows < 1 then maxRows = 1 end - totalPages = math.max(1, math.ceil(#filteredItems / maxRows)) - if currentPage > totalPages then currentPage = totalPages end - if currentPage < 1 then currentPage = 1 end - - -- Page controls (right side of row 6) - local pageStr = string.format("Pg %d/%d", currentPage, totalPages) - local navW = 3 + 1 + #pageStr + 1 + 3 - local navX = w - navW - - if currentPage > 1 then - monWrite(navX, 6, " < ", colors.white, colors.gray) - addZone(navX, 6, navX + 2, 6, "page_prev", nil) - else - monWrite(navX, 6, " < ", colors.lightGray, colors.black) - end - - monWrite(navX + 4, 6, pageStr, colors.lightGray, colors.black) - - local nextX = navX + 4 + #pageStr + 1 - if currentPage < totalPages then - monWrite(nextX, 6, " > ", colors.white, colors.gray) - addZone(nextX, 6, nextX + 2, 6, "page_next", nil) - else - monWrite(nextX, 6, " > ", colors.lightGray, colors.black) - end - - -- ===== Column headers (row 7) ===== - local row = 7 - monFill(row, colors.gray) - monWrite(2, row, "#", colors.lightGray, colors.gray) - monWrite(5, row, "Item", colors.lightGray, colors.gray) - monWrite(w - 22, row, "Qty", colors.lightGray, colors.gray) - monWrite(w - 14, row, "Stock", colors.lightGray, colors.gray) - monWrite(w - 1, row, ">", colors.lightGray, colors.gray) - row = row + 1 - - -- ===== Item rows (paginated + filtered) ===== - local maxCount = 0 - for _, item in ipairs(filteredItems) do - if item.total > maxCount then maxCount = item.total end - end - if maxCount == 0 then maxCount = 1 end - - local startIdx = (currentPage - 1) * maxRows + 1 - local endIdx = math.min(startIdx + maxRows - 1, #filteredItems) - - if #filteredItems == 0 then - monFill(8, colors.black) - monFill(9, colors.black) - if searchQuery ~= "" then - monCenter(9, "No items match \"" .. searchQuery .. "\"", colors.gray, colors.black) - else - monCenter(9, "No items in storage", colors.gray, colors.black) - end - row = 10 - else - for i = startIdx, endIdx do - local item = filteredItems[i] - local y = row - local short = item.name:gsub("^minecraft:", ""):gsub("_", " ") - short = short:sub(1,1):upper() .. short:sub(2) - - local maxNameLen = w - 30 - if #short > maxNameLen then - short = short:sub(1, maxNameLen - 2) .. ".." - end - - local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray - monFill(y, rowBg) - - monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg) - monWrite(5, y, short, colors.white, rowBg) - monWrite(w - 22, y, tostring(item.total), colors.yellow, rowBg) - - local ratio = item.total / maxCount - local barColor = colors.lime - if ratio < 0.25 then barColor = colors.red - elseif ratio < 0.5 then barColor = colors.orange - end - monBar(w - 14, y, 12, ratio, barColor, rowBg == colors.gray and colors.lightGray or colors.gray) - - monWrite(w - 1, y, ">", colors.orange, rowBg) - addZone(1, y, w, y, "order", item.name) - - row = row + 1 - end - end - - -- Fill remaining empty item rows - local lastItemRow = h - 3 - while row <= lastItemRow do - monFill(row, colors.black) - row = row + 1 - end - - if showKeyboard then - -- ===== Keyboard overlay (bottom 3 rows: h-2, h-1, h) ===== - local keyW = 3 - local keyGap = 1 - - local kbDefs = { - { keys = kbRows[1], specials = {{ label = " Bksp ", action = "kb_bksp", bg = colors.red }} }, - { keys = kbRows[2], specials = {{ label = " Done ", action = "kb_done", bg = colors.green }} }, - { keys = kbRows[3], specials = { - { label = " Space ", action = "kb_space", bg = colors.lightGray }, - { label = " Clr ", action = "kb_clear", bg = colors.orange }, - }}, - } - - for rowIdx, def in ipairs(kbDefs) do - local y = h - 3 + rowIdx - monFill(y, colors.black) - - -- Calculate total row width for centering - local keysW = #def.keys * keyW + math.max(0, #def.keys - 1) * keyGap - local specialsW = 0 - for _, sp in ipairs(def.specials) do - specialsW = specialsW + keyGap + #sp.label - end - local rowW = keysW + specialsW - local x = math.floor((w - rowW) / 2) + 1 - - -- Draw letter keys - for ki, key in ipairs(def.keys) do - monWrite(x, y, " " .. key .. " ", colors.white, colors.gray) - addZone(x, y, x + keyW - 1, y, "kb_key", key:lower()) - x = x + keyW - if ki < #def.keys then x = x + keyGap end - end - - -- Draw special keys - for _, sp in ipairs(def.specials) do - x = x + keyGap - monWrite(x, y, sp.label, colors.white, sp.bg) - addZone(x, y, x + #sp.label - 1, y, sp.action, nil) - x = x + #sp.label - end - end - else - -- ===== Status message (h-2) ===== - monFill(h - 2, colors.black) - if #activeAlerts > 0 then - -- Show low-stock alerts scrolling through them - local alertIdx = math.floor(os.epoch("utc") / 2000) % #activeAlerts + 1 - local a = activeAlerts[alertIdx] - local alertMsg = string.format(" LOW STOCK: %s (%d/%d) ", a.label, a.current, a.min) - monCenter(h - 2, alertMsg, colors.white, colors.red) - elseif statusTimer > 0 and #statusMessage > 0 then - monCenter(h - 2, statusMessage, statusColor, colors.black) - end - - -- ===== Footer (h-1) ===== - ensureItemList() - monFill(h - 1, colors.gray) - local footerLeft = string.format(" Total: %d items | %d types ", - cache.grandTotal, #cache.itemList) - monWrite(2, h - 1, footerLeft, colors.white, colors.gray) - - if searchQuery ~= "" then - local filterNote = string.format("| Showing %d ", #filteredItems) - monWrite(2 + #footerLeft + 1, h - 1, filterNote, colors.yellow, colors.gray) - end - - local timeStr = textutils.formatTime(os.time(), true) - monWrite(w - #timeStr - 1, h - 1, timeStr, colors.lightGray, colors.gray) - - -- ===== Bottom accent (h) ===== - monFill(h, colors.blue) - local bottomMsg = " Tap item to order " - if activity.dispensing then - bottomMsg = " DISPENSING... " - elseif activity.smelting then - bottomMsg = " SMELTING... " - elseif activity.sorting then - bottomMsg = " SORTING BARREL... " - elseif activity.defragging then - bottomMsg = " DEFRAGMENTING... " - elseif activity.composting then - bottomMsg = " COMPOSTING... " - end - monCenter(h, bottomMsg, colors.lightBlue, colors.blue) - end - - -- Flush to monitor - draw.setVisible(true) - - -- Swap zones - touchZones = pendingZones -end - -------------------------------------------------- --- Crafting helpers (delegated to shared ui module) -------------------------------------------------- - ---- Get stock total for an item from catalogue -local function getItemTotal(itemName) - local have = 0 - if cache.catalogue[itemName] then - for _, src in ipairs(cache.catalogue[itemName]) do - have = have + src.total - end - end - return have -end - -local function getRecipeIngredients(recipe) - return ui.getRecipeIngredients(recipe) -end - -local function canCraftRecipe(recipe) - return ui.canCraftRecipe(recipe, getItemTotal) -end - -local function maxCraftBatches(recipe) - return ui.maxCraftBatches(recipe, getItemTotal) -end - -local function getMissingIngredients(recipe) - return ui.getMissingIngredients(recipe, getItemTotal) -end - ---- Execute a craft via the networked turtle. --- Sends a craft_request message via modem with ingredient locations. --- The turtle pulls items herself, crafts, pushes results back, and replies. --- Requires the turtle to have a wired modem and run the updated craftingTurtle.lua. -local CRAFT_TIMEOUT = 15 -- seconds to wait for turtle reply - -local function craftItem(recipeIdx) - local recipe = CRAFTABLE[recipeIdx] - if not recipe then - log.error("CRAFT", "Invalid recipe index: %s", tostring(recipeIdx)) - return false, "Invalid recipe" - end - if not craftTurtleName then - log.error("CRAFT", "No turtle detected on network") - return false, "No turtle" - end - if not networkModem then - log.error("CRAFT", "No modem available for craft commands") - return false, "No modem" - end - - -- Verify the turtle is still on the network - if not peripheral.isPresent(craftTurtleName) then - log.error("CRAFT", "Turtle offline: %s", craftTurtleName) - return false, "Turtle offline" - end - - log.info("CRAFT", "Starting craft: %s (turtle: %s)", recipe.output, craftTurtleName) - - activity.crafting = true - needsRedraw = true - smelterNeedsRedraw = true - - local chests = getChests() - - -- Build the slot map: for each grid position that needs an ingredient, - -- find the exact chest and slot where the item is located. - local slotMap = {} -- turtleSlot -> { chestName, chestSlot, itemName, count } - local reservedSlots = {} -- "chestName:slot" -> true (prevent double-booking) - - for gridPos = 1, 9 do - local itemName = recipe.grid[gridPos] - if itemName then - local turtleSlot = GRID_TO_SLOT[gridPos] - local found = false - - if cache.catalogue[itemName] then - for _, source in ipairs(cache.catalogue[itemName]) do - local chest = wrapCached(source.chest) - if chest then - for slot, slotItem in pairs(chest.list()) do - local key = source.chest .. ":" .. slot - if slotItem.name == itemName and not reservedSlots[key] then - slotMap[tostring(turtleSlot)] = { - chestName = source.chest, - chestSlot = slot, - itemName = itemName, - count = 1, - } - reservedSlots[key] = true - found = true - break - end - end - end - if found then break end - end - end - - if not found then - log.error("CRAFT", "Cannot find %s in storage, aborting", itemName) - activity.crafting = false - needsRedraw = true - smelterNeedsRedraw = true - return false, "Missing ingredient: " .. itemName - end - end - end - - -- Send craft request to turtle via modem - local craftMessage = { - type = "craft_request", - recipeIdx = recipeIdx, - output = recipe.output, - slots = slotMap, - returnChests = chests, - } - - log.info("CRAFT", "Sending craft request to turtle on channel %d", CRAFT_CHANNEL) - networkModem.transmit(CRAFT_CHANNEL, CRAFT_REPLY_CHANNEL, craftMessage) - - -- Adjust cache: mark ingredients as "in transit" (removed from chests) - for _, info in pairs(slotMap) do - adjustCache(info.itemName, info.chestName, -info.count) - end - - -- Wait for reply from turtle with timeout - log.info("CRAFT", "Waiting for turtle reply (timeout: %ds)...", CRAFT_TIMEOUT) - local deadline = os.clock() + CRAFT_TIMEOUT - local result = nil - local bufferedMessages = {} -- Buffer ORDER_CHANNEL messages to avoid re-queue bounce - - while os.clock() < deadline do - local timerId = os.startTimer(math.max(0.1, deadline - os.clock())) - local event, p1, p2, p3, p4, p5 = os.pullEvent() - - if event == "modem_message" then - local channel = p2 - local message = p4 - if channel == CRAFT_REPLY_CHANNEL and type(message) == "table" and message.type == "craft_result" then - result = message - break - elseif channel == ORDER_CHANNEL then - -- Buffer for re-queue after loop exits (avoids bounce loop) - table.insert(bufferedMessages, {event, p1, p2, p3, p4, p5}) - end - elseif event == "timer" and p1 == timerId then - -- Timeout tick, loop will check deadline - end - end - - -- Re-queue any buffered ORDER_CHANNEL messages so Task 12 processes them - for _, msg in ipairs(bufferedMessages) do - os.queueEvent(table.unpack(msg)) - end - - activity.crafting = false - needsRedraw = true - smelterNeedsRedraw = true - - if not result then - -- Timeout — turtle didn't respond. Items may be stuck in turtle. - -- We've already adjusted the cache as if ingredients were consumed. - -- A manual scan will reconcile later. - log.error("CRAFT", "TIMEOUT: No reply from turtle within %ds", CRAFT_TIMEOUT) - log.error("CRAFT", "Items may be stuck in turtle. Run a manual scan to reconcile.") - return false, "Turtle timeout" - end - - if result.success then - -- Craft succeeded. The turtle already pushed results back to chests. - -- We need to credit the output items to the cache. - -- The ingredients were already debited above. - -- The turtle reports what it pushed; we credit the output item. - local totalOutput = result.totalOutput or recipe.count - -- Credit output across the chests (we don't know exactly which chest - -- the turtle pushed to, so trigger a targeted rescan) - -- For now, credit to the first available chest as an approximation. - -- The next periodic scan will reconcile. - if result.results then - for _, r in ipairs(result.results) do - -- Find which chest likely received it - for _, ch in ipairs(chests) do - local chest = wrapCached(ch) - if chest then - for slot, slotItem in pairs(chest.list()) do - if slotItem.name == r.name then - -- Found the output in a chest, credit it - adjustCache(r.name, ch, r.count) - goto credited - end - end - end - end - -- If we can't find it, still credit to first chest for cache consistency - if #chests > 0 then - adjustCache(r.name, chests[1], r.count) - end - ::credited:: - end - else - -- Fallback: credit the expected output - if #chests > 0 then - adjustCache(recipe.output, chests[1], totalOutput) - end - end - - local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ") - log.info("CRAFT", "OK: %s x%d", short, totalOutput) - return true - else - -- Craft failed. The turtle returned ingredients to chests. - -- We already debited the cache; now credit them back. - -- The returned items are back in the chests, so re-credit them. - for turtleSlotStr, info in pairs(slotMap) do - -- Re-credit the ingredient (it was returned) - adjustCache(info.itemName, info.chestName, info.count) - end - - local errMsg = result.error or "Craft failed" - log.error("CRAFT", "Failed: %s", errMsg) - return false, errMsg - end -end - -------------------------------------------------- --- Smelter Dashboard -------------------------------------------------- - -local function drawSmelterDashboard() - if not smelterMon then return end - - local w, h = smelterMon.getSize() - smelterPendingZones = {} - - -- Offscreen buffer - setDrawTarget(window.create(smelterMon, 1, 1, w, h, false)) - draw.setBackgroundColor(colors.black) - draw.clear() - - -- ===== Title bar ===== - monFill(1, colors.purple) - monCenter(1, " ** SMELTER DASHBOARD ** ", colors.white, colors.purple) - - -- ===== Status bar ===== - monFill(2, colors.gray) - local activeCount = 0 - for _, fs in ipairs(cache.furnaceStatus or {}) do - if fs.active then activeCount = activeCount + 1 end - end - local statusStr = string.format(" Furnaces: %d Active: %d", - cache.furnaceCount or 0, activeCount) - monWrite(2, 2, statusStr, colors.white, colors.gray) - - -- Pause/Resume button - local pauseLabel = smeltingPaused and " PAUSED " or " ACTIVE " - local pauseBg = smeltingPaused and colors.red or colors.lime - local pauseFg = smeltingPaused and colors.white or colors.black - monWrite(w - #pauseLabel, 2, pauseLabel, pauseFg, pauseBg) - addSmelterZone(w - #pauseLabel, 2, w - 1, 2, "toggle_pause", nil) - - -- ===== Divider ===== - monFill(3, colors.magenta) - monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.pink, colors.magenta) - - -- ===== Tab row ===== - monFill(4, colors.black) - local tabStatusBg = smelterView == "status" and colors.purple or colors.gray - local tabSmeltBg = smelterView == "smelt" and colors.purple or colors.gray - local tabCraftBg = smelterView == "craft" and colors.purple or colors.gray - local tabMissingBg = smelterView == "missing" and colors.purple or colors.gray - local bx1, by1, bx2, by2 - bx1, by1, bx2, by2 = drawButton(2, 4, "Status", colors.white, tabStatusBg) - addSmelterZone(bx1, by1, bx2, by2, "tab", "status") - - bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Smelt", colors.white, tabSmeltBg) - addSmelterZone(bx1, by1, bx2, by2, "tab", "smelt") - - bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Craft", colors.white, tabCraftBg) - addSmelterZone(bx1, by1, bx2, by2, "tab", "craft") - - bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Missing", colors.white, tabMissingBg) - addSmelterZone(bx1, by1, bx2, by2, "tab", "missing") - - -- Hoisted counter: reused by bottom accent bar to avoid re-scanning recipes - local craftAvailCount = nil - - if smelterView == "status" then - -- ===== Furnace Status View ===== - -- Column headers - monFill(5, colors.gray) - local outCol = math.floor(w * 0.40) - local fuelCol = math.floor(w * 0.65) - local statCol = w - 6 - monWrite(2, 5, "#", colors.lightGray, colors.gray) - monWrite(4, 5, "T", colors.lightGray, colors.gray) - monWrite(6, 5, "Input", colors.lightGray, colors.gray) - monWrite(outCol, 5, "Output", colors.lightGray, colors.gray) - monWrite(fuelCol, 5, "Fuel", colors.lightGray, colors.gray) - monWrite(statCol, 5, "State", colors.lightGray, colors.gray) - - -- Furnace rows - local furnaceList = cache.furnaceStatus or {} - local maxRows = h - 8 - if maxRows < 1 then maxRows = 1 end - smelterTotalPages = math.max(1, math.ceil(#furnaceList / maxRows)) - if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end - if smelterPage < 1 then smelterPage = 1 end - - local startIdx = (smelterPage - 1) * maxRows + 1 - local endIdx = math.min(startIdx + maxRows - 1, #furnaceList) - - local row = 6 - if #furnaceList == 0 then - monFill(7, colors.black) - monCenter(7, "No furnaces found on network", colors.gray, colors.black) - row = 8 - else - for i = startIdx, endIdx do - local fs = furnaceList[i] - local y = row - local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray - monFill(y, rowBg) - - -- Number - monWrite(2, y, string.format("%d", i), colors.lightBlue, rowBg) - - -- Type abbreviation - local typeAbbr = "F" - local typeColor = colors.orange - if fs.type == "minecraft:smoker" then - typeAbbr = "S" - typeColor = colors.green - elseif fs.type == "minecraft:blast_furnace" then - typeAbbr = "B" - typeColor = colors.cyan - end - monWrite(4, y, typeAbbr, typeColor, rowBg) - - -- Input - if fs.input then - local inName = fs.input.name:gsub("^minecraft:", ""):gsub("_", " ") - local maxIn = outCol - 8 - if #inName > maxIn then inName = inName:sub(1, maxIn - 2) .. ".." end - monWrite(6, y, inName, colors.white, rowBg) - monWrite(outCol - 4, y, "x" .. fs.input.count, colors.yellow, rowBg) - else - monWrite(6, y, "(empty)", colors.lightGray, rowBg) - end - - -- Output - if fs.output then - local outName = fs.output.name:gsub("^minecraft:", ""):gsub("_", " ") - local maxOut = fuelCol - outCol - 5 - if #outName > maxOut then outName = outName:sub(1, maxOut - 2) .. ".." end - monWrite(outCol, y, outName, colors.white, rowBg) - monWrite(fuelCol - 4, y, "x" .. fs.output.count, colors.yellow, rowBg) - else - monWrite(outCol, y, "-", colors.lightGray, rowBg) - end - - -- Fuel - if fs.fuel then - local fuelName = fs.fuel.name:gsub("^minecraft:", ""):gsub("_", " ") - local maxFuel = statCol - fuelCol - 4 - if #fuelName > maxFuel then fuelName = fuelName:sub(1, maxFuel - 2) .. ".." end - monWrite(fuelCol, y, fuelName, colors.white, rowBg) - monWrite(statCol - 4, y, "x" .. fs.fuel.count, colors.yellow, rowBg) - else - monWrite(fuelCol, y, "-", colors.lightGray, rowBg) - end - - -- Status indicator - if smeltingPaused then - monWrite(statCol, y, "PAUSE", colors.red, rowBg) - elseif fs.active then - monWrite(statCol, y, " COOK", colors.lime, rowBg) - elseif fs.input and not fs.fuel then - monWrite(statCol, y, "FUEL?", colors.orange, rowBg) - else - monWrite(statCol, y, " IDLE", colors.lightGray, rowBg) - end - - row = row + 1 - end - end - - -- Fill remaining rows - while row <= h - 2 do - monFill(row, colors.black) - row = row + 1 - end - - elseif smelterView == "smelt" then - -- ===== Smelt Recipe Manager View ===== - - -- Build sorted recipe list - local recipeList = {} - for inputName, recipe in pairs(SMELTABLE) do - local short = inputName:gsub("^minecraft:", ""):gsub("_", " ") - short = short:sub(1,1):upper() .. short:sub(2) - local resultShort = recipe.result:gsub("^minecraft:", ""):gsub("_", " ") - resultShort = resultShort:sub(1,1):upper() .. resultShort:sub(2) - local types = "" - if recipe.furnaceSet["minecraft:furnace"] then types = types .. "F" end - if recipe.furnaceSet["minecraft:smoker"] then types = types .. "S" end - if recipe.furnaceSet["minecraft:blast_furnace"] then types = types .. "B" end - local enabled = not disabledRecipes[inputName] - local inStorage = 0 - if cache.catalogue[inputName] then - for _, s in ipairs(cache.catalogue[inputName]) do - inStorage = inStorage + s.total - end - end - table.insert(recipeList, { - inputName = inputName, - inputShort = short, - resultShort = resultShort, - types = types, - enabled = enabled, - inStorage = inStorage, - }) - end - table.sort(recipeList, function(a, b) return a.inputShort < b.inputShort end) - - -- Column positions - local arrowCol = math.floor(w * 0.30) - local typeCol = math.floor(w * 0.60) - local stockCol = math.floor(w * 0.72) - local toggleCol = w - 5 - - -- Column headers - monFill(5, colors.gray) - monWrite(2, 5, "Input", colors.lightGray, colors.gray) - monWrite(arrowCol, 5, "Output", colors.lightGray, colors.gray) - monWrite(typeCol, 5, "Type", colors.lightGray, colors.gray) - monWrite(stockCol, 5, "Stock", colors.lightGray, colors.gray) - monWrite(toggleCol, 5, "On?", colors.lightGray, colors.gray) - - -- Bulk action buttons on tab row - local bulkX = w - 22 - bx1, by1, bx2, by2 = drawButton(bulkX, 4, "All On", colors.white, colors.green) - addSmelterZone(bx1, by1, bx2, by2, "enable_all", nil) - bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "All Off", colors.white, colors.red) - addSmelterZone(bx1, by1, bx2, by2, "disable_all", nil) - - -- Recipe rows - local maxRows = h - 8 - if maxRows < 1 then maxRows = 1 end - smelterTotalPages = math.max(1, math.ceil(#recipeList / maxRows)) - if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end - if smelterPage < 1 then smelterPage = 1 end - - local startIdx = (smelterPage - 1) * maxRows + 1 - local endIdx = math.min(startIdx + maxRows - 1, #recipeList) - - local row = 6 - for i = startIdx, endIdx do - local r = recipeList[i] - local y = row - local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray - monFill(y, rowBg) - - -- Input name - local maxInputLen = arrowCol - 3 - local inputDisplay = r.inputShort - if #inputDisplay > maxInputLen then - inputDisplay = inputDisplay:sub(1, maxInputLen - 2) .. ".." - end - monWrite(2, y, inputDisplay, colors.white, rowBg) - - -- Output - local maxOutLen = typeCol - arrowCol - 2 - local outDisplay = r.resultShort - if #outDisplay > maxOutLen then - outDisplay = outDisplay:sub(1, maxOutLen - 2) .. ".." - end - monWrite(arrowCol, y, outDisplay, colors.lightBlue, rowBg) - - -- Types - monWrite(typeCol, y, r.types, colors.orange, rowBg) - - -- Stock - monWrite(stockCol, y, tostring(r.inStorage), colors.yellow, rowBg) - - -- Toggle button - if r.enabled then - monWrite(toggleCol, y, " ON ", colors.white, colors.green) - else - monWrite(toggleCol, y, " OFF", colors.white, colors.red) - end - addSmelterZone(1, y, w, y, "toggle_recipe", r.inputName) - - row = row + 1 - end - - -- Fill remaining rows - while row <= h - 2 do - monFill(row, colors.black) - row = row + 1 - end - - elseif smelterView == "craft" then - -- ===== Available Crafting Recipes ===== - - -- Turtle status on tab row - local turtleOk = craftTurtleName and peripheral.isPresent(craftTurtleName) - local tLabel = turtleOk and " Turtle OK " or " No Turtle " - local tBg = turtleOk and colors.lime or colors.red - local tFg = turtleOk and colors.black or colors.white - monWrite(w - #tLabel, 4, tLabel, tFg, tBg) - - -- Build list of craftable recipes - local availList = {} - for idx, recipe in ipairs(CRAFTABLE) do - if canCraftRecipe(recipe) then - local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ") - short = short:sub(1,1):upper() .. short:sub(2) - local batches = maxCraftBatches(recipe) - table.insert(availList, { - idx = idx, - short = short, - count = recipe.count, - batches = batches, - }) - end - end - craftAvailCount = #availList - - -- Column headers - monFill(5, colors.gray) - local makeCol = w - 6 - monWrite(2, 5, "#", colors.lightGray, colors.gray) - monWrite(4, 5, "Output", colors.lightGray, colors.gray) - monWrite(math.floor(w * 0.45), 5, "Yield", colors.lightGray, colors.gray) - monWrite(math.floor(w * 0.60), 5, "Can Make", colors.lightGray, colors.gray) - monWrite(makeCol, 5, "Go", colors.lightGray, colors.gray) - - local maxRows = h - 8 - if maxRows < 1 then maxRows = 1 end - smelterTotalPages = math.max(1, math.ceil(#availList / maxRows)) - if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end - if smelterPage < 1 then smelterPage = 1 end - - local startIdx = (smelterPage - 1) * maxRows + 1 - local endIdx = math.min(startIdx + maxRows - 1, #availList) - - local row = 6 - if #availList == 0 then - monFill(7, colors.black) - monCenter(7, "No recipes available to craft", colors.gray, colors.black) - row = 8 - else - for i = startIdx, endIdx do - local r = availList[i] - local y = row - local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray - monFill(y, rowBg) - - monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg) - - local maxNameLen = math.floor(w * 0.40) - local nameDisplay = r.short - if #nameDisplay > maxNameLen then - nameDisplay = nameDisplay:sub(1, maxNameLen - 2) .. ".." - end - monWrite(4, y, nameDisplay, colors.white, rowBg) - - monWrite(math.floor(w * 0.45), y, "x" .. r.count, colors.yellow, rowBg) - monWrite(math.floor(w * 0.60), y, - string.format("x%d", r.batches), colors.lime, rowBg) - - -- MAKE button - if turtleOk then - monWrite(makeCol, y, " MAKE ", colors.white, colors.green) - addSmelterZone(makeCol, y, makeCol + 5, y, "craft", r.idx) - else - monWrite(makeCol, y, " ---- ", colors.gray, colors.black) - end - - row = row + 1 - end - end - - while row <= h - 2 do - monFill(row, colors.black) - row = row + 1 - end - - elseif smelterView == "missing" then - -- ===== Unavailable Crafting Recipes ===== - - -- Build list of recipes that CANNOT be crafted - local missList = {} - for idx, recipe in ipairs(CRAFTABLE) do - if not canCraftRecipe(recipe) then - local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ") - short = short:sub(1,1):upper() .. short:sub(2) - local missing = getMissingIngredients(recipe) - -- Build summary string - local parts = {} - for _, m in ipairs(missing) do - local mShort = m.name:gsub("^minecraft:", ""):gsub("_", " ") - table.insert(parts, string.format("%s %d/%d", mShort, m.have, m.need)) - end - table.insert(missList, { - idx = idx, - short = short, - count = recipe.count, - summary = table.concat(parts, ", "), - }) - end - end - craftAvailCount = #CRAFTABLE - #missList - - -- Column headers - monFill(5, colors.gray) - monWrite(2, 5, "#", colors.lightGray, colors.gray) - monWrite(4, 5, "Output", colors.lightGray, colors.gray) - monWrite(math.floor(w * 0.35), 5, "Missing (have/need)", colors.lightGray, colors.gray) - - local maxRows = h - 8 - if maxRows < 1 then maxRows = 1 end - smelterTotalPages = math.max(1, math.ceil(#missList / maxRows)) - if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end - if smelterPage < 1 then smelterPage = 1 end - - local startIdx = (smelterPage - 1) * maxRows + 1 - local endIdx = math.min(startIdx + maxRows - 1, #missList) - - local row = 6 - if #missList == 0 then - monFill(7, colors.black) - monCenter(7, "All recipes can be crafted!", colors.lime, colors.black) - row = 8 - else - for i = startIdx, endIdx do - local r = missList[i] - local y = row - local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray - monFill(y, rowBg) - - monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg) - - local nameCol = math.floor(w * 0.35) - 5 - local nameDisplay = r.short .. " x" .. r.count - if #nameDisplay > nameCol then - nameDisplay = nameDisplay:sub(1, nameCol - 2) .. ".." - end - monWrite(4, y, nameDisplay, colors.white, rowBg) - - -- Missing items summary - local missCol = math.floor(w * 0.35) - local missW = w - missCol - 1 - local summaryDisplay = r.summary - if #summaryDisplay > missW then - summaryDisplay = summaryDisplay:sub(1, missW - 2) .. ".." - end - monWrite(missCol, y, summaryDisplay, colors.red, rowBg) - - row = row + 1 - end - end - - while row <= h - 2 do - monFill(row, colors.black) - row = row + 1 - end - end - - -- ===== Pagination (h - 1) ===== - monFill(h - 1, colors.gray) - local pageStr = string.format("Pg %d/%d", smelterPage, smelterTotalPages) - monCenter(h - 1, pageStr, colors.white, colors.gray) - - if smelterPage > 1 then - monWrite(2, h - 1, " < ", colors.white, colors.lightGray) - addSmelterZone(2, h - 1, 4, h - 1, "page_prev", nil) - end - if smelterPage < smelterTotalPages then - monWrite(w - 3, h - 1, " > ", colors.white, colors.lightGray) - addSmelterZone(w - 3, h - 1, w - 1, h - 1, "page_next", nil) - end - - -- ===== Bottom accent ===== - monFill(h, colors.purple) - local bottomMsg = "" - if smelterView == "status" or smelterView == "smelt" then - local enabledCount = 0 - local totalRecipes = 0 - for _ in pairs(SMELTABLE) do totalRecipes = totalRecipes + 1 end - for inputName in pairs(SMELTABLE) do - if not disabledRecipes[inputName] then enabledCount = enabledCount + 1 end - end - bottomMsg = string.format(" Smelt: %d/%d enabled ", enabledCount, totalRecipes) - if activity.smelting then bottomMsg = " SMELTING... " end - elseif smelterView == "craft" then - bottomMsg = " Tap MAKE to craft " - if activity.crafting then bottomMsg = " CRAFTING... " end - elseif smelterView == "missing" then - local availC = craftAvailCount or 0 - bottomMsg = string.format(" Available: %d/%d recipes ", availC, #CRAFTABLE) - end - monCenter(h, bottomMsg, colors.pink, colors.purple) - - -- Flush to monitor - draw.setVisible(true) - - -- Swap zones - smelterTouchZones = smelterPendingZones -end - -------------------------------------------------- --- Barrel auto-sort -------------------------------------------------- - -local function sortBarrel(barrelOverride) - local barrelTarget = (barrelOverride and barrelOverride ~= "") and barrelOverride or BARREL_NAME - local barrel = wrapCached(barrelTarget) - if not barrel then return end - - local contents = barrel.list() - if not contents or not next(contents) then return end - - activity.sorting = true - needsRedraw = true - - local catalogue = cache.catalogue - local chests = getChests() - - for slot, item in pairs(contents) do - local moved = 0 - local triedChests = {} -- dedup: skip chests already tried via catalogue - - if catalogue[item.name] then - for _, entry in ipairs(catalogue[item.name]) do - triedChests[entry.chest] = true - local n = barrel.pushItems(entry.chest, slot) - if n and n > 0 then - moved = moved + n - adjustCache(item.name, entry.chest, n) - needsRedraw = true - smelterNeedsRedraw = true - log.info("SORT", "%s x%d -> %s", item.name, n, entry.chest) - end - if moved >= item.count then break end - end - end - - if moved < item.count then - for _, chest in ipairs(chests) do - if not triedChests[chest] then - local n = barrel.pushItems(chest, slot) - if n and n > 0 then - moved = moved + n - adjustCache(item.name, chest, n) - needsRedraw = true - smelterNeedsRedraw = true - log.info("SORT", "%s x%d -> %s", item.name, n, chest) - end - if moved >= item.count then break end - end - end - end - - if moved < item.count then - log.warn("SORT", "Could not sort %d remaining %s", item.count - moved, item.name) - end - end - - activity.sorting = false - needsRedraw = true -end - -------------------------------------------------- --- Auto-smelt -------------------------------------------------- - -local function autoSmelt() - if smeltingPaused then return false end - - local furnaces = getFurnaces() - if #furnaces == 0 then return false end - - local chests = getChests() - local catalogue = cache.catalogue - local didWork = false - local emptyInputFurnaces = {} -- collected during steps 1-3, filled in step 5 - - for _, fname in ipairs(furnaces) do - local furnace = wrapCached(fname) - if furnace then - local contents = furnace.list() - - -- 1) Pull finished output (slot 3) back to chests - if contents[SLOT_OUTPUT] then - local outputItem = contents[SLOT_OUTPUT] - local remaining = outputItem.count - for _, chest in ipairs(chests) do - local n = furnace.pushItems(chest, SLOT_OUTPUT) - if n and n > 0 then - adjustCache(outputItem.name, chest, n) - remaining = remaining - n - log.info("SMELT", "Output %s x%d -> %s", - outputItem.name, n, chest) - didWork = true - if remaining <= 0 then break end - end - end - if remaining > 0 then - log.warn("SMELT", "Could not move %d %s from %s output (chests full?)", - remaining, outputItem.name, fname) - end - end - - -- Also check all slots in case output ended up elsewhere - -- Some modded furnaces or CC versions may use different slot indices - for slot, item in pairs(contents) do - if slot ~= SLOT_INPUT and slot ~= SLOT_FUEL and slot ~= SLOT_OUTPUT then - for _, chest in ipairs(chests) do - local n = furnace.pushItems(chest, slot) - if n and n > 0 then - adjustCache(item.name, chest, n) - log.info("SMELT", "Extra slot %d: %s x%d -> %s", - slot, item.name, n, chest) - didWork = true - break - end - end - end - end - - -- Re-read after output pull - contents = furnace.list() - - -- 2) Check for incompatible items in input slot and remove them - local furnaceType = peripheral.getType(fname) - local inputItem = contents[SLOT_INPUT] - if inputItem then - local recipe = SMELTABLE[inputItem.name] - local validHere = recipe and recipe.furnaceSet[furnaceType] or false - if not validHere then - -- This item doesn't belong in this furnace type — pull it out - for _, chest in ipairs(chests) do - local n = furnace.pushItems(chest, SLOT_INPUT) - if n and n > 0 then - adjustCache(inputItem.name, chest, n) - log.info("SMELT", "Removed incompatible %s x%d from %s -> %s", - inputItem.name, n, fname, chest) - didWork = true - break - end - end - -- Re-read after removal - contents = furnace.list() - end - end - - -- 3) Refuel if fuel slot is empty or low - local fuelItem = contents[SLOT_FUEL] - local needFuel = not fuelItem or fuelItem.count < 8 - - if needFuel then - for _, fuel in ipairs(FUEL_LIST) do - if catalogue[fuel.name] then - for _, source in ipairs(catalogue[fuel.name]) do - local chest = wrapCached(source.chest) - if chest then - for slot, slotItem in pairs(chest.list()) do - if slotItem.name == fuel.name then - local toMove = math.min(16, slotItem.count) - local n = chest.pushItems(fname, slot, toMove, SLOT_FUEL) - if n and n > 0 then - adjustCache(fuel.name, source.chest, -n) - log.info("SMELT", "Fuel %s x%d -> %s", - fuel.name, n, fname) - didWork = true - needFuel = false - break - end - end - end - end - if not needFuel then break end - end - end - if not needFuel then break end - end - end - - -- 4) Collect furnaces with empty input for balanced loading below - inputItem = contents[SLOT_INPUT] - if not inputItem then - table.insert(emptyInputFurnaces, { name = fname, type = furnaceType }) - end - end - end - - -- 5) Balanced distribution of smeltable items across all empty furnaces - -- Instead of greedily filling one furnace at a time, we spread items - -- evenly so all compatible furnaces work in parallel. - if #emptyInputFurnaces > 0 then - -- Build a unified, deduplicated candidate list across all empty furnace types - local typesSeen = {} - for _, ef in ipairs(emptyInputFurnaces) do typesSeen[ef.type] = true end - - local allCandidates = {} - local candSeen = {} - for ftype in pairs(typesSeen) do - for _, cand in ipairs(smeltCandidatesByType[ftype] or {}) do - if not candSeen[cand.name] then - candSeen[cand.name] = true - table.insert(allCandidates, cand) - end - end - end - -- Sort: food first, then alphabetical (same priority as before) - table.sort(allCandidates, function(a, b) - if a.food ~= b.food then return a.food end - return a.name < b.name - end) - - local usedFurnaces = {} -- furnaces already assigned an item - - for _, cand in ipairs(allCandidates) do - local itemName = cand.name - if not disabledRecipes[itemName] and catalogue[itemName] then - -- Find all compatible empty furnaces not yet used - local compatFurnaces = {} - for _, ef in ipairs(emptyInputFurnaces) do - if not usedFurnaces[ef.name] and cand.recipe.furnaceSet[ef.type] then - table.insert(compatFurnaces, ef) - end - end - - if #compatFurnaces > 0 then - local totalInStorage = 0 - for _, src in ipairs(catalogue[itemName]) do - totalInStorage = totalInStorage + src.total - end - local available = totalInStorage - SMELT_RESERVE - if available > 0 then - local perFurnace = math.min(64, math.ceil(available / #compatFurnaces)) - - for _, ef in ipairs(compatFurnaces) do - if available <= 0 then break end - local toLoad = math.min(perFurnace, available) - local remaining = toLoad - local loaded = false - - -- Snapshot: adjustCache may remove entries during iteration - local srcSnapshot = { table.unpack(catalogue[itemName]) } - for _, source in ipairs(srcSnapshot) do - if source.total > 0 then - local chest = wrapCached(source.chest) - if chest then - for slot, slotItem in pairs(chest.list()) do - if slotItem.name == itemName then - local toMove = math.min(slotItem.count, remaining) - local n = chest.pushItems(ef.name, slot, toMove, SLOT_INPUT) - if n and n > 0 then - adjustCache(itemName, source.chest, -n) - log.info("SMELT", "Input %s x%d -> %s (balanced %d/furnace)", - itemName, n, ef.name, perFurnace) - didWork = true - remaining = remaining - n - available = available - n - if remaining <= 0 then - loaded = true - break - end - end - end - end - end - end -- source.total > 0 - if loaded then break end - end - - if loaded or remaining < toLoad then - usedFurnaces[ef.name] = true - end - end - end - end - end - end - end - - return didWork -end - -------------------------------------------------- --- Defrag (consolidate partial stacks) -------------------------------------------------- - -local function defragInventory() - local chests = getChests() - if #chests == 0 then return end - - activity.defragging = true - needsRedraw = true - - -- Build a map: itemName -> list of { chest, slot, count, maxCount } - local itemSlots = {} - for _, chestName in ipairs(chests) do - local inv = wrapCached(chestName) - if inv then - local contents = inv.list() - for slot, item in pairs(contents) do - if not itemSlots[item.name] then - itemSlots[item.name] = {} - end - -- CC:Tweaked: getItemDetail gives maxCount (stack size) - local maxCount = 64 - local ok, detail = pcall(inv.getItemDetail, slot) - if ok and detail and detail.maxCount then - maxCount = detail.maxCount - end - table.insert(itemSlots[item.name], { - chest = chestName, - slot = slot, - count = item.count, - max = maxCount, - }) - end - end - end - - -- For each item, try to merge partial stacks - local totalMerged = 0 - for itemName, slots in pairs(itemSlots) do - -- Sort: smallest stacks first (donors), fullest last (receivers) - table.sort(slots, function(a, b) return a.count < b.count end) - - local i = 1 -- donor (smallest) - local j = #slots -- receiver (largest, has room) - - while i < j do - local donor = slots[i] - local recv = slots[j] - - -- Skip if same slot - if donor.chest == recv.chest and donor.slot == recv.slot then - i = i + 1 - elseif donor.count == 0 then - i = i + 1 - elseif recv.count >= recv.max then - j = j - 1 - else - local space = recv.max - recv.count - local toMove = math.min(donor.count, space) - local donorInv = wrapCached(donor.chest) - if donorInv then - local n = donorInv.pushItems(recv.chest, donor.slot, toMove, recv.slot) - if n and n > 0 then - donor.count = donor.count - n - recv.count = recv.count + n - totalMerged = totalMerged + n - -- Update catalogue for cross-chest moves - if donor.chest ~= recv.chest then - adjustCache(itemName, donor.chest, -n) - adjustCache(itemName, recv.chest, n) - end - end - end - if donor.count <= 0 then i = i + 1 end - if recv.count >= recv.max then j = j - 1 end - end - end - end - - if totalMerged > 0 then - log.info("DEFRAG", "Consolidated %d items", totalMerged) - end - - activity.defragging = false - needsRedraw = true -end - -------------------------------------------------- --- Auto-compost -------------------------------------------------- - -local function autoCompost() - local catalogue = cache.catalogue - local chests = getChests() - local didWork = false - - -- 1) Pull bone meal from hopper back to chests - local hopper = wrapCached(COMPOST_HOPPER) - if hopper then - local contents = hopper.list() - if contents then - for slot, item in pairs(contents) do - for _, chest in ipairs(chests) do - local n = hopper.pushItems(chest, slot) - if n and n > 0 then - adjustCache(item.name, chest, n) - log.info("COMPOST", "%s x%d -> %s", item.name, n, chest) - didWork = true - break - end - end - end - end - end - - -- 2) Feed compostable items into dropper - local dropper = wrapCached(COMPOST_DROPPER) - if not dropper then return didWork end - - -- Check how much item capacity the dropper has (slots * 64 - current items) - local dropperContents = dropper.list() - local dropperUsedSlots = 0 - local dropperUsedItems = 0 - if dropperContents then - for _, item in pairs(dropperContents) do - dropperUsedSlots = dropperUsedSlots + 1 - dropperUsedItems = dropperUsedItems + item.count - end - end - local dropperSize = dropper.size() - local dropperFreeItems = (dropperSize * 64) - dropperUsedItems - - if dropperFreeItems <= 0 then return didWork end - - for _, itemName in ipairs(COMPOSTABLE) do - if dropperFreeItems <= 0 then break end - if catalogue[itemName] then - -- Count total in storage - local totalInStorage = 0 - for _, src in ipairs(catalogue[itemName]) do - totalInStorage = totalInStorage + src.total - end - - local reserve = COMPOST_TRASH[itemName] and 0 or COMPOST_RESERVE - local available = totalInStorage - reserve - if available > 0 then - local toFeed = math.min(available, dropperFreeItems) - local fed = 0 - -- Snapshot: adjustCache may remove entries during iteration - local srcSnapshot = { table.unpack(catalogue[itemName]) } - for _, source in ipairs(srcSnapshot) do - if source.total > 0 then - local chest = wrapCached(source.chest) - if chest then - for slot, slotItem in pairs(chest.list()) do - if slotItem.name == itemName then - local batch = math.min(slotItem.count, toFeed - fed) - local n = chest.pushItems(COMPOST_DROPPER, slot, batch) - if n and n > 0 then - adjustCache(itemName, source.chest, -n) - fed = fed + n - didWork = true - log.info("COMPOST", "Fed %s x%d -> dropper", - itemName, n) - if fed >= toFeed then break end - end - end - end - end - end -- source.total > 0 - if fed >= toFeed then break end - end - dropperFreeItems = dropperFreeItems - fed - end - end - end - - return didWork -end - -------------------------------------------------- --- Low-stock alert checker -------------------------------------------------- - -local function checkAlerts() - local alerts = {} - for _, alert in ipairs(LOW_STOCK_ALERTS) do - local total = 0 - if cache.catalogue[alert.name] then - for _, src in ipairs(cache.catalogue[alert.name]) do - total = total + src.total - end - end - if total < alert.min then - table.insert(alerts, { - label = alert.label, - current = total, - min = alert.min, - }) - end - end - activeAlerts = alerts - if #alerts > 0 then - needsRedraw = true - smelterNeedsRedraw = true - end -end - -------------------------------------------------- --- Order -------------------------------------------------- - -local function orderItem(itemName, amount, dropperOverride) - activity.dispensing = true - needsRedraw = true - - -- Use client-specified dropper if provided, otherwise master's default - local dropperTarget = (dropperOverride and dropperOverride ~= "") and dropperOverride or DROPPER_NAME - - local catalogue = cache.catalogue - - if not catalogue[itemName] then - statusMessage = "Not found: " .. itemName:gsub("^minecraft:", "") - statusColor = colors.red - statusTimer = 5 - activity.dispensing = false - needsRedraw = true - return false - end - - local dropper = wrapCached(dropperTarget) - if not dropper then - statusMessage = "Dropper offline: " .. dropperTarget - statusColor = colors.red - statusTimer = 5 - activity.dispensing = false - needsRedraw = true - return false - end - - local remaining = amount - -- Snapshot: adjustCache may remove entries during iteration - local srcSnapshot = { table.unpack(catalogue[itemName]) } - for _, entry in ipairs(srcSnapshot) do - if entry.total > 0 then - local chest = wrapCached(entry.chest) - if chest then - for slot, slotItem in pairs(chest.list()) do - if slotItem.name == itemName then - local toMove = math.min(remaining, slotItem.count) - local moved = chest.pushItems(dropperTarget, slot, toMove) - if moved and moved > 0 then - remaining = remaining - moved - adjustCache(itemName, entry.chest, -moved) - needsRedraw = true - smelterNeedsRedraw = true - log.info("ORDER", "%s x%d from %s", itemName, moved, entry.chest) - end - if remaining <= 0 then break end - end - end - end - end -- entry.total > 0 - if remaining <= 0 then break end - end - - local sent = amount - remaining - local short = itemName:gsub("^minecraft:", ""):gsub("_", " ") - if sent > 0 then - statusMessage = string.format("Dispensing %s x%d", short, sent) - statusColor = colors.lime - log.info("ORDER", "Ordered %s x%d", short, sent) - else - statusMessage = "Could not order " .. short - statusColor = colors.red - end - statusTimer = 5 - - activity.dispensing = false - needsRedraw = true - return sent > 0 -end - -------------------------------------------------- --- Touch handler (no peripheral calls — instant) -------------------------------------------------- - -local function handleTouch(x, y) - local action, data = hitTest(x, y) - if not action then - log.debug("TOUCH", "No zone hit") - return - end - - if action == "amount" then - selectedAmount = data - log.debug("UI", "Amount set to %s", data) - needsRedraw = true - - elseif action == "order" then - local itemName = data - if itemName then - local short = itemName:gsub("^minecraft:", ""):gsub("_", " ") - statusMessage = string.format("Ordering %s x%d...", short, selectedAmount) - statusColor = colors.cyan - statusTimer = 10 - activity.dispensing = true - needsRedraw = true - orderItem(itemName, selectedAmount) - end - - elseif action == "scan" then - statusMessage = "Refreshing..." - statusColor = colors.cyan - statusTimer = 3 - needsRedraw = true - log.debug("UI", "Manual refresh") - - elseif action == "kb_toggle" then - showKeyboard = not showKeyboard - log.debug("UI", "Keyboard %s", showKeyboard and "open" or "closed") - needsRedraw = true - - elseif action == "kb_key" then - if #searchQuery < 30 then - searchQuery = searchQuery .. data - end - currentPage = 1 - needsRedraw = true - - elseif action == "kb_bksp" then - if #searchQuery > 0 then - searchQuery = searchQuery:sub(1, -2) - end - currentPage = 1 - needsRedraw = true - - elseif action == "kb_space" then - if #searchQuery < 30 then - searchQuery = searchQuery .. " " - end - currentPage = 1 - needsRedraw = true - - elseif action == "kb_done" then - showKeyboard = false - log.debug("UI", "Keyboard closed") - needsRedraw = true - - elseif action == "kb_clear" then - searchQuery = "" - currentPage = 1 - log.debug("UI", "Search cleared") - needsRedraw = true - - elseif action == "page_prev" then - if currentPage > 1 then - currentPage = currentPage - 1 - log.debug("UI", "Page %d", currentPage) - end - needsRedraw = true - - elseif action == "page_next" then - if currentPage < totalPages then - currentPage = currentPage + 1 - log.debug("UI", "Page %d", currentPage) - end - needsRedraw = true - end -end - -------------------------------------------------- --- Smelter touch handler -------------------------------------------------- - -local function handleSmelterTouch(x, y) - local action, data = smelterHitTest(x, y) - if not action then return end - - if action == "tab" then - smelterView = data - smelterPage = 1 - log.debug("UI", "Tab: %s", data) - smelterNeedsRedraw = true - - elseif action == "toggle_pause" then - smeltingPaused = not smeltingPaused - log.debug("UI", "Smelting %s", smeltingPaused and "PAUSED" or "RESUMED") - saveDisabledRecipes() - smelterNeedsRedraw = true - needsRedraw = true - - elseif action == "toggle_recipe" then - if disabledRecipes[data] then - disabledRecipes[data] = nil - else - disabledRecipes[data] = true - end - local short = data:gsub("^minecraft:", ""):gsub("_", " ") - log.debug("UI", "Recipe %s: %s", short, disabledRecipes[data] and "OFF" or "ON") - saveDisabledRecipes() - smelterNeedsRedraw = true - - elseif action == "enable_all" then - disabledRecipes = {} - log.debug("UI", "All recipes enabled") - saveDisabledRecipes() - smelterNeedsRedraw = true - - elseif action == "disable_all" then - for inputName in pairs(SMELTABLE) do - disabledRecipes[inputName] = true - end - log.debug("UI", "All recipes disabled") - saveDisabledRecipes() - smelterNeedsRedraw = true - - elseif action == "page_prev" then - if smelterPage > 1 then - smelterPage = smelterPage - 1 - end - smelterNeedsRedraw = true - - elseif action == "page_next" then - if smelterPage < smelterTotalPages then - smelterPage = smelterPage + 1 - end - smelterNeedsRedraw = true - - elseif action == "craft" then - local recipeIdx = data - local recipe = CRAFTABLE[recipeIdx] - if recipe then - local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ") - log.info("CRAFT", "Craft request: %s (#%d)", short, recipeIdx) - local ok, err = craftItem(recipeIdx) - if ok then - statusMessage = "Crafted " .. short .. " x" .. recipe.count - statusColor = colors.lime - else - statusMessage = "Craft failed: " .. (err or "unknown") - statusColor = colors.red - end - statusTimer = 5 - needsRedraw = true - smelterNeedsRedraw = true - end - end -end - ------------------------------------------------- -- Network broadcast (sends state to client displays) ------------------------------------------------- local function broadcastState() - if not networkModem then return end - ensureItemList() + if not ctx.networkModem then return end + state.ensureItemList() - -- Build dynamic state (always included) - local state = { + local payload = { type = "state", - stateVersion = stateVersion, + stateVersion = state.stateVersion, cache = { itemList = cache.itemList, grandTotal = cache.grandTotal, @@ -2516,21 +96,20 @@ local function broadcastState() droppers = cache.droppers, }, activity = activity, - alerts = activeAlerts, - smeltingPaused = smeltingPaused, - disabledRecipes = disabledRecipes, - craftTurtleOk = craftTurtleName and peripheral.isPresent(craftTurtleName), + alerts = state.activeAlerts, + smeltingPaused = state.smeltingPaused, + disabledRecipes = state.disabledRecipes, + craftTurtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName), } - -- Include static config only when dirty (startup, scan, recipe toggles) - if configDirty then - state.smeltable = SMELTABLE - state.craftable = CRAFTABLE - configDirty = false + if state.configDirty then + payload.smeltable = cfg.SMELTABLE + payload.craftable = cfg.CRAFTABLE + state.configDirty = false end - networkModem.transmit(BROADCAST_CHANNEL, ORDER_CHANNEL, state) - lastBroadcastVersion = stateVersion + ctx.networkModem.transmit(cfg.BROADCAST_CHANNEL, cfg.ORDER_CHANNEL, payload) + state.lastBroadcastVersion = state.stateVersion end ------------------------------------------------- @@ -2539,47 +118,49 @@ end local function main() print("=================================") - print(" Inventory Manager v2 (Touch)") + print(" Inventory Manager v3 (Modular)") print("=================================") print("") - if peripheral.isPresent(DROPPER_NAME) then - log.info("INIT", "Dropper: %s", DROPPER_NAME) + -- 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", DROPPER_NAME) + log.warn("INIT", "Dropper not found: %s", cfg.DROPPER_NAME) end - if peripheral.isPresent(BARREL_NAME) then - log.info("INIT", "Barrel: %s", BARREL_NAME) + if peripheral.isPresent(cfg.BARREL_NAME) then + log.info("INIT", "Barrel: %s", cfg.BARREL_NAME) else - log.warn("INIT", "Barrel not found: %s", BARREL_NAME) + log.warn("INIT", "Barrel not found: %s", cfg.BARREL_NAME) end - if setupMonitor() then - log.info("INIT", "Monitor: %s", monName or MONITOR_SIDE) + -- Monitors + if display.setupMonitor() then + log.info("INIT", "Monitor: %s", display.monName or cfg.MONITOR_SIDE) else - log.warn("INIT", "No monitor on %s", MONITOR_SIDE) + log.warn("INIT", "No monitor on %s", cfg.MONITOR_SIDE) end - if setupSmelterMonitor() then - log.info("INIT", "Smelter monitor: %s", smelterMonName or SMELTER_MONITOR_SIDE) + 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", SMELTER_MONITOR_SIDE) + 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 - networkModem = peripheral.wrap(name) - networkModemName = name - networkModem.open(ORDER_CHANNEL) - networkModem.open(CRAFT_REPLY_CHANNEL) - networkModem.open(SYSTEM_CHANNEL) + 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 networkModem then - log.info("INIT", "Network modem: %s", networkModemName) + if ctx.networkModem then + log.info("INIT", "Network modem: %s", ctx.networkModemName) else log.warn("INIT", "No modem found for client sync") end @@ -2587,64 +168,63 @@ local function main() -- Detect crafting turtle on network for _, name in ipairs(peripheral.getNames()) do if name:match("^turtle_") then - craftTurtleName = name + ctx.craftTurtleName = name break end end - if craftTurtleName then - log.info("INIT", "Crafting turtle: %s", craftTurtleName) + 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 - loadDisabledRecipes() - if smeltingPaused then + 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(SMELTABLE) do totalRecipeCount = totalRecipeCount + 1 end - for k in pairs(SMELTABLE) do - if not disabledRecipes[k] then enabledCount = enabledCount + 1 end + 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 recipes enabled", enabledCount, totalRecipeCount) - -- Detect compost peripherals - if peripheral.isPresent(COMPOST_DROPPER) then - log.info("INIT", "Compost dropper: %s", COMPOST_DROPPER) + -- 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", COMPOST_DROPPER) + log.warn("INIT", "Compost dropper not found: %s", cfg.COMPOST_DROPPER) end - if peripheral.isPresent(COMPOST_HOPPER) then - log.info("INIT", "Compost hopper: %s", COMPOST_HOPPER) + 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", COMPOST_HOPPER) + log.warn("INIT", "Compost hopper not found: %s", cfg.COMPOST_HOPPER) end - log.info("INIT", "Tracking %d low-stock alerts", #LOW_STOCK_ALERTS) + 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 = loadCacheFromDisk() + 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 progress bar + -- No cache: do full scan with boot progress bar log.info("INIT", "No cache found. Scanning inventories...") - if mon then - local w, h = mon.getSize() - local buf = window.create(mon, 1, 1, w, h, false) + 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() - -- Title buf.setBackgroundColor(colors.blue) buf.setCursorPos(1, 1) buf.write(string.rep(" ", w)) @@ -2653,7 +233,6 @@ local function main() buf.setTextColor(colors.white) buf.write(title) - -- Scanning label local midY = math.floor(h / 2) buf.setBackgroundColor(colors.black) buf.setTextColor(colors.lightGray) @@ -2661,14 +240,12 @@ local function main() buf.setCursorPos(math.floor((w - #label) / 2) + 1, midY - 2) buf.write(label) - -- Chest name 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) - -- Progress bar local barW = math.min(w - 8, 40) local barX = math.floor((w - barW) / 2) + 1 local ratio = total > 0 and (current / total) or 0 @@ -2680,14 +257,12 @@ local function main() buf.setBackgroundColor(colors.gray) buf.write(string.rep(" ", barW - filled)) - -- Percentage + count 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) - -- Bottom accent buf.setCursorPos(1, h) buf.setBackgroundColor(colors.blue) buf.write(string.rep(" ", w)) @@ -2696,70 +271,72 @@ local function main() buf.setVisible(false) end - refreshCache(drawBoot) + ops.refreshCache(drawBoot) else - refreshCache() + 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 we loaded from disk cache, refresh immediately in background if cacheLoaded then - pcall(refreshCache) - pcall(checkAlerts) - needsRedraw = true - smelterNeedsRedraw = true + 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(SCAN_INTERVAL) - pcall(refreshCache) - pcall(checkAlerts) - needsRedraw = true - smelterNeedsRedraw = true + sleep(cfg.SCAN_INTERVAL) + pcall(ops.refreshCache) + pcall(ops.checkAlerts) + state.needsRedraw = true + state.smelterNeedsRedraw = true end end, -- Task 2: Barrel auto-sort function() while true do - pcall(sortBarrel) - sleep(POLL_INTERVAL) + pcall(ops.sortBarrel) + sleep(cfg.POLL_INTERVAL) end end, -- Task 3: Auto-smelt function() while true do - local ok, didWork = pcall(autoSmelt) + local ok, didWork = pcall(ops.autoSmelt) if ok and didWork then activity.smelting = true - needsRedraw = true - smelterNeedsRedraw = true + state.needsRedraw = true + state.smelterNeedsRedraw = true end activity.smelting = false - -- Update furnace status quickly after smelt cycle - pcall(refreshFurnaceStatus) - needsRedraw = true - smelterNeedsRedraw = true - sleep(SMELT_INTERVAL) + pcall(ops.refreshFurnaceStatus) + state.needsRedraw = true + state.smelterNeedsRedraw = true + sleep(cfg.SMELT_INTERVAL) end end, -- Task 4: Defrag (consolidate partial stacks) function() - sleep(10) -- initial delay to let first scan finish + sleep(10) while true do activity.defragging = true - needsRedraw = true - pcall(defragInventory) + state.needsRedraw = true + pcall(ops.defragInventory) activity.defragging = false - needsRedraw = true - sleep(DEFRAG_INTERVAL) + state.needsRedraw = true + sleep(cfg.DEFRAG_INTERVAL) end end, @@ -2767,46 +344,44 @@ local function main() function() while true do activity.composting = true - needsRedraw = true - pcall(autoCompost) + state.needsRedraw = true + pcall(ops.autoCompost) activity.composting = false - needsRedraw = true - pcall(checkAlerts) - sleep(COMPOST_INTERVAL) + state.needsRedraw = true + pcall(ops.checkAlerts) + sleep(cfg.COMPOST_INTERVAL) end end, -- Task 6: Low-stock alert checker function() - sleep(5) -- initial delay - pcall(checkAlerts) - needsRedraw = true + sleep(5) + pcall(ops.checkAlerts) + state.needsRedraw = true while true do - sleep(ALERT_INTERVAL) - pcall(checkAlerts) - needsRedraw = true + sleep(cfg.ALERT_INTERVAL) + pcall(ops.checkAlerts) + state.needsRedraw = true end end, - -- Task 7: Inventory dashboard redraw (event-driven, checks every 0.1s) + -- Task 7: Main dashboard redraw (event-driven, polls 0.1s) function() - needsRedraw = true + state.needsRedraw = true while true do - if needsRedraw then - needsRedraw = false - pcall(drawDashboard) + if state.needsRedraw then + state.needsRedraw = false + pcall(display.drawDashboard) end - -- Decrement status timer - if statusTimer > 0 then - statusTimer = statusTimer - 0.1 - if statusTimer <= 0 then - statusTimer = 0 - needsRedraw = true + if state.statusTimer > 0 then + state.statusTimer = state.statusTimer - 0.1 + if state.statusTimer <= 0 then + state.statusTimer = 0 + state.needsRedraw = true end end - -- Redraw periodically for alert cycling - if #activeAlerts > 0 then - needsRedraw = true + if #state.activeAlerts > 0 then + state.needsRedraw = true end sleep(0.1) end @@ -2814,11 +389,11 @@ local function main() -- Task 8: Smelter dashboard redraw function() - smelterNeedsRedraw = true + state.smelterNeedsRedraw = true while true do - if smelterNeedsRedraw then - smelterNeedsRedraw = false - pcall(drawSmelterDashboard) + if state.smelterNeedsRedraw then + state.smelterNeedsRedraw = false + pcall(display.drawSmelterDashboard) end sleep(0.1) end @@ -2828,12 +403,12 @@ local function main() function() while true do local event, side, x, y = os.pullEvent("monitor_touch") - if smelterMonName and side == smelterMonName then + if display.smelterMonName and side == display.smelterMonName then log.debug("TOUCH", "x=%d y=%d", x, y) - handleSmelterTouch(x, y) + display.handleSmelterTouch(x, y) else log.debug("TOUCH", "x=%d y=%d", x, y) - handleTouch(x, y) + display.handleTouch(x, y) end end end, @@ -2841,20 +416,20 @@ local function main() -- Task 10: Network state broadcast (skips if nothing changed) function() while true do - if stateVersion ~= lastBroadcastVersion then + if state.stateVersion ~= state.lastBroadcastVersion then pcall(broadcastState) end - sleep(BROADCAST_INTERVAL) + sleep(cfg.BROADCAST_INTERVAL) end end, - -- Task 11: Peripheral detach handler (invalidates cached handles) + -- Task 11: Peripheral detach handler function() while true do local event, name = os.pullEvent("peripheral_detach") if name then - invalidateWrapCache(name) - invalidatePeripheralCaches() + ops.invalidateWrapCache(name) + ops.invalidatePeripheralCaches() log.info("DETACH", "%s", name) end end @@ -2862,154 +437,158 @@ local function main() -- Task 12: Network order/command listener function() - if not networkModem then return end + if not ctx.networkModem then return end while true do local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message") - if channel == ORDER_CHANNEL and type(message) == "table" then - -- Idempotency: skip duplicate commands + 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(orderItem, message.itemName, message.amount, message.dropperName) - if not pok then - log.error("NET", "orderItem crashed: %s", tostring(success)) - success = false - statusMessage = "Order error" - statusColor = colors.red - statusTimer = 5 - needsRedraw = true - end - pcall(function() - networkModem.transmit(replyChannel, ORDER_CHANNEL, { - type = "order_result", - commandId = message.commandId, - success = success, - message = statusMessage, - color = statusColor, - }) - end) - pcall(broadcastState) - elseif message.type == "scan" then - log.info("NET", "Scan request from client") - configDirty = true -- resend full config after scan - pcall(refreshCache) - pcall(checkAlerts) - needsRedraw = true - smelterNeedsRedraw = true - pcall(broadcastState) - elseif message.type == "toggle_pause" then - smeltingPaused = not smeltingPaused - log.info("NET", "Smelting %s", smeltingPaused and "PAUSED" or "RESUMED") - saveDisabledRecipes() - bumpStateVersion() - smelterNeedsRedraw = true - needsRedraw = true - pcall(broadcastState) - elseif message.type == "toggle_recipe" and message.recipe then - if disabledRecipes[message.recipe] then - disabledRecipes[message.recipe] = nil - else - disabledRecipes[message.recipe] = true - end - log.info("NET", "Recipe toggle: %s", message.recipe) - saveDisabledRecipes() - configDirty = true - bumpStateVersion() - smelterNeedsRedraw = true - pcall(broadcastState) - elseif message.type == "enable_all" then - disabledRecipes = {} - log.info("NET", "All recipes enabled") - saveDisabledRecipes() - configDirty = true - bumpStateVersion() - smelterNeedsRedraw = true - pcall(broadcastState) - elseif message.type == "disable_all" then - for inputName in pairs(SMELTABLE) do - disabledRecipes[inputName] = true - end - log.info("NET", "All recipes disabled") - saveDisabledRecipes() - configDirty = true - bumpStateVersion() - smelterNeedsRedraw = true - pcall(broadcastState) - elseif message.type == "sort_barrel" and message.barrelName then - log.info("NET", "Sort barrel: %s", message.barrelName) - pcall(sortBarrel, message.barrelName) - pcall(broadcastState) - elseif message.type == "register_droppers" and message.clientId and message.droppers then - -- Client is announcing its locally-attached droppers - local cid = tostring(message.clientId) - clientDroppers[cid] = message.droppers - log.info("NET", "Client %s registered %d dropper(s)", cid, #message.droppers) - -- Rebuild the merged dropper list immediately - local seenNames = {} - local merged = {} - -- Master droppers first - for _, d in ipairs(cache.droppers or {}) do - if not d.clientId then - table.insert(merged, d) - seenNames[d.name] = true - end - end - -- Then all client droppers - for clientId, clientList in pairs(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 + 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) end + + end) -- pcall handler + if not handlerOk then + log.error("NET", "Handler error: %s", tostring(handlerErr)) end - cache.droppers = merged - bumpStateVersion() - pcall(broadcastState) - elseif message.type == "reboot" then - local target = message.target or "all" - log.info("NET", "Reboot command received, target: %s", target) - -- Broadcast reboot to all clients on system channel - networkModem.transmit(SYSTEM_CHANNEL, ORDER_CHANNEL, { - type = "reboot", - target = target, - }) - -- If manager itself is targeted, reboot after a short delay - 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(craftItem, message.recipeIdx) - if not pok then - log.error("NET", "craftItem crashed: %s", tostring(ok)) - err = tostring(ok) - ok = false - end - pcall(function() - networkModem.transmit(replyChannel, ORDER_CHANNEL, { - type = "craft_result", - commandId = message.commandId, - success = ok, - error = err, - }) - end) - smelterNeedsRedraw = true - needsRedraw = true - pcall(broadcastState) - end - end) -- end pcall handler - if not handlerOk then - log.error("NET", "Handler error: %s", tostring(handlerErr)) - end - end -- end idempotency else + end -- idempotency else end end end diff --git a/inventoryManager.lua.bak b/inventoryManager.lua.bak new file mode 100644 index 0000000..b87c6ed --- /dev/null +++ b/inventoryManager.lua.bak @@ -0,0 +1,3019 @@ +-- Inventory Manager: Touch UI on monitor +-- Main computer (networked). Computer 1 sits next to dropper_0 and auto-dispenses. + +------------------------------------------------- +-- Default configuration (overridden by .manager_config) +------------------------------------------------- + +local DROPPER_NAME = "minecraft:dropper_9" +local BARREL_NAME = "minecraft:barrel_0" +local POLL_INTERVAL = 2 -- seconds between barrel checks +local MONITOR_SIDE = "left" +local SCAN_INTERVAL = 120 -- seconds between full background scans +local SMELT_INTERVAL = 3 -- seconds between furnace checks +local SMELT_RESERVE = 128 -- keep at least 2 stacks of each raw material +local DEFRAG_INTERVAL = 600 -- seconds between defrag passes (10 min) +local COMPOST_INTERVAL = 3 -- seconds between composter checks +local ALERT_INTERVAL = 15 -- seconds between alert re-checks +local CACHE_FILE = ".inventory_cache" -- persistent cache file +local SMELTER_MONITOR_SIDE = "top" +local DISABLED_RECIPES_FILE = ".disabled_recipes" + +-- Network sync (for client displays) +local BROADCAST_CHANNEL = 4200 +local ORDER_CHANNEL = 4201 +local BROADCAST_INTERVAL = 1 -- seconds between state broadcasts + +-- Crafting turtle +local CRAFT_CHANNEL = 4203 +local CRAFT_REPLY_CHANNEL = 4204 + +-- Remote reboot system channel (all devices listen on this) +local SYSTEM_CHANNEL = 4205 + +------------------------------------------------- +-- Idempotent command tracking +------------------------------------------------- + +local processedCmdIds = {} -- commandId -> os.epoch("utc") ms +local CMD_TTL_MS = 300000 -- 5 minutes + +local function isCommandDuplicate(commandId) + if not commandId then return false end + local entry = processedCmdIds[commandId] + if entry and (os.epoch("utc") - entry) < CMD_TTL_MS then + return true + end + return false +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 + +------------------------------------------------- +-- Load config from file if present +------------------------------------------------- + +local CONFIG_FILE = ".manager_config" +local _loadedConfig = nil -- stored for deferred overrides (compost settings declared later) + +------------------------------------------------- +-- Structured logging +------------------------------------------------- + +local log = dofile("lib/log.lua") + +local function loadConfig() + if not fs.exists(CONFIG_FILE) then return end + local f = fs.open(CONFIG_FILE, "r") + local data = f.readAll() + f.close() + local ok, cfg = pcall(textutils.unserialiseJSON, data) + if not ok or not cfg then + log.warn("CONFIG", "Failed to parse %s", CONFIG_FILE) + return + end + _loadedConfig = cfg + -- Peripheral names + if cfg.dropperName then DROPPER_NAME = cfg.dropperName end + if cfg.barrelName then BARREL_NAME = cfg.barrelName end + if cfg.monitorSide then MONITOR_SIDE = cfg.monitorSide end + if cfg.smelterMonitorSide then SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end + -- Timing intervals + if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end + if cfg.scanInterval then SCAN_INTERVAL = cfg.scanInterval end + if cfg.smeltInterval then SMELT_INTERVAL = cfg.smeltInterval end + if cfg.defragInterval then DEFRAG_INTERVAL = cfg.defragInterval end + if cfg.compostInterval then COMPOST_INTERVAL = cfg.compostInterval end + if cfg.alertInterval then ALERT_INTERVAL = cfg.alertInterval end + if cfg.broadcastInterval then BROADCAST_INTERVAL = cfg.broadcastInterval end + -- Reserves + if cfg.smeltReserve then SMELT_RESERVE = cfg.smeltReserve end + -- Modem channels + if cfg.broadcastChannel then BROADCAST_CHANNEL = cfg.broadcastChannel end + if cfg.orderChannel then ORDER_CHANNEL = cfg.orderChannel end + if cfg.craftChannel then CRAFT_CHANNEL = cfg.craftChannel end + if cfg.craftReplyChannel then CRAFT_REPLY_CHANNEL = cfg.craftReplyChannel end + -- Log level + if cfg.logLevel then log.setLevel(cfg.logLevel) end + log.info("CONFIG", "Loaded from %s", CONFIG_FILE) +end + +loadConfig() + +------------------------------------------------- +-- Furnace types to manage +------------------------------------------------- + +local FURNACE_TYPES = { + "minecraft:furnace", + "minecraft:smoker", + "minecraft:blast_furnace", +} + +-- Furnace slots: 1 = input, 2 = fuel, 3 = output (standard Minecraft) +local SLOT_INPUT = 1 +local SLOT_FUEL = 2 +local SLOT_OUTPUT = 3 + +------------------------------------------------- +-- Load data tables from external files (data/) +------------------------------------------------- + +local SMELTABLE = dofile("data/smeltable.lua") +local FUEL_LIST = dofile("data/fuel.lua") +local _compostData = dofile("data/compostable.lua") +local COMPOSTABLE = _compostData.items +local COMPOST_TRASH = _compostData.trash +local CRAFTABLE = dofile("data/craftable.lua") +local LOW_STOCK_ALERTS = dofile("data/alerts.lua") + +-- Pre-build furnace compatibility sets for O(1) lookup +for _, recipe in pairs(SMELTABLE) do + recipe.furnaceSet = {} + for _, ft in ipairs(recipe.furnaces) do + recipe.furnaceSet[ft] = true + end +end + +-- Pre-built smelt candidate lists per furnace type (sorted: food first, then alpha) +local smeltCandidatesByType = {} +do + for _, ftype in ipairs(FURNACE_TYPES) do + smeltCandidatesByType[ftype] = {} + end + for itemName, recipe in pairs(SMELTABLE) do + local isFood = recipe.furnaceSet["minecraft:smoker"] or false + for ft, _ in pairs(recipe.furnaceSet) do + table.insert(smeltCandidatesByType[ft], { name = itemName, recipe = recipe, food = isFood }) + end + end + for _, list in pairs(smeltCandidatesByType) do + table.sort(list, function(a, b) + if a.food ~= b.food then return a.food end + return a.name < b.name + end) + end +end + +-- Build fuel set for quick lookup +local FUEL_SET = {} +for _, f in ipairs(FUEL_LIST) do FUEL_SET[f.name] = true end + +-- Build compostable set for quick lookup +local COMPOSTABLE_SET = {} +for _, name in ipairs(COMPOSTABLE) do COMPOSTABLE_SET[name] = true end + +-- Compost settings (overridable via config) +local COMPOST_RESERVE = 128 -- 2 stacks +local COMPOST_DROPPER = "minecraft:dropper_10" +local COMPOST_HOPPER = "minecraft:hopper_0" + +if _loadedConfig then + if _loadedConfig.compostReserve then COMPOST_RESERVE = _loadedConfig.compostReserve end + if _loadedConfig.compostDropper then COMPOST_DROPPER = _loadedConfig.compostDropper end + if _loadedConfig.compostHopper then COMPOST_HOPPER = _loadedConfig.compostHopper end +end + +-- Crafting grid-to-slot mapping +local GRID_TO_SLOT = {1, 2, 3, 5, 6, 7, 9, 10, 11} + +------------------------------------------------- +-- Shared UI helpers (drawing, zones, craft math) +------------------------------------------------- + +local ui = dofile("lib/ui.lua") + +-- Active alerts (populated by checkAlerts) +local activeAlerts = {} + +------------------------------------------------- +-- State version tracking (for delta broadcasting) +------------------------------------------------- + +local stateVersion = 0 +local lastBroadcastVersion = -1 +local configDirty = true + +local function bumpStateVersion() + stateVersion = stateVersion + 1 +end + +------------------------------------------------- +-- Cached data (updated by background scanner) +------------------------------------------------- + +local cache = { + catalogue = {}, -- itemName -> { {chest=name, total=N}, ... } + itemList = {}, -- sorted list of { name, total } + itemListDirty = false, -- lazy rebuild flag for itemList + grandTotal = 0, + chestCount = 0, + totalSlots = 0, + usedSlots = 0, + freeSlots = 0, + usedRatio = 0, + dropperOk = false, + barrelOk = false, + furnaceCount = 0, + furnaceStatus = {}, -- per-furnace { name, type, input, fuel, output, active } + droppers = {}, -- list of available dropper peripherals for dispensing +} + +-- Client-registered droppers, keyed by clientId +local clientDroppers = {} + +------------------------------------------------- +-- Activity state (shown on monitor) +------------------------------------------------- + +local activity = { + sorting = false, -- barrel sort in progress + dispensing = false, -- order in progress + scanning = false, -- background scan in progress + smelting = false, -- auto-smelt in progress + defragging = false, -- defrag in progress + composting = false, -- auto-compost in progress + crafting = false, -- crafting in progress +} + +------------------------------------------------- +-- Instant cache adjustment (no scan needed) +------------------------------------------------- + +--- Adjust cached counts for a single item move. +-- delta > 0 means items arrived in chestName; +-- delta < 0 means items left chestName. +local function adjustCache(itemName, chestName, delta) + if delta == 0 then return end + + -- 1) catalogue: per-chest totals + local cat = cache.catalogue + if delta > 0 then + -- Items added to a chest + if not cat[itemName] then cat[itemName] = {} end + local found = false + for _, entry in ipairs(cat[itemName]) do + if entry.chest == chestName then + entry.total = entry.total + delta + found = true + break + end + end + if not found then + table.insert(cat[itemName], { chest = chestName, total = delta }) + end + else + -- Items removed from a chest + if cat[itemName] then + for idx, entry in ipairs(cat[itemName]) do + if entry.chest == chestName then + entry.total = entry.total + delta -- delta is negative + if entry.total <= 0 then + table.remove(cat[itemName], idx) + end + break + end + end + -- Remove item from catalogue entirely if no sources left + if #cat[itemName] == 0 then + cat[itemName] = nil + end + end + end + + -- 2) Mark itemList as needing rebuild (deferred until actually read) + cache.itemListDirty = true + + -- 3) Update grandTotal incrementally + cache.grandTotal = cache.grandTotal + delta + + -- 4) Bump state version for delta broadcasting + bumpStateVersion() +end + +--- Rebuild itemList from catalogue if dirty (lazy rebuild) +local function ensureItemList() + if not cache.itemListDirty then return end + local itemList = {} + local grandTotal = 0 + for name, sources in pairs(cache.catalogue) do + local total = 0 + for _, s in ipairs(sources) do total = total + s.total end + grandTotal = grandTotal + total + table.insert(itemList, { name = name, total = total }) + end + table.sort(itemList, function(a, b) return a.total > b.total end) + cache.itemList = itemList + cache.grandTotal = grandTotal + cache.itemListDirty = false +end + +------------------------------------------------- +-- Inventory helpers (cached peripheral lists) +------------------------------------------------- + +local PERIPHERAL_CACHE_TTL = 5 -- seconds +local cachedChests = nil +local cachedChestsTime = 0 +local cachedFurnaces = nil +local cachedFurnacesTime = 0 + +local function invalidatePeripheralCaches() + cachedChests = nil + cachedFurnaces = nil +end + +-- Peripheral handle cache (avoids re-creating proxy tables on every call) +-- Handles are cleared on peripheral_detach events and during full scans. +local wrapCache = {} + +local function wrapCached(name) + local handle = wrapCache[name] + if handle then return handle end + handle = peripheral.wrap(name) + if handle then + wrapCache[name] = handle + end + return handle +end + +local function invalidateWrapCache(name) + if name then + wrapCache[name] = nil + else + wrapCache = {} + end +end + +local function getChests() + local now = os.clock() + if cachedChests and (now - cachedChestsTime) < PERIPHERAL_CACHE_TTL then + return cachedChests + end + local chests = {} + for _, name in ipairs(peripheral.getNames()) do + if peripheral.getType(name) == "minecraft:chest" then + table.insert(chests, name) + end + end + cachedChests = chests + cachedChestsTime = now + return chests +end + +local function getFurnaces() + local now = os.clock() + if cachedFurnaces and (now - cachedFurnacesTime) < PERIPHERAL_CACHE_TTL then + return cachedFurnaces + end + local furnaces = {} + for _, ftype in ipairs(FURNACE_TYPES) do + for _, name in ipairs(peripheral.getNames()) do + if peripheral.getType(name) == ftype then + table.insert(furnaces, name) + end + end + end + cachedFurnaces = furnaces + cachedFurnacesTime = now + return furnaces +end + +local function refreshFurnaceStatus() + local furnaces = getFurnaces() + local status = {} + for _, fname in ipairs(furnaces) do + local furnace = wrapCached(fname) + if furnace then + local contents = furnace.list() + local entry = { + name = fname, + type = peripheral.getType(fname), + input = contents[SLOT_INPUT] or nil, + fuel = contents[SLOT_FUEL] or nil, + output = contents[SLOT_OUTPUT] or nil, + active = (contents[SLOT_INPUT] ~= nil and contents[SLOT_FUEL] ~= nil), + } + table.insert(status, entry) + end + end + cache.furnaceStatus = status + bumpStateVersion() +end + +local function scanInventory(deviceName) + local inv = wrapCached(deviceName) + if not inv then return {} end + local result = {} + for slot, item in pairs(inv.list()) do + if not result[item.name] then + result[item.name] = { total = 0, slots = {} } + end + result[item.name].total = result[item.name].total + item.count + result[item.name].slots[slot] = { name = item.name, count = item.count } + end + return result +end + +-- Full scan: updates the global cache +-- onProgress(current, total, chestName) is called per chest if provided +local function refreshCache(onProgress) + activity.scanning = true + + -- Clear handle cache so we pick up any changes + invalidateWrapCache() + + -- Single pass over peripherals: classify into chests and furnaces + local chests = {} + local furnaces = {} + local furnaceTypeSet = {} + for _, ft in ipairs(FURNACE_TYPES) do furnaceTypeSet[ft] = true end + + for _, name in ipairs(peripheral.getNames()) do + local ptype = peripheral.getType(name) + if ptype == "minecraft:chest" then + table.insert(chests, name) + elseif furnaceTypeSet[ptype] then + table.insert(furnaces, name) + end + end + + -- Update peripheral caches so getChests()/getFurnaces() stay fresh + local now = os.clock() + cachedChests = chests + cachedChestsTime = now + cachedFurnaces = furnaces + cachedFurnacesTime = now + + local catalogue = {} + local totalSlots = 0 + local usedSlots = 0 + + for ci, chest in ipairs(chests) do + if onProgress then onProgress(ci, #chests, chest) end + local inv = wrapCached(chest) + if inv then + totalSlots = totalSlots + inv.size() + local contents = inv.list() + for slot, item in pairs(contents) do + usedSlots = usedSlots + 1 + if not catalogue[item.name] then + catalogue[item.name] = {} + end + -- Accumulate per-chest totals + local found = false + for _, entry in ipairs(catalogue[item.name]) do + if entry.chest == chest then + entry.total = entry.total + item.count + found = true + break + end + end + if not found then + table.insert(catalogue[item.name], { chest = chest, total = item.count }) + end + end + end + end + + -- Build sorted item list + local itemList = {} + local grandTotal = 0 + for itemName, sources in pairs(catalogue) do + local total = 0 + for _, s in ipairs(sources) do total = total + s.total end + grandTotal = grandTotal + total + table.insert(itemList, { name = itemName, total = total }) + end + table.sort(itemList, function(a, b) return a.total > b.total end) + + -- Update cache atomically + cache.catalogue = catalogue + cache.itemList = itemList + cache.grandTotal = grandTotal + cache.chestCount = #chests + cache.totalSlots = totalSlots + cache.usedSlots = usedSlots + cache.freeSlots = totalSlots - usedSlots + cache.usedRatio = totalSlots > 0 and (usedSlots / totalSlots) or 0 + cache.dropperOk = peripheral.isPresent(DROPPER_NAME) + cache.barrelOk = peripheral.isPresent(BARREL_NAME) + + -- Discover all droppers on the network (for location-based dispensing) + -- Use name-based matching as peripheral.getType() may return "inventory" + -- as the primary type in some CC:Tweaked versions (1.99+) + local droppers = {} + for _, name in ipairs(peripheral.getNames()) do + local isDropper = name:match("^minecraft:dropper_") + if not isDropper and peripheral.hasType then + isDropper = peripheral.hasType(name, "minecraft:dropper") + end + if not isDropper then + isDropper = peripheral.getType(name) == "minecraft:dropper" + end + if isDropper and name ~= COMPOST_DROPPER then + local isDefault = (name == DROPPER_NAME) + table.insert(droppers, { name = name, isDefault = isDefault }) + end + end + -- Sort so default dropper comes first + table.sort(droppers, function(a, b) + if a.isDefault ~= b.isDefault then return a.isDefault end + return a.name < b.name + end) + + -- Merge in droppers registered by remote clients + local seenNames = {} + for _, d in ipairs(droppers) do seenNames[d.name] = true end + for clientId, clientList in pairs(clientDroppers) do + for _, d in ipairs(clientList) do + if not seenNames[d.name] then + table.insert(droppers, { name = d.name, isDefault = false, clientId = clientId }) + seenNames[d.name] = true + end + end + end + + cache.droppers = droppers + + -- Furnace count already computed from single-pass above + cache.furnaceCount = #furnaces + + -- Scan furnace contents for smelter dashboard + refreshFurnaceStatus() + + activity.scanning = false + bumpStateVersion() + + -- Persist cache to disk + pcall(function() + local data = { + catalogue = cache.catalogue, + 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, + savedAt = os.epoch("utc"), + } + local f = fs.open(CACHE_FILE, "w") + f.write(textutils.serialise(data)) + f.close() + end) +end + +-- Load cache from disk (returns true if loaded) +local function loadCacheFromDisk() + if not fs.exists(CACHE_FILE) then return false end + local ok, err = pcall(function() + local f = fs.open(CACHE_FILE, "r") + local raw = f.readAll() + f.close() + local data = textutils.unserialise(raw) + if data and data.catalogue and data.itemList then + cache.catalogue = data.catalogue + cache.itemList = data.itemList + cache.grandTotal = data.grandTotal or 0 + cache.chestCount = data.chestCount or 0 + cache.totalSlots = data.totalSlots or 0 + cache.usedSlots = data.usedSlots or 0 + cache.freeSlots = data.freeSlots or 0 + cache.usedRatio = data.usedRatio or 0 + cache.dropperOk = data.dropperOk or false + cache.barrelOk = data.barrelOk or false + cache.furnaceCount = data.furnaceCount or 0 + cache.furnaceStatus = data.furnaceStatus or {} + cache.droppers = data.droppers or {} + else + error("invalid cache data") + end + end) + if not ok then + log.warn("INIT", "Could not load cache: %s", tostring(err)) + return false + end + return true +end + +------------------------------------------------- +-- Monitor setup +------------------------------------------------- + +local mon = nil +local monName = nil +local smelterMon = nil +local smelterMonName = nil +local networkModem = nil +local networkModemName = nil +local craftTurtleName = nil + +local function setupMonitor() + mon, monName = ui.setupMonitor(MONITOR_SIDE, SMELTER_MONITOR_SIDE) + return mon ~= nil +end + +local function setupSmelterMonitor() + smelterMon, smelterMonName = ui.setupSmelterMonitor(SMELTER_MONITOR_SIDE, monName) + return smelterMon ~= nil +end + +------------------------------------------------- +-- UI State +------------------------------------------------- + +local selectedAmount = 1 +local amountOptions = {1, 4, 8, 16, 32, 64} +local statusMessage = "" +local statusColor = colors.white +local statusTimer = 0 + +local touchZones = {} +local pendingZones = {} +local needsRedraw = true + +local currentPage = 1 +local totalPages = 1 +local searchQuery = "" +local showKeyboard = false + +-- Keyboard layout +local kbRows = { + {"Q","W","E","R","T","Y","U","I","O","P"}, + {"A","S","D","F","G","H","J","K","L"}, + {"Z","X","C","V","B","N","M"}, +} + +------------------------------------------------- +-- Smelter dashboard state +------------------------------------------------- + +local smelterView = "status" -- "status" or "recipes" +local smelterPage = 1 +local smelterTotalPages = 1 +local smelterTouchZones = {} +local smelterPendingZones = {} +local smelterNeedsRedraw = true +local smeltingPaused = false +local disabledRecipes = {} -- { ["minecraft:raw_iron"] = true } + +local function loadDisabledRecipes() + if not fs.exists(DISABLED_RECIPES_FILE) then return end + pcall(function() + local f = fs.open(DISABLED_RECIPES_FILE, "r") + local raw = f.readAll() + f.close() + local data = textutils.unserialise(raw) + if type(data) == "table" then + if data.disabled then disabledRecipes = data.disabled end + if data.paused ~= nil then smeltingPaused = data.paused end + end + end) +end + +local function saveDisabledRecipes() + pcall(function() + local f = fs.open(DISABLED_RECIPES_FILE, "w") + f.write(textutils.serialise({ disabled = disabledRecipes, paused = smeltingPaused })) + f.close() + end) +end + +-- Get items filtered by search query +local function getFilteredItems() + ensureItemList() + return ui.getFilteredItems(cache.itemList, searchQuery) +end + +local function addZone(x1, y1, x2, y2, action, data) + ui.addZone(pendingZones, x1, y1, x2, y2, action, data) +end + +local function hitTest(x, y) + return ui.hitTest(touchZones, x, y) +end + +local function addSmelterZone(x1, y1, x2, y2, action, data) + ui.addZone(smelterPendingZones, x1, y1, x2, y2, action, data) +end + +local function smelterHitTest(x, y) + return ui.hitTest(smelterTouchZones, x, y) +end + +------------------------------------------------- +-- Drawing helpers (delegated to shared ui module) +------------------------------------------------- + +local draw = nil -- set to window target before each draw cycle + +local function setDrawTarget(target) + draw = target + ui.draw = target +end + +local function monWrite(x, y, text, fg, bg) ui.monWrite(x, y, text, fg, bg) end +local function monFill(y, color) ui.monFill(y, color) end +local function monCenter(y, text, fg, bg) ui.monCenter(y, text, fg, bg) end +local function monBar(x, y, w, r, bc, bgc) ui.monBar(x, y, w, r, bc, bgc) end +local function drawButton(x, y, t, fg, bg, pl, pr) return ui.drawButton(x, y, t, fg, bg, pl, pr) end + +------------------------------------------------- +-- Dashboard (reads ONLY from cache — no peripheral calls — instant) +------------------------------------------------- + +local function drawDashboard() + if not mon then return end + + local w, h = mon.getSize() + pendingZones = {} + + -- Offscreen buffer + setDrawTarget(window.create(mon, 1, 1, w, h, false)) + draw.setBackgroundColor(colors.black) + draw.clear() + + -- ===== Title bar ===== + monFill(1, colors.blue) + monCenter(1, " ** INVENTORY MANAGER ** ", colors.white, colors.blue) + + -- ===== Status bar ===== + monFill(2, colors.gray) + local statusParts = {} + table.insert(statusParts, string.format(" Chests: %d", cache.chestCount)) + table.insert(statusParts, cache.dropperOk and "Dropper: OK" or "Dropper: --") + table.insert(statusParts, cache.barrelOk and "Barrel: OK" or "Barrel: --") + if cache.furnaceCount and cache.furnaceCount > 0 then + table.insert(statusParts, string.format("Furnaces: %d", cache.furnaceCount)) + end + + -- Activity indicators + local actParts = {} + if activity.sorting then table.insert(actParts, "SORTING") end + if activity.dispensing then table.insert(actParts, "DISPENSING") end + if activity.smelting then table.insert(actParts, "SMELTING") end + if activity.scanning then table.insert(actParts, "SCANNING") end + if activity.defragging then table.insert(actParts, "DEFRAG") end + if activity.composting then table.insert(actParts, "COMPOST") end + + monWrite(2, 2, table.concat(statusParts, " | "), colors.white, colors.gray) + + if #actParts > 0 then + local actStr = " " .. table.concat(actParts, " | ") .. " " + monWrite(w - #actStr, 2, actStr, colors.white, colors.orange) + end + + -- ===== Divider ===== + monFill(3, colors.lightBlue) + monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.cyan, colors.lightBlue) + + -- ===== Storage capacity ===== + monFill(4, colors.black) + local capLabel = string.format(" Storage: %d/%d slots (%d free)", + cache.usedSlots, cache.totalSlots, cache.freeSlots) + monWrite(2, 4, capLabel, colors.lightGray, colors.black) + + local barStart = #capLabel + 4 + local barWidth = w - barStart - 2 + if barWidth > 4 then + local barColor = colors.lime + if cache.usedRatio > 0.9 then barColor = colors.red + elseif cache.usedRatio > 0.7 then barColor = colors.orange + elseif cache.usedRatio > 0.5 then barColor = colors.yellow + end + monBar(barStart, 4, barWidth, cache.usedRatio, barColor, colors.gray) + local pctStr = string.format(" %d%% ", math.floor(cache.usedRatio * 100)) + local pctX = barStart + math.floor(barWidth / 2) - math.floor(#pctStr / 2) + monWrite(pctX, 4, pctStr, colors.white, barColor) + end + + -- ===== Amount selector (row 5) ===== + monFill(5, colors.black) + monWrite(2, 5, "Qty:", colors.lightGray, colors.black) + local btnX = 7 + for _, amt in ipairs(amountOptions) do + local label = tostring(amt) + local bg = (amt == selectedAmount) and colors.cyan or colors.gray + local fg = (amt == selectedAmount) and colors.white or colors.lightGray + local x1, y1, x2, y2 = drawButton(btnX, 5, label, fg, bg) + addZone(x1, y1, x2, y2, "amount", amt) + btnX = x2 + 2 + end + + -- Refresh button + local refreshBg = activity.scanning and colors.yellow or colors.green + local refreshFg = activity.scanning and colors.black or colors.white + local refreshTxt = activity.scanning and "Scanning" or "Refresh" + local scanX = w - #refreshTxt - 3 + local sx1, sy1, sx2, sy2 = drawButton(scanX, 5, refreshTxt, refreshFg, refreshBg, 1, 1) + addZone(sx1, sy1, sx2, sy2, "scan", nil) + + -- ===== Search bar + Pagination (row 6) ===== + monFill(6, colors.black) + + -- Keyboard toggle button + local kbLabel = showKeyboard and " X " or " ? " + local kbBg = showKeyboard and colors.red or colors.purple + monWrite(2, 6, kbLabel, colors.white, kbBg) + addZone(2, 6, 4, 6, "kb_toggle", nil) + + -- Search query display + local queryDisplay = searchQuery + if showKeyboard then + queryDisplay = queryDisplay .. "|" + elseif queryDisplay == "" then + queryDisplay = "search..." + end + local fieldW = math.floor(w * 0.4) + if fieldW < 10 then fieldW = 10 end + local displayText = queryDisplay:sub(1, fieldW) + displayText = displayText .. string.rep("_", math.max(0, fieldW - #displayText)) + monWrite(6, 6, displayText, + (searchQuery == "" and not showKeyboard) and colors.gray or colors.white, + colors.black) + addZone(6, 6, 5 + fieldW, 6, "kb_toggle", nil) + + -- Filter items + local filteredItems = getFilteredItems() + + -- Pagination + local maxRows = h - 10 + if maxRows < 1 then maxRows = 1 end + totalPages = math.max(1, math.ceil(#filteredItems / maxRows)) + if currentPage > totalPages then currentPage = totalPages end + if currentPage < 1 then currentPage = 1 end + + -- Page controls (right side of row 6) + local pageStr = string.format("Pg %d/%d", currentPage, totalPages) + local navW = 3 + 1 + #pageStr + 1 + 3 + local navX = w - navW + + if currentPage > 1 then + monWrite(navX, 6, " < ", colors.white, colors.gray) + addZone(navX, 6, navX + 2, 6, "page_prev", nil) + else + monWrite(navX, 6, " < ", colors.lightGray, colors.black) + end + + monWrite(navX + 4, 6, pageStr, colors.lightGray, colors.black) + + local nextX = navX + 4 + #pageStr + 1 + if currentPage < totalPages then + monWrite(nextX, 6, " > ", colors.white, colors.gray) + addZone(nextX, 6, nextX + 2, 6, "page_next", nil) + else + monWrite(nextX, 6, " > ", colors.lightGray, colors.black) + end + + -- ===== Column headers (row 7) ===== + local row = 7 + monFill(row, colors.gray) + monWrite(2, row, "#", colors.lightGray, colors.gray) + monWrite(5, row, "Item", colors.lightGray, colors.gray) + monWrite(w - 22, row, "Qty", colors.lightGray, colors.gray) + monWrite(w - 14, row, "Stock", colors.lightGray, colors.gray) + monWrite(w - 1, row, ">", colors.lightGray, colors.gray) + row = row + 1 + + -- ===== Item rows (paginated + filtered) ===== + local maxCount = 0 + for _, item in ipairs(filteredItems) do + if item.total > maxCount then maxCount = item.total end + end + if maxCount == 0 then maxCount = 1 end + + local startIdx = (currentPage - 1) * maxRows + 1 + local endIdx = math.min(startIdx + maxRows - 1, #filteredItems) + + if #filteredItems == 0 then + monFill(8, colors.black) + monFill(9, colors.black) + if searchQuery ~= "" then + monCenter(9, "No items match \"" .. searchQuery .. "\"", colors.gray, colors.black) + else + monCenter(9, "No items in storage", colors.gray, colors.black) + end + row = 10 + else + for i = startIdx, endIdx do + local item = filteredItems[i] + local y = row + local short = item.name:gsub("^minecraft:", ""):gsub("_", " ") + short = short:sub(1,1):upper() .. short:sub(2) + + local maxNameLen = w - 30 + if #short > maxNameLen then + short = short:sub(1, maxNameLen - 2) .. ".." + end + + local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray + monFill(y, rowBg) + + monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg) + monWrite(5, y, short, colors.white, rowBg) + monWrite(w - 22, y, tostring(item.total), colors.yellow, rowBg) + + local ratio = item.total / maxCount + local barColor = colors.lime + if ratio < 0.25 then barColor = colors.red + elseif ratio < 0.5 then barColor = colors.orange + end + monBar(w - 14, y, 12, ratio, barColor, rowBg == colors.gray and colors.lightGray or colors.gray) + + monWrite(w - 1, y, ">", colors.orange, rowBg) + addZone(1, y, w, y, "order", item.name) + + row = row + 1 + end + end + + -- Fill remaining empty item rows + local lastItemRow = h - 3 + while row <= lastItemRow do + monFill(row, colors.black) + row = row + 1 + end + + if showKeyboard then + -- ===== Keyboard overlay (bottom 3 rows: h-2, h-1, h) ===== + local keyW = 3 + local keyGap = 1 + + local kbDefs = { + { keys = kbRows[1], specials = {{ label = " Bksp ", action = "kb_bksp", bg = colors.red }} }, + { keys = kbRows[2], specials = {{ label = " Done ", action = "kb_done", bg = colors.green }} }, + { keys = kbRows[3], specials = { + { label = " Space ", action = "kb_space", bg = colors.lightGray }, + { label = " Clr ", action = "kb_clear", bg = colors.orange }, + }}, + } + + for rowIdx, def in ipairs(kbDefs) do + local y = h - 3 + rowIdx + monFill(y, colors.black) + + -- Calculate total row width for centering + local keysW = #def.keys * keyW + math.max(0, #def.keys - 1) * keyGap + local specialsW = 0 + for _, sp in ipairs(def.specials) do + specialsW = specialsW + keyGap + #sp.label + end + local rowW = keysW + specialsW + local x = math.floor((w - rowW) / 2) + 1 + + -- Draw letter keys + for ki, key in ipairs(def.keys) do + monWrite(x, y, " " .. key .. " ", colors.white, colors.gray) + addZone(x, y, x + keyW - 1, y, "kb_key", key:lower()) + x = x + keyW + if ki < #def.keys then x = x + keyGap end + end + + -- Draw special keys + for _, sp in ipairs(def.specials) do + x = x + keyGap + monWrite(x, y, sp.label, colors.white, sp.bg) + addZone(x, y, x + #sp.label - 1, y, sp.action, nil) + x = x + #sp.label + end + end + else + -- ===== Status message (h-2) ===== + monFill(h - 2, colors.black) + if #activeAlerts > 0 then + -- Show low-stock alerts scrolling through them + local alertIdx = math.floor(os.epoch("utc") / 2000) % #activeAlerts + 1 + local a = activeAlerts[alertIdx] + local alertMsg = string.format(" LOW STOCK: %s (%d/%d) ", a.label, a.current, a.min) + monCenter(h - 2, alertMsg, colors.white, colors.red) + elseif statusTimer > 0 and #statusMessage > 0 then + monCenter(h - 2, statusMessage, statusColor, colors.black) + end + + -- ===== Footer (h-1) ===== + ensureItemList() + monFill(h - 1, colors.gray) + local footerLeft = string.format(" Total: %d items | %d types ", + cache.grandTotal, #cache.itemList) + monWrite(2, h - 1, footerLeft, colors.white, colors.gray) + + if searchQuery ~= "" then + local filterNote = string.format("| Showing %d ", #filteredItems) + monWrite(2 + #footerLeft + 1, h - 1, filterNote, colors.yellow, colors.gray) + end + + local timeStr = textutils.formatTime(os.time(), true) + monWrite(w - #timeStr - 1, h - 1, timeStr, colors.lightGray, colors.gray) + + -- ===== Bottom accent (h) ===== + monFill(h, colors.blue) + local bottomMsg = " Tap item to order " + if activity.dispensing then + bottomMsg = " DISPENSING... " + elseif activity.smelting then + bottomMsg = " SMELTING... " + elseif activity.sorting then + bottomMsg = " SORTING BARREL... " + elseif activity.defragging then + bottomMsg = " DEFRAGMENTING... " + elseif activity.composting then + bottomMsg = " COMPOSTING... " + end + monCenter(h, bottomMsg, colors.lightBlue, colors.blue) + end + + -- Flush to monitor + draw.setVisible(true) + + -- Swap zones + touchZones = pendingZones +end + +------------------------------------------------- +-- Crafting helpers (delegated to shared ui module) +------------------------------------------------- + +--- Get stock total for an item from catalogue +local function getItemTotal(itemName) + local have = 0 + if cache.catalogue[itemName] then + for _, src in ipairs(cache.catalogue[itemName]) do + have = have + src.total + end + end + return have +end + +local function getRecipeIngredients(recipe) + return ui.getRecipeIngredients(recipe) +end + +local function canCraftRecipe(recipe) + return ui.canCraftRecipe(recipe, getItemTotal) +end + +local function maxCraftBatches(recipe) + return ui.maxCraftBatches(recipe, getItemTotal) +end + +local function getMissingIngredients(recipe) + return ui.getMissingIngredients(recipe, getItemTotal) +end + +--- Execute a craft via the networked turtle. +-- Sends a craft_request message via modem with ingredient locations. +-- The turtle pulls items herself, crafts, pushes results back, and replies. +-- Requires the turtle to have a wired modem and run the updated craftingTurtle.lua. +local CRAFT_TIMEOUT = 15 -- seconds to wait for turtle reply + +local function craftItem(recipeIdx) + local recipe = CRAFTABLE[recipeIdx] + if not recipe then + log.error("CRAFT", "Invalid recipe index: %s", tostring(recipeIdx)) + return false, "Invalid recipe" + end + if not craftTurtleName then + log.error("CRAFT", "No turtle detected on network") + return false, "No turtle" + end + if not networkModem then + log.error("CRAFT", "No modem available for craft commands") + return false, "No modem" + end + + -- Verify the turtle is still on the network + if not peripheral.isPresent(craftTurtleName) then + log.error("CRAFT", "Turtle offline: %s", craftTurtleName) + return false, "Turtle offline" + end + + log.info("CRAFT", "Starting craft: %s (turtle: %s)", recipe.output, craftTurtleName) + + activity.crafting = true + needsRedraw = true + smelterNeedsRedraw = true + + local chests = getChests() + + -- Build the slot map: for each grid position that needs an ingredient, + -- find the exact chest and slot where the item is located. + local slotMap = {} -- turtleSlot -> { chestName, chestSlot, itemName, count } + local reservedSlots = {} -- "chestName:slot" -> true (prevent double-booking) + + for gridPos = 1, 9 do + local itemName = recipe.grid[gridPos] + if itemName then + local turtleSlot = GRID_TO_SLOT[gridPos] + local found = false + + if cache.catalogue[itemName] then + for _, source in ipairs(cache.catalogue[itemName]) do + local chest = wrapCached(source.chest) + if chest then + for slot, slotItem in pairs(chest.list()) do + local key = source.chest .. ":" .. slot + if slotItem.name == itemName and not reservedSlots[key] then + slotMap[tostring(turtleSlot)] = { + chestName = source.chest, + chestSlot = slot, + itemName = itemName, + count = 1, + } + reservedSlots[key] = true + found = true + break + end + end + end + if found then break end + end + end + + if not found then + log.error("CRAFT", "Cannot find %s in storage, aborting", itemName) + activity.crafting = false + needsRedraw = true + smelterNeedsRedraw = true + return false, "Missing ingredient: " .. itemName + end + end + end + + -- Send craft request to turtle via modem + local craftMessage = { + type = "craft_request", + recipeIdx = recipeIdx, + output = recipe.output, + slots = slotMap, + returnChests = chests, + } + + log.info("CRAFT", "Sending craft request to turtle on channel %d", CRAFT_CHANNEL) + networkModem.transmit(CRAFT_CHANNEL, CRAFT_REPLY_CHANNEL, craftMessage) + + -- Adjust cache: mark ingredients as "in transit" (removed from chests) + for _, info in pairs(slotMap) do + adjustCache(info.itemName, info.chestName, -info.count) + end + + -- Wait for reply from turtle with timeout + log.info("CRAFT", "Waiting for turtle reply (timeout: %ds)...", CRAFT_TIMEOUT) + local deadline = os.clock() + CRAFT_TIMEOUT + local result = nil + local bufferedMessages = {} -- Buffer ORDER_CHANNEL messages to avoid re-queue bounce + + while os.clock() < deadline do + local timerId = os.startTimer(math.max(0.1, deadline - os.clock())) + local event, p1, p2, p3, p4, p5 = os.pullEvent() + + if event == "modem_message" then + local channel = p2 + local message = p4 + if channel == CRAFT_REPLY_CHANNEL and type(message) == "table" and message.type == "craft_result" then + result = message + break + elseif channel == ORDER_CHANNEL then + -- Buffer for re-queue after loop exits (avoids bounce loop) + table.insert(bufferedMessages, {event, p1, p2, p3, p4, p5}) + end + elseif event == "timer" and p1 == timerId then + -- Timeout tick, loop will check deadline + end + end + + -- Re-queue any buffered ORDER_CHANNEL messages so Task 12 processes them + for _, msg in ipairs(bufferedMessages) do + os.queueEvent(table.unpack(msg)) + end + + activity.crafting = false + needsRedraw = true + smelterNeedsRedraw = true + + if not result then + -- Timeout — turtle didn't respond. Items may be stuck in turtle. + -- We've already adjusted the cache as if ingredients were consumed. + -- A manual scan will reconcile later. + log.error("CRAFT", "TIMEOUT: No reply from turtle within %ds", CRAFT_TIMEOUT) + log.error("CRAFT", "Items may be stuck in turtle. Run a manual scan to reconcile.") + return false, "Turtle timeout" + end + + if result.success then + -- Craft succeeded. The turtle already pushed results back to chests. + -- We need to credit the output items to the cache. + -- The ingredients were already debited above. + -- The turtle reports what it pushed; we credit the output item. + local totalOutput = result.totalOutput or recipe.count + -- Credit output across the chests (we don't know exactly which chest + -- the turtle pushed to, so trigger a targeted rescan) + -- For now, credit to the first available chest as an approximation. + -- The next periodic scan will reconcile. + if result.results then + for _, r in ipairs(result.results) do + -- Find which chest likely received it + for _, ch in ipairs(chests) do + local chest = wrapCached(ch) + if chest then + for slot, slotItem in pairs(chest.list()) do + if slotItem.name == r.name then + -- Found the output in a chest, credit it + adjustCache(r.name, ch, r.count) + goto credited + end + end + end + end + -- If we can't find it, still credit to first chest for cache consistency + if #chests > 0 then + adjustCache(r.name, chests[1], r.count) + end + ::credited:: + end + else + -- Fallback: credit the expected output + if #chests > 0 then + adjustCache(recipe.output, chests[1], totalOutput) + end + end + + local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ") + log.info("CRAFT", "OK: %s x%d", short, totalOutput) + return true + else + -- Craft failed. The turtle returned ingredients to chests. + -- We already debited the cache; now credit them back. + -- The returned items are back in the chests, so re-credit them. + for turtleSlotStr, info in pairs(slotMap) do + -- Re-credit the ingredient (it was returned) + adjustCache(info.itemName, info.chestName, info.count) + end + + local errMsg = result.error or "Craft failed" + log.error("CRAFT", "Failed: %s", errMsg) + return false, errMsg + end +end + +------------------------------------------------- +-- Smelter Dashboard +------------------------------------------------- + +local function drawSmelterDashboard() + if not smelterMon then return end + + local w, h = smelterMon.getSize() + smelterPendingZones = {} + + -- Offscreen buffer + setDrawTarget(window.create(smelterMon, 1, 1, w, h, false)) + draw.setBackgroundColor(colors.black) + draw.clear() + + -- ===== Title bar ===== + monFill(1, colors.purple) + monCenter(1, " ** SMELTER DASHBOARD ** ", colors.white, colors.purple) + + -- ===== Status bar ===== + monFill(2, colors.gray) + local activeCount = 0 + for _, fs in ipairs(cache.furnaceStatus or {}) do + if fs.active then activeCount = activeCount + 1 end + end + local statusStr = string.format(" Furnaces: %d Active: %d", + cache.furnaceCount or 0, activeCount) + monWrite(2, 2, statusStr, colors.white, colors.gray) + + -- Pause/Resume button + local pauseLabel = smeltingPaused and " PAUSED " or " ACTIVE " + local pauseBg = smeltingPaused and colors.red or colors.lime + local pauseFg = smeltingPaused and colors.white or colors.black + monWrite(w - #pauseLabel, 2, pauseLabel, pauseFg, pauseBg) + addSmelterZone(w - #pauseLabel, 2, w - 1, 2, "toggle_pause", nil) + + -- ===== Divider ===== + monFill(3, colors.magenta) + monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.pink, colors.magenta) + + -- ===== Tab row ===== + monFill(4, colors.black) + local tabStatusBg = smelterView == "status" and colors.purple or colors.gray + local tabSmeltBg = smelterView == "smelt" and colors.purple or colors.gray + local tabCraftBg = smelterView == "craft" and colors.purple or colors.gray + local tabMissingBg = smelterView == "missing" and colors.purple or colors.gray + local bx1, by1, bx2, by2 + bx1, by1, bx2, by2 = drawButton(2, 4, "Status", colors.white, tabStatusBg) + addSmelterZone(bx1, by1, bx2, by2, "tab", "status") + + bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Smelt", colors.white, tabSmeltBg) + addSmelterZone(bx1, by1, bx2, by2, "tab", "smelt") + + bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Craft", colors.white, tabCraftBg) + addSmelterZone(bx1, by1, bx2, by2, "tab", "craft") + + bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Missing", colors.white, tabMissingBg) + addSmelterZone(bx1, by1, bx2, by2, "tab", "missing") + + -- Hoisted counter: reused by bottom accent bar to avoid re-scanning recipes + local craftAvailCount = nil + + if smelterView == "status" then + -- ===== Furnace Status View ===== + -- Column headers + monFill(5, colors.gray) + local outCol = math.floor(w * 0.40) + local fuelCol = math.floor(w * 0.65) + local statCol = w - 6 + monWrite(2, 5, "#", colors.lightGray, colors.gray) + monWrite(4, 5, "T", colors.lightGray, colors.gray) + monWrite(6, 5, "Input", colors.lightGray, colors.gray) + monWrite(outCol, 5, "Output", colors.lightGray, colors.gray) + monWrite(fuelCol, 5, "Fuel", colors.lightGray, colors.gray) + monWrite(statCol, 5, "State", colors.lightGray, colors.gray) + + -- Furnace rows + local furnaceList = cache.furnaceStatus or {} + local maxRows = h - 8 + if maxRows < 1 then maxRows = 1 end + smelterTotalPages = math.max(1, math.ceil(#furnaceList / maxRows)) + if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end + if smelterPage < 1 then smelterPage = 1 end + + local startIdx = (smelterPage - 1) * maxRows + 1 + local endIdx = math.min(startIdx + maxRows - 1, #furnaceList) + + local row = 6 + if #furnaceList == 0 then + monFill(7, colors.black) + monCenter(7, "No furnaces found on network", colors.gray, colors.black) + row = 8 + else + for i = startIdx, endIdx do + local fs = furnaceList[i] + local y = row + local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray + monFill(y, rowBg) + + -- Number + monWrite(2, y, string.format("%d", i), colors.lightBlue, rowBg) + + -- Type abbreviation + local typeAbbr = "F" + local typeColor = colors.orange + if fs.type == "minecraft:smoker" then + typeAbbr = "S" + typeColor = colors.green + elseif fs.type == "minecraft:blast_furnace" then + typeAbbr = "B" + typeColor = colors.cyan + end + monWrite(4, y, typeAbbr, typeColor, rowBg) + + -- Input + if fs.input then + local inName = fs.input.name:gsub("^minecraft:", ""):gsub("_", " ") + local maxIn = outCol - 8 + if #inName > maxIn then inName = inName:sub(1, maxIn - 2) .. ".." end + monWrite(6, y, inName, colors.white, rowBg) + monWrite(outCol - 4, y, "x" .. fs.input.count, colors.yellow, rowBg) + else + monWrite(6, y, "(empty)", colors.lightGray, rowBg) + end + + -- Output + if fs.output then + local outName = fs.output.name:gsub("^minecraft:", ""):gsub("_", " ") + local maxOut = fuelCol - outCol - 5 + if #outName > maxOut then outName = outName:sub(1, maxOut - 2) .. ".." end + monWrite(outCol, y, outName, colors.white, rowBg) + monWrite(fuelCol - 4, y, "x" .. fs.output.count, colors.yellow, rowBg) + else + monWrite(outCol, y, "-", colors.lightGray, rowBg) + end + + -- Fuel + if fs.fuel then + local fuelName = fs.fuel.name:gsub("^minecraft:", ""):gsub("_", " ") + local maxFuel = statCol - fuelCol - 4 + if #fuelName > maxFuel then fuelName = fuelName:sub(1, maxFuel - 2) .. ".." end + monWrite(fuelCol, y, fuelName, colors.white, rowBg) + monWrite(statCol - 4, y, "x" .. fs.fuel.count, colors.yellow, rowBg) + else + monWrite(fuelCol, y, "-", colors.lightGray, rowBg) + end + + -- Status indicator + if smeltingPaused then + monWrite(statCol, y, "PAUSE", colors.red, rowBg) + elseif fs.active then + monWrite(statCol, y, " COOK", colors.lime, rowBg) + elseif fs.input and not fs.fuel then + monWrite(statCol, y, "FUEL?", colors.orange, rowBg) + else + monWrite(statCol, y, " IDLE", colors.lightGray, rowBg) + end + + row = row + 1 + end + end + + -- Fill remaining rows + while row <= h - 2 do + monFill(row, colors.black) + row = row + 1 + end + + elseif smelterView == "smelt" then + -- ===== Smelt Recipe Manager View ===== + + -- Build sorted recipe list + local recipeList = {} + for inputName, recipe in pairs(SMELTABLE) do + local short = inputName:gsub("^minecraft:", ""):gsub("_", " ") + short = short:sub(1,1):upper() .. short:sub(2) + local resultShort = recipe.result:gsub("^minecraft:", ""):gsub("_", " ") + resultShort = resultShort:sub(1,1):upper() .. resultShort:sub(2) + local types = "" + if recipe.furnaceSet["minecraft:furnace"] then types = types .. "F" end + if recipe.furnaceSet["minecraft:smoker"] then types = types .. "S" end + if recipe.furnaceSet["minecraft:blast_furnace"] then types = types .. "B" end + local enabled = not disabledRecipes[inputName] + local inStorage = 0 + if cache.catalogue[inputName] then + for _, s in ipairs(cache.catalogue[inputName]) do + inStorage = inStorage + s.total + end + end + table.insert(recipeList, { + inputName = inputName, + inputShort = short, + resultShort = resultShort, + types = types, + enabled = enabled, + inStorage = inStorage, + }) + end + table.sort(recipeList, function(a, b) return a.inputShort < b.inputShort end) + + -- Column positions + local arrowCol = math.floor(w * 0.30) + local typeCol = math.floor(w * 0.60) + local stockCol = math.floor(w * 0.72) + local toggleCol = w - 5 + + -- Column headers + monFill(5, colors.gray) + monWrite(2, 5, "Input", colors.lightGray, colors.gray) + monWrite(arrowCol, 5, "Output", colors.lightGray, colors.gray) + monWrite(typeCol, 5, "Type", colors.lightGray, colors.gray) + monWrite(stockCol, 5, "Stock", colors.lightGray, colors.gray) + monWrite(toggleCol, 5, "On?", colors.lightGray, colors.gray) + + -- Bulk action buttons on tab row + local bulkX = w - 22 + bx1, by1, bx2, by2 = drawButton(bulkX, 4, "All On", colors.white, colors.green) + addSmelterZone(bx1, by1, bx2, by2, "enable_all", nil) + bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "All Off", colors.white, colors.red) + addSmelterZone(bx1, by1, bx2, by2, "disable_all", nil) + + -- Recipe rows + local maxRows = h - 8 + if maxRows < 1 then maxRows = 1 end + smelterTotalPages = math.max(1, math.ceil(#recipeList / maxRows)) + if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end + if smelterPage < 1 then smelterPage = 1 end + + local startIdx = (smelterPage - 1) * maxRows + 1 + local endIdx = math.min(startIdx + maxRows - 1, #recipeList) + + local row = 6 + for i = startIdx, endIdx do + local r = recipeList[i] + local y = row + local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray + monFill(y, rowBg) + + -- Input name + local maxInputLen = arrowCol - 3 + local inputDisplay = r.inputShort + if #inputDisplay > maxInputLen then + inputDisplay = inputDisplay:sub(1, maxInputLen - 2) .. ".." + end + monWrite(2, y, inputDisplay, colors.white, rowBg) + + -- Output + local maxOutLen = typeCol - arrowCol - 2 + local outDisplay = r.resultShort + if #outDisplay > maxOutLen then + outDisplay = outDisplay:sub(1, maxOutLen - 2) .. ".." + end + monWrite(arrowCol, y, outDisplay, colors.lightBlue, rowBg) + + -- Types + monWrite(typeCol, y, r.types, colors.orange, rowBg) + + -- Stock + monWrite(stockCol, y, tostring(r.inStorage), colors.yellow, rowBg) + + -- Toggle button + if r.enabled then + monWrite(toggleCol, y, " ON ", colors.white, colors.green) + else + monWrite(toggleCol, y, " OFF", colors.white, colors.red) + end + addSmelterZone(1, y, w, y, "toggle_recipe", r.inputName) + + row = row + 1 + end + + -- Fill remaining rows + while row <= h - 2 do + monFill(row, colors.black) + row = row + 1 + end + + elseif smelterView == "craft" then + -- ===== Available Crafting Recipes ===== + + -- Turtle status on tab row + local turtleOk = craftTurtleName and peripheral.isPresent(craftTurtleName) + local tLabel = turtleOk and " Turtle OK " or " No Turtle " + local tBg = turtleOk and colors.lime or colors.red + local tFg = turtleOk and colors.black or colors.white + monWrite(w - #tLabel, 4, tLabel, tFg, tBg) + + -- Build list of craftable recipes + local availList = {} + for idx, recipe in ipairs(CRAFTABLE) do + if canCraftRecipe(recipe) then + local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ") + short = short:sub(1,1):upper() .. short:sub(2) + local batches = maxCraftBatches(recipe) + table.insert(availList, { + idx = idx, + short = short, + count = recipe.count, + batches = batches, + }) + end + end + craftAvailCount = #availList + + -- Column headers + monFill(5, colors.gray) + local makeCol = w - 6 + monWrite(2, 5, "#", colors.lightGray, colors.gray) + monWrite(4, 5, "Output", colors.lightGray, colors.gray) + monWrite(math.floor(w * 0.45), 5, "Yield", colors.lightGray, colors.gray) + monWrite(math.floor(w * 0.60), 5, "Can Make", colors.lightGray, colors.gray) + monWrite(makeCol, 5, "Go", colors.lightGray, colors.gray) + + local maxRows = h - 8 + if maxRows < 1 then maxRows = 1 end + smelterTotalPages = math.max(1, math.ceil(#availList / maxRows)) + if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end + if smelterPage < 1 then smelterPage = 1 end + + local startIdx = (smelterPage - 1) * maxRows + 1 + local endIdx = math.min(startIdx + maxRows - 1, #availList) + + local row = 6 + if #availList == 0 then + monFill(7, colors.black) + monCenter(7, "No recipes available to craft", colors.gray, colors.black) + row = 8 + else + for i = startIdx, endIdx do + local r = availList[i] + local y = row + local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray + monFill(y, rowBg) + + monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg) + + local maxNameLen = math.floor(w * 0.40) + local nameDisplay = r.short + if #nameDisplay > maxNameLen then + nameDisplay = nameDisplay:sub(1, maxNameLen - 2) .. ".." + end + monWrite(4, y, nameDisplay, colors.white, rowBg) + + monWrite(math.floor(w * 0.45), y, "x" .. r.count, colors.yellow, rowBg) + monWrite(math.floor(w * 0.60), y, + string.format("x%d", r.batches), colors.lime, rowBg) + + -- MAKE button + if turtleOk then + monWrite(makeCol, y, " MAKE ", colors.white, colors.green) + addSmelterZone(makeCol, y, makeCol + 5, y, "craft", r.idx) + else + monWrite(makeCol, y, " ---- ", colors.gray, colors.black) + end + + row = row + 1 + end + end + + while row <= h - 2 do + monFill(row, colors.black) + row = row + 1 + end + + elseif smelterView == "missing" then + -- ===== Unavailable Crafting Recipes ===== + + -- Build list of recipes that CANNOT be crafted + local missList = {} + for idx, recipe in ipairs(CRAFTABLE) do + if not canCraftRecipe(recipe) then + local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ") + short = short:sub(1,1):upper() .. short:sub(2) + local missing = getMissingIngredients(recipe) + -- Build summary string + local parts = {} + for _, m in ipairs(missing) do + local mShort = m.name:gsub("^minecraft:", ""):gsub("_", " ") + table.insert(parts, string.format("%s %d/%d", mShort, m.have, m.need)) + end + table.insert(missList, { + idx = idx, + short = short, + count = recipe.count, + summary = table.concat(parts, ", "), + }) + end + end + craftAvailCount = #CRAFTABLE - #missList + + -- Column headers + monFill(5, colors.gray) + monWrite(2, 5, "#", colors.lightGray, colors.gray) + monWrite(4, 5, "Output", colors.lightGray, colors.gray) + monWrite(math.floor(w * 0.35), 5, "Missing (have/need)", colors.lightGray, colors.gray) + + local maxRows = h - 8 + if maxRows < 1 then maxRows = 1 end + smelterTotalPages = math.max(1, math.ceil(#missList / maxRows)) + if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end + if smelterPage < 1 then smelterPage = 1 end + + local startIdx = (smelterPage - 1) * maxRows + 1 + local endIdx = math.min(startIdx + maxRows - 1, #missList) + + local row = 6 + if #missList == 0 then + monFill(7, colors.black) + monCenter(7, "All recipes can be crafted!", colors.lime, colors.black) + row = 8 + else + for i = startIdx, endIdx do + local r = missList[i] + local y = row + local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray + monFill(y, rowBg) + + monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg) + + local nameCol = math.floor(w * 0.35) - 5 + local nameDisplay = r.short .. " x" .. r.count + if #nameDisplay > nameCol then + nameDisplay = nameDisplay:sub(1, nameCol - 2) .. ".." + end + monWrite(4, y, nameDisplay, colors.white, rowBg) + + -- Missing items summary + local missCol = math.floor(w * 0.35) + local missW = w - missCol - 1 + local summaryDisplay = r.summary + if #summaryDisplay > missW then + summaryDisplay = summaryDisplay:sub(1, missW - 2) .. ".." + end + monWrite(missCol, y, summaryDisplay, colors.red, rowBg) + + row = row + 1 + end + end + + while row <= h - 2 do + monFill(row, colors.black) + row = row + 1 + end + end + + -- ===== Pagination (h - 1) ===== + monFill(h - 1, colors.gray) + local pageStr = string.format("Pg %d/%d", smelterPage, smelterTotalPages) + monCenter(h - 1, pageStr, colors.white, colors.gray) + + if smelterPage > 1 then + monWrite(2, h - 1, " < ", colors.white, colors.lightGray) + addSmelterZone(2, h - 1, 4, h - 1, "page_prev", nil) + end + if smelterPage < smelterTotalPages then + monWrite(w - 3, h - 1, " > ", colors.white, colors.lightGray) + addSmelterZone(w - 3, h - 1, w - 1, h - 1, "page_next", nil) + end + + -- ===== Bottom accent ===== + monFill(h, colors.purple) + local bottomMsg = "" + if smelterView == "status" or smelterView == "smelt" then + local enabledCount = 0 + local totalRecipes = 0 + for _ in pairs(SMELTABLE) do totalRecipes = totalRecipes + 1 end + for inputName in pairs(SMELTABLE) do + if not disabledRecipes[inputName] then enabledCount = enabledCount + 1 end + end + bottomMsg = string.format(" Smelt: %d/%d enabled ", enabledCount, totalRecipes) + if activity.smelting then bottomMsg = " SMELTING... " end + elseif smelterView == "craft" then + bottomMsg = " Tap MAKE to craft " + if activity.crafting then bottomMsg = " CRAFTING... " end + elseif smelterView == "missing" then + local availC = craftAvailCount or 0 + bottomMsg = string.format(" Available: %d/%d recipes ", availC, #CRAFTABLE) + end + monCenter(h, bottomMsg, colors.pink, colors.purple) + + -- Flush to monitor + draw.setVisible(true) + + -- Swap zones + smelterTouchZones = smelterPendingZones +end + +------------------------------------------------- +-- Barrel auto-sort +------------------------------------------------- + +local function sortBarrel(barrelOverride) + local barrelTarget = (barrelOverride and barrelOverride ~= "") and barrelOverride or BARREL_NAME + local barrel = wrapCached(barrelTarget) + if not barrel then return end + + local contents = barrel.list() + if not contents or not next(contents) then return end + + activity.sorting = true + needsRedraw = true + + local catalogue = cache.catalogue + local chests = getChests() + + for slot, item in pairs(contents) do + local moved = 0 + local triedChests = {} -- dedup: skip chests already tried via catalogue + + if catalogue[item.name] then + for _, entry in ipairs(catalogue[item.name]) do + triedChests[entry.chest] = true + local n = barrel.pushItems(entry.chest, slot) + if n and n > 0 then + moved = moved + n + adjustCache(item.name, entry.chest, n) + needsRedraw = true + smelterNeedsRedraw = true + log.info("SORT", "%s x%d -> %s", item.name, n, entry.chest) + end + if moved >= item.count then break end + end + end + + if moved < item.count then + for _, chest in ipairs(chests) do + if not triedChests[chest] then + local n = barrel.pushItems(chest, slot) + if n and n > 0 then + moved = moved + n + adjustCache(item.name, chest, n) + needsRedraw = true + smelterNeedsRedraw = true + log.info("SORT", "%s x%d -> %s", item.name, n, chest) + end + if moved >= item.count then break end + end + end + end + + if moved < item.count then + log.warn("SORT", "Could not sort %d remaining %s", item.count - moved, item.name) + end + end + + activity.sorting = false + needsRedraw = true +end + +------------------------------------------------- +-- Auto-smelt +------------------------------------------------- + +local function autoSmelt() + if smeltingPaused then return false end + + local furnaces = getFurnaces() + if #furnaces == 0 then return false end + + local chests = getChests() + local catalogue = cache.catalogue + local didWork = false + local emptyInputFurnaces = {} -- collected during steps 1-3, filled in step 5 + + for _, fname in ipairs(furnaces) do + local furnace = wrapCached(fname) + if furnace then + local contents = furnace.list() + + -- 1) Pull finished output (slot 3) back to chests + if contents[SLOT_OUTPUT] then + local outputItem = contents[SLOT_OUTPUT] + local remaining = outputItem.count + for _, chest in ipairs(chests) do + local n = furnace.pushItems(chest, SLOT_OUTPUT) + if n and n > 0 then + adjustCache(outputItem.name, chest, n) + remaining = remaining - n + log.info("SMELT", "Output %s x%d -> %s", + outputItem.name, n, chest) + didWork = true + if remaining <= 0 then break end + end + end + if remaining > 0 then + log.warn("SMELT", "Could not move %d %s from %s output (chests full?)", + remaining, outputItem.name, fname) + end + end + + -- Also check all slots in case output ended up elsewhere + -- Some modded furnaces or CC versions may use different slot indices + for slot, item in pairs(contents) do + if slot ~= SLOT_INPUT and slot ~= SLOT_FUEL and slot ~= SLOT_OUTPUT then + for _, chest in ipairs(chests) do + local n = furnace.pushItems(chest, slot) + if n and n > 0 then + adjustCache(item.name, chest, n) + log.info("SMELT", "Extra slot %d: %s x%d -> %s", + slot, item.name, n, chest) + didWork = true + break + end + end + end + end + + -- Re-read after output pull + contents = furnace.list() + + -- 2) Check for incompatible items in input slot and remove them + local furnaceType = peripheral.getType(fname) + local inputItem = contents[SLOT_INPUT] + if inputItem then + local recipe = SMELTABLE[inputItem.name] + local validHere = recipe and recipe.furnaceSet[furnaceType] or false + if not validHere then + -- This item doesn't belong in this furnace type — pull it out + for _, chest in ipairs(chests) do + local n = furnace.pushItems(chest, SLOT_INPUT) + if n and n > 0 then + adjustCache(inputItem.name, chest, n) + log.info("SMELT", "Removed incompatible %s x%d from %s -> %s", + inputItem.name, n, fname, chest) + didWork = true + break + end + end + -- Re-read after removal + contents = furnace.list() + end + end + + -- 3) Refuel if fuel slot is empty or low + local fuelItem = contents[SLOT_FUEL] + local needFuel = not fuelItem or fuelItem.count < 8 + + if needFuel then + for _, fuel in ipairs(FUEL_LIST) do + if catalogue[fuel.name] then + for _, source in ipairs(catalogue[fuel.name]) do + local chest = wrapCached(source.chest) + if chest then + for slot, slotItem in pairs(chest.list()) do + if slotItem.name == fuel.name then + local toMove = math.min(16, slotItem.count) + local n = chest.pushItems(fname, slot, toMove, SLOT_FUEL) + if n and n > 0 then + adjustCache(fuel.name, source.chest, -n) + log.info("SMELT", "Fuel %s x%d -> %s", + fuel.name, n, fname) + didWork = true + needFuel = false + break + end + end + end + end + if not needFuel then break end + end + end + if not needFuel then break end + end + end + + -- 4) Collect furnaces with empty input for balanced loading below + inputItem = contents[SLOT_INPUT] + if not inputItem then + table.insert(emptyInputFurnaces, { name = fname, type = furnaceType }) + end + end + end + + -- 5) Balanced distribution of smeltable items across all empty furnaces + -- Instead of greedily filling one furnace at a time, we spread items + -- evenly so all compatible furnaces work in parallel. + if #emptyInputFurnaces > 0 then + -- Build a unified, deduplicated candidate list across all empty furnace types + local typesSeen = {} + for _, ef in ipairs(emptyInputFurnaces) do typesSeen[ef.type] = true end + + local allCandidates = {} + local candSeen = {} + for ftype in pairs(typesSeen) do + for _, cand in ipairs(smeltCandidatesByType[ftype] or {}) do + if not candSeen[cand.name] then + candSeen[cand.name] = true + table.insert(allCandidates, cand) + end + end + end + -- Sort: food first, then alphabetical (same priority as before) + table.sort(allCandidates, function(a, b) + if a.food ~= b.food then return a.food end + return a.name < b.name + end) + + local usedFurnaces = {} -- furnaces already assigned an item + + for _, cand in ipairs(allCandidates) do + local itemName = cand.name + if not disabledRecipes[itemName] and catalogue[itemName] then + -- Find all compatible empty furnaces not yet used + local compatFurnaces = {} + for _, ef in ipairs(emptyInputFurnaces) do + if not usedFurnaces[ef.name] and cand.recipe.furnaceSet[ef.type] then + table.insert(compatFurnaces, ef) + end + end + + if #compatFurnaces > 0 then + local totalInStorage = 0 + for _, src in ipairs(catalogue[itemName]) do + totalInStorage = totalInStorage + src.total + end + local available = totalInStorage - SMELT_RESERVE + if available > 0 then + local perFurnace = math.min(64, math.ceil(available / #compatFurnaces)) + + for _, ef in ipairs(compatFurnaces) do + if available <= 0 then break end + local toLoad = math.min(perFurnace, available) + local remaining = toLoad + local loaded = false + + -- Snapshot: adjustCache may remove entries during iteration + local srcSnapshot = { table.unpack(catalogue[itemName]) } + for _, source in ipairs(srcSnapshot) do + if source.total > 0 then + local chest = wrapCached(source.chest) + if chest then + for slot, slotItem in pairs(chest.list()) do + if slotItem.name == itemName then + local toMove = math.min(slotItem.count, remaining) + local n = chest.pushItems(ef.name, slot, toMove, SLOT_INPUT) + if n and n > 0 then + adjustCache(itemName, source.chest, -n) + log.info("SMELT", "Input %s x%d -> %s (balanced %d/furnace)", + itemName, n, ef.name, perFurnace) + didWork = true + remaining = remaining - n + available = available - n + if remaining <= 0 then + loaded = true + break + end + end + end + end + end + end -- source.total > 0 + if loaded then break end + end + + if loaded or remaining < toLoad then + usedFurnaces[ef.name] = true + end + end + end + end + end + end + end + + return didWork +end + +------------------------------------------------- +-- Defrag (consolidate partial stacks) +------------------------------------------------- + +local function defragInventory() + local chests = getChests() + if #chests == 0 then return end + + activity.defragging = true + needsRedraw = true + + -- Build a map: itemName -> list of { chest, slot, count, maxCount } + local itemSlots = {} + for _, chestName in ipairs(chests) do + local inv = wrapCached(chestName) + if inv then + local contents = inv.list() + for slot, item in pairs(contents) do + if not itemSlots[item.name] then + itemSlots[item.name] = {} + end + -- CC:Tweaked: getItemDetail gives maxCount (stack size) + local maxCount = 64 + local ok, detail = pcall(inv.getItemDetail, slot) + if ok and detail and detail.maxCount then + maxCount = detail.maxCount + end + table.insert(itemSlots[item.name], { + chest = chestName, + slot = slot, + count = item.count, + max = maxCount, + }) + end + end + end + + -- For each item, try to merge partial stacks + local totalMerged = 0 + for itemName, slots in pairs(itemSlots) do + -- Sort: smallest stacks first (donors), fullest last (receivers) + table.sort(slots, function(a, b) return a.count < b.count end) + + local i = 1 -- donor (smallest) + local j = #slots -- receiver (largest, has room) + + while i < j do + local donor = slots[i] + local recv = slots[j] + + -- Skip if same slot + if donor.chest == recv.chest and donor.slot == recv.slot then + i = i + 1 + elseif donor.count == 0 then + i = i + 1 + elseif recv.count >= recv.max then + j = j - 1 + else + local space = recv.max - recv.count + local toMove = math.min(donor.count, space) + local donorInv = wrapCached(donor.chest) + if donorInv then + local n = donorInv.pushItems(recv.chest, donor.slot, toMove, recv.slot) + if n and n > 0 then + donor.count = donor.count - n + recv.count = recv.count + n + totalMerged = totalMerged + n + -- Update catalogue for cross-chest moves + if donor.chest ~= recv.chest then + adjustCache(itemName, donor.chest, -n) + adjustCache(itemName, recv.chest, n) + end + end + end + if donor.count <= 0 then i = i + 1 end + if recv.count >= recv.max then j = j - 1 end + end + end + end + + if totalMerged > 0 then + log.info("DEFRAG", "Consolidated %d items", totalMerged) + end + + activity.defragging = false + needsRedraw = true +end + +------------------------------------------------- +-- Auto-compost +------------------------------------------------- + +local function autoCompost() + local catalogue = cache.catalogue + local chests = getChests() + local didWork = false + + -- 1) Pull bone meal from hopper back to chests + local hopper = wrapCached(COMPOST_HOPPER) + if hopper then + local contents = hopper.list() + if contents then + for slot, item in pairs(contents) do + for _, chest in ipairs(chests) do + local n = hopper.pushItems(chest, slot) + if n and n > 0 then + adjustCache(item.name, chest, n) + log.info("COMPOST", "%s x%d -> %s", item.name, n, chest) + didWork = true + break + end + end + end + end + end + + -- 2) Feed compostable items into dropper + local dropper = wrapCached(COMPOST_DROPPER) + if not dropper then return didWork end + + -- Check how much item capacity the dropper has (slots * 64 - current items) + local dropperContents = dropper.list() + local dropperUsedSlots = 0 + local dropperUsedItems = 0 + if dropperContents then + for _, item in pairs(dropperContents) do + dropperUsedSlots = dropperUsedSlots + 1 + dropperUsedItems = dropperUsedItems + item.count + end + end + local dropperSize = dropper.size() + local dropperFreeItems = (dropperSize * 64) - dropperUsedItems + + if dropperFreeItems <= 0 then return didWork end + + for _, itemName in ipairs(COMPOSTABLE) do + if dropperFreeItems <= 0 then break end + if catalogue[itemName] then + -- Count total in storage + local totalInStorage = 0 + for _, src in ipairs(catalogue[itemName]) do + totalInStorage = totalInStorage + src.total + end + + local reserve = COMPOST_TRASH[itemName] and 0 or COMPOST_RESERVE + local available = totalInStorage - reserve + if available > 0 then + local toFeed = math.min(available, dropperFreeItems) + local fed = 0 + -- Snapshot: adjustCache may remove entries during iteration + local srcSnapshot = { table.unpack(catalogue[itemName]) } + for _, source in ipairs(srcSnapshot) do + if source.total > 0 then + local chest = wrapCached(source.chest) + if chest then + for slot, slotItem in pairs(chest.list()) do + if slotItem.name == itemName then + local batch = math.min(slotItem.count, toFeed - fed) + local n = chest.pushItems(COMPOST_DROPPER, slot, batch) + if n and n > 0 then + adjustCache(itemName, source.chest, -n) + fed = fed + n + didWork = true + log.info("COMPOST", "Fed %s x%d -> dropper", + itemName, n) + if fed >= toFeed then break end + end + end + end + end + end -- source.total > 0 + if fed >= toFeed then break end + end + dropperFreeItems = dropperFreeItems - fed + end + end + end + + return didWork +end + +------------------------------------------------- +-- Low-stock alert checker +------------------------------------------------- + +local function checkAlerts() + local alerts = {} + for _, alert in ipairs(LOW_STOCK_ALERTS) do + local total = 0 + if cache.catalogue[alert.name] then + for _, src in ipairs(cache.catalogue[alert.name]) do + total = total + src.total + end + end + if total < alert.min then + table.insert(alerts, { + label = alert.label, + current = total, + min = alert.min, + }) + end + end + activeAlerts = alerts + if #alerts > 0 then + needsRedraw = true + smelterNeedsRedraw = true + end +end + +------------------------------------------------- +-- Order +------------------------------------------------- + +local function orderItem(itemName, amount, dropperOverride) + activity.dispensing = true + needsRedraw = true + + -- Use client-specified dropper if provided, otherwise master's default + local dropperTarget = (dropperOverride and dropperOverride ~= "") and dropperOverride or DROPPER_NAME + + local catalogue = cache.catalogue + + if not catalogue[itemName] then + statusMessage = "Not found: " .. itemName:gsub("^minecraft:", "") + statusColor = colors.red + statusTimer = 5 + activity.dispensing = false + needsRedraw = true + return false + end + + local dropper = wrapCached(dropperTarget) + if not dropper then + statusMessage = "Dropper offline: " .. dropperTarget + statusColor = colors.red + statusTimer = 5 + activity.dispensing = false + needsRedraw = true + return false + end + + local remaining = amount + -- Snapshot: adjustCache may remove entries during iteration + local srcSnapshot = { table.unpack(catalogue[itemName]) } + for _, entry in ipairs(srcSnapshot) do + if entry.total > 0 then + local chest = wrapCached(entry.chest) + if chest then + for slot, slotItem in pairs(chest.list()) do + if slotItem.name == itemName then + local toMove = math.min(remaining, slotItem.count) + local moved = chest.pushItems(dropperTarget, slot, toMove) + if moved and moved > 0 then + remaining = remaining - moved + adjustCache(itemName, entry.chest, -moved) + needsRedraw = true + smelterNeedsRedraw = true + log.info("ORDER", "%s x%d from %s", itemName, moved, entry.chest) + end + if remaining <= 0 then break end + end + end + end + end -- entry.total > 0 + if remaining <= 0 then break end + end + + local sent = amount - remaining + local short = itemName:gsub("^minecraft:", ""):gsub("_", " ") + if sent > 0 then + statusMessage = string.format("Dispensing %s x%d", short, sent) + statusColor = colors.lime + log.info("ORDER", "Ordered %s x%d", short, sent) + else + statusMessage = "Could not order " .. short + statusColor = colors.red + end + statusTimer = 5 + + activity.dispensing = false + needsRedraw = true + return sent > 0 +end + +------------------------------------------------- +-- Touch handler (no peripheral calls — instant) +------------------------------------------------- + +local function handleTouch(x, y) + local action, data = hitTest(x, y) + if not action then + log.debug("TOUCH", "No zone hit") + return + end + + if action == "amount" then + selectedAmount = data + log.debug("UI", "Amount set to %s", data) + needsRedraw = true + + elseif action == "order" then + local itemName = data + if itemName then + local short = itemName:gsub("^minecraft:", ""):gsub("_", " ") + statusMessage = string.format("Ordering %s x%d...", short, selectedAmount) + statusColor = colors.cyan + statusTimer = 10 + activity.dispensing = true + needsRedraw = true + orderItem(itemName, selectedAmount) + end + + elseif action == "scan" then + statusMessage = "Refreshing..." + statusColor = colors.cyan + statusTimer = 3 + needsRedraw = true + log.debug("UI", "Manual refresh") + + elseif action == "kb_toggle" then + showKeyboard = not showKeyboard + log.debug("UI", "Keyboard %s", showKeyboard and "open" or "closed") + needsRedraw = true + + elseif action == "kb_key" then + if #searchQuery < 30 then + searchQuery = searchQuery .. data + end + currentPage = 1 + needsRedraw = true + + elseif action == "kb_bksp" then + if #searchQuery > 0 then + searchQuery = searchQuery:sub(1, -2) + end + currentPage = 1 + needsRedraw = true + + elseif action == "kb_space" then + if #searchQuery < 30 then + searchQuery = searchQuery .. " " + end + currentPage = 1 + needsRedraw = true + + elseif action == "kb_done" then + showKeyboard = false + log.debug("UI", "Keyboard closed") + needsRedraw = true + + elseif action == "kb_clear" then + searchQuery = "" + currentPage = 1 + log.debug("UI", "Search cleared") + needsRedraw = true + + elseif action == "page_prev" then + if currentPage > 1 then + currentPage = currentPage - 1 + log.debug("UI", "Page %d", currentPage) + end + needsRedraw = true + + elseif action == "page_next" then + if currentPage < totalPages then + currentPage = currentPage + 1 + log.debug("UI", "Page %d", currentPage) + end + needsRedraw = true + end +end + +------------------------------------------------- +-- Smelter touch handler +------------------------------------------------- + +local function handleSmelterTouch(x, y) + local action, data = smelterHitTest(x, y) + if not action then return end + + if action == "tab" then + smelterView = data + smelterPage = 1 + log.debug("UI", "Tab: %s", data) + smelterNeedsRedraw = true + + elseif action == "toggle_pause" then + smeltingPaused = not smeltingPaused + log.debug("UI", "Smelting %s", smeltingPaused and "PAUSED" or "RESUMED") + saveDisabledRecipes() + smelterNeedsRedraw = true + needsRedraw = true + + elseif action == "toggle_recipe" then + if disabledRecipes[data] then + disabledRecipes[data] = nil + else + disabledRecipes[data] = true + end + local short = data:gsub("^minecraft:", ""):gsub("_", " ") + log.debug("UI", "Recipe %s: %s", short, disabledRecipes[data] and "OFF" or "ON") + saveDisabledRecipes() + smelterNeedsRedraw = true + + elseif action == "enable_all" then + disabledRecipes = {} + log.debug("UI", "All recipes enabled") + saveDisabledRecipes() + smelterNeedsRedraw = true + + elseif action == "disable_all" then + for inputName in pairs(SMELTABLE) do + disabledRecipes[inputName] = true + end + log.debug("UI", "All recipes disabled") + saveDisabledRecipes() + smelterNeedsRedraw = true + + elseif action == "page_prev" then + if smelterPage > 1 then + smelterPage = smelterPage - 1 + end + smelterNeedsRedraw = true + + elseif action == "page_next" then + if smelterPage < smelterTotalPages then + smelterPage = smelterPage + 1 + end + smelterNeedsRedraw = true + + elseif action == "craft" then + local recipeIdx = data + local recipe = CRAFTABLE[recipeIdx] + if recipe then + local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ") + log.info("CRAFT", "Craft request: %s (#%d)", short, recipeIdx) + local ok, err = craftItem(recipeIdx) + if ok then + statusMessage = "Crafted " .. short .. " x" .. recipe.count + statusColor = colors.lime + else + statusMessage = "Craft failed: " .. (err or "unknown") + statusColor = colors.red + end + statusTimer = 5 + needsRedraw = true + smelterNeedsRedraw = true + end + end +end + +------------------------------------------------- +-- Network broadcast (sends state to client displays) +------------------------------------------------- + +local function broadcastState() + if not networkModem then return end + ensureItemList() + + -- Build dynamic state (always included) + local state = { + type = "state", + stateVersion = 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 = activeAlerts, + smeltingPaused = smeltingPaused, + disabledRecipes = disabledRecipes, + craftTurtleOk = craftTurtleName and peripheral.isPresent(craftTurtleName), + } + + -- Include static config only when dirty (startup, scan, recipe toggles) + if configDirty then + state.smeltable = SMELTABLE + state.craftable = CRAFTABLE + configDirty = false + end + + networkModem.transmit(BROADCAST_CHANNEL, ORDER_CHANNEL, state) + lastBroadcastVersion = stateVersion +end + +------------------------------------------------- +-- Main +------------------------------------------------- + +local function main() + print("=================================") + print(" Inventory Manager v2 (Touch)") + print("=================================") + print("") + + if peripheral.isPresent(DROPPER_NAME) then + log.info("INIT", "Dropper: %s", DROPPER_NAME) + else + log.warn("INIT", "Dropper not found: %s", DROPPER_NAME) + end + + if peripheral.isPresent(BARREL_NAME) then + log.info("INIT", "Barrel: %s", BARREL_NAME) + else + log.warn("INIT", "Barrel not found: %s", BARREL_NAME) + end + + if setupMonitor() then + log.info("INIT", "Monitor: %s", monName or MONITOR_SIDE) + else + log.warn("INIT", "No monitor on %s", MONITOR_SIDE) + end + + if setupSmelterMonitor() then + log.info("INIT", "Smelter monitor: %s", smelterMonName or SMELTER_MONITOR_SIDE) + else + log.warn("INIT", "No smelter monitor on %s", SMELTER_MONITOR_SIDE) + end + + -- Find modem for client communication + for _, name in ipairs(peripheral.getNames()) do + if peripheral.getType(name) == "modem" then + networkModem = peripheral.wrap(name) + networkModemName = name + networkModem.open(ORDER_CHANNEL) + networkModem.open(CRAFT_REPLY_CHANNEL) + networkModem.open(SYSTEM_CHANNEL) + break + end + end + if networkModem then + log.info("INIT", "Network modem: %s", 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 + craftTurtleName = name + break + end + end + if craftTurtleName then + log.info("INIT", "Crafting turtle: %s", craftTurtleName) + else + log.warn("INIT", "No crafting turtle found") + end + + -- Load recipe toggles from disk + loadDisabledRecipes() + if smeltingPaused then + log.info("INIT", "Smelting is PAUSED (toggle on smelter monitor)") + end + local enabledCount = 0 + local totalRecipeCount = 0 + for _ in pairs(SMELTABLE) do totalRecipeCount = totalRecipeCount + 1 end + for k in pairs(SMELTABLE) do + if not disabledRecipes[k] then enabledCount = enabledCount + 1 end + end + log.info("INIT", "%d/%d recipes enabled", enabledCount, totalRecipeCount) + + -- Detect compost peripherals + if peripheral.isPresent(COMPOST_DROPPER) then + log.info("INIT", "Compost dropper: %s", COMPOST_DROPPER) + else + log.warn("INIT", "Compost dropper not found: %s", COMPOST_DROPPER) + end + if peripheral.isPresent(COMPOST_HOPPER) then + log.info("INIT", "Compost hopper: %s", COMPOST_HOPPER) + else + log.warn("INIT", "Compost hopper not found: %s", COMPOST_HOPPER) + end + + log.info("INIT", "Tracking %d low-stock alerts", #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 = 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 progress bar + log.info("INIT", "No cache found. Scanning inventories...") + if mon then + local w, h = mon.getSize() + local buf = window.create(mon, 1, 1, w, h, false) + + local function drawBoot(current, total, chestName) + buf.setBackgroundColor(colors.black) + buf.clear() + + -- Title + 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) + + -- Scanning label + 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) + + -- Chest name + 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) + + -- Progress bar + 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)) + + -- Percentage + count + 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) + + -- Bottom accent + buf.setCursorPos(1, h) + buf.setBackgroundColor(colors.blue) + buf.write(string.rep(" ", w)) + + buf.setVisible(true) + buf.setVisible(false) + end + + refreshCache(drawBoot) + else + refreshCache() + end + log.info("INIT", "Done. Found %d item types.", #cache.itemList) + end + print("") + + parallel.waitForAny( + -- Task 1: Background inventory scanner + function() + -- If we loaded from disk cache, refresh immediately in background + if cacheLoaded then + pcall(refreshCache) + pcall(checkAlerts) + needsRedraw = true + smelterNeedsRedraw = true + log.info("INIT", "Background refresh complete. %d types.", #cache.itemList) + end + while true do + sleep(SCAN_INTERVAL) + pcall(refreshCache) + pcall(checkAlerts) + needsRedraw = true + smelterNeedsRedraw = true + end + end, + + -- Task 2: Barrel auto-sort + function() + while true do + pcall(sortBarrel) + sleep(POLL_INTERVAL) + end + end, + + -- Task 3: Auto-smelt + function() + while true do + local ok, didWork = pcall(autoSmelt) + if ok and didWork then + activity.smelting = true + needsRedraw = true + smelterNeedsRedraw = true + end + activity.smelting = false + -- Update furnace status quickly after smelt cycle + pcall(refreshFurnaceStatus) + needsRedraw = true + smelterNeedsRedraw = true + sleep(SMELT_INTERVAL) + end + end, + + -- Task 4: Defrag (consolidate partial stacks) + function() + sleep(10) -- initial delay to let first scan finish + while true do + activity.defragging = true + needsRedraw = true + pcall(defragInventory) + activity.defragging = false + needsRedraw = true + sleep(DEFRAG_INTERVAL) + end + end, + + -- Task 5: Auto-compost + function() + while true do + activity.composting = true + needsRedraw = true + pcall(autoCompost) + activity.composting = false + needsRedraw = true + pcall(checkAlerts) + sleep(COMPOST_INTERVAL) + end + end, + + -- Task 6: Low-stock alert checker + function() + sleep(5) -- initial delay + pcall(checkAlerts) + needsRedraw = true + while true do + sleep(ALERT_INTERVAL) + pcall(checkAlerts) + needsRedraw = true + end + end, + + -- Task 7: Inventory dashboard redraw (event-driven, checks every 0.1s) + function() + needsRedraw = true + while true do + if needsRedraw then + needsRedraw = false + pcall(drawDashboard) + end + -- Decrement status timer + if statusTimer > 0 then + statusTimer = statusTimer - 0.1 + if statusTimer <= 0 then + statusTimer = 0 + needsRedraw = true + end + end + -- Redraw periodically for alert cycling + if #activeAlerts > 0 then + needsRedraw = true + end + sleep(0.1) + end + end, + + -- Task 8: Smelter dashboard redraw + function() + smelterNeedsRedraw = true + while true do + if smelterNeedsRedraw then + smelterNeedsRedraw = false + pcall(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 smelterMonName and side == smelterMonName then + log.debug("TOUCH", "x=%d y=%d", x, y) + handleSmelterTouch(x, y) + else + log.debug("TOUCH", "x=%d y=%d", x, y) + handleTouch(x, y) + end + end + end, + + -- Task 10: Network state broadcast (skips if nothing changed) + function() + while true do + if stateVersion ~= lastBroadcastVersion then + pcall(broadcastState) + end + sleep(BROADCAST_INTERVAL) + end + end, + + -- Task 11: Peripheral detach handler (invalidates cached handles) + function() + while true do + local event, name = os.pullEvent("peripheral_detach") + if name then + invalidateWrapCache(name) + invalidatePeripheralCaches() + log.info("DETACH", "%s", name) + end + end + end, + + -- Task 12: Network order/command listener + function() + if not networkModem then return end + while true do + local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message") + if channel == ORDER_CHANNEL and type(message) == "table" then + -- Idempotency: skip duplicate commands + 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(orderItem, message.itemName, message.amount, message.dropperName) + if not pok then + log.error("NET", "orderItem crashed: %s", tostring(success)) + success = false + statusMessage = "Order error" + statusColor = colors.red + statusTimer = 5 + needsRedraw = true + end + pcall(function() + networkModem.transmit(replyChannel, ORDER_CHANNEL, { + type = "order_result", + commandId = message.commandId, + success = success, + message = statusMessage, + color = statusColor, + }) + end) + pcall(broadcastState) + elseif message.type == "scan" then + log.info("NET", "Scan request from client") + configDirty = true -- resend full config after scan + pcall(refreshCache) + pcall(checkAlerts) + needsRedraw = true + smelterNeedsRedraw = true + pcall(broadcastState) + elseif message.type == "toggle_pause" then + smeltingPaused = not smeltingPaused + log.info("NET", "Smelting %s", smeltingPaused and "PAUSED" or "RESUMED") + saveDisabledRecipes() + bumpStateVersion() + smelterNeedsRedraw = true + needsRedraw = true + pcall(broadcastState) + elseif message.type == "toggle_recipe" and message.recipe then + if disabledRecipes[message.recipe] then + disabledRecipes[message.recipe] = nil + else + disabledRecipes[message.recipe] = true + end + log.info("NET", "Recipe toggle: %s", message.recipe) + saveDisabledRecipes() + configDirty = true + bumpStateVersion() + smelterNeedsRedraw = true + pcall(broadcastState) + elseif message.type == "enable_all" then + disabledRecipes = {} + log.info("NET", "All recipes enabled") + saveDisabledRecipes() + configDirty = true + bumpStateVersion() + smelterNeedsRedraw = true + pcall(broadcastState) + elseif message.type == "disable_all" then + for inputName in pairs(SMELTABLE) do + disabledRecipes[inputName] = true + end + log.info("NET", "All recipes disabled") + saveDisabledRecipes() + configDirty = true + bumpStateVersion() + smelterNeedsRedraw = true + pcall(broadcastState) + elseif message.type == "sort_barrel" and message.barrelName then + log.info("NET", "Sort barrel: %s", message.barrelName) + pcall(sortBarrel, message.barrelName) + pcall(broadcastState) + elseif message.type == "register_droppers" and message.clientId and message.droppers then + -- Client is announcing its locally-attached droppers + local cid = tostring(message.clientId) + clientDroppers[cid] = message.droppers + log.info("NET", "Client %s registered %d dropper(s)", cid, #message.droppers) + -- Rebuild the merged dropper list immediately + local seenNames = {} + local merged = {} + -- Master droppers first + for _, d in ipairs(cache.droppers or {}) do + if not d.clientId then + table.insert(merged, d) + seenNames[d.name] = true + end + end + -- Then all client droppers + for clientId, clientList in pairs(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 + bumpStateVersion() + pcall(broadcastState) + elseif message.type == "reboot" then + local target = message.target or "all" + log.info("NET", "Reboot command received, target: %s", target) + -- Broadcast reboot to all clients on system channel + networkModem.transmit(SYSTEM_CHANNEL, ORDER_CHANNEL, { + type = "reboot", + target = target, + }) + -- If manager itself is targeted, reboot after a short delay + 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(craftItem, message.recipeIdx) + if not pok then + log.error("NET", "craftItem crashed: %s", tostring(ok)) + err = tostring(ok) + ok = false + end + pcall(function() + networkModem.transmit(replyChannel, ORDER_CHANNEL, { + type = "craft_result", + commandId = message.commandId, + success = ok, + error = err, + }) + end) + smelterNeedsRedraw = true + needsRedraw = true + pcall(broadcastState) + end + end) -- end pcall handler + if not handlerOk then + log.error("NET", "Handler error: %s", tostring(handlerErr)) + end + end -- end idempotency else + end + end + end + ) +end + +main()