1461 lines
54 KiB
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
|