Files
Inventory-Manager-CC/inventoryManager.lua

2934 lines
108 KiB
Lua

-- Inventory Manager: Touch UI on monitor
-- Main computer (networked). Computer 1 sits next to dropper_0 and auto-dispenses.
-------------------------------------------------
-- Default configuration (overridden by .manager_config)
-------------------------------------------------
local DROPPER_NAME = "minecraft:dropper_9"
local BARREL_NAME = "minecraft:barrel_0"
local POLL_INTERVAL = 2 -- seconds between barrel checks
local MONITOR_SIDE = "left"
local SCAN_INTERVAL = 120 -- seconds between full background scans
local SMELT_INTERVAL = 3 -- seconds between furnace checks
local SMELT_RESERVE = 128 -- keep at least 2 stacks of each raw material
local DEFRAG_INTERVAL = 600 -- seconds between defrag passes (10 min)
local COMPOST_INTERVAL = 3 -- seconds between composter checks
local ALERT_INTERVAL = 15 -- seconds between alert re-checks
local CACHE_FILE = ".inventory_cache" -- persistent cache file
local SMELTER_MONITOR_SIDE = "top"
local DISABLED_RECIPES_FILE = ".disabled_recipes"
-- Network sync (for client displays)
local BROADCAST_CHANNEL = 4200
local ORDER_CHANNEL = 4201
local BROADCAST_INTERVAL = 1 -- seconds between state broadcasts
-- Crafting turtle
local CRAFT_CHANNEL = 4203
local CRAFT_REPLY_CHANNEL = 4204
-------------------------------------------------
-- Load config from file if present
-------------------------------------------------
local CONFIG_FILE = ".manager_config"
local _loadedConfig = nil -- stored for deferred overrides (compost settings declared later)
-------------------------------------------------
-- Structured logging
-------------------------------------------------
local log = dofile("lib/log.lua")
local function loadConfig()
if not fs.exists(CONFIG_FILE) then return end
local f = fs.open(CONFIG_FILE, "r")
local data = f.readAll()
f.close()
local ok, cfg = pcall(textutils.unserialiseJSON, data)
if not ok or not cfg then
log.warn("CONFIG", "Failed to parse %s", CONFIG_FILE)
return
end
_loadedConfig = cfg
-- Peripheral names
if cfg.dropperName then DROPPER_NAME = cfg.dropperName end
if cfg.barrelName then BARREL_NAME = cfg.barrelName end
if cfg.monitorSide then MONITOR_SIDE = cfg.monitorSide end
if cfg.smelterMonitorSide then SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end
-- Timing intervals
if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end
if cfg.scanInterval then SCAN_INTERVAL = cfg.scanInterval end
if cfg.smeltInterval then SMELT_INTERVAL = cfg.smeltInterval end
if cfg.defragInterval then DEFRAG_INTERVAL = cfg.defragInterval end
if cfg.compostInterval then COMPOST_INTERVAL = cfg.compostInterval end
if cfg.alertInterval then ALERT_INTERVAL = cfg.alertInterval end
if cfg.broadcastInterval then BROADCAST_INTERVAL = cfg.broadcastInterval end
-- Reserves
if cfg.smeltReserve then SMELT_RESERVE = cfg.smeltReserve end
-- Modem channels
if cfg.broadcastChannel then BROADCAST_CHANNEL = cfg.broadcastChannel end
if cfg.orderChannel then ORDER_CHANNEL = cfg.orderChannel end
if cfg.craftChannel then CRAFT_CHANNEL = cfg.craftChannel end
if cfg.craftReplyChannel then CRAFT_REPLY_CHANNEL = cfg.craftReplyChannel end
log.info("CONFIG", "Loaded from %s", CONFIG_FILE)
end
loadConfig()
-------------------------------------------------
-- Furnace types to manage
-------------------------------------------------
local FURNACE_TYPES = {
"minecraft:furnace",
"minecraft:smoker",
"minecraft:blast_furnace",
}
-- Furnace slots: 1 = input, 2 = fuel, 3 = output (standard Minecraft)
local SLOT_INPUT = 1
local SLOT_FUEL = 2
local SLOT_OUTPUT = 3
-------------------------------------------------
-- Load data tables from external files (data/)
-------------------------------------------------
local SMELTABLE = dofile("data/smeltable.lua")
local FUEL_LIST = dofile("data/fuel.lua")
local _compostData = dofile("data/compostable.lua")
local COMPOSTABLE = _compostData.items
local COMPOST_TRASH = _compostData.trash
local CRAFTABLE = dofile("data/craftable.lua")
local LOW_STOCK_ALERTS = dofile("data/alerts.lua")
-- Pre-build furnace compatibility sets for O(1) lookup
for _, recipe in pairs(SMELTABLE) do
recipe.furnaceSet = {}
for _, ft in ipairs(recipe.furnaces) do
recipe.furnaceSet[ft] = true
end
end
-- Pre-built smelt candidate lists per furnace type (sorted: food first, then alpha)
local smeltCandidatesByType = {}
do
for _, ftype in ipairs(FURNACE_TYPES) do
smeltCandidatesByType[ftype] = {}
end
for itemName, recipe in pairs(SMELTABLE) do
local isFood = recipe.furnaceSet["minecraft:smoker"] or false
for ft, _ in pairs(recipe.furnaceSet) do
table.insert(smeltCandidatesByType[ft], { name = itemName, recipe = recipe, food = isFood })
end
end
for _, list in pairs(smeltCandidatesByType) do
table.sort(list, function(a, b)
if a.food ~= b.food then return a.food end
return a.name < b.name
end)
end
end
-- Build fuel set for quick lookup
local FUEL_SET = {}
for _, f in ipairs(FUEL_LIST) do FUEL_SET[f.name] = true end
-- Build compostable set for quick lookup
local COMPOSTABLE_SET = {}
for _, name in ipairs(COMPOSTABLE) do COMPOSTABLE_SET[name] = true end
-- Compost settings (overridable via config)
local COMPOST_RESERVE = 128 -- 2 stacks
local COMPOST_DROPPER = "minecraft:dropper_10"
local COMPOST_HOPPER = "minecraft:hopper_0"
if _loadedConfig then
if _loadedConfig.compostReserve then COMPOST_RESERVE = _loadedConfig.compostReserve end
if _loadedConfig.compostDropper then COMPOST_DROPPER = _loadedConfig.compostDropper end
if _loadedConfig.compostHopper then COMPOST_HOPPER = _loadedConfig.compostHopper end
end
-- Crafting grid-to-slot mapping
local GRID_TO_SLOT = {1, 2, 3, 5, 6, 7, 9, 10, 11}
-------------------------------------------------
-- Shared UI helpers (drawing, zones, craft math)
-------------------------------------------------
local ui = dofile("lib/ui.lua")
-- Active alerts (populated by checkAlerts)
local activeAlerts = {}
-------------------------------------------------
-- State version tracking (for delta broadcasting)
-------------------------------------------------
local stateVersion = 0
local lastBroadcastVersion = -1
local configDirty = true
local function bumpStateVersion()
stateVersion = stateVersion + 1
end
-------------------------------------------------
-- Cached data (updated by background scanner)
-------------------------------------------------
local cache = {
catalogue = {}, -- itemName -> { {chest=name, total=N}, ... }
itemList = {}, -- sorted list of { name, total }
itemListDirty = false, -- lazy rebuild flag for itemList
grandTotal = 0,
chestCount = 0,
totalSlots = 0,
usedSlots = 0,
freeSlots = 0,
usedRatio = 0,
dropperOk = false,
barrelOk = false,
furnaceCount = 0,
furnaceStatus = {}, -- per-furnace { name, type, input, fuel, output, active }
droppers = {}, -- list of available dropper peripherals for dispensing
}
-- Client-registered droppers, keyed by clientId
local clientDroppers = {}
-------------------------------------------------
-- Activity state (shown on monitor)
-------------------------------------------------
local activity = {
sorting = false, -- barrel sort in progress
dispensing = false, -- order in progress
scanning = false, -- background scan in progress
smelting = false, -- auto-smelt in progress
defragging = false, -- defrag in progress
composting = false, -- auto-compost in progress
crafting = false, -- crafting in progress
}
-------------------------------------------------
-- Instant cache adjustment (no scan needed)
-------------------------------------------------
--- Adjust cached counts for a single item move.
-- delta > 0 means items arrived in chestName;
-- delta < 0 means items left chestName.
local function adjustCache(itemName, chestName, delta)
if delta == 0 then return end
-- 1) catalogue: per-chest totals
local cat = cache.catalogue
if delta > 0 then
-- Items added to a chest
if not cat[itemName] then cat[itemName] = {} end
local found = false
for _, entry in ipairs(cat[itemName]) do
if entry.chest == chestName then
entry.total = entry.total + delta
found = true
break
end
end
if not found then
table.insert(cat[itemName], { chest = chestName, total = delta })
end
else
-- Items removed from a chest
if cat[itemName] then
for idx, entry in ipairs(cat[itemName]) do
if entry.chest == chestName then
entry.total = entry.total + delta -- delta is negative
if entry.total <= 0 then
table.remove(cat[itemName], idx)
end
break
end
end
-- Remove item from catalogue entirely if no sources left
if #cat[itemName] == 0 then
cat[itemName] = nil
end
end
end
-- 2) Mark itemList as needing rebuild (deferred until actually read)
cache.itemListDirty = true
-- 3) Update grandTotal incrementally
cache.grandTotal = cache.grandTotal + delta
-- 4) Bump state version for delta broadcasting
bumpStateVersion()
end
--- Rebuild itemList from catalogue if dirty (lazy rebuild)
local function ensureItemList()
if not cache.itemListDirty then return end
local itemList = {}
local grandTotal = 0
for name, sources in pairs(cache.catalogue) do
local total = 0
for _, s in ipairs(sources) do total = total + s.total end
grandTotal = grandTotal + total
table.insert(itemList, { name = name, total = total })
end
table.sort(itemList, function(a, b) return a.total > b.total end)
cache.itemList = itemList
cache.grandTotal = grandTotal
cache.itemListDirty = false
end
-------------------------------------------------
-- Inventory helpers (cached peripheral lists)
-------------------------------------------------
local PERIPHERAL_CACHE_TTL = 5 -- seconds
local cachedChests = nil
local cachedChestsTime = 0
local cachedFurnaces = nil
local cachedFurnacesTime = 0
local function invalidatePeripheralCaches()
cachedChests = nil
cachedFurnaces = nil
end
-- Peripheral handle cache (avoids re-creating proxy tables on every call)
-- Handles are cleared on peripheral_detach events and during full scans.
local wrapCache = {}
local function wrapCached(name)
local handle = wrapCache[name]
if handle then return handle end
handle = peripheral.wrap(name)
if handle then
wrapCache[name] = handle
end
return handle
end
local function invalidateWrapCache(name)
if name then
wrapCache[name] = nil
else
wrapCache = {}
end
end
local function getChests()
local now = os.clock()
if cachedChests and (now - cachedChestsTime) < PERIPHERAL_CACHE_TTL then
return cachedChests
end
local chests = {}
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "minecraft:chest" then
table.insert(chests, name)
end
end
cachedChests = chests
cachedChestsTime = now
return chests
end
local function getFurnaces()
local now = os.clock()
if cachedFurnaces and (now - cachedFurnacesTime) < PERIPHERAL_CACHE_TTL then
return cachedFurnaces
end
local furnaces = {}
for _, ftype in ipairs(FURNACE_TYPES) do
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == ftype then
table.insert(furnaces, name)
end
end
end
cachedFurnaces = furnaces
cachedFurnacesTime = now
return furnaces
end
local function refreshFurnaceStatus()
local furnaces = getFurnaces()
local status = {}
for _, fname in ipairs(furnaces) do
local furnace = wrapCached(fname)
if furnace then
local contents = furnace.list()
local entry = {
name = fname,
type = peripheral.getType(fname),
input = contents[SLOT_INPUT] or nil,
fuel = contents[SLOT_FUEL] or nil,
output = contents[SLOT_OUTPUT] or nil,
active = (contents[SLOT_INPUT] ~= nil and contents[SLOT_FUEL] ~= nil),
}
table.insert(status, entry)
end
end
cache.furnaceStatus = status
bumpStateVersion()
end
local function scanInventory(deviceName)
local inv = wrapCached(deviceName)
if not inv then return {} end
local result = {}
for slot, item in pairs(inv.list()) do
if not result[item.name] then
result[item.name] = { total = 0, slots = {} }
end
result[item.name].total = result[item.name].total + item.count
result[item.name].slots[slot] = { name = item.name, count = item.count }
end
return result
end
-- Full scan: updates the global cache
-- onProgress(current, total, chestName) is called per chest if provided
local function refreshCache(onProgress)
activity.scanning = true
-- Clear handle cache so we pick up any changes
invalidateWrapCache()
-- Single pass over peripherals: classify into chests and furnaces
local chests = {}
local furnaces = {}
local furnaceTypeSet = {}
for _, ft in ipairs(FURNACE_TYPES) do furnaceTypeSet[ft] = true end
for _, name in ipairs(peripheral.getNames()) do
local ptype = peripheral.getType(name)
if ptype == "minecraft:chest" then
table.insert(chests, name)
elseif furnaceTypeSet[ptype] then
table.insert(furnaces, name)
end
end
-- Update peripheral caches so getChests()/getFurnaces() stay fresh
local now = os.clock()
cachedChests = chests
cachedChestsTime = now
cachedFurnaces = furnaces
cachedFurnacesTime = now
local catalogue = {}
local totalSlots = 0
local usedSlots = 0
for ci, chest in ipairs(chests) do
if onProgress then onProgress(ci, #chests, chest) end
local inv = wrapCached(chest)
if inv then
totalSlots = totalSlots + inv.size()
local contents = inv.list()
for slot, item in pairs(contents) do
usedSlots = usedSlots + 1
if not catalogue[item.name] then
catalogue[item.name] = {}
end
-- Accumulate per-chest totals
local found = false
for _, entry in ipairs(catalogue[item.name]) do
if entry.chest == chest then
entry.total = entry.total + item.count
found = true
break
end
end
if not found then
table.insert(catalogue[item.name], { chest = chest, total = item.count })
end
end
end
end
-- Build sorted item list
local itemList = {}
local grandTotal = 0
for itemName, sources in pairs(catalogue) do
local total = 0
for _, s in ipairs(sources) do total = total + s.total end
grandTotal = grandTotal + total
table.insert(itemList, { name = itemName, total = total })
end
table.sort(itemList, function(a, b) return a.total > b.total end)
-- Update cache atomically
cache.catalogue = catalogue
cache.itemList = itemList
cache.grandTotal = grandTotal
cache.chestCount = #chests
cache.totalSlots = totalSlots
cache.usedSlots = usedSlots
cache.freeSlots = totalSlots - usedSlots
cache.usedRatio = totalSlots > 0 and (usedSlots / totalSlots) or 0
cache.dropperOk = peripheral.isPresent(DROPPER_NAME)
cache.barrelOk = peripheral.isPresent(BARREL_NAME)
-- Discover all droppers on the network (for location-based dispensing)
-- Use name-based matching as peripheral.getType() may return "inventory"
-- as the primary type in some CC:Tweaked versions (1.99+)
local droppers = {}
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 and name ~= COMPOST_DROPPER then
local isDefault = (name == DROPPER_NAME)
table.insert(droppers, { name = name, isDefault = isDefault })
end
end
-- Sort so default dropper comes first
table.sort(droppers, function(a, b)
if a.isDefault ~= b.isDefault then return a.isDefault end
return a.name < b.name
end)
-- Merge in droppers registered by remote clients
local seenNames = {}
for _, d in ipairs(droppers) do seenNames[d.name] = true end
for clientId, clientList in pairs(clientDroppers) do
for _, d in ipairs(clientList) do
if not seenNames[d.name] then
table.insert(droppers, { name = d.name, isDefault = false, clientId = clientId })
seenNames[d.name] = true
end
end
end
cache.droppers = droppers
-- Furnace count already computed from single-pass above
cache.furnaceCount = #furnaces
-- Scan furnace contents for smelter dashboard
refreshFurnaceStatus()
activity.scanning = false
bumpStateVersion()
-- Persist cache to disk
pcall(function()
local data = {
catalogue = cache.catalogue,
itemList = cache.itemList,
grandTotal = cache.grandTotal,
chestCount = cache.chestCount,
totalSlots = cache.totalSlots,
usedSlots = cache.usedSlots,
freeSlots = cache.freeSlots,
usedRatio = cache.usedRatio,
dropperOk = cache.dropperOk,
barrelOk = cache.barrelOk,
furnaceCount = cache.furnaceCount,
furnaceStatus = cache.furnaceStatus,
droppers = cache.droppers,
savedAt = os.epoch("utc"),
}
local f = fs.open(CACHE_FILE, "w")
f.write(textutils.serialise(data))
f.close()
end)
end
-- Load cache from disk (returns true if loaded)
local function loadCacheFromDisk()
if not fs.exists(CACHE_FILE) then return false end
local ok, err = pcall(function()
local f = fs.open(CACHE_FILE, "r")
local raw = f.readAll()
f.close()
local data = textutils.unserialise(raw)
if data and data.catalogue and data.itemList then
cache.catalogue = data.catalogue
cache.itemList = data.itemList
cache.grandTotal = data.grandTotal or 0
cache.chestCount = data.chestCount or 0
cache.totalSlots = data.totalSlots or 0
cache.usedSlots = data.usedSlots or 0
cache.freeSlots = data.freeSlots or 0
cache.usedRatio = data.usedRatio or 0
cache.dropperOk = data.dropperOk or false
cache.barrelOk = data.barrelOk or false
cache.furnaceCount = data.furnaceCount or 0
cache.furnaceStatus = data.furnaceStatus or {}
cache.droppers = data.droppers or {}
else
error("invalid cache data")
end
end)
if not ok then
log.warn("INIT", "Could not load cache: %s", tostring(err))
return false
end
return true
end
-------------------------------------------------
-- Monitor setup
-------------------------------------------------
local mon = nil
local monName = nil
local smelterMon = nil
local smelterMonName = nil
local networkModem = nil
local networkModemName = nil
local craftTurtleName = 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
-------------------------------------------------
-- UI State
-------------------------------------------------
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
-- Keyboard layout
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" -- "status" or "recipes"
local smelterPage = 1
local smelterTotalPages = 1
local smelterTouchZones = {}
local smelterPendingZones = {}
local smelterNeedsRedraw = true
local smeltingPaused = false
local disabledRecipes = {} -- { ["minecraft:raw_iron"] = true }
local function loadDisabledRecipes()
if not fs.exists(DISABLED_RECIPES_FILE) then return end
pcall(function()
local f = fs.open(DISABLED_RECIPES_FILE, "r")
local raw = f.readAll()
f.close()
local data = textutils.unserialise(raw)
if type(data) == "table" then
if data.disabled then disabledRecipes = data.disabled end
if data.paused ~= nil then smeltingPaused = data.paused end
end
end)
end
local function saveDisabledRecipes()
pcall(function()
local f = fs.open(DISABLED_RECIPES_FILE, "w")
f.write(textutils.serialise({ disabled = disabledRecipes, paused = smeltingPaused }))
f.close()
end)
end
-- Get items filtered by search query
local function getFilteredItems()
ensureItemList()
return ui.getFilteredItems(cache.itemList, searchQuery)
end
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
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 -- set to window target before each draw cycle
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
-------------------------------------------------
-- Dashboard (reads ONLY from cache — no peripheral calls — instant)
-------------------------------------------------
local function drawDashboard()
if not mon then return end
local w, h = mon.getSize()
pendingZones = {}
-- Offscreen buffer
setDrawTarget(window.create(mon, 1, 1, w, h, false))
draw.setBackgroundColor(colors.black)
draw.clear()
-- ===== 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
-- Activity indicators
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
-- Refresh button
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)
-- Keyboard toggle button
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)
-- Search query display
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)
-- Filter items
local filteredItems = getFilteredItems()
-- Pagination
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
-- Page controls (right side of row 6)
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 (paginated + filtered) =====
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
-- Fill remaining empty item rows
local lastItemRow = h - 3
while row <= lastItemRow do
monFill(row, colors.black)
row = row + 1
end
if showKeyboard then
-- ===== Keyboard overlay (bottom 3 rows: h-2, h-1, h) =====
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)
-- Calculate total row width for centering
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
-- Draw letter keys
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
-- Draw special keys
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
-- Show low-stock alerts scrolling through them
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) =====
ensureItemList()
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
-- Flush to monitor
draw.setVisible(true)
-- Swap zones
touchZones = pendingZones
end
-------------------------------------------------
-- Crafting helpers (delegated to shared ui module)
-------------------------------------------------
--- Get stock total for an item from catalogue
local function getItemTotal(itemName)
local have = 0
if cache.catalogue[itemName] then
for _, src in ipairs(cache.catalogue[itemName]) do
have = have + src.total
end
end
return have
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
--- Execute a craft via the networked turtle.
-- Sends a craft_request message via modem with ingredient locations.
-- The turtle pulls items herself, crafts, pushes results back, and replies.
-- Requires the turtle to have a wired modem and run the updated craftingTurtle.lua.
local CRAFT_TIMEOUT = 15 -- seconds to wait for turtle reply
local function craftItem(recipeIdx)
local recipe = CRAFTABLE[recipeIdx]
if not recipe then
log.error("CRAFT", "Invalid recipe index: %s", tostring(recipeIdx))
return false, "Invalid recipe"
end
if not craftTurtleName then
log.error("CRAFT", "No turtle detected on network")
return false, "No turtle"
end
if not networkModem then
log.error("CRAFT", "No modem available for craft commands")
return false, "No modem"
end
-- Verify the turtle is still on the network
if not peripheral.isPresent(craftTurtleName) then
log.error("CRAFT", "Turtle offline: %s", craftTurtleName)
return false, "Turtle offline"
end
log.info("CRAFT", "Starting craft: %s (turtle: %s)", recipe.output, craftTurtleName)
activity.crafting = true
needsRedraw = true
smelterNeedsRedraw = true
local chests = getChests()
-- Build the slot map: for each grid position that needs an ingredient,
-- find the exact chest and slot where the item is located.
local slotMap = {} -- turtleSlot -> { chestName, chestSlot, itemName, count }
local reservedSlots = {} -- "chestName:slot" -> true (prevent double-booking)
for gridPos = 1, 9 do
local itemName = recipe.grid[gridPos]
if itemName then
local turtleSlot = GRID_TO_SLOT[gridPos]
local found = false
if cache.catalogue[itemName] then
for _, source in ipairs(cache.catalogue[itemName]) do
local chest = wrapCached(source.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
local key = source.chest .. ":" .. slot
if slotItem.name == itemName and not reservedSlots[key] then
slotMap[tostring(turtleSlot)] = {
chestName = source.chest,
chestSlot = slot,
itemName = itemName,
count = 1,
}
reservedSlots[key] = true
found = true
break
end
end
end
if found then break end
end
end
if not found then
log.error("CRAFT", "Cannot find %s in storage, aborting", itemName)
activity.crafting = false
needsRedraw = true
smelterNeedsRedraw = true
return false, "Missing ingredient: " .. itemName
end
end
end
-- Send craft request to turtle via modem
local craftMessage = {
type = "craft_request",
recipeIdx = recipeIdx,
output = recipe.output,
slots = slotMap,
returnChests = chests,
}
log.info("CRAFT", "Sending craft request to turtle on channel %d", CRAFT_CHANNEL)
networkModem.transmit(CRAFT_CHANNEL, CRAFT_REPLY_CHANNEL, craftMessage)
-- Adjust cache: mark ingredients as "in transit" (removed from chests)
for _, info in pairs(slotMap) do
adjustCache(info.itemName, info.chestName, -info.count)
end
-- Wait for reply from turtle with timeout
log.info("CRAFT", "Waiting for turtle reply (timeout: %ds)...", CRAFT_TIMEOUT)
local deadline = os.clock() + CRAFT_TIMEOUT
local result = nil
while os.clock() < deadline do
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
local event, p1, p2, p3, p4 = os.pullEvent()
if event == "modem_message" then
local channel = p2
local message = p4
if channel == CRAFT_REPLY_CHANNEL and type(message) == "table" and message.type == "craft_result" then
result = message
break
end
elseif event == "timer" and p1 == timerId then
-- Timeout tick, loop will check deadline
end
end
activity.crafting = false
needsRedraw = true
smelterNeedsRedraw = true
if not result then
-- Timeout — turtle didn't respond. Items may be stuck in turtle.
-- We've already adjusted the cache as if ingredients were consumed.
-- A manual scan will reconcile later.
log.error("CRAFT", "TIMEOUT: No reply from turtle within %ds", CRAFT_TIMEOUT)
log.error("CRAFT", "Items may be stuck in turtle. Run a manual scan to reconcile.")
return false, "Turtle timeout"
end
if result.success then
-- Craft succeeded. The turtle already pushed results back to chests.
-- We need to credit the output items to the cache.
-- The ingredients were already debited above.
-- The turtle reports what it pushed; we credit the output item.
local totalOutput = result.totalOutput or recipe.count
-- Credit output across the chests (we don't know exactly which chest
-- the turtle pushed to, so trigger a targeted rescan)
-- For now, credit to the first available chest as an approximation.
-- The next periodic scan will reconcile.
if result.results then
for _, r in ipairs(result.results) do
-- Find which chest likely received it
for _, ch in ipairs(chests) do
local chest = wrapCached(ch)
if chest then
for slot, slotItem in pairs(chest.list()) do
if slotItem.name == r.name then
-- Found the output in a chest, credit it
adjustCache(r.name, ch, r.count)
goto credited
end
end
end
end
-- If we can't find it, still credit to first chest for cache consistency
if #chests > 0 then
adjustCache(r.name, chests[1], r.count)
end
::credited::
end
else
-- Fallback: credit the expected output
if #chests > 0 then
adjustCache(recipe.output, chests[1], totalOutput)
end
end
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
log.info("CRAFT", "OK: %s x%d", short, totalOutput)
return true
else
-- Craft failed. The turtle returned ingredients to chests.
-- We already debited the cache; now credit them back.
-- The returned items are back in the chests, so re-credit them.
for turtleSlotStr, info in pairs(slotMap) do
-- Re-credit the ingredient (it was returned)
adjustCache(info.itemName, info.chestName, info.count)
end
local errMsg = result.error or "Craft failed"
log.error("CRAFT", "Failed: %s", errMsg)
return false, errMsg
end
end
-------------------------------------------------
-- Smelter Dashboard
-------------------------------------------------
local function drawSmelterDashboard()
if not smelterMon then return end
local w, h = smelterMon.getSize()
smelterPendingZones = {}
-- Offscreen buffer
setDrawTarget(window.create(smelterMon, 1, 1, w, h, false))
draw.setBackgroundColor(colors.black)
draw.clear()
-- ===== 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)
-- Pause/Resume button
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")
-- Hoisted counter: reused by bottom accent bar to avoid re-scanning recipes
local craftAvailCount = nil
if smelterView == "status" then
-- ===== Furnace Status View =====
-- Column headers
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)
-- Furnace rows
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)
-- Number
monWrite(2, y, string.format("%d", i), colors.lightBlue, rowBg)
-- Type abbreviation
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)
-- Input
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
-- Output
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
-- Fuel
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
-- Status indicator
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
-- Fill remaining rows
while row <= h - 2 do
monFill(row, colors.black)
row = row + 1
end
elseif smelterView == "smelt" then
-- ===== Smelt Recipe Manager View =====
-- Build sorted recipe list
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 = ""
if recipe.furnaceSet["minecraft:furnace"] then types = types .. "F" end
if recipe.furnaceSet["minecraft:smoker"] then types = types .. "S" end
if recipe.furnaceSet["minecraft:blast_furnace"] then types = types .. "B" end
local enabled = not disabledRecipes[inputName]
local inStorage = 0
if cache.catalogue[inputName] then
for _, s in ipairs(cache.catalogue[inputName]) do
inStorage = inStorage + s.total
end
end
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)
-- Column positions
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
-- Column headers
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)
-- Bulk action buttons on tab row
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)
-- Recipe rows
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)
-- Input name
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)
-- Output
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)
-- Types
monWrite(typeCol, y, r.types, colors.orange, rowBg)
-- Stock
monWrite(stockCol, y, tostring(r.inStorage), colors.yellow, rowBg)
-- Toggle button
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
-- Fill remaining rows
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 turtleOk = craftTurtleName and peripheral.isPresent(craftTurtleName)
local tLabel = turtleOk and " Turtle OK " or " No Turtle "
local tBg = turtleOk and colors.lime or colors.red
local tFg = turtleOk and colors.black or colors.white
monWrite(w - #tLabel, 4, tLabel, tFg, tBg)
-- Build list of craftable recipes
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
craftAvailCount = #availList
-- Column headers
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)
-- MAKE button
if turtleOk 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 =====
-- Build list of recipes that CANNOT be crafted
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)
-- Build summary string
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
craftAvailCount = #CRAFTABLE - #missList
-- Column headers
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)
-- Missing items summary
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 availC = craftAvailCount or 0
bottomMsg = string.format(" Available: %d/%d recipes ", availC, #CRAFTABLE)
end
monCenter(h, bottomMsg, colors.pink, colors.purple)
-- Flush to monitor
draw.setVisible(true)
-- Swap zones
smelterTouchZones = smelterPendingZones
end
-------------------------------------------------
-- Barrel auto-sort
-------------------------------------------------
local function sortBarrel(barrelOverride)
local barrelTarget = (barrelOverride and barrelOverride ~= "") and barrelOverride or BARREL_NAME
local barrel = wrapCached(barrelTarget)
if not barrel then return end
local contents = barrel.list()
if not contents or not next(contents) then return end
activity.sorting = true
needsRedraw = true
local catalogue = cache.catalogue
local chests = getChests()
for slot, item in pairs(contents) do
local moved = 0
local triedChests = {} -- dedup: skip chests already tried via catalogue
if catalogue[item.name] then
for _, entry in ipairs(catalogue[item.name]) do
triedChests[entry.chest] = true
local n = barrel.pushItems(entry.chest, slot)
if n and n > 0 then
moved = moved + n
adjustCache(item.name, entry.chest, n)
needsRedraw = true
smelterNeedsRedraw = true
log.info("SORT", "%s x%d -> %s", item.name, n, entry.chest)
end
if moved >= item.count then break end
end
end
if moved < item.count then
for _, chest in ipairs(chests) do
if not triedChests[chest] then
local n = barrel.pushItems(chest, slot)
if n and n > 0 then
moved = moved + n
adjustCache(item.name, chest, n)
needsRedraw = true
smelterNeedsRedraw = true
log.info("SORT", "%s x%d -> %s", item.name, n, chest)
end
if moved >= item.count then break end
end
end
end
if moved < item.count then
log.warn("SORT", "Could not sort %d remaining %s", item.count - moved, item.name)
end
end
activity.sorting = false
needsRedraw = true
end
-------------------------------------------------
-- Auto-smelt
-------------------------------------------------
local function autoSmelt()
if smeltingPaused then return false end
local furnaces = getFurnaces()
if #furnaces == 0 then return end
local chests = getChests()
local catalogue = cache.catalogue
local didWork = false
local emptyInputFurnaces = {} -- collected during steps 1-3, filled in step 5
for _, fname in ipairs(furnaces) do
local furnace = wrapCached(fname)
if furnace then
local contents = furnace.list()
-- 1) Pull finished output (slot 3) back to chests
if contents[SLOT_OUTPUT] then
local outputItem = contents[SLOT_OUTPUT]
local remaining = outputItem.count
for _, chest in ipairs(chests) do
local n = furnace.pushItems(chest, SLOT_OUTPUT)
if n and n > 0 then
adjustCache(outputItem.name, chest, n)
remaining = remaining - n
log.info("SMELT", "Output %s x%d -> %s",
outputItem.name, n, chest)
didWork = true
if remaining <= 0 then break end
end
end
if remaining > 0 then
log.warn("SMELT", "Could not move %d %s from %s output (chests full?)",
remaining, outputItem.name, fname)
end
end
-- Also check all slots in case output ended up elsewhere
-- Some modded furnaces or CC versions may use different slot indices
for slot, item in pairs(contents) do
if slot ~= SLOT_INPUT and slot ~= SLOT_FUEL and slot ~= SLOT_OUTPUT then
for _, chest in ipairs(chests) do
local n = furnace.pushItems(chest, slot)
if n and n > 0 then
adjustCache(item.name, chest, n)
log.info("SMELT", "Extra slot %d: %s x%d -> %s",
slot, item.name, n, chest)
didWork = true
break
end
end
end
end
-- Re-read after output pull
contents = furnace.list()
-- 2) Check for incompatible items in input slot and remove them
local furnaceType = peripheral.getType(fname)
local inputItem = contents[SLOT_INPUT]
if inputItem then
local recipe = SMELTABLE[inputItem.name]
local validHere = recipe and recipe.furnaceSet[furnaceType] or false
if not validHere then
-- This item doesn't belong in this furnace type — pull it out
for _, chest in ipairs(chests) do
local n = furnace.pushItems(chest, SLOT_INPUT)
if n and n > 0 then
adjustCache(inputItem.name, chest, n)
log.info("SMELT", "Removed incompatible %s x%d from %s -> %s",
inputItem.name, n, fname, chest)
didWork = true
break
end
end
-- Re-read after removal
contents = furnace.list()
end
end
-- 3) Refuel if fuel slot is empty or low
local fuelItem = contents[SLOT_FUEL]
local needFuel = not fuelItem or fuelItem.count < 8
if needFuel then
for _, fuel in ipairs(FUEL_LIST) do
if catalogue[fuel.name] then
for _, source in ipairs(catalogue[fuel.name]) do
local chest = wrapCached(source.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
if slotItem.name == fuel.name then
local toMove = math.min(16, slotItem.count)
local n = chest.pushItems(fname, slot, toMove, SLOT_FUEL)
if n and n > 0 then
adjustCache(fuel.name, source.chest, -n)
log.info("SMELT", "Fuel %s x%d -> %s",
fuel.name, n, fname)
didWork = true
needFuel = false
break
end
end
end
end
if not needFuel then break end
end
end
if not needFuel then break end
end
end
-- 4) Collect furnaces with empty input for balanced loading below
inputItem = contents[SLOT_INPUT]
if not inputItem then
table.insert(emptyInputFurnaces, { name = fname, type = furnaceType })
end
end
end
-- 5) Balanced distribution of smeltable items across all empty furnaces
-- Instead of greedily filling one furnace at a time, we spread items
-- evenly so all compatible furnaces work in parallel.
if #emptyInputFurnaces > 0 then
-- Build a unified, deduplicated candidate list across all empty furnace types
local typesSeen = {}
for _, ef in ipairs(emptyInputFurnaces) do typesSeen[ef.type] = true end
local allCandidates = {}
local candSeen = {}
for ftype in pairs(typesSeen) do
for _, cand in ipairs(smeltCandidatesByType[ftype] or {}) do
if not candSeen[cand.name] then
candSeen[cand.name] = true
table.insert(allCandidates, cand)
end
end
end
-- Sort: food first, then alphabetical (same priority as before)
table.sort(allCandidates, function(a, b)
if a.food ~= b.food then return a.food end
return a.name < b.name
end)
local usedFurnaces = {} -- furnaces already assigned an item
for _, cand in ipairs(allCandidates) do
local itemName = cand.name
if not disabledRecipes[itemName] and catalogue[itemName] then
-- Find all compatible empty furnaces not yet used
local compatFurnaces = {}
for _, ef in ipairs(emptyInputFurnaces) do
if not usedFurnaces[ef.name] and cand.recipe.furnaceSet[ef.type] then
table.insert(compatFurnaces, ef)
end
end
if #compatFurnaces > 0 then
local totalInStorage = 0
for _, src in ipairs(catalogue[itemName]) do
totalInStorage = totalInStorage + src.total
end
local available = totalInStorage - SMELT_RESERVE
if available > 0 then
local perFurnace = math.min(64, math.ceil(available / #compatFurnaces))
for _, ef in ipairs(compatFurnaces) do
if available <= 0 then break end
local toLoad = math.min(perFurnace, available)
local remaining = toLoad
local loaded = false
for _, source in ipairs(catalogue[itemName]) do
local chest = wrapCached(source.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
if slotItem.name == itemName then
local toMove = math.min(slotItem.count, remaining)
local n = chest.pushItems(ef.name, slot, toMove, SLOT_INPUT)
if n and n > 0 then
adjustCache(itemName, source.chest, -n)
log.info("SMELT", "Input %s x%d -> %s (balanced %d/furnace)",
itemName, n, ef.name, perFurnace)
didWork = true
remaining = remaining - n
available = available - n
if remaining <= 0 then
loaded = true
break
end
end
end
end
end
if loaded then break end
end
if loaded or remaining < toLoad then
usedFurnaces[ef.name] = true
end
end
end
end
end
end
end
return didWork
end
-------------------------------------------------
-- Defrag (consolidate partial stacks)
-------------------------------------------------
local function defragInventory()
local chests = getChests()
if #chests == 0 then return end
activity.defragging = true
needsRedraw = true
-- Build a map: itemName -> list of { chest, slot, count, maxCount }
local itemSlots = {}
for _, chestName in ipairs(chests) do
local inv = wrapCached(chestName)
if inv then
local contents = inv.list()
local detail_cache = {}
for slot, item in pairs(contents) do
if not itemSlots[item.name] then
itemSlots[item.name] = {}
end
-- CC:Tweaked: getItemDetail gives maxCount (stack size)
local maxCount = 64
local ok, detail = pcall(inv.getItemDetail, slot)
if ok and detail and detail.maxCount then
maxCount = detail.maxCount
end
table.insert(itemSlots[item.name], {
chest = chestName,
slot = slot,
count = item.count,
max = maxCount,
})
end
end
end
-- For each item, try to merge partial stacks
local totalMerged = 0
for itemName, slots in pairs(itemSlots) do
-- Sort: smallest stacks first (donors), fullest last (receivers)
table.sort(slots, function(a, b) return a.count < b.count end)
local i = 1 -- donor (smallest)
local j = #slots -- receiver (largest, has room)
while i < j do
local donor = slots[i]
local recv = slots[j]
-- Skip if same slot
if donor.chest == recv.chest and donor.slot == recv.slot then
i = i + 1
elseif donor.count == 0 then
i = i + 1
elseif recv.count >= recv.max then
j = j - 1
else
local space = recv.max - recv.count
local toMove = math.min(donor.count, space)
local donorInv = wrapCached(donor.chest)
if donorInv then
local n = donorInv.pushItems(recv.chest, donor.slot, toMove, recv.slot)
if n and n > 0 then
donor.count = donor.count - n
recv.count = recv.count + n
totalMerged = totalMerged + n
end
end
if donor.count <= 0 then i = i + 1 end
if recv.count >= recv.max then j = j - 1 end
end
end
end
if totalMerged > 0 then
log.info("DEFRAG", "Consolidated %d items", totalMerged)
end
activity.defragging = false
needsRedraw = true
end
-------------------------------------------------
-- Auto-compost
-------------------------------------------------
local function autoCompost()
local catalogue = cache.catalogue
local chests = getChests()
local didWork = false
-- 1) Pull bone meal from hopper back to chests
local hopper = wrapCached(COMPOST_HOPPER)
if hopper then
local contents = hopper.list()
if contents then
for slot, item in pairs(contents) do
for _, chest in ipairs(chests) do
local n = hopper.pushItems(chest, slot)
if n and n > 0 then
adjustCache(item.name, chest, n)
log.info("COMPOST", "%s x%d -> %s", item.name, n, chest)
didWork = true
break
end
end
end
end
end
-- 2) Feed compostable items into dropper
local dropper = wrapCached(COMPOST_DROPPER)
if not dropper then return didWork end
-- Check how much item capacity the dropper has (slots * 64 - current items)
local dropperContents = dropper.list()
local dropperUsedSlots = 0
local dropperUsedItems = 0
if dropperContents then
for _, item in pairs(dropperContents) do
dropperUsedSlots = dropperUsedSlots + 1
dropperUsedItems = dropperUsedItems + item.count
end
end
local dropperSize = dropper.size()
local dropperFreeSlots = dropperSize - dropperUsedSlots
local dropperFreeItems = (dropperSize * 64) - dropperUsedItems
if dropperFreeItems <= 0 then return didWork end
for _, itemName in ipairs(COMPOSTABLE) do
if dropperFreeItems <= 0 then break end
if catalogue[itemName] then
-- Count total in storage
local totalInStorage = 0
for _, src in ipairs(catalogue[itemName]) do
totalInStorage = totalInStorage + src.total
end
local reserve = COMPOST_TRASH[itemName] and 0 or COMPOST_RESERVE
local available = totalInStorage - reserve
if available > 0 then
local toFeed = math.min(available, dropperFreeItems)
local fed = 0
for _, source in ipairs(catalogue[itemName]) do
local chest = wrapCached(source.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
if slotItem.name == itemName then
local batch = math.min(slotItem.count, toFeed - fed)
local n = chest.pushItems(COMPOST_DROPPER, slot, batch)
if n and n > 0 then
adjustCache(itemName, source.chest, -n)
fed = fed + n
didWork = true
log.info("COMPOST", "Fed %s x%d -> dropper",
itemName, n)
if fed >= toFeed then break end
end
end
end
end
if fed >= toFeed then break end
end
dropperFreeItems = dropperFreeItems - fed
end
end
end
return didWork
end
-------------------------------------------------
-- Low-stock alert checker
-------------------------------------------------
local function checkAlerts()
local alerts = {}
for _, alert in ipairs(LOW_STOCK_ALERTS) do
local total = 0
if cache.catalogue[alert.name] then
for _, src in ipairs(cache.catalogue[alert.name]) do
total = total + src.total
end
end
if total < alert.min then
table.insert(alerts, {
label = alert.label,
current = total,
min = alert.min,
})
end
end
activeAlerts = alerts
if #alerts > 0 then
needsRedraw = true
smelterNeedsRedraw = true
end
end
-------------------------------------------------
-- Order
-------------------------------------------------
local function orderItem(itemName, amount, dropperOverride)
activity.dispensing = true
needsRedraw = true
-- Use client-specified dropper if provided, otherwise master's default
local dropperTarget = (dropperOverride and dropperOverride ~= "") and dropperOverride or DROPPER_NAME
local catalogue = cache.catalogue
if not catalogue[itemName] then
statusMessage = "Not found: " .. itemName:gsub("^minecraft:", "")
statusColor = colors.red
statusTimer = 5
activity.dispensing = false
needsRedraw = true
return false
end
local dropper = wrapCached(dropperTarget)
if not dropper then
statusMessage = "Dropper offline: " .. dropperTarget
statusColor = colors.red
statusTimer = 5
activity.dispensing = false
needsRedraw = true
return false
end
local remaining = amount
for _, entry in ipairs(catalogue[itemName]) do
local chest = wrapCached(entry.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
if slotItem.name == itemName then
local toMove = math.min(remaining, slotItem.count)
local moved = chest.pushItems(dropperTarget, slot, toMove)
if moved and moved > 0 then
remaining = remaining - moved
adjustCache(itemName, entry.chest, -moved)
needsRedraw = true
smelterNeedsRedraw = true
log.info("ORDER", "%s x%d from %s", itemName, moved, entry.chest)
end
if remaining <= 0 then break end
end
end
end
if remaining <= 0 then break end
end
local sent = amount - remaining
local short = itemName:gsub("^minecraft:", ""):gsub("_", " ")
if sent > 0 then
statusMessage = string.format("Dispensing %s x%d", short, sent)
statusColor = colors.lime
log.info("ORDER", "Ordered %s x%d", short, sent)
else
statusMessage = "Could not order " .. short
statusColor = colors.red
end
statusTimer = 5
activity.dispensing = false
needsRedraw = true
return sent > 0
end
-------------------------------------------------
-- Touch handler (no peripheral calls — instant)
-------------------------------------------------
local function handleTouch(x, y)
local action, data = hitTest(x, y)
if not action then
log.debug("TOUCH", "No zone hit")
return
end
if action == "amount" then
selectedAmount = data
log.debug("UI", "Amount set to %s", data)
needsRedraw = true
elseif action == "order" then
local itemName = data
if itemName then
local short = itemName:gsub("^minecraft:", ""):gsub("_", " ")
statusMessage = string.format("Ordering %s x%d...", short, selectedAmount)
statusColor = colors.cyan
statusTimer = 10
activity.dispensing = true
needsRedraw = true
orderItem(itemName, selectedAmount)
end
elseif action == "scan" then
statusMessage = "Refreshing..."
statusColor = colors.cyan
statusTimer = 3
needsRedraw = true
log.debug("UI", "Manual refresh")
elseif action == "kb_toggle" then
showKeyboard = not showKeyboard
log.debug("UI", "Keyboard %s", showKeyboard and "open" or "closed")
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
log.debug("UI", "Keyboard closed")
needsRedraw = true
elseif action == "kb_clear" then
searchQuery = ""
currentPage = 1
log.debug("UI", "Search cleared")
needsRedraw = true
elseif action == "page_prev" then
if currentPage > 1 then
currentPage = currentPage - 1
log.debug("UI", "Page %d", currentPage)
end
needsRedraw = true
elseif action == "page_next" then
if currentPage < totalPages then
currentPage = currentPage + 1
log.debug("UI", "Page %d", currentPage)
end
needsRedraw = true
end
end
-------------------------------------------------
-- Smelter touch handler
-------------------------------------------------
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
log.debug("UI", "Tab: %s", data)
smelterNeedsRedraw = true
elseif action == "toggle_pause" then
smeltingPaused = not smeltingPaused
log.debug("UI", "Smelting %s", smeltingPaused and "PAUSED" or "RESUMED")
saveDisabledRecipes()
smelterNeedsRedraw = true
needsRedraw = true
elseif action == "toggle_recipe" then
if disabledRecipes[data] then
disabledRecipes[data] = nil
else
disabledRecipes[data] = true
end
local short = data:gsub("^minecraft:", ""):gsub("_", " ")
log.debug("UI", "Recipe %s: %s", short, disabledRecipes[data] and "OFF" or "ON")
saveDisabledRecipes()
smelterNeedsRedraw = true
elseif action == "enable_all" then
disabledRecipes = {}
log.debug("UI", "All recipes enabled")
saveDisabledRecipes()
smelterNeedsRedraw = true
elseif action == "disable_all" then
for inputName in pairs(SMELTABLE) do
disabledRecipes[inputName] = true
end
log.debug("UI", "All recipes disabled")
saveDisabledRecipes()
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
local recipeIdx = data
local recipe = CRAFTABLE[recipeIdx]
if recipe then
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
log.info("CRAFT", "Craft request: %s (#%d)", short, recipeIdx)
local ok, err = craftItem(recipeIdx)
if ok then
statusMessage = "Crafted " .. short .. " x" .. recipe.count
statusColor = colors.lime
else
statusMessage = "Craft failed: " .. (err or "unknown")
statusColor = colors.red
end
statusTimer = 5
needsRedraw = true
smelterNeedsRedraw = true
end
end
end
-------------------------------------------------
-- Network broadcast (sends state to client displays)
-------------------------------------------------
local function broadcastState(force)
if not networkModem then return end
ensureItemList()
-- Build dynamic state (always included)
local state = {
type = "state",
cache = {
itemList = cache.itemList,
grandTotal = cache.grandTotal,
chestCount = cache.chestCount,
totalSlots = cache.totalSlots,
usedSlots = cache.usedSlots,
freeSlots = cache.freeSlots,
usedRatio = cache.usedRatio,
dropperOk = cache.dropperOk,
barrelOk = cache.barrelOk,
furnaceCount = cache.furnaceCount,
furnaceStatus = cache.furnaceStatus,
droppers = cache.droppers,
},
activity = activity,
alerts = activeAlerts,
smeltingPaused = smeltingPaused,
disabledRecipes = disabledRecipes,
craftTurtleOk = craftTurtleName and peripheral.isPresent(craftTurtleName),
}
-- Include static config only when dirty (startup, scan, recipe toggles)
if configDirty then
state.smeltable = SMELTABLE
state.craftable = CRAFTABLE
configDirty = false
end
networkModem.transmit(BROADCAST_CHANNEL, ORDER_CHANNEL, state)
lastBroadcastVersion = stateVersion
end
-------------------------------------------------
-- Main
-------------------------------------------------
local function main()
print("=================================")
print(" Inventory Manager v2 (Touch)")
print("=================================")
print("")
if peripheral.isPresent(DROPPER_NAME) then
print("[OK] Dropper: " .. DROPPER_NAME)
else
print("[WARN] Dropper not found: " .. DROPPER_NAME)
end
if peripheral.isPresent(BARREL_NAME) then
print("[OK] Barrel: " .. BARREL_NAME)
else
print("[WARN] Barrel not found: " .. BARREL_NAME)
end
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 client communication
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" then
networkModem = peripheral.wrap(name)
networkModemName = name
networkModem.open(ORDER_CHANNEL)
networkModem.open(CRAFT_REPLY_CHANNEL)
break
end
end
if networkModem then
print("[OK] Network modem: " .. networkModemName)
else
print("[WARN] No modem found for client sync")
end
-- Detect crafting turtle on network
for _, name in ipairs(peripheral.getNames()) do
if name:match("^turtle_") then
craftTurtleName = name
break
end
end
if craftTurtleName then
print("[OK] Crafting turtle: " .. craftTurtleName)
else
print("[WARN] No crafting turtle found")
end
-- Load recipe toggles from disk
loadDisabledRecipes()
if smeltingPaused then
print("[INIT] Smelting is PAUSED (toggle on smelter monitor)")
end
local enabledCount = 0
local totalRecipeCount = 0
for _ in pairs(SMELTABLE) do totalRecipeCount = totalRecipeCount + 1 end
for k in pairs(SMELTABLE) do
if not disabledRecipes[k] then enabledCount = enabledCount + 1 end
end
print(string.format("[INIT] %d/%d recipes enabled", enabledCount, totalRecipeCount))
-- Detect compost peripherals
if peripheral.isPresent(COMPOST_DROPPER) then
print("[OK] Compost dropper: " .. COMPOST_DROPPER)
else
print("[WARN] Compost dropper not found: " .. COMPOST_DROPPER)
end
if peripheral.isPresent(COMPOST_HOPPER) then
print("[OK] Compost hopper: " .. COMPOST_HOPPER)
else
print("[WARN] Compost hopper not found: " .. COMPOST_HOPPER)
end
print(string.format("[INIT] Tracking %d low-stock alerts", #LOW_STOCK_ALERTS))
print("")
print("Console shows log. Use the monitors to interact.")
print("")
-- Try loading cached inventory from disk for instant startup
local cacheLoaded = loadCacheFromDisk()
if cacheLoaded then
print("[INIT] Loaded cached inventory (" .. #cache.itemList .. " types)")
print("[INIT] Background refresh starting...")
else
-- No cache: do full scan with progress bar
print("[INIT] No cache found. Scanning inventories...")
if mon then
local w, h = mon.getSize()
local buf = window.create(mon, 1, 1, w, h, false)
local function drawBoot(current, total, chestName)
buf.setBackgroundColor(colors.black)
buf.clear()
-- Title
buf.setBackgroundColor(colors.blue)
buf.setCursorPos(1, 1)
buf.write(string.rep(" ", w))
local title = " INVENTORY MANAGER "
buf.setCursorPos(math.floor((w - #title) / 2) + 1, 1)
buf.setTextColor(colors.white)
buf.write(title)
-- Scanning label
local midY = math.floor(h / 2)
buf.setBackgroundColor(colors.black)
buf.setTextColor(colors.lightGray)
local label = "Scanning inventories..."
buf.setCursorPos(math.floor((w - #label) / 2) + 1, midY - 2)
buf.write(label)
-- Chest name
local short = chestName or ""
if #short > w - 4 then short = ".." .. short:sub(-(w - 6)) end
buf.setTextColor(colors.gray)
buf.setCursorPos(math.floor((w - #short) / 2) + 1, midY - 1)
buf.write(short)
-- Progress bar
local barW = math.min(w - 8, 40)
local barX = math.floor((w - barW) / 2) + 1
local ratio = total > 0 and (current / total) or 0
local filled = math.floor(ratio * barW)
buf.setCursorPos(barX, midY + 1)
buf.setBackgroundColor(colors.lime)
buf.write(string.rep(" ", filled))
buf.setBackgroundColor(colors.gray)
buf.write(string.rep(" ", barW - filled))
-- Percentage + count
buf.setBackgroundColor(colors.black)
buf.setTextColor(colors.white)
local pct = string.format("%d/%d (%d%%)", current, total, math.floor(ratio * 100))
buf.setCursorPos(math.floor((w - #pct) / 2) + 1, midY + 3)
buf.write(pct)
-- Bottom accent
buf.setCursorPos(1, h)
buf.setBackgroundColor(colors.blue)
buf.write(string.rep(" ", w))
buf.setVisible(true)
buf.setVisible(false)
end
refreshCache(drawBoot)
else
refreshCache()
end
print("[INIT] Done. Found " .. #cache.itemList .. " item types.")
end
print("")
parallel.waitForAny(
-- Task 1: Background inventory scanner
function()
-- If we loaded from disk cache, refresh immediately in background
if cacheLoaded then
pcall(refreshCache)
pcall(checkAlerts)
needsRedraw = true
smelterNeedsRedraw = true
print("[INIT] Background refresh complete. " .. #cache.itemList .. " types.")
end
while true do
sleep(SCAN_INTERVAL)
pcall(refreshCache)
pcall(checkAlerts)
needsRedraw = true
smelterNeedsRedraw = true
end
end,
-- Task 2: Barrel auto-sort
function()
while true do
pcall(sortBarrel)
sleep(POLL_INTERVAL)
end
end,
-- Task 3: Auto-smelt
function()
while true do
activity.smelting = true
needsRedraw = true
smelterNeedsRedraw = true
local ok, didWork = pcall(autoSmelt)
activity.smelting = false
-- Update furnace status quickly after smelt cycle
pcall(refreshFurnaceStatus)
needsRedraw = true
smelterNeedsRedraw = true
sleep(SMELT_INTERVAL)
end
end,
-- Task 4: Defrag (consolidate partial stacks)
function()
sleep(10) -- initial delay to let first scan finish
while true do
activity.defragging = true
needsRedraw = true
pcall(defragInventory)
activity.defragging = false
needsRedraw = true
sleep(DEFRAG_INTERVAL)
end
end,
-- Task 5: Auto-compost
function()
while true do
activity.composting = true
needsRedraw = true
pcall(autoCompost)
activity.composting = false
needsRedraw = true
pcall(checkAlerts)
sleep(COMPOST_INTERVAL)
end
end,
-- Task 6: Low-stock alert checker
function()
sleep(5) -- initial delay
pcall(checkAlerts)
needsRedraw = true
while true do
sleep(ALERT_INTERVAL)
pcall(checkAlerts)
needsRedraw = true
end
end,
-- Task 7: Inventory dashboard redraw (event-driven, checks every 0.1s)
function()
needsRedraw = true
while true do
if needsRedraw then
needsRedraw = false
pcall(drawDashboard)
end
-- Decrement status timer
if statusTimer > 0 then
statusTimer = statusTimer - 0.1
if statusTimer <= 0 then
statusTimer = 0
needsRedraw = true
end
end
-- Redraw periodically for alert cycling
if #activeAlerts > 0 then
needsRedraw = true
end
sleep(0.1)
end
end,
-- Task 8: Smelter dashboard redraw
function()
smelterNeedsRedraw = true
while true do
if smelterNeedsRedraw then
smelterNeedsRedraw = false
pcall(drawSmelterDashboard)
end
sleep(0.1)
end
end,
-- Task 9: 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,
-- Task 10: Network state broadcast (skips if nothing changed)
function()
while true do
if stateVersion ~= lastBroadcastVersion then
pcall(broadcastState)
end
sleep(BROADCAST_INTERVAL)
end
end,
-- Task 11: Peripheral detach handler (invalidates cached handles)
function()
while true do
local event, name = os.pullEvent("peripheral_detach")
if name then
invalidateWrapCache(name)
invalidatePeripheralCaches()
print("[DETACH] " .. name)
end
end
end,
-- Task 12: Network order/command listener
function()
if not networkModem then return end
while true do
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
if channel == ORDER_CHANNEL and type(message) == "table" then
local handlerOk, handlerErr = pcall(function()
if message.type == "order" and message.itemName and message.amount then
print(string.format("[NET] Order: %s x%d", message.itemName, message.amount))
local pok, success = pcall(orderItem, message.itemName, message.amount, message.dropperName)
if not pok then
print("[NET] orderItem crashed: " .. tostring(success))
success = false
statusMessage = "Order error"
statusColor = colors.red
statusTimer = 5
end
pcall(function()
networkModem.transmit(replyChannel, ORDER_CHANNEL, {
type = "order_result",
success = success,
message = statusMessage,
color = statusColor,
})
end)
pcall(broadcastState)
elseif message.type == "scan" then
print("[NET] Scan request from client")
configDirty = true -- resend full config after scan
pcall(refreshCache)
pcall(checkAlerts)
needsRedraw = true
smelterNeedsRedraw = true
pcall(broadcastState)
elseif message.type == "toggle_pause" then
smeltingPaused = not smeltingPaused
print("[NET] Smelting " .. (smeltingPaused and "PAUSED" or "RESUMED"))
saveDisabledRecipes()
bumpStateVersion()
smelterNeedsRedraw = true
needsRedraw = true
pcall(broadcastState)
elseif message.type == "toggle_recipe" and message.recipe then
if disabledRecipes[message.recipe] then
disabledRecipes[message.recipe] = nil
else
disabledRecipes[message.recipe] = true
end
print("[NET] Recipe toggle: " .. message.recipe)
saveDisabledRecipes()
configDirty = true
bumpStateVersion()
smelterNeedsRedraw = true
pcall(broadcastState)
elseif message.type == "enable_all" then
disabledRecipes = {}
print("[NET] All recipes enabled")
saveDisabledRecipes()
configDirty = true
bumpStateVersion()
smelterNeedsRedraw = true
pcall(broadcastState)
elseif message.type == "disable_all" then
for inputName in pairs(SMELTABLE) do
disabledRecipes[inputName] = true
end
print("[NET] All recipes disabled")
saveDisabledRecipes()
configDirty = true
bumpStateVersion()
smelterNeedsRedraw = true
pcall(broadcastState)
elseif message.type == "sort_barrel" and message.barrelName then
print("[NET] Sort barrel: " .. message.barrelName)
pcall(sortBarrel, message.barrelName)
pcall(broadcastState)
elseif message.type == "register_droppers" and message.clientId and message.droppers then
-- Client is announcing its locally-attached droppers
local cid = tostring(message.clientId)
clientDroppers[cid] = message.droppers
print(string.format("[NET] Client %s registered %d dropper(s)", cid, #message.droppers))
-- Rebuild the merged dropper list immediately
local seenNames = {}
local merged = {}
-- Master droppers first
for _, d in ipairs(cache.droppers or {}) do
if not d.clientId then
table.insert(merged, d)
seenNames[d.name] = true
end
end
-- Then all client droppers
for clientId, clientList in pairs(clientDroppers) do
for _, d in ipairs(clientList) do
if not seenNames[d.name] then
table.insert(merged, { name = d.name, isDefault = false, clientId = clientId })
seenNames[d.name] = true
end
end
end
cache.droppers = merged
pcall(broadcastState)
elseif message.type == "craft" and message.recipeIdx then
print(string.format("[NET] Craft request: recipe #%d", message.recipeIdx))
local pok, ok, err = pcall(craftItem, message.recipeIdx)
if not pok then
print("[NET] craftItem crashed: " .. tostring(ok))
err = tostring(ok)
ok = false
end
pcall(function()
networkModem.transmit(replyChannel, ORDER_CHANNEL, {
type = "craft_result",
success = ok,
error = err,
})
end)
smelterNeedsRedraw = true
needsRedraw = true
pcall(broadcastState)
end
end) -- end pcall handler
if not handlerOk then
print("[NET] Handler error: " .. tostring(handlerErr))
end
end
end
end
)
end
main()