2996 lines
110 KiB
Lua
2996 lines
110 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
|
|
|
|
-------------------------------------------------
|
|
-- Idempotent command tracking
|
|
-------------------------------------------------
|
|
|
|
local processedCmdIds = {} -- commandId -> os.epoch("utc") ms
|
|
local CMD_TTL_MS = 300000 -- 5 minutes
|
|
|
|
local function isCommandDuplicate(commandId)
|
|
if not commandId then return false end
|
|
local entry = processedCmdIds[commandId]
|
|
if entry and (os.epoch("utc") - entry) < CMD_TTL_MS then
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function recordCommandId(commandId)
|
|
if not commandId then return end
|
|
processedCmdIds[commandId] = os.epoch("utc")
|
|
end
|
|
|
|
local function cleanupCommandIds()
|
|
local cutoff = os.epoch("utc") - CMD_TTL_MS
|
|
for id, ts in pairs(processedCmdIds) do
|
|
if ts < cutoff then processedCmdIds[id] = nil end
|
|
end
|
|
end
|
|
|
|
-------------------------------------------------
|
|
-- 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 level
|
|
if cfg.logLevel then log.setLevel(cfg.logLevel) 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, p5 = 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
|
|
elseif channel == ORDER_CHANNEL then
|
|
-- Re-queue so Task 12 picks it up after craftItem returns
|
|
os.queueEvent(event, p1, p2, p3, p4, p5)
|
|
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 false 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
|
|
|
|
-- Snapshot: adjustCache may remove entries during iteration
|
|
local srcSnapshot = { table.unpack(catalogue[itemName]) }
|
|
for _, source in ipairs(srcSnapshot) do
|
|
if source.total > 0 then
|
|
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
|
|
end -- source.total > 0
|
|
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()
|
|
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
|
|
-- Update catalogue for cross-chest moves
|
|
if donor.chest ~= recv.chest then
|
|
adjustCache(itemName, donor.chest, -n)
|
|
adjustCache(itemName, recv.chest, n)
|
|
end
|
|
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 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
|
|
-- Snapshot: adjustCache may remove entries during iteration
|
|
local srcSnapshot = { table.unpack(catalogue[itemName]) }
|
|
for _, source in ipairs(srcSnapshot) do
|
|
if source.total > 0 then
|
|
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
|
|
end -- source.total > 0
|
|
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
|
|
-- Snapshot: adjustCache may remove entries during iteration
|
|
local srcSnapshot = { table.unpack(catalogue[itemName]) }
|
|
for _, entry in ipairs(srcSnapshot) do
|
|
if entry.total > 0 then
|
|
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
|
|
end -- entry.total > 0
|
|
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()
|
|
if not networkModem then return end
|
|
ensureItemList()
|
|
|
|
-- Build dynamic state (always included)
|
|
local state = {
|
|
type = "state",
|
|
stateVersion = stateVersion,
|
|
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
|
|
log.info("INIT", "Dropper: %s", DROPPER_NAME)
|
|
else
|
|
log.warn("INIT", "Dropper not found: %s", DROPPER_NAME)
|
|
end
|
|
|
|
if peripheral.isPresent(BARREL_NAME) then
|
|
log.info("INIT", "Barrel: %s", BARREL_NAME)
|
|
else
|
|
log.warn("INIT", "Barrel not found: %s", BARREL_NAME)
|
|
end
|
|
|
|
if setupMonitor() then
|
|
log.info("INIT", "Monitor: %s", monName or MONITOR_SIDE)
|
|
else
|
|
log.warn("INIT", "No monitor on %s", MONITOR_SIDE)
|
|
end
|
|
|
|
if setupSmelterMonitor() then
|
|
log.info("INIT", "Smelter monitor: %s", smelterMonName or SMELTER_MONITOR_SIDE)
|
|
else
|
|
log.warn("INIT", "No smelter monitor on %s", 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
|
|
log.info("INIT", "Network modem: %s", networkModemName)
|
|
else
|
|
log.warn("INIT", "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
|
|
log.info("INIT", "Crafting turtle: %s", craftTurtleName)
|
|
else
|
|
log.warn("INIT", "No crafting turtle found")
|
|
end
|
|
|
|
-- Load recipe toggles from disk
|
|
loadDisabledRecipes()
|
|
if smeltingPaused then
|
|
log.info("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
|
|
log.info("INIT", "%d/%d recipes enabled", enabledCount, totalRecipeCount)
|
|
|
|
-- Detect compost peripherals
|
|
if peripheral.isPresent(COMPOST_DROPPER) then
|
|
log.info("INIT", "Compost dropper: %s", COMPOST_DROPPER)
|
|
else
|
|
log.warn("INIT", "Compost dropper not found: %s", COMPOST_DROPPER)
|
|
end
|
|
if peripheral.isPresent(COMPOST_HOPPER) then
|
|
log.info("INIT", "Compost hopper: %s", COMPOST_HOPPER)
|
|
else
|
|
log.warn("INIT", "Compost hopper not found: %s", COMPOST_HOPPER)
|
|
end
|
|
|
|
log.info("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
|
|
log.info("INIT", "Loaded cached inventory (%d types)", #cache.itemList)
|
|
log.info("INIT", "Background refresh starting...")
|
|
else
|
|
-- No cache: do full scan with progress bar
|
|
log.info("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
|
|
log.info("INIT", "Done. Found %d item types.", #cache.itemList)
|
|
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
|
|
log.info("INIT", "Background refresh complete. %d types.", #cache.itemList)
|
|
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
|
|
local ok, didWork = pcall(autoSmelt)
|
|
if ok and didWork then
|
|
activity.smelting = true
|
|
needsRedraw = true
|
|
smelterNeedsRedraw = true
|
|
end
|
|
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
|
|
log.debug("TOUCH", "x=%d y=%d", x, y)
|
|
handleSmelterTouch(x, y)
|
|
else
|
|
log.debug("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()
|
|
log.info("DETACH", "%s", 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
|
|
-- Idempotency: skip duplicate commands
|
|
if isCommandDuplicate(message.commandId) then
|
|
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId))
|
|
else
|
|
recordCommandId(message.commandId)
|
|
cleanupCommandIds()
|
|
local handlerOk, handlerErr = pcall(function()
|
|
if message.type == "order" and message.itemName and message.amount then
|
|
log.info("NET", "Order: %s x%d", message.itemName, message.amount)
|
|
local pok, success = pcall(orderItem, message.itemName, message.amount, message.dropperName)
|
|
if not pok then
|
|
log.error("NET", "orderItem crashed: %s", tostring(success))
|
|
success = false
|
|
statusMessage = "Order error"
|
|
statusColor = colors.red
|
|
statusTimer = 5
|
|
needsRedraw = true
|
|
end
|
|
pcall(function()
|
|
networkModem.transmit(replyChannel, ORDER_CHANNEL, {
|
|
type = "order_result",
|
|
commandId = message.commandId,
|
|
success = success,
|
|
message = statusMessage,
|
|
color = statusColor,
|
|
})
|
|
end)
|
|
pcall(broadcastState)
|
|
elseif message.type == "scan" then
|
|
log.info("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
|
|
log.info("NET", "Smelting %s", 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
|
|
log.info("NET", "Recipe toggle: %s", message.recipe)
|
|
saveDisabledRecipes()
|
|
configDirty = true
|
|
bumpStateVersion()
|
|
smelterNeedsRedraw = true
|
|
pcall(broadcastState)
|
|
elseif message.type == "enable_all" then
|
|
disabledRecipes = {}
|
|
log.info("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
|
|
log.info("NET", "All recipes disabled")
|
|
saveDisabledRecipes()
|
|
configDirty = true
|
|
bumpStateVersion()
|
|
smelterNeedsRedraw = true
|
|
pcall(broadcastState)
|
|
elseif message.type == "sort_barrel" and message.barrelName then
|
|
log.info("NET", "Sort barrel: %s", 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
|
|
log.info("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
|
|
bumpStateVersion()
|
|
pcall(broadcastState)
|
|
elseif message.type == "craft" and message.recipeIdx then
|
|
log.info("NET", "Craft request: recipe #%d", message.recipeIdx)
|
|
local pok, ok, err = pcall(craftItem, message.recipeIdx)
|
|
if not pok then
|
|
log.error("NET", "craftItem crashed: %s", tostring(ok))
|
|
err = tostring(ok)
|
|
ok = false
|
|
end
|
|
pcall(function()
|
|
networkModem.transmit(replyChannel, ORDER_CHANNEL, {
|
|
type = "craft_result",
|
|
commandId = message.commandId,
|
|
success = ok,
|
|
error = err,
|
|
})
|
|
end)
|
|
smelterNeedsRedraw = true
|
|
needsRedraw = true
|
|
pcall(broadcastState)
|
|
end
|
|
end) -- end pcall handler
|
|
if not handlerOk then
|
|
log.error("NET", "Handler error: %s", tostring(handlerErr))
|
|
end
|
|
end -- end idempotency else
|
|
end
|
|
end
|
|
end
|
|
)
|
|
end
|
|
|
|
main()
|