diff --git a/inventoryClient.lua b/inventoryClient.lua new file mode 100644 index 0000000..751a694 --- /dev/null +++ b/inventoryClient.lua @@ -0,0 +1,1128 @@ +-- 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" + +------------------------------------------------- +-- 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, +} + +local activeAlerts = {} +local smeltingPaused = false +local disabledRecipes = {} +local SMELTABLE = {} -- populated from master broadcast +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 + +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 tabRecipesBg = smelterView == "recipes" 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, "Recipes", colors.white, tabRecipesBg) + addSmelterZone(bx1, by1, bx2, by2, "tab", "recipes") + + 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 + + else + -- ===== 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 + 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 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 + local bottomMsg = string.format(" Recipes: %d/%d enabled ", enabledCount, totalRecipes) + if activity.smelting then bottomMsg = " SMELTING... " 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, + }) + 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 + 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 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 + 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: 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()