Two robustness improvements to the Network capture/processor split:
1. Processor wake-up: replaced os.queueEvent('network_queued') /
os.pullEvent('network_queued') with sleep(0) polling. Custom events
can be consumed by other coroutines (e.g. craftItem's unfiltered
os.pullEvent()) or swallowed by the OS event layer. Polling the
shared queue every tick is simpler and guaranteed reliable.
2. craftItem: removed ORDER_CHANNEL message buffering and re-queuing.
With the dedicated Network-capture task, ORDER_CHANNEL messages are
already safely captured into networkQueue. The old buffering caused
double-capture: capture task adds to queue, craftItem also buffers,
then re-queues via os.queueEvent -> capture captures again -> dup.
The commandId dedup caught these, but removing the source is cleaner.
1455 lines
54 KiB
Lua
1455 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
|
|
|
|
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
|