From acfaad67f72d2d90c008968af01064d3d5b9e367 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sun, 22 Mar 2026 11:30:57 -0400 Subject: [PATCH] Add peripheral helpers and inventory operations in operations.lua --- manager/operations.lua | 1043 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1043 insertions(+) create mode 100644 manager/operations.lua diff --git a/manager/operations.lua b/manager/operations.lua new file mode 100644 index 0000000..3c81ed3 --- /dev/null +++ b/manager/operations.lua @@ -0,0 +1,1043 @@ +-- manager/operations.lua — Peripheral helpers + all inventory operations +-- Usage: local ops = dofile("manager/operations.lua")(ctx) + +return function(ctx) + +local cfg = ctx.cfg +local state = ctx.state +local log = ctx.log +local ui = ctx.ui + +local cache = state.cache +local activity = state.activity + +local O = {} + +------------------------------------------------- +-- Peripheral caching +------------------------------------------------- + +local cachedChests = nil +local cachedChestsTime = 0 +local cachedFurnaces = nil +local cachedFurnacesTime = 0 +local wrapCache = {} + +function O.invalidatePeripheralCaches() + cachedChests = nil + cachedFurnaces = nil +end + +function O.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 + +function O.invalidateWrapCache(name) + if name then + wrapCache[name] = nil + else + wrapCache = {} + end +end + +function O.getChests() + local now = os.clock() + if cachedChests and (now - cachedChestsTime) < cfg.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 + +function O.getFurnaces() + local now = os.clock() + if cachedFurnaces and (now - cachedFurnacesTime) < cfg.PERIPHERAL_CACHE_TTL then + return cachedFurnaces + end + local furnaces = {} + for _, ftype in ipairs(cfg.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 + +function O.refreshFurnaceStatus() + local furnaces = O.getFurnaces() + local status = {} + for _, fname in ipairs(furnaces) do + local furnace = O.wrapCached(fname) + if furnace then + local contents = furnace.list() + local entry = { + name = fname, + type = peripheral.getType(fname), + input = contents[cfg.SLOT_INPUT] or nil, + fuel = contents[cfg.SLOT_FUEL] or nil, + output = contents[cfg.SLOT_OUTPUT] or nil, + active = (contents[cfg.SLOT_INPUT] ~= nil and contents[cfg.SLOT_FUEL] ~= nil), + } + table.insert(status, entry) + end + end + cache.furnaceStatus = status + state.bumpStateVersion() +end + +function O.scanInventory(deviceName) + local inv = O.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 cache refresh +------------------------------------------------- + +function O.refreshCache(onProgress) + activity.scanning = true + O.invalidateWrapCache() + + local chests = {} + local furnaces = {} + local furnaceTypeSet = {} + for _, ft in ipairs(cfg.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 + + 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 = O.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 + 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 + + 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) + + cache.catalogue = catalogue + cache.itemList = itemList + cache.itemListDirty = false + 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(cfg.DROPPER_NAME) + cache.barrelOk = peripheral.isPresent(cfg.BARREL_NAME) + + -- Discover droppers + 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 ~= cfg.COMPOST_DROPPER then + local isDefault = (name == cfg.DROPPER_NAME) + table.insert(droppers, { name = name, isDefault = isDefault }) + end + end + table.sort(droppers, function(a, b) + if a.isDefault ~= b.isDefault then return a.isDefault end + return a.name < b.name + end) + + local seenNames = {} + for _, d in ipairs(droppers) do seenNames[d.name] = true end + for clientId, clientList in pairs(state.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 + cache.furnaceCount = #furnaces + + O.refreshFurnaceStatus() + activity.scanning = false + state.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(cfg.CACHE_FILE, "w") + f.write(textutils.serialise(data)) + f.close() + end) +end + +function O.loadCacheFromDisk() + if not fs.exists(cfg.CACHE_FILE) then return false end + local ok, err = pcall(function() + local f = fs.open(cfg.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 + +------------------------------------------------- +-- Barrel auto-sort +------------------------------------------------- + +function O.sortBarrel(barrelOverride) + local barrelTarget = (barrelOverride and barrelOverride ~= "") and barrelOverride or cfg.BARREL_NAME + local barrel = O.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 + state.needsRedraw = true + + local catalogue = cache.catalogue + local chests = O.getChests() + + for slot, item in pairs(contents) do + local moved = 0 + local triedChests = {} + + 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 + state.adjustCache(item.name, entry.chest, n) + state.needsRedraw = true + state.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 + state.adjustCache(item.name, chest, n) + state.needsRedraw = true + state.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 + state.needsRedraw = true +end + +------------------------------------------------- +-- Auto-smelt +------------------------------------------------- + +function O.autoSmelt() + if state.smeltingPaused then return false end + + local furnaces = O.getFurnaces() + if #furnaces == 0 then return false end + + local chests = O.getChests() + local catalogue = cache.catalogue + local didWork = false + local emptyInputFurnaces = {} + + for _, fname in ipairs(furnaces) do + local furnace = O.wrapCached(fname) + if furnace then + local contents = furnace.list() + + -- 1) Pull finished output + if contents[cfg.SLOT_OUTPUT] then + local outputItem = contents[cfg.SLOT_OUTPUT] + local remaining = outputItem.count + for _, chest in ipairs(chests) do + local n = furnace.pushItems(chest, cfg.SLOT_OUTPUT) + if n and n > 0 then + state.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 + + -- Check extra slots + for slot, item in pairs(contents) do + if slot ~= cfg.SLOT_INPUT and slot ~= cfg.SLOT_FUEL and slot ~= cfg.SLOT_OUTPUT then + for _, chest in ipairs(chests) do + local n = furnace.pushItems(chest, slot) + if n and n > 0 then + state.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 + + contents = furnace.list() + + -- 2) Remove incompatible items from input + local furnaceType = peripheral.getType(fname) + local inputItem = contents[cfg.SLOT_INPUT] + if inputItem then + local recipe = cfg.SMELTABLE[inputItem.name] + local validHere = recipe and recipe.furnaceSet[furnaceType] or false + if not validHere then + for _, chest in ipairs(chests) do + local n = furnace.pushItems(chest, cfg.SLOT_INPUT) + if n and n > 0 then + state.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 + contents = furnace.list() + end + end + + -- 3) Refuel + local fuelItem = contents[cfg.SLOT_FUEL] + local needFuel = not fuelItem or fuelItem.count < 8 + + if needFuel then + for _, fuel in ipairs(cfg.FUEL_LIST) do + if catalogue[fuel.name] then + for _, source in ipairs(catalogue[fuel.name]) do + local chest = O.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, cfg.SLOT_FUEL) + if n and n > 0 then + state.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 empty-input furnaces + inputItem = contents[cfg.SLOT_INPUT] + if not inputItem then + table.insert(emptyInputFurnaces, { name = fname, type = furnaceType }) + end + end + end + + -- 5) Balanced distribution + if #emptyInputFurnaces > 0 then + 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(cfg.smeltCandidatesByType[ftype] or {}) do + if not candSeen[cand.name] then + candSeen[cand.name] = true + table.insert(allCandidates, cand) + end + end + end + table.sort(allCandidates, function(a, b) + if a.food ~= b.food then return a.food end + return a.name < b.name + end) + + local usedFurnaces = {} + + for _, cand in ipairs(allCandidates) do + local itemName = cand.name + if not state.disabledRecipes[itemName] and catalogue[itemName] then + 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 - cfg.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 + + local srcSnapshot = { table.unpack(catalogue[itemName]) } + for _, source in ipairs(srcSnapshot) do + if source.total > 0 then + local chest = O.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, cfg.SLOT_INPUT) + if n and n > 0 then + state.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 + 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) +------------------------------------------------- + +function O.defragInventory() + local chests = O.getChests() + if #chests == 0 then return end + + activity.defragging = true + state.needsRedraw = true + + local itemSlots = {} + for _, chestName in ipairs(chests) do + local inv = O.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 + 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 + + local totalMerged = 0 + for itemName, slots in pairs(itemSlots) do + table.sort(slots, function(a, b) return a.count < b.count end) + + local i = 1 + local j = #slots + + while i < j do + local donor = slots[i] + local recv = slots[j] + + 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 = O.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 + if donor.chest ~= recv.chest then + state.adjustCache(itemName, donor.chest, -n) + state.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 + state.needsRedraw = true +end + +------------------------------------------------- +-- Auto-compost +------------------------------------------------- + +function O.autoCompost() + local catalogue = cache.catalogue + local chests = O.getChests() + local didWork = false + + local hopper = O.wrapCached(cfg.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 + state.adjustCache(item.name, chest, n) + log.info("COMPOST", "%s x%d -> %s", item.name, n, chest) + didWork = true + break + end + end + end + end + end + + local dropper = O.wrapCached(cfg.COMPOST_DROPPER) + if not dropper then return didWork end + + local dropperContents = dropper.list() + local dropperUsedItems = 0 + if dropperContents then + for _, item in pairs(dropperContents) do + 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(cfg.COMPOSTABLE) do + if dropperFreeItems <= 0 then break end + if catalogue[itemName] then + local totalInStorage = 0 + for _, src in ipairs(catalogue[itemName]) do + totalInStorage = totalInStorage + src.total + end + + local reserve = cfg.COMPOST_TRASH[itemName] and 0 or cfg.COMPOST_RESERVE + local available = totalInStorage - reserve + if available > 0 then + local toFeed = math.min(available, dropperFreeItems) + local fed = 0 + local srcSnapshot = { table.unpack(catalogue[itemName]) } + for _, source in ipairs(srcSnapshot) do + if source.total > 0 then + local chest = O.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(cfg.COMPOST_DROPPER, slot, batch) + if n and n > 0 then + state.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 + if fed >= toFeed then break end + end + dropperFreeItems = dropperFreeItems - fed + end + end + end + + return didWork +end + +------------------------------------------------- +-- Low-stock alert checker +------------------------------------------------- + +function O.checkAlerts() + local alerts = {} + for _, alert in ipairs(cfg.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 + state.activeAlerts = alerts + if #alerts > 0 then + state.needsRedraw = true + state.smelterNeedsRedraw = true + end +end + +------------------------------------------------- +-- Order / Dispense +------------------------------------------------- + +function O.orderItem(itemName, amount, dropperOverride) + activity.dispensing = true + state.needsRedraw = true + + local dropperTarget = (dropperOverride and dropperOverride ~= "") and dropperOverride or cfg.DROPPER_NAME + local catalogue = cache.catalogue + + if not catalogue[itemName] then + state.statusMessage = "Not found: " .. itemName:gsub("^minecraft:", "") + state.statusColor = colors.red + state.statusTimer = 5 + activity.dispensing = false + state.needsRedraw = true + return false + end + + local dropper = O.wrapCached(dropperTarget) + if not dropper then + state.statusMessage = "Dropper offline: " .. dropperTarget + state.statusColor = colors.red + state.statusTimer = 5 + activity.dispensing = false + state.needsRedraw = true + return false + end + + local remaining = amount + local srcSnapshot = { table.unpack(catalogue[itemName]) } + for _, entry in ipairs(srcSnapshot) do + if entry.total > 0 then + local chest = O.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 + state.adjustCache(itemName, entry.chest, -moved) + state.needsRedraw = true + state.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 + if remaining <= 0 then break end + end + + local sent = amount - remaining + local short = itemName:gsub("^minecraft:", ""):gsub("_", " ") + if sent > 0 then + state.statusMessage = string.format("Dispensing %s x%d", short, sent) + state.statusColor = colors.lime + log.info("ORDER", "Ordered %s x%d", short, sent) + else + state.statusMessage = "Could not order " .. short + state.statusColor = colors.red + end + state.statusTimer = 5 + + activity.dispensing = false + state.needsRedraw = true + return sent > 0 +end + +------------------------------------------------- +-- Crafting +------------------------------------------------- + +function O.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 + +function O.getRecipeIngredients(recipe) + return ui.getRecipeIngredients(recipe) +end + +function O.canCraftRecipe(recipe) + return ui.canCraftRecipe(recipe, O.getItemTotal) +end + +function O.maxCraftBatches(recipe) + return ui.maxCraftBatches(recipe, O.getItemTotal) +end + +function O.getMissingIngredients(recipe) + return ui.getMissingIngredients(recipe, O.getItemTotal) +end + +function O.craftItem(recipeIdx) + local recipe = cfg.CRAFTABLE[recipeIdx] + if not recipe then + log.error("CRAFT", "Invalid recipe index: %s", tostring(recipeIdx)) + return false, "Invalid recipe" + end + if not ctx.craftTurtleName then + log.error("CRAFT", "No turtle detected on network") + return false, "No turtle" + end + if not ctx.networkModem then + log.error("CRAFT", "No modem available for craft commands") + return false, "No modem" + end + if not peripheral.isPresent(ctx.craftTurtleName) then + log.error("CRAFT", "Turtle offline: %s", ctx.craftTurtleName) + return false, "Turtle offline" + end + + log.info("CRAFT", "Starting craft: %s (turtle: %s)", recipe.output, ctx.craftTurtleName) + + activity.crafting = true + state.needsRedraw = true + state.smelterNeedsRedraw = true + + local chests = O.getChests() + local slotMap = {} + local reservedSlots = {} + + for gridPos = 1, 9 do + local itemName = recipe.grid[gridPos] + if itemName then + local turtleSlot = cfg.GRID_TO_SLOT[gridPos] + local found = false + + if cache.catalogue[itemName] then + for _, source in ipairs(cache.catalogue[itemName]) do + local chest = O.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 + state.needsRedraw = true + state.smelterNeedsRedraw = true + return false, "Missing ingredient: " .. itemName + end + end + end + + 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", cfg.CRAFT_CHANNEL) + ctx.networkModem.transmit(cfg.CRAFT_CHANNEL, cfg.CRAFT_REPLY_CHANNEL, craftMessage) + + for _, info in pairs(slotMap) do + state.adjustCache(info.itemName, info.chestName, -info.count) + end + + log.info("CRAFT", "Waiting for turtle reply (timeout: %ds)...", cfg.CRAFT_TIMEOUT) + local deadline = os.clock() + cfg.CRAFT_TIMEOUT + local result = nil + local bufferedMessages = {} + + 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 == cfg.CRAFT_REPLY_CHANNEL and type(message) == "table" and message.type == "craft_result" then + result = message + break + elseif channel == cfg.ORDER_CHANNEL then + table.insert(bufferedMessages, {event, p1, p2, p3, p4, p5}) + end + elseif event == "timer" and p1 == timerId then + -- Timeout tick + end + end + + for _, msg in ipairs(bufferedMessages) do + os.queueEvent(table.unpack(msg)) + end + + activity.crafting = false + state.needsRedraw = true + state.smelterNeedsRedraw = true + + if not result then + log.error("CRAFT", "TIMEOUT: No reply from turtle within %ds", cfg.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 + local totalOutput = result.totalOutput or recipe.count + if result.results then + for _, r in ipairs(result.results) do + for _, ch in ipairs(chests) do + local chest = O.wrapCached(ch) + if chest then + for slot, slotItem in pairs(chest.list()) do + if slotItem.name == r.name then + state.adjustCache(r.name, ch, r.count) + goto credited + end + end + end + end + if #chests > 0 then + state.adjustCache(r.name, chests[1], r.count) + end + ::credited:: + end + else + if #chests > 0 then + state.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 + for turtleSlotStr, info in pairs(slotMap) do + state.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 + +------------------------------------------------- +-- Smelting recipe persistence +------------------------------------------------- + +function O.loadDisabledRecipes() + if not fs.exists(cfg.DISABLED_RECIPES_FILE) then return end + pcall(function() + local f = fs.open(cfg.DISABLED_RECIPES_FILE, "r") + local raw = f.readAll() + f.close() + local data = textutils.unserialise(raw) + if type(data) == "table" then + if data.disabled then state.disabledRecipes = data.disabled end + if data.paused ~= nil then state.smeltingPaused = data.paused end + end + end) +end + +function O.saveDisabledRecipes() + pcall(function() + local f = fs.open(cfg.DISABLED_RECIPES_FILE, "w") + f.write(textutils.serialise({ disabled = state.disabledRecipes, paused = state.smeltingPaused })) + f.close() + end) +end + +return O + +end