Files
Inventory-Manager-CC/inventoryClient.lua
MayaTheShy 32ae5611e7 Add inventoryClient.lua for display-only dashboard interface
- Implemented a client-side dashboard that connects to the master inventoryManager via wired modem.
- The dashboard displays inventory status without performing any automation tasks.
- Added configuration for communication channels and monitor setup.
- Created UI elements for displaying item lists, status messages, and touch interactions.
- Included functionality for handling touch events and sending commands to the master.
- Integrated smelter dashboard with similar display capabilities.
2026-03-15 23:55:18 -04:00

1129 lines
40 KiB
Lua

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