-- 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. ------------------------------------------------- -- Configuration ------------------------------------------------- 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" ------------------------------------------------- -- 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 = peripheral.wrap(MONITOR_SIDE) if mon and mon.setTextScale then monName = MONITOR_SIDE else mon = nil end if not mon then for _, name in ipairs(peripheral.getNames()) do if peripheral.getType(name) == "monitor" and name ~= SMELTER_MONITOR_SIDE then mon = peripheral.wrap(name) monName = name break end end end if not mon then return false end mon.setTextScale(0.5) mon.clear() return true end local function setupSmelterMonitor() smelterMon = peripheral.wrap(SMELTER_MONITOR_SIDE) if smelterMon and smelterMon.setTextScale then smelterMonName = SMELTER_MONITOR_SIDE else smelterMon = nil end if not smelterMon then for _, name in ipairs(peripheral.getNames()) do if peripheral.getType(name) == "monitor" and name ~= monName then smelterMon = peripheral.wrap(name) smelterMonName = name break end end end if not smelterMon then return false end smelterMon.setTextScale(0.5) smelterMon.clear() return true end ------------------------------------------------- -- Filtered items helper ------------------------------------------------- local function getFilteredItems() local filtered = {} for _, item in ipairs(cache.itemList) do if searchQuery == "" then table.insert(filtered, item) else local lower = item.name:lower():gsub("minecraft:", ""):gsub("_", " ") if lower:find(searchQuery:lower(), 1, true) then table.insert(filtered, item) end end end return filtered end ------------------------------------------------- -- Touch zone helpers ------------------------------------------------- local function addZone(x1, y1, x2, y2, action, data) table.insert(pendingZones, { x1 = x1, y1 = y1, x2 = x2, y2 = y2, action = action, data = data }) end local function hitTest(x, y) for _, zone in ipairs(touchZones) do if x >= zone.x1 and x <= zone.x2 and y >= zone.y1 and y <= zone.y2 then return zone.action, zone.data end end return nil, nil end ------------------------------------------------- -- Crafting helpers (display-only, no peripheral calls) ------------------------------------------------- local function getRecipeIngredients(recipe) local ingredients = {} for _, item in ipairs(recipe.grid) do if item then ingredients[item] = (ingredients[item] or 0) + 1 end end return ingredients end local function canCraftRecipe(recipe) local ingredients = getRecipeIngredients(recipe) local itemTotals = {} for _, item in ipairs(cache.itemList) do itemTotals[item.name] = item.total end for itemName, needed in pairs(ingredients) do if (itemTotals[itemName] or 0) < needed then return false end end return true end local function maxCraftBatches(recipe) local ingredients = getRecipeIngredients(recipe) local itemTotals = {} for _, item in ipairs(cache.itemList) do itemTotals[item.name] = item.total end local minBatches = math.huge for itemName, needed in pairs(ingredients) do local batches = math.floor((itemTotals[itemName] or 0) / needed) if batches < minBatches then minBatches = batches end end if minBatches == math.huge then return 0 end return minBatches end local function getMissingIngredients(recipe) local ingredients = getRecipeIngredients(recipe) local itemTotals = {} for _, item in ipairs(cache.itemList) do itemTotals[item.name] = item.total end local missing = {} for itemName, needed in pairs(ingredients) do local have = itemTotals[itemName] or 0 if have < needed then table.insert(missing, { name = itemName, have = have, need = needed }) end end return missing end ------------------------------------------------- -- Touch zone helpers ------------------------------------------------- local function addSmelterZone(x1, y1, x2, y2, action, data) table.insert(smelterPendingZones, { x1 = x1, y1 = y1, x2 = x2, y2 = y2, action = action, data = data }) end local function smelterHitTest(x, y) for _, zone in ipairs(smelterTouchZones) do if x >= zone.x1 and x <= zone.x2 and y >= zone.y1 and y <= zone.y2 then return zone.action, zone.data end end return nil, nil end ------------------------------------------------- -- Drawing helpers ------------------------------------------------- local draw = nil local function monWrite(x, y, text, fg, bg) draw.setCursorPos(x, y) if fg then draw.setTextColor(fg) end if bg then draw.setBackgroundColor(bg) end draw.write(text) end local function monFill(y, color) local w, _ = draw.getSize() draw.setCursorPos(1, y) draw.setBackgroundColor(color) draw.write(string.rep(" ", w)) end local function monCenter(y, text, fg, bg) local w, _ = draw.getSize() local x = math.floor((w - #text) / 2) + 1 monWrite(x, y, text, fg, bg) end local function monBar(x, y, width, ratio, barColor, bgColor) local filled = math.floor(ratio * width) draw.setCursorPos(x, y) draw.setBackgroundColor(barColor) draw.write(string.rep(" ", filled)) draw.setBackgroundColor(bgColor) draw.write(string.rep(" ", width - filled)) end local function drawButton(x, y, text, fg, bg, padLeft, padRight) padLeft = padLeft or 1 padRight = padRight or 1 local full = string.rep(" ", padLeft) .. text .. string.rep(" ", padRight) monWrite(x, y, full, fg, bg) return x, y, x + #full - 1, y end ------------------------------------------------- -- Send command to master ------------------------------------------------- local function sendToMaster(message) if not networkModem then return end networkModem.transmit(ORDER_CHANNEL, CLIENT_CHANNEL, message) end ------------------------------------------------- -- Inventory Dashboard (identical to master) ------------------------------------------------- local function drawDashboard() if not mon then return end local w, h = mon.getSize() pendingZones = {} draw = 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 = {} draw = 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 print("[UI] Amount set to " .. 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, }) print(string.format("[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" }) print("[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" }) print("[SMELT-UI] Toggle pause sent to master") smelterNeedsRedraw = true elseif action == "toggle_recipe" then sendToMaster({ type = "toggle_recipe", recipe = data }) print("[SMELT-UI] Toggle recipe sent: " .. data) smelterNeedsRedraw = true elseif action == "enable_all" then sendToMaster({ type = "enable_all" }) print("[SMELT-UI] Enable all sent to master") smelterNeedsRedraw = true elseif action == "disable_all" then sendToMaster({ type = "disable_all" }) print("[SMELT-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("_", " ") print("[CRAFT-UI] Craft request sent: " .. short) end smelterNeedsRedraw = true end end ------------------------------------------------- -- Main ------------------------------------------------- local function main() print("=================================") print(" Inventory Client (Display Only)") print("=================================") print("") if setupMonitor() then print("[OK] Monitor: " .. (monName or MONITOR_SIDE)) else print("[WARN] No monitor on " .. MONITOR_SIDE) end if setupSmelterMonitor() then print("[OK] Smelter monitor: " .. (smelterMonName or SMELTER_MONITOR_SIDE)) else print("[WARN] No smelter monitor on " .. 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) print("[OK] Modem: " .. name) break end end if not networkModem then print("[ERR] 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 or cache.grandTotal cache.chestCount = message.cache.chestCount or cache.chestCount cache.totalSlots = message.cache.totalSlots or cache.totalSlots cache.usedSlots = message.cache.usedSlots or cache.usedSlots cache.freeSlots = message.cache.freeSlots or cache.freeSlots cache.usedRatio = message.cache.usedRatio or cache.usedRatio cache.dropperOk = message.cache.dropperOk cache.barrelOk = message.cache.barrelOk cache.furnaceCount = 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 print("[OK] 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 print("[OK] " .. statusMessage) else print("[WARN] " .. statusMessage) end elseif channel == CLIENT_CHANNEL and type(message) == "table" and message.type == "craft_result" then -- Craft result from master statusMessage = message.message or "" statusColor = message.success and colors.lime or colors.red statusTimer = 5 smelterNeedsRedraw = true if message.success then print("[OK] " .. statusMessage) else print("[WARN] " .. 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: Client barrel auto-sort (sends to master only when barrel has items) function() if CLIENT_BARREL_NAME == "" then return end print("[OK] Client barrel: " .. 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 5: Touch event listener (both monitors) function() while true do local event, side, x, y = os.pullEvent("monitor_touch") if smelterMonName and side == smelterMonName then print(string.format("[SMELT-TOUCH] x=%d y=%d", x, y)) handleSmelterTouch(x, y) else print(string.format("[TOUCH] x=%d y=%d", x, y)) handleTouch(x, y) end end end ) end main()