Files
Inventory-Manager-CC/inventoryClient.lua

1402 lines
51 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.
-------------------------------------------------
-- Default configuration (overridden by .client_config)
-------------------------------------------------
local BROADCAST_CHANNEL = 4200 -- master sends state on this channel
local ORDER_CHANNEL = 4201 -- master listens for commands here
local CLIENT_CHANNEL = 4202 -- client listens for replies here
local MONITOR_SIDE = "left"
local SMELTER_MONITOR_SIDE = "top"
-- Remote station: set these to use a different dropper/barrel than the master.
-- Leave empty ("") to use the master's default dropper/barrel.
local CLIENT_DROPPER_NAME = "" -- e.g. "minecraft:dropper_5"
local CLIENT_BARREL_NAME = "" -- e.g. "minecraft:barrel_3"
local DROPPER_ANNOUNCE_INTERVAL = 30 -- seconds between dropper announcements
-------------------------------------------------
-- Load config from file if present
-------------------------------------------------
local CLIENT_CONFIG_FILE = ".client_config"
local function loadConfig()
if not fs.exists(CLIENT_CONFIG_FILE) then return end
local f = fs.open(CLIENT_CONFIG_FILE, "r")
local data = f.readAll()
f.close()
local ok, cfg = pcall(textutils.unserialiseJSON, data)
if not ok or not cfg then
print("[WARN] Failed to parse " .. CLIENT_CONFIG_FILE)
return
end
-- Channels
if cfg.broadcastChannel then BROADCAST_CHANNEL = cfg.broadcastChannel end
if cfg.orderChannel then ORDER_CHANNEL = cfg.orderChannel end
if cfg.clientChannel then CLIENT_CHANNEL = cfg.clientChannel end
-- Peripherals
if cfg.monitorSide then MONITOR_SIDE = cfg.monitorSide end
if cfg.smelterMonitorSide then SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end
if cfg.dropperName then CLIENT_DROPPER_NAME = cfg.dropperName end
if cfg.barrelName then CLIENT_BARREL_NAME = cfg.barrelName end
-- Timing
if cfg.dropperAnnounceInterval then DROPPER_ANNOUNCE_INTERVAL = cfg.dropperAnnounceInterval end
print("[CONFIG] Loaded from " .. CLIENT_CONFIG_FILE)
end
loadConfig()
-------------------------------------------------
-- State (received from master)
-------------------------------------------------
local cache = {
itemList = {},
grandTotal = 0,
chestCount = 0,
totalSlots = 0,
usedSlots = 0,
freeSlots = 0,
usedRatio = 0,
dropperOk = false,
barrelOk = false,
furnaceCount = 0,
furnaceStatus = {},
}
local activity = {
sorting = false,
dispensing = false,
scanning = false,
smelting = false,
defragging = false,
composting = false,
crafting = false,
}
local activeAlerts = {}
local smeltingPaused = false
local disabledRecipes = {}
local SMELTABLE = {} -- populated from master broadcast
local CRAFTABLE = {} -- populated from master broadcast
local craftTurtleOk = false
local connected = false -- true once first state received
-------------------------------------------------
-- Shared UI helpers
-------------------------------------------------
local ui = dofile("lib/ui.lua")
-------------------------------------------------
-- UI State (local to client)
-------------------------------------------------
local selectedAmount = 1
local amountOptions = {1, 4, 8, 16, 32, 64}
local statusMessage = ""
local statusColor = colors.white
local statusTimer = 0
local touchZones = {}
local pendingZones = {}
local needsRedraw = true
local currentPage = 1
local totalPages = 1
local searchQuery = ""
local showKeyboard = false
local kbRows = {
{"Q","W","E","R","T","Y","U","I","O","P"},
{"A","S","D","F","G","H","J","K","L"},
{"Z","X","C","V","B","N","M"},
}
-------------------------------------------------
-- Smelter dashboard state
-------------------------------------------------
local smelterView = "status"
local smelterPage = 1
local smelterTotalPages = 1
local smelterTouchZones = {}
local smelterPendingZones = {}
local smelterNeedsRedraw = true
-------------------------------------------------
-- Monitor setup
-------------------------------------------------
local mon = nil
local monName = nil
local smelterMon = nil
local smelterMonName = nil
local networkModem = nil
local function setupMonitor()
mon, monName = ui.setupMonitor(MONITOR_SIDE, SMELTER_MONITOR_SIDE)
return mon ~= nil
end
local function setupSmelterMonitor()
smelterMon, smelterMonName = ui.setupSmelterMonitor(SMELTER_MONITOR_SIDE, monName)
return smelterMon ~= nil
end
-------------------------------------------------
-- Filtered items helper
-------------------------------------------------
local function getFilteredItems()
return ui.getFilteredItems(cache.itemList, searchQuery)
end
-------------------------------------------------
-- Touch zone helpers
-------------------------------------------------
local function addZone(x1, y1, x2, y2, action, data)
ui.addZone(pendingZones, x1, y1, x2, y2, action, data)
end
local function hitTest(x, y)
return ui.hitTest(touchZones, x, y)
end
-------------------------------------------------
-- Crafting helpers (delegated to shared ui module)
-------------------------------------------------
--- Get stock total for an item from itemList
local function getItemTotal(itemName)
for _, item in ipairs(cache.itemList) do
if item.name == itemName then return item.total end
end
return 0
end
local function getRecipeIngredients(recipe)
return ui.getRecipeIngredients(recipe)
end
local function canCraftRecipe(recipe)
return ui.canCraftRecipe(recipe, getItemTotal)
end
local function maxCraftBatches(recipe)
return ui.maxCraftBatches(recipe, getItemTotal)
end
local function getMissingIngredients(recipe)
return ui.getMissingIngredients(recipe, getItemTotal)
end
-------------------------------------------------
-- Smelter touch zone helpers
-------------------------------------------------
local function addSmelterZone(x1, y1, x2, y2, action, data)
ui.addZone(smelterPendingZones, x1, y1, x2, y2, action, data)
end
local function smelterHitTest(x, y)
return ui.hitTest(smelterTouchZones, x, y)
end
-------------------------------------------------
-- Drawing helpers (delegated to shared ui module)
-------------------------------------------------
local draw = nil
local function setDrawTarget(target)
draw = target
ui.draw = target
end
local function monWrite(x, y, text, fg, bg) ui.monWrite(x, y, text, fg, bg) end
local function monFill(y, color) ui.monFill(y, color) end
local function monCenter(y, text, fg, bg) ui.monCenter(y, text, fg, bg) end
local function monBar(x, y, w, r, bc, bgc) ui.monBar(x, y, w, r, bc, bgc) end
local function drawButton(x, y, t, fg, bg, pl, pr) return ui.drawButton(x, y, t, fg, bg, pl, pr) end
-------------------------------------------------
-- Send command to master
-------------------------------------------------
local function sendToMaster(message)
if not networkModem then return end
networkModem.transmit(ORDER_CHANNEL, CLIENT_CHANNEL, message)
end
-------------------------------------------------
-- Client dropper discovery
-------------------------------------------------
local function discoverLocalDroppers()
local droppers = {}
-- Check configured dropper first
if CLIENT_DROPPER_NAME ~= "" then
local exists = peripheral.wrap(CLIENT_DROPPER_NAME) ~= nil
if exists then
table.insert(droppers, { name = CLIENT_DROPPER_NAME, isDefault = false, clientId = os.getComputerID() })
end
end
-- Also discover any other droppers on client's local network
for _, name in ipairs(peripheral.getNames()) do
local isDropper = name:match("^minecraft:dropper_")
if not isDropper and peripheral.hasType then
isDropper = peripheral.hasType(name, "minecraft:dropper")
end
if not isDropper then
isDropper = peripheral.getType(name) == "minecraft:dropper"
end
if isDropper then
-- Avoid duplicates with configured dropper
local isDuplicate = false
for _, d in ipairs(droppers) do
if d.name == name then isDuplicate = true; break end
end
if not isDuplicate then
table.insert(droppers, { name = name, isDefault = false, clientId = os.getComputerID() })
end
end
end
return droppers
end
local function announceDroppers()
local droppers = discoverLocalDroppers()
if #droppers > 0 then
sendToMaster({
type = "register_droppers",
clientId = os.getComputerID(),
droppers = droppers,
})
end
end
-------------------------------------------------
-- Inventory Dashboard (identical to master)
-------------------------------------------------
local function drawDashboard()
if not mon then return end
local w, h = mon.getSize()
pendingZones = {}
setDrawTarget(window.create(mon, 1, 1, w, h, false))
draw.setBackgroundColor(colors.black)
draw.clear()
if not connected then
monFill(1, colors.blue)
monCenter(1, " ** INVENTORY CLIENT ** ", colors.white, colors.blue)
local midY = math.floor(h / 2)
monCenter(midY - 1, "Waiting for master...", colors.lightGray, colors.black)
monCenter(midY + 1, "Make sure the master is running", colors.gray, colors.black)
monCenter(midY + 2, "and connected via wired modem.", colors.gray, colors.black)
monFill(h, colors.blue)
draw.setVisible(true)
touchZones = pendingZones
return
end
-- ===== Title bar =====
monFill(1, colors.blue)
monCenter(1, " ** INVENTORY MANAGER ** ", colors.white, colors.blue)
-- ===== Status bar =====
monFill(2, colors.gray)
local statusParts = {}
table.insert(statusParts, string.format(" Chests: %d", cache.chestCount))
table.insert(statusParts, cache.dropperOk and "Dropper: OK" or "Dropper: --")
table.insert(statusParts, cache.barrelOk and "Barrel: OK" or "Barrel: --")
if cache.furnaceCount and cache.furnaceCount > 0 then
table.insert(statusParts, string.format("Furnaces: %d", cache.furnaceCount))
end
local actParts = {}
if activity.sorting then table.insert(actParts, "SORTING") end
if activity.dispensing then table.insert(actParts, "DISPENSING") end
if activity.smelting then table.insert(actParts, "SMELTING") end
if activity.scanning then table.insert(actParts, "SCANNING") end
if activity.defragging then table.insert(actParts, "DEFRAG") end
if activity.composting then table.insert(actParts, "COMPOST") end
monWrite(2, 2, table.concat(statusParts, " | "), colors.white, colors.gray)
if #actParts > 0 then
local actStr = " " .. table.concat(actParts, " | ") .. " "
monWrite(w - #actStr, 2, actStr, colors.white, colors.orange)
end
-- ===== Divider =====
monFill(3, colors.lightBlue)
monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.cyan, colors.lightBlue)
-- ===== Storage capacity =====
monFill(4, colors.black)
local capLabel = string.format(" Storage: %d/%d slots (%d free)",
cache.usedSlots, cache.totalSlots, cache.freeSlots)
monWrite(2, 4, capLabel, colors.lightGray, colors.black)
local barStart = #capLabel + 4
local barWidth = w - barStart - 2
if barWidth > 4 then
local barColor = colors.lime
if cache.usedRatio > 0.9 then barColor = colors.red
elseif cache.usedRatio > 0.7 then barColor = colors.orange
elseif cache.usedRatio > 0.5 then barColor = colors.yellow
end
monBar(barStart, 4, barWidth, cache.usedRatio, barColor, colors.gray)
local pctStr = string.format(" %d%% ", math.floor(cache.usedRatio * 100))
local pctX = barStart + math.floor(barWidth / 2) - math.floor(#pctStr / 2)
monWrite(pctX, 4, pctStr, colors.white, barColor)
end
-- ===== Amount selector (row 5) =====
monFill(5, colors.black)
monWrite(2, 5, "Qty:", colors.lightGray, colors.black)
local btnX = 7
for _, amt in ipairs(amountOptions) do
local label = tostring(amt)
local bg = (amt == selectedAmount) and colors.cyan or colors.gray
local fg = (amt == selectedAmount) and colors.white or colors.lightGray
local x1, y1, x2, y2 = drawButton(btnX, 5, label, fg, bg)
addZone(x1, y1, x2, y2, "amount", amt)
btnX = x2 + 2
end
local refreshBg = activity.scanning and colors.yellow or colors.green
local refreshFg = activity.scanning and colors.black or colors.white
local refreshTxt = activity.scanning and "Scanning" or "Refresh"
local scanX = w - #refreshTxt - 3
local sx1, sy1, sx2, sy2 = drawButton(scanX, 5, refreshTxt, refreshFg, refreshBg, 1, 1)
addZone(sx1, sy1, sx2, sy2, "scan", nil)
-- ===== Search bar + Pagination (row 6) =====
monFill(6, colors.black)
local kbLabel = showKeyboard and " X " or " ? "
local kbBg = showKeyboard and colors.red or colors.purple
monWrite(2, 6, kbLabel, colors.white, kbBg)
addZone(2, 6, 4, 6, "kb_toggle", nil)
local queryDisplay = searchQuery
if showKeyboard then
queryDisplay = queryDisplay .. "|"
elseif queryDisplay == "" then
queryDisplay = "search..."
end
local fieldW = math.floor(w * 0.4)
if fieldW < 10 then fieldW = 10 end
local displayText = queryDisplay:sub(1, fieldW)
displayText = displayText .. string.rep("_", math.max(0, fieldW - #displayText))
monWrite(6, 6, displayText,
(searchQuery == "" and not showKeyboard) and colors.gray or colors.white,
colors.black)
addZone(6, 6, 5 + fieldW, 6, "kb_toggle", nil)
local filteredItems = getFilteredItems()
local maxRows = h - 10
if maxRows < 1 then maxRows = 1 end
totalPages = math.max(1, math.ceil(#filteredItems / maxRows))
if currentPage > totalPages then currentPage = totalPages end
if currentPage < 1 then currentPage = 1 end
local pageStr = string.format("Pg %d/%d", currentPage, totalPages)
local navW = 3 + 1 + #pageStr + 1 + 3
local navX = w - navW
if currentPage > 1 then
monWrite(navX, 6, " < ", colors.white, colors.gray)
addZone(navX, 6, navX + 2, 6, "page_prev", nil)
else
monWrite(navX, 6, " < ", colors.lightGray, colors.black)
end
monWrite(navX + 4, 6, pageStr, colors.lightGray, colors.black)
local nextX = navX + 4 + #pageStr + 1
if currentPage < totalPages then
monWrite(nextX, 6, " > ", colors.white, colors.gray)
addZone(nextX, 6, nextX + 2, 6, "page_next", nil)
else
monWrite(nextX, 6, " > ", colors.lightGray, colors.black)
end
-- ===== Column headers (row 7) =====
local row = 7
monFill(row, colors.gray)
monWrite(2, row, "#", colors.lightGray, colors.gray)
monWrite(5, row, "Item", colors.lightGray, colors.gray)
monWrite(w - 22, row, "Qty", colors.lightGray, colors.gray)
monWrite(w - 14, row, "Stock", colors.lightGray, colors.gray)
monWrite(w - 1, row, ">", colors.lightGray, colors.gray)
row = row + 1
-- ===== Item rows =====
local maxCount = 0
for _, item in ipairs(filteredItems) do
if item.total > maxCount then maxCount = item.total end
end
if maxCount == 0 then maxCount = 1 end
local startIdx = (currentPage - 1) * maxRows + 1
local endIdx = math.min(startIdx + maxRows - 1, #filteredItems)
if #filteredItems == 0 then
monFill(8, colors.black)
monFill(9, colors.black)
if searchQuery ~= "" then
monCenter(9, "No items match \"" .. searchQuery .. "\"", colors.gray, colors.black)
else
monCenter(9, "No items in storage", colors.gray, colors.black)
end
row = 10
else
for i = startIdx, endIdx do
local item = filteredItems[i]
local y = row
local short = item.name:gsub("^minecraft:", ""):gsub("_", " ")
short = short:sub(1,1):upper() .. short:sub(2)
local maxNameLen = w - 30
if #short > maxNameLen then
short = short:sub(1, maxNameLen - 2) .. ".."
end
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
monFill(y, rowBg)
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
monWrite(5, y, short, colors.white, rowBg)
monWrite(w - 22, y, tostring(item.total), colors.yellow, rowBg)
local ratio = item.total / maxCount
local barColor = colors.lime
if ratio < 0.25 then barColor = colors.red
elseif ratio < 0.5 then barColor = colors.orange
end
monBar(w - 14, y, 12, ratio, barColor, rowBg == colors.gray and colors.lightGray or colors.gray)
monWrite(w - 1, y, ">", colors.orange, rowBg)
addZone(1, y, w, y, "order", item.name)
row = row + 1
end
end
local lastItemRow = h - 3
while row <= lastItemRow do
monFill(row, colors.black)
row = row + 1
end
if showKeyboard then
local keyW = 3
local keyGap = 1
local kbDefs = {
{ keys = kbRows[1], specials = {{ label = " Bksp ", action = "kb_bksp", bg = colors.red }} },
{ keys = kbRows[2], specials = {{ label = " Done ", action = "kb_done", bg = colors.green }} },
{ keys = kbRows[3], specials = {
{ label = " Space ", action = "kb_space", bg = colors.lightGray },
{ label = " Clr ", action = "kb_clear", bg = colors.orange },
}},
}
for rowIdx, def in ipairs(kbDefs) do
local y = h - 3 + rowIdx
monFill(y, colors.black)
local keysW = #def.keys * keyW + math.max(0, #def.keys - 1) * keyGap
local specialsW = 0
for _, sp in ipairs(def.specials) do
specialsW = specialsW + keyGap + #sp.label
end
local rowW = keysW + specialsW
local x = math.floor((w - rowW) / 2) + 1
for ki, key in ipairs(def.keys) do
monWrite(x, y, " " .. key .. " ", colors.white, colors.gray)
addZone(x, y, x + keyW - 1, y, "kb_key", key:lower())
x = x + keyW
if ki < #def.keys then x = x + keyGap end
end
for _, sp in ipairs(def.specials) do
x = x + keyGap
monWrite(x, y, sp.label, colors.white, sp.bg)
addZone(x, y, x + #sp.label - 1, y, sp.action, nil)
x = x + #sp.label
end
end
else
-- ===== Status message (h-2) =====
monFill(h - 2, colors.black)
if #activeAlerts > 0 then
local alertIdx = math.floor(os.epoch("utc") / 2000) % #activeAlerts + 1
local a = activeAlerts[alertIdx]
local alertMsg = string.format(" LOW STOCK: %s (%d/%d) ", a.label, a.current, a.min)
monCenter(h - 2, alertMsg, colors.white, colors.red)
elseif statusTimer > 0 and #statusMessage > 0 then
monCenter(h - 2, statusMessage, statusColor, colors.black)
end
-- ===== Footer (h-1) =====
monFill(h - 1, colors.gray)
local footerLeft = string.format(" Total: %d items | %d types ",
cache.grandTotal, #cache.itemList)
monWrite(2, h - 1, footerLeft, colors.white, colors.gray)
if searchQuery ~= "" then
local filterNote = string.format("| Showing %d ", #filteredItems)
monWrite(2 + #footerLeft + 1, h - 1, filterNote, colors.yellow, colors.gray)
end
local timeStr = textutils.formatTime(os.time(), true)
monWrite(w - #timeStr - 1, h - 1, timeStr, colors.lightGray, colors.gray)
-- ===== Bottom accent (h) =====
monFill(h, colors.blue)
local bottomMsg = " Tap item to order "
if activity.dispensing then
bottomMsg = " DISPENSING... "
elseif activity.smelting then
bottomMsg = " SMELTING... "
elseif activity.sorting then
bottomMsg = " SORTING BARREL... "
elseif activity.defragging then
bottomMsg = " DEFRAGMENTING... "
elseif activity.composting then
bottomMsg = " COMPOSTING... "
end
monCenter(h, bottomMsg, colors.lightBlue, colors.blue)
end
draw.setVisible(true)
touchZones = pendingZones
end
-------------------------------------------------
-- Smelter Dashboard (identical to master)
-------------------------------------------------
local function drawSmelterDashboard()
if not smelterMon then return end
local w, h = smelterMon.getSize()
smelterPendingZones = {}
setDrawTarget(window.create(smelterMon, 1, 1, w, h, false))
draw.setBackgroundColor(colors.black)
draw.clear()
if not connected then
monFill(1, colors.purple)
monCenter(1, " ** SMELTER CLIENT ** ", colors.white, colors.purple)
monCenter(math.floor(h / 2), "Waiting for master...", colors.gray, colors.black)
monFill(h, colors.purple)
draw.setVisible(true)
smelterTouchZones = smelterPendingZones
return
end
-- ===== Title bar =====
monFill(1, colors.purple)
monCenter(1, " ** SMELTER DASHBOARD ** ", colors.white, colors.purple)
-- ===== Status bar =====
monFill(2, colors.gray)
local activeCount = 0
for _, fs in ipairs(cache.furnaceStatus or {}) do
if fs.active then activeCount = activeCount + 1 end
end
local statusStr = string.format(" Furnaces: %d Active: %d",
cache.furnaceCount or 0, activeCount)
monWrite(2, 2, statusStr, colors.white, colors.gray)
local pauseLabel = smeltingPaused and " PAUSED " or " ACTIVE "
local pauseBg = smeltingPaused and colors.red or colors.lime
local pauseFg = smeltingPaused and colors.white or colors.black
monWrite(w - #pauseLabel, 2, pauseLabel, pauseFg, pauseBg)
addSmelterZone(w - #pauseLabel, 2, w - 1, 2, "toggle_pause", nil)
-- ===== Divider =====
monFill(3, colors.magenta)
monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.pink, colors.magenta)
-- ===== Tab row =====
monFill(4, colors.black)
local tabStatusBg = smelterView == "status" and colors.purple or colors.gray
local tabSmeltBg = smelterView == "smelt" and colors.purple or colors.gray
local tabCraftBg = smelterView == "craft" and colors.purple or colors.gray
local tabMissingBg = smelterView == "missing" and colors.purple or colors.gray
local bx1, by1, bx2, by2
bx1, by1, bx2, by2 = drawButton(2, 4, "Status", colors.white, tabStatusBg)
addSmelterZone(bx1, by1, bx2, by2, "tab", "status")
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Smelt", colors.white, tabSmeltBg)
addSmelterZone(bx1, by1, bx2, by2, "tab", "smelt")
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Craft", colors.white, tabCraftBg)
addSmelterZone(bx1, by1, bx2, by2, "tab", "craft")
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Missing", colors.white, tabMissingBg)
addSmelterZone(bx1, by1, bx2, by2, "tab", "missing")
if smelterView == "status" then
-- ===== Furnace Status View =====
monFill(5, colors.gray)
local outCol = math.floor(w * 0.40)
local fuelCol = math.floor(w * 0.65)
local statCol = w - 6
monWrite(2, 5, "#", colors.lightGray, colors.gray)
monWrite(4, 5, "T", colors.lightGray, colors.gray)
monWrite(6, 5, "Input", colors.lightGray, colors.gray)
monWrite(outCol, 5, "Output", colors.lightGray, colors.gray)
monWrite(fuelCol, 5, "Fuel", colors.lightGray, colors.gray)
monWrite(statCol, 5, "State", colors.lightGray, colors.gray)
local furnaceList = cache.furnaceStatus or {}
local maxRows = h - 8
if maxRows < 1 then maxRows = 1 end
smelterTotalPages = math.max(1, math.ceil(#furnaceList / maxRows))
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
if smelterPage < 1 then smelterPage = 1 end
local startIdx = (smelterPage - 1) * maxRows + 1
local endIdx = math.min(startIdx + maxRows - 1, #furnaceList)
local row = 6
if #furnaceList == 0 then
monFill(7, colors.black)
monCenter(7, "No furnaces found on network", colors.gray, colors.black)
row = 8
else
for i = startIdx, endIdx do
local fs = furnaceList[i]
local y = row
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
monFill(y, rowBg)
monWrite(2, y, string.format("%d", i), colors.lightBlue, rowBg)
local typeAbbr = "F"
local typeColor = colors.orange
if fs.type == "minecraft:smoker" then
typeAbbr = "S"
typeColor = colors.green
elseif fs.type == "minecraft:blast_furnace" then
typeAbbr = "B"
typeColor = colors.cyan
end
monWrite(4, y, typeAbbr, typeColor, rowBg)
if fs.input then
local inName = fs.input.name:gsub("^minecraft:", ""):gsub("_", " ")
local maxIn = outCol - 8
if #inName > maxIn then inName = inName:sub(1, maxIn - 2) .. ".." end
monWrite(6, y, inName, colors.white, rowBg)
monWrite(outCol - 4, y, "x" .. fs.input.count, colors.yellow, rowBg)
else
monWrite(6, y, "(empty)", colors.lightGray, rowBg)
end
if fs.output then
local outName = fs.output.name:gsub("^minecraft:", ""):gsub("_", " ")
local maxOut = fuelCol - outCol - 5
if #outName > maxOut then outName = outName:sub(1, maxOut - 2) .. ".." end
monWrite(outCol, y, outName, colors.white, rowBg)
monWrite(fuelCol - 4, y, "x" .. fs.output.count, colors.yellow, rowBg)
else
monWrite(outCol, y, "-", colors.lightGray, rowBg)
end
if fs.fuel then
local fuelName = fs.fuel.name:gsub("^minecraft:", ""):gsub("_", " ")
local maxFuel = statCol - fuelCol - 4
if #fuelName > maxFuel then fuelName = fuelName:sub(1, maxFuel - 2) .. ".." end
monWrite(fuelCol, y, fuelName, colors.white, rowBg)
monWrite(statCol - 4, y, "x" .. fs.fuel.count, colors.yellow, rowBg)
else
monWrite(fuelCol, y, "-", colors.lightGray, rowBg)
end
if smeltingPaused then
monWrite(statCol, y, "PAUSE", colors.red, rowBg)
elseif fs.active then
monWrite(statCol, y, " COOK", colors.lime, rowBg)
elseif fs.input and not fs.fuel then
monWrite(statCol, y, "FUEL?", colors.orange, rowBg)
else
monWrite(statCol, y, " IDLE", colors.lightGray, rowBg)
end
row = row + 1
end
end
while row <= h - 2 do
monFill(row, colors.black)
row = row + 1
end
elseif smelterView == "smelt" then
-- ===== Smelt Recipe Manager View =====
-- Build item totals lookup from itemList
local itemTotals = {}
for _, item in ipairs(cache.itemList) do
itemTotals[item.name] = item.total
end
local recipeList = {}
for inputName, recipe in pairs(SMELTABLE) do
local short = inputName:gsub("^minecraft:", ""):gsub("_", " ")
short = short:sub(1,1):upper() .. short:sub(2)
local resultShort = recipe.result:gsub("^minecraft:", ""):gsub("_", " ")
resultShort = resultShort:sub(1,1):upper() .. resultShort:sub(2)
local types = ""
for _, ft in ipairs(recipe.furnaces) do
if ft == "minecraft:furnace" then types = types .. "F"
elseif ft == "minecraft:smoker" then types = types .. "S"
elseif ft == "minecraft:blast_furnace" then types = types .. "B"
end
end
local enabled = not disabledRecipes[inputName]
local inStorage = itemTotals[inputName] or 0
table.insert(recipeList, {
inputName = inputName,
inputShort = short,
resultShort = resultShort,
types = types,
enabled = enabled,
inStorage = inStorage,
})
end
table.sort(recipeList, function(a, b) return a.inputShort < b.inputShort end)
local arrowCol = math.floor(w * 0.30)
local typeCol = math.floor(w * 0.60)
local stockCol = math.floor(w * 0.72)
local toggleCol = w - 5
monFill(5, colors.gray)
monWrite(2, 5, "Input", colors.lightGray, colors.gray)
monWrite(arrowCol, 5, "Output", colors.lightGray, colors.gray)
monWrite(typeCol, 5, "Type", colors.lightGray, colors.gray)
monWrite(stockCol, 5, "Stock", colors.lightGray, colors.gray)
monWrite(toggleCol, 5, "On?", colors.lightGray, colors.gray)
local bulkX = w - 22
bx1, by1, bx2, by2 = drawButton(bulkX, 4, "All On", colors.white, colors.green)
addSmelterZone(bx1, by1, bx2, by2, "enable_all", nil)
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "All Off", colors.white, colors.red)
addSmelterZone(bx1, by1, bx2, by2, "disable_all", nil)
local maxRows = h - 8
if maxRows < 1 then maxRows = 1 end
smelterTotalPages = math.max(1, math.ceil(#recipeList / maxRows))
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
if smelterPage < 1 then smelterPage = 1 end
local startIdx = (smelterPage - 1) * maxRows + 1
local endIdx = math.min(startIdx + maxRows - 1, #recipeList)
local row = 6
for i = startIdx, endIdx do
local r = recipeList[i]
local y = row
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
monFill(y, rowBg)
local maxInputLen = arrowCol - 3
local inputDisplay = r.inputShort
if #inputDisplay > maxInputLen then
inputDisplay = inputDisplay:sub(1, maxInputLen - 2) .. ".."
end
monWrite(2, y, inputDisplay, colors.white, rowBg)
local maxOutLen = typeCol - arrowCol - 2
local outDisplay = r.resultShort
if #outDisplay > maxOutLen then
outDisplay = outDisplay:sub(1, maxOutLen - 2) .. ".."
end
monWrite(arrowCol, y, outDisplay, colors.lightBlue, rowBg)
monWrite(typeCol, y, r.types, colors.orange, rowBg)
monWrite(stockCol, y, tostring(r.inStorage), colors.yellow, rowBg)
if r.enabled then
monWrite(toggleCol, y, " ON ", colors.white, colors.green)
else
monWrite(toggleCol, y, " OFF", colors.white, colors.red)
end
addSmelterZone(1, y, w, y, "toggle_recipe", r.inputName)
row = row + 1
end
while row <= h - 2 do
monFill(row, colors.black)
row = row + 1
end
elseif smelterView == "craft" then
-- ===== Available Crafting Recipes =====
-- Turtle status on tab row
local tLabel = craftTurtleOk and " Turtle OK " or " No Turtle "
local tBg = craftTurtleOk and colors.lime or colors.red
local tFg = craftTurtleOk and colors.black or colors.white
monWrite(w - #tLabel, 4, tLabel, tFg, tBg)
local availList = {}
for idx, recipe in ipairs(CRAFTABLE) do
if canCraftRecipe(recipe) then
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
short = short:sub(1,1):upper() .. short:sub(2)
local batches = maxCraftBatches(recipe)
table.insert(availList, {
idx = idx,
short = short,
count = recipe.count,
batches = batches,
})
end
end
monFill(5, colors.gray)
local makeCol = w - 6
monWrite(2, 5, "#", colors.lightGray, colors.gray)
monWrite(4, 5, "Output", colors.lightGray, colors.gray)
monWrite(math.floor(w * 0.45), 5, "Yield", colors.lightGray, colors.gray)
monWrite(math.floor(w * 0.60), 5, "Can Make", colors.lightGray, colors.gray)
monWrite(makeCol, 5, "Go", colors.lightGray, colors.gray)
local maxRows = h - 8
if maxRows < 1 then maxRows = 1 end
smelterTotalPages = math.max(1, math.ceil(#availList / maxRows))
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
if smelterPage < 1 then smelterPage = 1 end
local startIdx = (smelterPage - 1) * maxRows + 1
local endIdx = math.min(startIdx + maxRows - 1, #availList)
local row = 6
if #availList == 0 then
monFill(7, colors.black)
monCenter(7, "No recipes available to craft", colors.gray, colors.black)
row = 8
else
for i = startIdx, endIdx do
local r = availList[i]
local y = row
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
monFill(y, rowBg)
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
local maxNameLen = math.floor(w * 0.40)
local nameDisplay = r.short
if #nameDisplay > maxNameLen then
nameDisplay = nameDisplay:sub(1, maxNameLen - 2) .. ".."
end
monWrite(4, y, nameDisplay, colors.white, rowBg)
monWrite(math.floor(w * 0.45), y, "x" .. r.count, colors.yellow, rowBg)
monWrite(math.floor(w * 0.60), y,
string.format("x%d", r.batches), colors.lime, rowBg)
if craftTurtleOk then
monWrite(makeCol, y, " MAKE ", colors.white, colors.green)
addSmelterZone(makeCol, y, makeCol + 5, y, "craft", r.idx)
else
monWrite(makeCol, y, " ---- ", colors.gray, colors.black)
end
row = row + 1
end
end
while row <= h - 2 do
monFill(row, colors.black)
row = row + 1
end
elseif smelterView == "missing" then
-- ===== Unavailable Crafting Recipes =====
local missList = {}
for idx, recipe in ipairs(CRAFTABLE) do
if not canCraftRecipe(recipe) then
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
short = short:sub(1,1):upper() .. short:sub(2)
local missing = getMissingIngredients(recipe)
local parts = {}
for _, m in ipairs(missing) do
local mShort = m.name:gsub("^minecraft:", ""):gsub("_", " ")
table.insert(parts, string.format("%s %d/%d", mShort, m.have, m.need))
end
table.insert(missList, {
idx = idx,
short = short,
count = recipe.count,
summary = table.concat(parts, ", "),
})
end
end
monFill(5, colors.gray)
monWrite(2, 5, "#", colors.lightGray, colors.gray)
monWrite(4, 5, "Output", colors.lightGray, colors.gray)
monWrite(math.floor(w * 0.35), 5, "Missing (have/need)", colors.lightGray, colors.gray)
local maxRows = h - 8
if maxRows < 1 then maxRows = 1 end
smelterTotalPages = math.max(1, math.ceil(#missList / maxRows))
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
if smelterPage < 1 then smelterPage = 1 end
local startIdx = (smelterPage - 1) * maxRows + 1
local endIdx = math.min(startIdx + maxRows - 1, #missList)
local row = 6
if #missList == 0 then
monFill(7, colors.black)
monCenter(7, "All recipes can be crafted!", colors.lime, colors.black)
row = 8
else
for i = startIdx, endIdx do
local r = missList[i]
local y = row
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
monFill(y, rowBg)
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
local nameCol = math.floor(w * 0.35) - 5
local nameDisplay = r.short .. " x" .. r.count
if #nameDisplay > nameCol then
nameDisplay = nameDisplay:sub(1, nameCol - 2) .. ".."
end
monWrite(4, y, nameDisplay, colors.white, rowBg)
local missCol = math.floor(w * 0.35)
local missW = w - missCol - 1
local summaryDisplay = r.summary
if #summaryDisplay > missW then
summaryDisplay = summaryDisplay:sub(1, missW - 2) .. ".."
end
monWrite(missCol, y, summaryDisplay, colors.red, rowBg)
row = row + 1
end
end
while row <= h - 2 do
monFill(row, colors.black)
row = row + 1
end
end
-- ===== Pagination (h - 1) =====
monFill(h - 1, colors.gray)
local pageStr = string.format("Pg %d/%d", smelterPage, smelterTotalPages)
monCenter(h - 1, pageStr, colors.white, colors.gray)
if smelterPage > 1 then
monWrite(2, h - 1, " < ", colors.white, colors.lightGray)
addSmelterZone(2, h - 1, 4, h - 1, "page_prev", nil)
end
if smelterPage < smelterTotalPages then
monWrite(w - 3, h - 1, " > ", colors.white, colors.lightGray)
addSmelterZone(w - 3, h - 1, w - 1, h - 1, "page_next", nil)
end
-- ===== Bottom accent =====
monFill(h, colors.purple)
local bottomMsg = ""
if smelterView == "status" or smelterView == "smelt" then
local enabledCount = 0
local totalRecipes = 0
for _ in pairs(SMELTABLE) do totalRecipes = totalRecipes + 1 end
for inputName in pairs(SMELTABLE) do
if not disabledRecipes[inputName] then enabledCount = enabledCount + 1 end
end
bottomMsg = string.format(" Smelt: %d/%d enabled ", enabledCount, totalRecipes)
if activity.smelting then bottomMsg = " SMELTING... " end
elseif smelterView == "craft" then
bottomMsg = " Tap MAKE to craft "
if activity.crafting then bottomMsg = " CRAFTING... " end
elseif smelterView == "missing" then
local totalC = #CRAFTABLE
local availC = 0
for _, r in ipairs(CRAFTABLE) do
if canCraftRecipe(r) then availC = availC + 1 end
end
bottomMsg = string.format(" Available: %d/%d recipes ", availC, totalC)
end
monCenter(h, bottomMsg, colors.pink, colors.purple)
draw.setVisible(true)
smelterTouchZones = smelterPendingZones
end
-------------------------------------------------
-- Touch handler (sends orders to master via modem)
-------------------------------------------------
local function handleTouch(x, y)
local action, data = hitTest(x, y)
if not action then return end
if action == "amount" then
selectedAmount = data
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: Announce local droppers to master periodically
function()
-- Initial announcement after short delay
sleep(3)
pcall(announceDroppers)
while true do
sleep(DROPPER_ANNOUNCE_INTERVAL)
pcall(announceDroppers)
end
end,
-- Task 5: Client barrel auto-sort (sends to master only when barrel has items)
function()
if CLIENT_BARREL_NAME == "" then return end
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 6: Touch event listener (both monitors)
function()
while true do
local event, side, x, y = os.pullEvent("monitor_touch")
if smelterMonName and side == smelterMonName then
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()