-- 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 -- Parallel inventory scanning (chunked) local CHUNK = cfg.PARALLEL_SCAN_CHUNKS or 8 local scanData = {} local scanFns = {} for _, chest in ipairs(chests) do table.insert(scanFns, function() local inv = O.wrapCached(chest) if inv then scanData[chest] = { size = inv.size(), contents = inv.list(), } end end) end for i = 1, #scanFns, CHUNK do local chunk = {} local chunkEnd = math.min(i + CHUNK - 1, #scanFns) for j = i, chunkEnd do chunk[#chunk + 1] = scanFns[j] end if onProgress then onProgress(chunkEnd, #chests, chests[chunkEnd] or "scanning...") end parallel.waitForAll(table.unpack(chunk)) end for _, chest in ipairs(chests) do local data = scanData[chest] if data then totalSlots = totalSlots + data.size for slot, item in pairs(data.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 ------------------------------------------------- --- Get chests sorted by priority (highest first). -- Falls back to name order if no priority is configured. function O.getChestsByPriority() local chests = O.getChests() local priority = cfg.CHEST_PRIORITY if not priority or not next(priority) then return chests end local sorted = { table.unpack(chests) } table.sort(sorted, function(a, b) local pa = priority[a] or 0 local pb = priority[b] or 0 if pa ~= pb then return pa > pb end return a < b end) return sorted end 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.getChestsByPriority() 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 ------------------------------------------------- -- Collection hopper emptying (egg spawner, mob farm, etc.) ------------------------------------------------- function O.collectHoppers() if #cfg.COLLECTION_HOPPERS == 0 then return false end local didWork = false local chests = O.getChestsByPriority() local catalogue = cache.catalogue for _, hopperName in ipairs(cfg.COLLECTION_HOPPERS) do local hopper = O.wrapCached(hopperName) if hopper then local contents = hopper.list() if contents and next(contents) then for slot, item in pairs(contents) do local moved = 0 local triedChests = {} -- Prefer chests where item already exists if catalogue[item.name] then for _, entry in ipairs(catalogue[item.name]) do triedChests[entry.chest] = true local n = hopper.pushItems(entry.chest, slot) if n and n > 0 then moved = moved + n state.adjustCache(item.name, entry.chest, n) didWork = true log.info("COLLECT", "%s x%d -> %s (from %s)", item.name, n, entry.chest, hopperName) end if moved >= item.count then break end end end -- Overflow to any chest with space if moved < item.count then for _, chest in ipairs(chests) do if not triedChests[chest] then local n = hopper.pushItems(chest, slot) if n and n > 0 then moved = moved + n state.adjustCache(item.name, chest, n) didWork = true log.info("COLLECT", "%s x%d -> %s (from %s)", item.name, n, chest, hopperName) end if moved >= item.count then break end end end end end end end end return didWork end ------------------------------------------------- -- Auto-discard excess stock ------------------------------------------------- function O.discardExcess() if #cfg.TRASH_DROPPERS == 0 then return false end local catalogue = cache.catalogue local didWork = false -- Build a flat list of dropper handles + free capacity local droppers = {} for _, dName in ipairs(cfg.TRASH_DROPPERS) do local d = O.wrapCached(dName) if d then local used = 0 local contents = d.list() if contents then for _, item in pairs(contents) do used = used + item.count end end local free = (d.size() * 64) - used if free > 0 then table.insert(droppers, { name = dName, handle = d, free = free }) else log.debug("DISCARD", "Dropper %s is full, skipping", dName) end end end if #droppers == 0 then log.debug("DISCARD", "All trash droppers full or offline, cannot discard") return false end -- Round-robin index across droppers local dIdx = 1 for itemName, maxCount in pairs(cfg.STOCK_LIMITS) do if catalogue[itemName] then local totalInStorage = 0 for _, src in ipairs(catalogue[itemName]) do totalInStorage = totalInStorage + src.total end local excess = totalInStorage - maxCount if excess > 0 then log.info("DISCARD", "%s: %d in stock, limit %d, discarding %d", itemName, totalInStorage, maxCount, excess) local discarded = 0 local srcSnapshot = { table.unpack(catalogue[itemName]) } for _, source in ipairs(srcSnapshot) do if discarded >= excess then break end 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 -- Find a dropper with free space (round-robin) local startIdx = dIdx local dropper = nil repeat if droppers[dIdx].free > 0 then dropper = droppers[dIdx] end dIdx = (dIdx % #droppers) + 1 until dropper or dIdx == startIdx if not dropper then break end -- all droppers full local batch = math.min(slotItem.count, excess - discarded, dropper.free) local n = chest.pushItems(dropper.name, slot, batch) if n and n > 0 then state.adjustCache(itemName, source.chest, -n) dropper.free = dropper.free - n discarded = discarded + n didWork = true end if discarded >= excess then break end end end end end end if discarded > 0 then log.info("DISCARD", "Discarded %s x%d", itemName, discarded) end end end end return didWork end ------------------------------------------------- -- Auto-craft excess stock into target items ------------------------------------------------- function O.autoCraft() if not ctx.craftEngine then return false end if not ctx.craftTurtleName then return false end local catalogue = cache.catalogue local didWork = false -- Phase 1: Explicit rules from auto_craft.lua for _, rule in ipairs(cfg.AUTO_CRAFT_RULES) do local inputName = rule.input local reserve = rule.reserve or 0 local outputName = rule.output if catalogue[inputName] then local totalInStorage = 0 for _, src in ipairs(catalogue[inputName]) do totalInStorage = totalInStorage + src.total end local excess = totalInStorage - reserve if excess > 0 then local recipe = cfg.recipeBook.getCraftingRecipe(outputName) if recipe then local ingredients = cfg.recipeBook.getIngredients(recipe) local inputPerCraft = ingredients[inputName] or 0 if inputPerCraft > 0 then local batches = math.floor(excess / inputPerCraft) batches = math.min(batches, 64) if batches > 0 then local craftCount = batches * recipe.count log.info("AUTOCRAFT", "%s: %d excess (reserve %d), crafting %d x %s", inputName, excess, reserve, craftCount, outputName) local ok, err = O.recursiveCraft(outputName, craftCount) if ok then didWork = true log.info("AUTOCRAFT", "Crafted %s x%d", outputName, craftCount) else log.warn("AUTOCRAFT", "Failed to craft %s: %s", outputName, tostring(err)) end end end else log.warn("AUTOCRAFT", "No recipe found for output: %s", outputName) end end end end -- Phase 2: Smart excess-to-craft — auto-discover recipes for over-stocked items if cfg.AUTO_CRAFT_FROM_EXCESS then -- Track outputs we've already handled via explicit rules to avoid duplicates local handledOutputs = {} for _, rule in ipairs(cfg.AUTO_CRAFT_RULES) do handledOutputs[rule.output] = true end for itemName, maxCount in pairs(cfg.STOCK_LIMITS) do if catalogue[itemName] then local totalInStorage = 0 for _, src in ipairs(catalogue[itemName]) do totalInStorage = totalInStorage + src.total end local excess = totalInStorage - maxCount if excess > 0 then -- Find all crafting recipes that use this item local usingRecipes = cfg.recipeBook.findRecipesUsing(itemName) for _, recipe in ipairs(usingRecipes) do if not handledOutputs[recipe.output] then -- Check how much of the output we already have local outputTotal = O.getItemTotal(recipe.output) if outputTotal < cfg.AUTO_CRAFT_OUTPUT_CAP then local ingredients = cfg.recipeBook.getIngredients(recipe) local inputPerCraft = ingredients[itemName] or 0 if inputPerCraft > 0 then -- Only craft up to the output cap local outputRoom = cfg.AUTO_CRAFT_OUTPUT_CAP - outputTotal local maxBatches = math.floor(excess / inputPerCraft) local batchesByRoom = math.ceil(outputRoom / recipe.count) local batches = math.min(maxBatches, batchesByRoom, 64) if batches > 0 then -- Check all other ingredients are available local canCraft = true for ingr, needed in pairs(ingredients) do if ingr ~= itemName then local have = O.getItemTotal(ingr) if have < needed * batches then canCraft = false break end end end if canCraft then local craftCount = batches * recipe.count log.info("AUTOCRAFT", "Smart: %s over limit, crafting %d x %s", itemName, craftCount, recipe.output) local ok, err = O.recursiveCraft(recipe.output, craftCount) if ok then didWork = true handledOutputs[recipe.output] = true log.info("AUTOCRAFT", "Smart-crafted %s x%d", recipe.output, craftCount) -- Recalculate excess after crafting break else log.warn("AUTOCRAFT", "Smart craft failed %s: %s", recipe.output, tostring(err)) end end end end end end end end 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 ------------------------------------------------- -- Supply chest (builder / manifest-based stocking) ------------------------------------------------- function O.supplyChest() if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then return false end local supply = O.wrapCached(cfg.SUPPLY_CHEST) if not supply then return false end -- Scan what's already in the supply chest local existing = {} local contents = supply.list() if contents then for _, item in pairs(contents) do existing[item.name] = (existing[item.name] or 0) + item.count end end local catalogue = cache.catalogue local didWork = false for _, manifest in ipairs(cfg.SUPPLY_MANIFEST) do local have = existing[manifest.name] or 0 local want = manifest.count or 0 local deficit = want - have if deficit > 0 and catalogue[manifest.name] then local remaining = deficit local srcSnapshot = { table.unpack(catalogue[manifest.name]) } 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 == manifest.name then local toMove = math.min(slotItem.count, remaining) local n = chest.pushItems(cfg.SUPPLY_CHEST, slot, toMove) if n and n > 0 then state.adjustCache(manifest.name, source.chest, -n) remaining = remaining - n didWork = true log.info("SUPPLY", "%s x%d -> %s", manifest.name, n, cfg.SUPPLY_CHEST) if remaining <= 0 then break end end end end end end if remaining <= 0 then break end end end end return didWork 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, batches) batches = batches or 1 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 -- Clamp batches to 64 (max stack size per slot) batches = math.min(batches, 64) log.info("CRAFT", "Starting craft: %s x%d (%d batches, turtle: %s)", recipe.output, batches * recipe.count, batches, ctx.craftTurtleName) activity.crafting = true state.needsRedraw = true state.smelterNeedsRedraw = true local chests = O.getChests() local slotMap = {} local reservedSlots = {} -- Sum how many of each ingredient we need total local ingredientTotals = {} for gridPos = 1, 9 do local itemName = recipe.grid[gridPos] if itemName then ingredientTotals[itemName] = (ingredientTotals[itemName] or 0) + batches end end -- Check we have enough of each ingredient for itemName, needed in pairs(ingredientTotals) do local have = O.getItemTotal(itemName) if have < needed then log.error("CRAFT", "Not enough %s: have %d, need %d", itemName, have, needed) activity.crafting = false state.needsRedraw = true state.smelterNeedsRedraw = true return false, string.format("Need %d %s, have %d", needed, itemName, have) end end 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 local pullCount = math.min(batches, slotItem.count) slotMap[tostring(turtleSlot)] = { chestName = source.chest, chestSlot = slot, itemName = itemName, count = pullCount, } 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 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 end -- ORDER_CHANNEL messages are captured by the Network-capture task elseif event == "timer" and p1 == timerId then -- Timeout tick end 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 ------------------------------------------------- -- Recursive crafting (multi-step chains) ------------------------------------------------- function O.recursiveCraft(outputName, count) if not ctx.craftEngine then return false, "Craft engine not initialized" end activity.crafting = true state.needsRedraw = true state.smelterNeedsRedraw = true local ok, err = ctx.craftEngine.executeChain(outputName, count, ctx) activity.crafting = false state.needsRedraw = true state.smelterNeedsRedraw = true return ok, err 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