Files
Inventory-Manager-CC/manager/operations.lua

1461 lines
54 KiB
Lua

-- 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
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
-------------------------------------------------
-- 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