-- 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()