-- Inventory Client: Display-only dashboard -- Connects to the master inventoryManager via wired modem. -- Shows the same dashboard but runs NO automation tasks. -- Orders and smelter controls are sent to the master. ------------------------------------------------- -- Default configuration (overridden by .client_config) ------------------------------------------------- local BROADCAST_CHANNEL = 4200 -- master sends state on this channel local ORDER_CHANNEL = 4201 -- master listens for commands here local CLIENT_CHANNEL = 4202 -- client listens for replies here local MONITOR_SIDE = "left" local SMELTER_MONITOR_SIDE = "top" -- Remote station: set these to use a different dropper/barrel than the master. -- Leave empty ("") to use the master's default dropper/barrel. local CLIENT_DROPPER_NAME = "" -- e.g. "minecraft:dropper_5" local CLIENT_BARREL_NAME = "" -- e.g. "minecraft:barrel_3" local DROPPER_ANNOUNCE_INTERVAL = 30 -- seconds between dropper announcements ------------------------------------------------- -- Load config from file if present ------------------------------------------------- local _baseDir = fs.getDir(shell.getRunningProgram()) local function _path(rel) return fs.combine(_baseDir, rel) end local CLIENT_CONFIG_FILE = _path(".client_config") ------------------------------------------------- -- Shared UI helpers (loaded early for config use) ------------------------------------------------- local ui = dofile(_path("lib/ui.lua")) local log = dofile(_path("lib/log.lua")) local function loadConfig() if not fs.exists(CLIENT_CONFIG_FILE) then return end local f = fs.open(CLIENT_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", CLIENT_CONFIG_FILE) return end -- Channels if cfg.broadcastChannel then BROADCAST_CHANNEL = cfg.broadcastChannel end if cfg.orderChannel then ORDER_CHANNEL = cfg.orderChannel end if cfg.clientChannel then CLIENT_CHANNEL = cfg.clientChannel end -- Peripherals if cfg.monitorSide then MONITOR_SIDE = cfg.monitorSide end if cfg.smelterMonitorSide then SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end if cfg.dropperName then CLIENT_DROPPER_NAME = cfg.dropperName end if cfg.barrelName then CLIENT_BARREL_NAME = cfg.barrelName end -- Timing if cfg.dropperAnnounceInterval then DROPPER_ANNOUNCE_INTERVAL = cfg.dropperAnnounceInterval end if cfg.logLevel then log.setLevel(cfg.logLevel) end log.info("CONFIG", "Loaded from %s", CLIENT_CONFIG_FILE) end loadConfig() ------------------------------------------------- -- State (received from master) ------------------------------------------------- local cache = { itemList = {}, grandTotal = 0, chestCount = 0, totalSlots = 0, usedSlots = 0, freeSlots = 0, usedRatio = 0, dropperOk = false, barrelOk = false, furnaceCount = 0, furnaceStatus = {}, } local activity = { sorting = false, dispensing = false, scanning = false, smelting = false, defragging = false, composting = false, crafting = false, } local activeAlerts = {} local smeltingPaused = false local disabledRecipes = {} local SMELTABLE = {} -- populated from master broadcast local CRAFTABLE = {} -- populated from master broadcast local craftTurtleOk = false local connected = false -- true once first state received ------------------------------------------------- -- UI State (local to client) ------------------------------------------------- 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 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" local smelterPage = 1 local smelterTotalPages = 1 local smelterTouchZones = {} local smelterPendingZones = {} local smelterNeedsRedraw = true ------------------------------------------------- -- Monitor setup ------------------------------------------------- local mon = nil local monName = nil local smelterMon = nil local smelterMonName = nil local networkModem = 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 ------------------------------------------------- -- Filtered items helper ------------------------------------------------- local function getFilteredItems() return ui.getFilteredItems(cache.itemList, searchQuery) end ------------------------------------------------- -- Touch zone helpers ------------------------------------------------- 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 ------------------------------------------------- -- Crafting helpers (delegated to shared ui module) ------------------------------------------------- --- Get stock total for an item from itemList local function getItemTotal(itemName) for _, item in ipairs(cache.itemList) do if item.name == itemName then return item.total end end return 0 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 ------------------------------------------------- -- Smelter touch zone helpers ------------------------------------------------- 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 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 ------------------------------------------------- -- Command ID generation (idempotency) ------------------------------------------------- local cmdCounter = 0 local function newCommandId() cmdCounter = cmdCounter + 1 return string.format("client_%d_%d_%d", os.getComputerID(), os.epoch("utc"), cmdCounter) end ------------------------------------------------- -- Send command to master ------------------------------------------------- local function sendToMaster(message) if not networkModem then return end if not message.commandId then message.commandId = newCommandId() end networkModem.transmit(ORDER_CHANNEL, CLIENT_CHANNEL, message) end ------------------------------------------------- -- Client dropper discovery ------------------------------------------------- local function discoverLocalDroppers() local droppers = {} -- Check configured dropper first if CLIENT_DROPPER_NAME ~= "" then local exists = peripheral.wrap(CLIENT_DROPPER_NAME) ~= nil if exists then table.insert(droppers, { name = CLIENT_DROPPER_NAME, isDefault = false, clientId = os.getComputerID() }) end end -- Also discover any other droppers on client's local network 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 then -- Avoid duplicates with configured dropper local isDuplicate = false for _, d in ipairs(droppers) do if d.name == name then isDuplicate = true; break end end if not isDuplicate then table.insert(droppers, { name = name, isDefault = false, clientId = os.getComputerID() }) end end end return droppers end local function announceDroppers() local droppers = discoverLocalDroppers() if #droppers > 0 then sendToMaster({ type = "register_droppers", clientId = os.getComputerID(), droppers = droppers, }) end end ------------------------------------------------- -- Inventory Dashboard (identical to master) ------------------------------------------------- local function drawDashboard() if not mon then return end local w, h = mon.getSize() pendingZones = {} setDrawTarget(window.create(mon, 1, 1, w, h, false)) draw.setBackgroundColor(colors.black) draw.clear() if not connected then monFill(1, colors.blue) monCenter(1, " ** INVENTORY CLIENT ** ", colors.white, colors.blue) local midY = math.floor(h / 2) monCenter(midY - 1, "Waiting for master...", colors.lightGray, colors.black) monCenter(midY + 1, "Make sure the master is running", colors.gray, colors.black) monCenter(midY + 2, "and connected via wired modem.", colors.gray, colors.black) monFill(h, colors.blue) draw.setVisible(true) touchZones = pendingZones return end -- ===== 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 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 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) 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) 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) local filteredItems = getFilteredItems() 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 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 ===== 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 local lastItemRow = h - 3 while row <= lastItemRow do monFill(row, colors.black) row = row + 1 end if showKeyboard then 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) 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 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 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 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) ===== 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 draw.setVisible(true) touchZones = pendingZones end ------------------------------------------------- -- Smelter Dashboard (identical to master) ------------------------------------------------- local function drawSmelterDashboard() if not smelterMon then return end local w, h = smelterMon.getSize() smelterPendingZones = {} setDrawTarget(window.create(smelterMon, 1, 1, w, h, false)) draw.setBackgroundColor(colors.black) draw.clear() if not connected then monFill(1, colors.purple) monCenter(1, " ** SMELTER CLIENT ** ", colors.white, colors.purple) monCenter(math.floor(h / 2), "Waiting for master...", colors.gray, colors.black) monFill(h, colors.purple) draw.setVisible(true) smelterTouchZones = smelterPendingZones return end -- ===== 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) 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") if smelterView == "status" then -- ===== Furnace Status View ===== 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) 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) monWrite(2, y, string.format("%d", i), colors.lightBlue, rowBg) 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) 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 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 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 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 while row <= h - 2 do monFill(row, colors.black) row = row + 1 end elseif smelterView == "smelt" then -- ===== Smelt Recipe Manager View ===== -- Build item totals lookup from itemList local itemTotals = {} for _, item in ipairs(cache.itemList) do itemTotals[item.name] = item.total end 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 = "" for _, ft in ipairs(recipe.furnaces) do if ft == "minecraft:furnace" then types = types .. "F" elseif ft == "minecraft:smoker" then types = types .. "S" elseif ft == "minecraft:blast_furnace" then types = types .. "B" end end local enabled = not disabledRecipes[inputName] local inStorage = itemTotals[inputName] or 0 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) 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 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) 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) 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) 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) 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) monWrite(typeCol, y, r.types, colors.orange, rowBg) monWrite(stockCol, y, tostring(r.inStorage), colors.yellow, rowBg) 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 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 tLabel = craftTurtleOk and " Turtle OK " or " No Turtle " local tBg = craftTurtleOk and colors.lime or colors.red local tFg = craftTurtleOk and colors.black or colors.white monWrite(w - #tLabel, 4, tLabel, tFg, tBg) 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 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) if craftTurtleOk 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 ===== 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) 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 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) 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 totalC = #CRAFTABLE local availC = 0 for _, r in ipairs(CRAFTABLE) do if canCraftRecipe(r) then availC = availC + 1 end end bottomMsg = string.format(" Available: %d/%d recipes ", availC, totalC) end monCenter(h, bottomMsg, colors.pink, colors.purple) draw.setVisible(true) smelterTouchZones = smelterPendingZones end ------------------------------------------------- -- Touch handler (sends orders to master via modem) ------------------------------------------------- local function handleTouch(x, y) local action, data = hitTest(x, y) if not action then 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 needsRedraw = true -- Send order to master sendToMaster({ type = "order", itemName = itemName, amount = selectedAmount, dropperName = CLIENT_DROPPER_NAME ~= "" and CLIENT_DROPPER_NAME or nil, }) log.info("ORDER", "Sent to master: %s x%d", itemName, selectedAmount) end elseif action == "scan" then statusMessage = "Requesting refresh..." statusColor = colors.cyan statusTimer = 3 needsRedraw = true sendToMaster({ type = "scan" }) log.debug("UI", "Scan request sent to master") elseif action == "kb_toggle" then showKeyboard = not showKeyboard 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 needsRedraw = true elseif action == "kb_clear" then searchQuery = "" currentPage = 1 needsRedraw = true elseif action == "page_prev" then if currentPage > 1 then currentPage = currentPage - 1 end needsRedraw = true elseif action == "page_next" then if currentPage < totalPages then currentPage = currentPage + 1 end needsRedraw = true end end ------------------------------------------------- -- Smelter touch handler (sends commands to master) ------------------------------------------------- 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 smelterNeedsRedraw = true elseif action == "toggle_pause" then sendToMaster({ type = "toggle_pause" }) log.debug("UI", "Toggle pause sent to master") smelterNeedsRedraw = true elseif action == "toggle_recipe" then sendToMaster({ type = "toggle_recipe", recipe = data }) log.debug("UI", "Toggle recipe sent: %s", data) smelterNeedsRedraw = true elseif action == "enable_all" then sendToMaster({ type = "enable_all" }) log.debug("UI", "Enable all sent to master") smelterNeedsRedraw = true elseif action == "disable_all" then sendToMaster({ type = "disable_all" }) log.debug("UI", "Disable all sent to master") 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 sendToMaster({ type = "craft", recipeIdx = data }) local recipe = CRAFTABLE[data] if recipe then local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ") log.info("CRAFT", "Craft request sent: %s", short) end smelterNeedsRedraw = true end end ------------------------------------------------- -- Main ------------------------------------------------- local function main() print("=================================") print(" Inventory Client (Display Only)") print("=================================") print("") 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 _, name in ipairs(peripheral.getNames()) do if peripheral.getType(name) == "modem" then networkModem = peripheral.wrap(name) networkModem.open(BROADCAST_CHANNEL) networkModem.open(CLIENT_CHANNEL) log.info("INIT", "Modem: %s", name) break end end if not networkModem then log.error("INIT", "No modem found! Cannot receive data from master.") print(" Attach a wired modem and restart.") return end print("") print("Listening for master broadcasts on channel " .. BROADCAST_CHANNEL) print("Console shows log. Use the monitors to interact.") print("") -- Draw initial "waiting" screen needsRedraw = true smelterNeedsRedraw = true parallel.waitForAny( -- Task 1: Modem receiver (state updates + order replies) function() while true do local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message") if channel == BROADCAST_CHANNEL and type(message) == "table" and message.type == "state" then -- Update all state from master if message.cache then cache.itemList = message.cache.itemList or cache.itemList cache.grandTotal = message.cache.grandTotal ~= nil and message.cache.grandTotal or cache.grandTotal cache.chestCount = message.cache.chestCount ~= nil and message.cache.chestCount or cache.chestCount cache.totalSlots = message.cache.totalSlots ~= nil and message.cache.totalSlots or cache.totalSlots cache.usedSlots = message.cache.usedSlots ~= nil and message.cache.usedSlots or cache.usedSlots cache.freeSlots = message.cache.freeSlots ~= nil and message.cache.freeSlots or cache.freeSlots cache.usedRatio = message.cache.usedRatio ~= nil and message.cache.usedRatio or cache.usedRatio cache.dropperOk = message.cache.dropperOk cache.barrelOk = message.cache.barrelOk cache.furnaceCount = message.cache.furnaceCount ~= nil and message.cache.furnaceCount or cache.furnaceCount cache.furnaceStatus = message.cache.furnaceStatus or cache.furnaceStatus end if message.activity then activity = message.activity end if message.alerts then activeAlerts = message.alerts end if message.smeltingPaused ~= nil then smeltingPaused = message.smeltingPaused end if message.disabledRecipes then disabledRecipes = message.disabledRecipes end if message.smeltable then SMELTABLE = message.smeltable end if message.craftable then CRAFTABLE = message.craftable end if message.craftTurtleOk ~= nil then craftTurtleOk = message.craftTurtleOk end if not connected then connected = true log.info("NET", "Connected to master!") end needsRedraw = true smelterNeedsRedraw = true elseif channel == CLIENT_CHANNEL and type(message) == "table" and message.type == "order_result" then -- Order result from master statusMessage = message.message or "" statusColor = message.color or (message.success and colors.lime or colors.red) statusTimer = 5 needsRedraw = true if message.success then log.info("ORDER", "%s", statusMessage) else log.warn("ORDER", "%s", statusMessage) end elseif channel == CLIENT_CHANNEL and type(message) == "table" and message.type == "craft_result" then -- Craft result from master statusMessage = message.error or (message.success and "Craft complete" or "Craft failed") statusColor = message.success and colors.lime or colors.red statusTimer = 5 smelterNeedsRedraw = true if message.success then log.info("CRAFT", "%s", statusMessage) else log.warn("CRAFT", "%s", statusMessage) end end end end, -- Task 2: Inventory dashboard redraw function() needsRedraw = true while true do if needsRedraw then needsRedraw = false pcall(drawDashboard) end if statusTimer > 0 then statusTimer = statusTimer - 0.1 if statusTimer <= 0 then statusTimer = 0 needsRedraw = true end end if #activeAlerts > 0 then needsRedraw = true end sleep(0.1) end end, -- Task 3: Smelter dashboard redraw function() smelterNeedsRedraw = true while true do if smelterNeedsRedraw then smelterNeedsRedraw = false pcall(drawSmelterDashboard) end sleep(0.1) end end, -- Task 4: Announce local droppers to master periodically function() -- Initial announcement after short delay sleep(3) pcall(announceDroppers) while true do sleep(DROPPER_ANNOUNCE_INTERVAL) pcall(announceDroppers) end end, -- Task 5: Client barrel auto-sort (sends to master only when barrel has items) function() if CLIENT_BARREL_NAME == "" then return end log.info("INIT", "Client barrel: %s", CLIENT_BARREL_NAME) while true do local barrel = peripheral.wrap(CLIENT_BARREL_NAME) if barrel then local contents = barrel.list() if contents and next(contents) then sendToMaster({ type = "sort_barrel", barrelName = CLIENT_BARREL_NAME }) end end sleep(2) end end, -- Task 6: 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 ) end main()