1044 lines
36 KiB
Lua
1044 lines
36 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
|
|
|
|
for ci, chest in ipairs(chests) do
|
|
if onProgress then onProgress(ci, #chests, chest) end
|
|
local inv = O.wrapCached(chest)
|
|
if inv then
|
|
totalSlots = totalSlots + inv.size()
|
|
local contents = inv.list()
|
|
for slot, item in pairs(contents) do
|
|
usedSlots = usedSlots + 1
|
|
if not catalogue[item.name] then
|
|
catalogue[item.name] = {}
|
|
end
|
|
local found = false
|
|
for _, entry in ipairs(catalogue[item.name]) do
|
|
if entry.chest == chest then
|
|
entry.total = entry.total + item.count
|
|
found = true
|
|
break
|
|
end
|
|
end
|
|
if not found then
|
|
table.insert(catalogue[item.name], { chest = chest, total = item.count })
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local itemList = {}
|
|
local grandTotal = 0
|
|
for itemName, sources in pairs(catalogue) do
|
|
local total = 0
|
|
for _, s in ipairs(sources) do total = total + s.total end
|
|
grandTotal = grandTotal + total
|
|
table.insert(itemList, { name = itemName, total = total })
|
|
end
|
|
table.sort(itemList, function(a, b) return a.total > b.total end)
|
|
|
|
cache.catalogue = catalogue
|
|
cache.itemList = itemList
|
|
cache.itemListDirty = false
|
|
cache.grandTotal = grandTotal
|
|
cache.chestCount = #chests
|
|
cache.totalSlots = totalSlots
|
|
cache.usedSlots = usedSlots
|
|
cache.freeSlots = totalSlots - usedSlots
|
|
cache.usedRatio = totalSlots > 0 and (usedSlots / totalSlots) or 0
|
|
cache.dropperOk = peripheral.isPresent(cfg.DROPPER_NAME)
|
|
cache.barrelOk = peripheral.isPresent(cfg.BARREL_NAME)
|
|
|
|
-- Discover droppers
|
|
local droppers = {}
|
|
for _, name in ipairs(peripheral.getNames()) do
|
|
local isDropper = name:match("^minecraft:dropper_")
|
|
if not isDropper and peripheral.hasType then
|
|
isDropper = peripheral.hasType(name, "minecraft:dropper")
|
|
end
|
|
if not isDropper then
|
|
isDropper = peripheral.getType(name) == "minecraft:dropper"
|
|
end
|
|
if isDropper and name ~= cfg.COMPOST_DROPPER then
|
|
local isDefault = (name == cfg.DROPPER_NAME)
|
|
table.insert(droppers, { name = name, isDefault = isDefault })
|
|
end
|
|
end
|
|
table.sort(droppers, function(a, b)
|
|
if a.isDefault ~= b.isDefault then return a.isDefault end
|
|
return a.name < b.name
|
|
end)
|
|
|
|
local seenNames = {}
|
|
for _, d in ipairs(droppers) do seenNames[d.name] = true end
|
|
for clientId, clientList in pairs(state.clientDroppers) do
|
|
for _, d in ipairs(clientList) do
|
|
if not seenNames[d.name] then
|
|
table.insert(droppers, { name = d.name, isDefault = false, clientId = clientId })
|
|
seenNames[d.name] = true
|
|
end
|
|
end
|
|
end
|
|
cache.droppers = droppers
|
|
cache.furnaceCount = #furnaces
|
|
|
|
O.refreshFurnaceStatus()
|
|
activity.scanning = false
|
|
state.bumpStateVersion()
|
|
|
|
-- Persist cache to disk
|
|
pcall(function()
|
|
local data = {
|
|
catalogue = cache.catalogue,
|
|
itemList = cache.itemList,
|
|
grandTotal = cache.grandTotal,
|
|
chestCount = cache.chestCount,
|
|
totalSlots = cache.totalSlots,
|
|
usedSlots = cache.usedSlots,
|
|
freeSlots = cache.freeSlots,
|
|
usedRatio = cache.usedRatio,
|
|
dropperOk = cache.dropperOk,
|
|
barrelOk = cache.barrelOk,
|
|
furnaceCount = cache.furnaceCount,
|
|
furnaceStatus = cache.furnaceStatus,
|
|
droppers = cache.droppers,
|
|
savedAt = os.epoch("utc"),
|
|
}
|
|
local f = fs.open(cfg.CACHE_FILE, "w")
|
|
f.write(textutils.serialise(data))
|
|
f.close()
|
|
end)
|
|
end
|
|
|
|
function O.loadCacheFromDisk()
|
|
if not fs.exists(cfg.CACHE_FILE) then return false end
|
|
local ok, err = pcall(function()
|
|
local f = fs.open(cfg.CACHE_FILE, "r")
|
|
local raw = f.readAll()
|
|
f.close()
|
|
local data = textutils.unserialise(raw)
|
|
if data and data.catalogue and data.itemList then
|
|
cache.catalogue = data.catalogue
|
|
cache.itemList = data.itemList
|
|
cache.grandTotal = data.grandTotal or 0
|
|
cache.chestCount = data.chestCount or 0
|
|
cache.totalSlots = data.totalSlots or 0
|
|
cache.usedSlots = data.usedSlots or 0
|
|
cache.freeSlots = data.freeSlots or 0
|
|
cache.usedRatio = data.usedRatio or 0
|
|
cache.dropperOk = data.dropperOk or false
|
|
cache.barrelOk = data.barrelOk or false
|
|
cache.furnaceCount = data.furnaceCount or 0
|
|
cache.furnaceStatus = data.furnaceStatus or {}
|
|
cache.droppers = data.droppers or {}
|
|
else
|
|
error("invalid cache data")
|
|
end
|
|
end)
|
|
if not ok then
|
|
log.warn("INIT", "Could not load cache: %s", tostring(err))
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
-------------------------------------------------
|
|
-- Barrel auto-sort
|
|
-------------------------------------------------
|
|
|
|
function O.sortBarrel(barrelOverride)
|
|
local barrelTarget = (barrelOverride and barrelOverride ~= "") and barrelOverride or cfg.BARREL_NAME
|
|
local barrel = O.wrapCached(barrelTarget)
|
|
if not barrel then return end
|
|
|
|
local contents = barrel.list()
|
|
if not contents or not next(contents) then return end
|
|
|
|
activity.sorting = true
|
|
state.needsRedraw = true
|
|
|
|
local catalogue = cache.catalogue
|
|
local chests = O.getChests()
|
|
|
|
for slot, item in pairs(contents) do
|
|
local moved = 0
|
|
local triedChests = {}
|
|
|
|
if catalogue[item.name] then
|
|
for _, entry in ipairs(catalogue[item.name]) do
|
|
triedChests[entry.chest] = true
|
|
local n = barrel.pushItems(entry.chest, slot)
|
|
if n and n > 0 then
|
|
moved = moved + n
|
|
state.adjustCache(item.name, entry.chest, n)
|
|
state.needsRedraw = true
|
|
state.smelterNeedsRedraw = true
|
|
log.info("SORT", "%s x%d -> %s", item.name, n, entry.chest)
|
|
end
|
|
if moved >= item.count then break end
|
|
end
|
|
end
|
|
|
|
if moved < item.count then
|
|
for _, chest in ipairs(chests) do
|
|
if not triedChests[chest] then
|
|
local n = barrel.pushItems(chest, slot)
|
|
if n and n > 0 then
|
|
moved = moved + n
|
|
state.adjustCache(item.name, chest, n)
|
|
state.needsRedraw = true
|
|
state.smelterNeedsRedraw = true
|
|
log.info("SORT", "%s x%d -> %s", item.name, n, chest)
|
|
end
|
|
if moved >= item.count then break end
|
|
end
|
|
end
|
|
end
|
|
|
|
if moved < item.count then
|
|
log.warn("SORT", "Could not sort %d remaining %s", item.count - moved, item.name)
|
|
end
|
|
end
|
|
|
|
activity.sorting = false
|
|
state.needsRedraw = true
|
|
end
|
|
|
|
-------------------------------------------------
|
|
-- Auto-smelt
|
|
-------------------------------------------------
|
|
|
|
function O.autoSmelt()
|
|
if state.smeltingPaused then return false end
|
|
|
|
local furnaces = O.getFurnaces()
|
|
if #furnaces == 0 then return false end
|
|
|
|
local chests = O.getChests()
|
|
local catalogue = cache.catalogue
|
|
local didWork = false
|
|
local emptyInputFurnaces = {}
|
|
|
|
for _, fname in ipairs(furnaces) do
|
|
local furnace = O.wrapCached(fname)
|
|
if furnace then
|
|
local contents = furnace.list()
|
|
|
|
-- 1) Pull finished output
|
|
if contents[cfg.SLOT_OUTPUT] then
|
|
local outputItem = contents[cfg.SLOT_OUTPUT]
|
|
local remaining = outputItem.count
|
|
for _, chest in ipairs(chests) do
|
|
local n = furnace.pushItems(chest, cfg.SLOT_OUTPUT)
|
|
if n and n > 0 then
|
|
state.adjustCache(outputItem.name, chest, n)
|
|
remaining = remaining - n
|
|
log.info("SMELT", "Output %s x%d -> %s", outputItem.name, n, chest)
|
|
didWork = true
|
|
if remaining <= 0 then break end
|
|
end
|
|
end
|
|
if remaining > 0 then
|
|
log.warn("SMELT", "Could not move %d %s from %s output (chests full?)",
|
|
remaining, outputItem.name, fname)
|
|
end
|
|
end
|
|
|
|
-- Check extra slots
|
|
for slot, item in pairs(contents) do
|
|
if slot ~= cfg.SLOT_INPUT and slot ~= cfg.SLOT_FUEL and slot ~= cfg.SLOT_OUTPUT then
|
|
for _, chest in ipairs(chests) do
|
|
local n = furnace.pushItems(chest, slot)
|
|
if n and n > 0 then
|
|
state.adjustCache(item.name, chest, n)
|
|
log.info("SMELT", "Extra slot %d: %s x%d -> %s", slot, item.name, n, chest)
|
|
didWork = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
contents = furnace.list()
|
|
|
|
-- 2) Remove incompatible items from input
|
|
local furnaceType = peripheral.getType(fname)
|
|
local inputItem = contents[cfg.SLOT_INPUT]
|
|
if inputItem then
|
|
local recipe = cfg.SMELTABLE[inputItem.name]
|
|
local validHere = recipe and recipe.furnaceSet[furnaceType] or false
|
|
if not validHere then
|
|
for _, chest in ipairs(chests) do
|
|
local n = furnace.pushItems(chest, cfg.SLOT_INPUT)
|
|
if n and n > 0 then
|
|
state.adjustCache(inputItem.name, chest, n)
|
|
log.info("SMELT", "Removed incompatible %s x%d from %s -> %s",
|
|
inputItem.name, n, fname, chest)
|
|
didWork = true
|
|
break
|
|
end
|
|
end
|
|
contents = furnace.list()
|
|
end
|
|
end
|
|
|
|
-- 3) Refuel
|
|
local fuelItem = contents[cfg.SLOT_FUEL]
|
|
local needFuel = not fuelItem or fuelItem.count < 8
|
|
|
|
if needFuel then
|
|
for _, fuel in ipairs(cfg.FUEL_LIST) do
|
|
if catalogue[fuel.name] then
|
|
for _, source in ipairs(catalogue[fuel.name]) do
|
|
local chest = O.wrapCached(source.chest)
|
|
if chest then
|
|
for slot, slotItem in pairs(chest.list()) do
|
|
if slotItem.name == fuel.name then
|
|
local toMove = math.min(16, slotItem.count)
|
|
local n = chest.pushItems(fname, slot, toMove, cfg.SLOT_FUEL)
|
|
if n and n > 0 then
|
|
state.adjustCache(fuel.name, source.chest, -n)
|
|
log.info("SMELT", "Fuel %s x%d -> %s", fuel.name, n, fname)
|
|
didWork = true
|
|
needFuel = false
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if not needFuel then break end
|
|
end
|
|
end
|
|
if not needFuel then break end
|
|
end
|
|
end
|
|
|
|
-- 4) Collect empty-input furnaces
|
|
inputItem = contents[cfg.SLOT_INPUT]
|
|
if not inputItem then
|
|
table.insert(emptyInputFurnaces, { name = fname, type = furnaceType })
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 5) Balanced distribution
|
|
if #emptyInputFurnaces > 0 then
|
|
local typesSeen = {}
|
|
for _, ef in ipairs(emptyInputFurnaces) do typesSeen[ef.type] = true end
|
|
|
|
local allCandidates = {}
|
|
local candSeen = {}
|
|
for ftype in pairs(typesSeen) do
|
|
for _, cand in ipairs(cfg.smeltCandidatesByType[ftype] or {}) do
|
|
if not candSeen[cand.name] then
|
|
candSeen[cand.name] = true
|
|
table.insert(allCandidates, cand)
|
|
end
|
|
end
|
|
end
|
|
table.sort(allCandidates, function(a, b)
|
|
if a.food ~= b.food then return a.food end
|
|
return a.name < b.name
|
|
end)
|
|
|
|
local usedFurnaces = {}
|
|
|
|
for _, cand in ipairs(allCandidates) do
|
|
local itemName = cand.name
|
|
if not state.disabledRecipes[itemName] and catalogue[itemName] then
|
|
local compatFurnaces = {}
|
|
for _, ef in ipairs(emptyInputFurnaces) do
|
|
if not usedFurnaces[ef.name] and cand.recipe.furnaceSet[ef.type] then
|
|
table.insert(compatFurnaces, ef)
|
|
end
|
|
end
|
|
|
|
if #compatFurnaces > 0 then
|
|
local totalInStorage = 0
|
|
for _, src in ipairs(catalogue[itemName]) do
|
|
totalInStorage = totalInStorage + src.total
|
|
end
|
|
local available = totalInStorage - cfg.SMELT_RESERVE
|
|
if available > 0 then
|
|
local perFurnace = math.min(64, math.ceil(available / #compatFurnaces))
|
|
|
|
for _, ef in ipairs(compatFurnaces) do
|
|
if available <= 0 then break end
|
|
local toLoad = math.min(perFurnace, available)
|
|
local remaining = toLoad
|
|
local loaded = false
|
|
|
|
local srcSnapshot = { table.unpack(catalogue[itemName]) }
|
|
for _, source in ipairs(srcSnapshot) do
|
|
if source.total > 0 then
|
|
local chest = O.wrapCached(source.chest)
|
|
if chest then
|
|
for slot, slotItem in pairs(chest.list()) do
|
|
if slotItem.name == itemName then
|
|
local toMove = math.min(slotItem.count, remaining)
|
|
local n = chest.pushItems(ef.name, slot, toMove, cfg.SLOT_INPUT)
|
|
if n and n > 0 then
|
|
state.adjustCache(itemName, source.chest, -n)
|
|
log.info("SMELT", "Input %s x%d -> %s (balanced %d/furnace)",
|
|
itemName, n, ef.name, perFurnace)
|
|
didWork = true
|
|
remaining = remaining - n
|
|
available = available - n
|
|
if remaining <= 0 then
|
|
loaded = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if loaded then break end
|
|
end
|
|
|
|
if loaded or remaining < toLoad then
|
|
usedFurnaces[ef.name] = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return didWork
|
|
end
|
|
|
|
-------------------------------------------------
|
|
-- Defrag (consolidate partial stacks)
|
|
-------------------------------------------------
|
|
|
|
function O.defragInventory()
|
|
local chests = O.getChests()
|
|
if #chests == 0 then return end
|
|
|
|
activity.defragging = true
|
|
state.needsRedraw = true
|
|
|
|
local itemSlots = {}
|
|
for _, chestName in ipairs(chests) do
|
|
local inv = O.wrapCached(chestName)
|
|
if inv then
|
|
local contents = inv.list()
|
|
for slot, item in pairs(contents) do
|
|
if not itemSlots[item.name] then
|
|
itemSlots[item.name] = {}
|
|
end
|
|
local maxCount = 64
|
|
local ok, detail = pcall(inv.getItemDetail, slot)
|
|
if ok and detail and detail.maxCount then
|
|
maxCount = detail.maxCount
|
|
end
|
|
table.insert(itemSlots[item.name], {
|
|
chest = chestName,
|
|
slot = slot,
|
|
count = item.count,
|
|
max = maxCount,
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
local totalMerged = 0
|
|
for itemName, slots in pairs(itemSlots) do
|
|
table.sort(slots, function(a, b) return a.count < b.count end)
|
|
|
|
local i = 1
|
|
local j = #slots
|
|
|
|
while i < j do
|
|
local donor = slots[i]
|
|
local recv = slots[j]
|
|
|
|
if donor.chest == recv.chest and donor.slot == recv.slot then
|
|
i = i + 1
|
|
elseif donor.count == 0 then
|
|
i = i + 1
|
|
elseif recv.count >= recv.max then
|
|
j = j - 1
|
|
else
|
|
local space = recv.max - recv.count
|
|
local toMove = math.min(donor.count, space)
|
|
local donorInv = O.wrapCached(donor.chest)
|
|
if donorInv then
|
|
local n = donorInv.pushItems(recv.chest, donor.slot, toMove, recv.slot)
|
|
if n and n > 0 then
|
|
donor.count = donor.count - n
|
|
recv.count = recv.count + n
|
|
totalMerged = totalMerged + n
|
|
if donor.chest ~= recv.chest then
|
|
state.adjustCache(itemName, donor.chest, -n)
|
|
state.adjustCache(itemName, recv.chest, n)
|
|
end
|
|
end
|
|
end
|
|
if donor.count <= 0 then i = i + 1 end
|
|
if recv.count >= recv.max then j = j - 1 end
|
|
end
|
|
end
|
|
end
|
|
|
|
if totalMerged > 0 then
|
|
log.info("DEFRAG", "Consolidated %d items", totalMerged)
|
|
end
|
|
|
|
activity.defragging = false
|
|
state.needsRedraw = true
|
|
end
|
|
|
|
-------------------------------------------------
|
|
-- Auto-compost
|
|
-------------------------------------------------
|
|
|
|
function O.autoCompost()
|
|
local catalogue = cache.catalogue
|
|
local chests = O.getChests()
|
|
local didWork = false
|
|
|
|
local hopper = O.wrapCached(cfg.COMPOST_HOPPER)
|
|
if hopper then
|
|
local contents = hopper.list()
|
|
if contents then
|
|
for slot, item in pairs(contents) do
|
|
for _, chest in ipairs(chests) do
|
|
local n = hopper.pushItems(chest, slot)
|
|
if n and n > 0 then
|
|
state.adjustCache(item.name, chest, n)
|
|
log.info("COMPOST", "%s x%d -> %s", item.name, n, chest)
|
|
didWork = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local dropper = O.wrapCached(cfg.COMPOST_DROPPER)
|
|
if not dropper then return didWork end
|
|
|
|
local dropperContents = dropper.list()
|
|
local dropperUsedItems = 0
|
|
if dropperContents then
|
|
for _, item in pairs(dropperContents) do
|
|
dropperUsedItems = dropperUsedItems + item.count
|
|
end
|
|
end
|
|
local dropperSize = dropper.size()
|
|
local dropperFreeItems = (dropperSize * 64) - dropperUsedItems
|
|
|
|
if dropperFreeItems <= 0 then return didWork end
|
|
|
|
for _, itemName in ipairs(cfg.COMPOSTABLE) do
|
|
if dropperFreeItems <= 0 then break end
|
|
if catalogue[itemName] then
|
|
local totalInStorage = 0
|
|
for _, src in ipairs(catalogue[itemName]) do
|
|
totalInStorage = totalInStorage + src.total
|
|
end
|
|
|
|
local reserve = cfg.COMPOST_TRASH[itemName] and 0 or cfg.COMPOST_RESERVE
|
|
local available = totalInStorage - reserve
|
|
if available > 0 then
|
|
local toFeed = math.min(available, dropperFreeItems)
|
|
local fed = 0
|
|
local srcSnapshot = { table.unpack(catalogue[itemName]) }
|
|
for _, source in ipairs(srcSnapshot) do
|
|
if source.total > 0 then
|
|
local chest = O.wrapCached(source.chest)
|
|
if chest then
|
|
for slot, slotItem in pairs(chest.list()) do
|
|
if slotItem.name == itemName then
|
|
local batch = math.min(slotItem.count, toFeed - fed)
|
|
local n = chest.pushItems(cfg.COMPOST_DROPPER, slot, batch)
|
|
if n and n > 0 then
|
|
state.adjustCache(itemName, source.chest, -n)
|
|
fed = fed + n
|
|
didWork = true
|
|
log.info("COMPOST", "Fed %s x%d -> dropper", itemName, n)
|
|
if fed >= toFeed then break end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if fed >= toFeed then break end
|
|
end
|
|
dropperFreeItems = dropperFreeItems - fed
|
|
end
|
|
end
|
|
end
|
|
|
|
return didWork
|
|
end
|
|
|
|
-------------------------------------------------
|
|
-- Low-stock alert checker
|
|
-------------------------------------------------
|
|
|
|
function O.checkAlerts()
|
|
local alerts = {}
|
|
for _, alert in ipairs(cfg.LOW_STOCK_ALERTS) do
|
|
local total = 0
|
|
if cache.catalogue[alert.name] then
|
|
for _, src in ipairs(cache.catalogue[alert.name]) do
|
|
total = total + src.total
|
|
end
|
|
end
|
|
if total < alert.min then
|
|
table.insert(alerts, {
|
|
label = alert.label,
|
|
current = total,
|
|
min = alert.min,
|
|
})
|
|
end
|
|
end
|
|
state.activeAlerts = alerts
|
|
if #alerts > 0 then
|
|
state.needsRedraw = true
|
|
state.smelterNeedsRedraw = true
|
|
end
|
|
end
|
|
|
|
-------------------------------------------------
|
|
-- Order / Dispense
|
|
-------------------------------------------------
|
|
|
|
function O.orderItem(itemName, amount, dropperOverride)
|
|
activity.dispensing = true
|
|
state.needsRedraw = true
|
|
|
|
local dropperTarget = (dropperOverride and dropperOverride ~= "") and dropperOverride or cfg.DROPPER_NAME
|
|
local catalogue = cache.catalogue
|
|
|
|
if not catalogue[itemName] then
|
|
state.statusMessage = "Not found: " .. itemName:gsub("^minecraft:", "")
|
|
state.statusColor = colors.red
|
|
state.statusTimer = 5
|
|
activity.dispensing = false
|
|
state.needsRedraw = true
|
|
return false
|
|
end
|
|
|
|
local dropper = O.wrapCached(dropperTarget)
|
|
if not dropper then
|
|
state.statusMessage = "Dropper offline: " .. dropperTarget
|
|
state.statusColor = colors.red
|
|
state.statusTimer = 5
|
|
activity.dispensing = false
|
|
state.needsRedraw = true
|
|
return false
|
|
end
|
|
|
|
local remaining = amount
|
|
local srcSnapshot = { table.unpack(catalogue[itemName]) }
|
|
for _, entry in ipairs(srcSnapshot) do
|
|
if entry.total > 0 then
|
|
local chest = O.wrapCached(entry.chest)
|
|
if chest then
|
|
for slot, slotItem in pairs(chest.list()) do
|
|
if slotItem.name == itemName then
|
|
local toMove = math.min(remaining, slotItem.count)
|
|
local moved = chest.pushItems(dropperTarget, slot, toMove)
|
|
if moved and moved > 0 then
|
|
remaining = remaining - moved
|
|
state.adjustCache(itemName, entry.chest, -moved)
|
|
state.needsRedraw = true
|
|
state.smelterNeedsRedraw = true
|
|
log.info("ORDER", "%s x%d from %s", itemName, moved, entry.chest)
|
|
end
|
|
if remaining <= 0 then break end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if remaining <= 0 then break end
|
|
end
|
|
|
|
local sent = amount - remaining
|
|
local short = itemName:gsub("^minecraft:", ""):gsub("_", " ")
|
|
if sent > 0 then
|
|
state.statusMessage = string.format("Dispensing %s x%d", short, sent)
|
|
state.statusColor = colors.lime
|
|
log.info("ORDER", "Ordered %s x%d", short, sent)
|
|
else
|
|
state.statusMessage = "Could not order " .. short
|
|
state.statusColor = colors.red
|
|
end
|
|
state.statusTimer = 5
|
|
|
|
activity.dispensing = false
|
|
state.needsRedraw = true
|
|
return sent > 0
|
|
end
|
|
|
|
-------------------------------------------------
|
|
-- Crafting
|
|
-------------------------------------------------
|
|
|
|
function O.getItemTotal(itemName)
|
|
local have = 0
|
|
if cache.catalogue[itemName] then
|
|
for _, src in ipairs(cache.catalogue[itemName]) do
|
|
have = have + src.total
|
|
end
|
|
end
|
|
return have
|
|
end
|
|
|
|
function O.getRecipeIngredients(recipe)
|
|
return ui.getRecipeIngredients(recipe)
|
|
end
|
|
|
|
function O.canCraftRecipe(recipe)
|
|
return ui.canCraftRecipe(recipe, O.getItemTotal)
|
|
end
|
|
|
|
function O.maxCraftBatches(recipe)
|
|
return ui.maxCraftBatches(recipe, O.getItemTotal)
|
|
end
|
|
|
|
function O.getMissingIngredients(recipe)
|
|
return ui.getMissingIngredients(recipe, O.getItemTotal)
|
|
end
|
|
|
|
function O.craftItem(recipeIdx)
|
|
local recipe = cfg.CRAFTABLE[recipeIdx]
|
|
if not recipe then
|
|
log.error("CRAFT", "Invalid recipe index: %s", tostring(recipeIdx))
|
|
return false, "Invalid recipe"
|
|
end
|
|
if not ctx.craftTurtleName then
|
|
log.error("CRAFT", "No turtle detected on network")
|
|
return false, "No turtle"
|
|
end
|
|
if not ctx.networkModem then
|
|
log.error("CRAFT", "No modem available for craft commands")
|
|
return false, "No modem"
|
|
end
|
|
if not peripheral.isPresent(ctx.craftTurtleName) then
|
|
log.error("CRAFT", "Turtle offline: %s", ctx.craftTurtleName)
|
|
return false, "Turtle offline"
|
|
end
|
|
|
|
log.info("CRAFT", "Starting craft: %s (turtle: %s)", recipe.output, ctx.craftTurtleName)
|
|
|
|
activity.crafting = true
|
|
state.needsRedraw = true
|
|
state.smelterNeedsRedraw = true
|
|
|
|
local chests = O.getChests()
|
|
local slotMap = {}
|
|
local reservedSlots = {}
|
|
|
|
for gridPos = 1, 9 do
|
|
local itemName = recipe.grid[gridPos]
|
|
if itemName then
|
|
local turtleSlot = cfg.GRID_TO_SLOT[gridPos]
|
|
local found = false
|
|
|
|
if cache.catalogue[itemName] then
|
|
for _, source in ipairs(cache.catalogue[itemName]) do
|
|
local chest = O.wrapCached(source.chest)
|
|
if chest then
|
|
for slot, slotItem in pairs(chest.list()) do
|
|
local key = source.chest .. ":" .. slot
|
|
if slotItem.name == itemName and not reservedSlots[key] then
|
|
slotMap[tostring(turtleSlot)] = {
|
|
chestName = source.chest,
|
|
chestSlot = slot,
|
|
itemName = itemName,
|
|
count = 1,
|
|
}
|
|
reservedSlots[key] = true
|
|
found = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if found then break end
|
|
end
|
|
end
|
|
|
|
if not found then
|
|
log.error("CRAFT", "Cannot find %s in storage, aborting", itemName)
|
|
activity.crafting = false
|
|
state.needsRedraw = true
|
|
state.smelterNeedsRedraw = true
|
|
return false, "Missing ingredient: " .. itemName
|
|
end
|
|
end
|
|
end
|
|
|
|
local craftMessage = {
|
|
type = "craft_request",
|
|
recipeIdx = recipeIdx,
|
|
output = recipe.output,
|
|
slots = slotMap,
|
|
returnChests = chests,
|
|
}
|
|
|
|
log.info("CRAFT", "Sending craft request to turtle on channel %d", cfg.CRAFT_CHANNEL)
|
|
ctx.networkModem.transmit(cfg.CRAFT_CHANNEL, cfg.CRAFT_REPLY_CHANNEL, craftMessage)
|
|
|
|
for _, info in pairs(slotMap) do
|
|
state.adjustCache(info.itemName, info.chestName, -info.count)
|
|
end
|
|
|
|
log.info("CRAFT", "Waiting for turtle reply (timeout: %ds)...", cfg.CRAFT_TIMEOUT)
|
|
local deadline = os.clock() + cfg.CRAFT_TIMEOUT
|
|
local result = nil
|
|
local bufferedMessages = {}
|
|
|
|
while os.clock() < deadline do
|
|
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
|
|
local event, p1, p2, p3, p4, p5 = os.pullEvent()
|
|
|
|
if event == "modem_message" then
|
|
local channel = p2
|
|
local message = p4
|
|
if channel == cfg.CRAFT_REPLY_CHANNEL and type(message) == "table" and message.type == "craft_result" then
|
|
result = message
|
|
break
|
|
elseif channel == cfg.ORDER_CHANNEL then
|
|
table.insert(bufferedMessages, {event, p1, p2, p3, p4, p5})
|
|
end
|
|
elseif event == "timer" and p1 == timerId then
|
|
-- Timeout tick
|
|
end
|
|
end
|
|
|
|
for _, msg in ipairs(bufferedMessages) do
|
|
os.queueEvent(table.unpack(msg))
|
|
end
|
|
|
|
activity.crafting = false
|
|
state.needsRedraw = true
|
|
state.smelterNeedsRedraw = true
|
|
|
|
if not result then
|
|
log.error("CRAFT", "TIMEOUT: No reply from turtle within %ds", cfg.CRAFT_TIMEOUT)
|
|
log.error("CRAFT", "Items may be stuck in turtle. Run a manual scan to reconcile.")
|
|
return false, "Turtle timeout"
|
|
end
|
|
|
|
if result.success then
|
|
local totalOutput = result.totalOutput or recipe.count
|
|
if result.results then
|
|
for _, r in ipairs(result.results) do
|
|
for _, ch in ipairs(chests) do
|
|
local chest = O.wrapCached(ch)
|
|
if chest then
|
|
for slot, slotItem in pairs(chest.list()) do
|
|
if slotItem.name == r.name then
|
|
state.adjustCache(r.name, ch, r.count)
|
|
goto credited
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if #chests > 0 then
|
|
state.adjustCache(r.name, chests[1], r.count)
|
|
end
|
|
::credited::
|
|
end
|
|
else
|
|
if #chests > 0 then
|
|
state.adjustCache(recipe.output, chests[1], totalOutput)
|
|
end
|
|
end
|
|
|
|
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
|
|
log.info("CRAFT", "OK: %s x%d", short, totalOutput)
|
|
return true
|
|
else
|
|
for turtleSlotStr, info in pairs(slotMap) do
|
|
state.adjustCache(info.itemName, info.chestName, info.count)
|
|
end
|
|
local errMsg = result.error or "Craft failed"
|
|
log.error("CRAFT", "Failed: %s", errMsg)
|
|
return false, errMsg
|
|
end
|
|
end
|
|
|
|
-------------------------------------------------
|
|
-- Smelting recipe persistence
|
|
-------------------------------------------------
|
|
|
|
function O.loadDisabledRecipes()
|
|
if not fs.exists(cfg.DISABLED_RECIPES_FILE) then return end
|
|
pcall(function()
|
|
local f = fs.open(cfg.DISABLED_RECIPES_FILE, "r")
|
|
local raw = f.readAll()
|
|
f.close()
|
|
local data = textutils.unserialise(raw)
|
|
if type(data) == "table" then
|
|
if data.disabled then state.disabledRecipes = data.disabled end
|
|
if data.paused ~= nil then state.smeltingPaused = data.paused end
|
|
end
|
|
end)
|
|
end
|
|
|
|
function O.saveDisabledRecipes()
|
|
pcall(function()
|
|
local f = fs.open(cfg.DISABLED_RECIPES_FILE, "w")
|
|
f.write(textutils.serialise({ disabled = state.disabledRecipes, paused = state.smeltingPaused }))
|
|
f.close()
|
|
end)
|
|
end
|
|
|
|
return O
|
|
|
|
end
|