-- Crafting backend: takes a plan from autocraft.plan() and physically performs -- the crafts on the turtle, pulling ingredients from storage (inv) and -- pushing the results back. -- -- This talks to storage *only* through the inv abstraction -- no raw ra / -- chest peripheral access here, so it doesn't care whether inv is backed by -- tiny_ra_library, a plain chest, or anything else. The storage layer must -- expose: -- inv:getItem(name) -> truthy if at least one is available -- inv:sendItemToSelf(name, perip?, max?, id?) -> count moved into turtle -- inv:sendItemAwayMultiple(slots, perip?, id?, max?) -> count moved out of turtle -- inv:listItemAmounts() -> snapshot of what storage holds -- (the inventory modules in src/modules/inventory/* already provide these.) -- -- ============================================================================ -- DEBUG LOGGING: this version logs everything to /craft-debug.log so we can -- see exactly what the plan looks like and what happens slot-by-slot during -- placement. Delete the log() calls (or just the log file) once we've found -- the bug. Every line is timestamped with os.clock() and a sequence number -- so ordering is unambiguous even if things get interleaved with prints -- elsewhere. -- ============================================================================ local modems = { peripheral.find("modem") } local localModem = nil local localInv = nil for _, m in pairs(modems) do if type(m.getNameLocal) == "function" then localModem = m local localName = m.getNameLocal() if localName then localInv = peripheral.wrap(localName) end end end local autocraft = require("modules.autocraft") local craft = {} local LOG_PATH = "/craft-debug.log" local logSeq = 0 --- Append one line to the debug log. Never throws -- logging must not be --- able to take down a craft run. local function log(fmt, ...) logSeq = logSeq + 1 local okFmt, msg = pcall(string.format, fmt, ...) if not okFmt then msg = tostring(fmt) end local line = ("[%04d @ %7.2f] %s"):format(logSeq, os.clock(), msg) local okOpen, f = pcall(fs.open, LOG_PATH, "a") if okOpen and f then f.writeLine(line) f.close() end end --- Fast-transfer items internally without changing turtle selection or costing ticks. ---@param srcSlot integer ---@param destSlot integer ---@param count integer|nil local function fastTransfer(srcSlot, destSlot, count) if srcSlot == destSlot then return end if localInv then log("fast transfer " .. localInv) -- Fast path: instant internal transfer via local modem inventory wrapper localInv.pushItems(localModem.getNameLocal(), srcSlot, count, destSlot) else log("slow transfer") -- Fallback path: standard turtle API turtle.select(srcSlot) turtle.transferTo(destSlot, count) end end --- Best-effort serialization of a table for logging. local function dump(t) local ok, s = pcall(textutils.serialize, t) if ok then return s end return tostring(t) end --- Log every occupied slot in the turtle's own inventory right now. local function logTurtleInventory(label) local lines = {} for slot = 1, 16 do local d = turtle.getItemDetail(slot) if d then lines[#lines + 1] = (" slot %2d: %s x%d"):format(slot, d.name, d.count) end end if #lines == 0 then log("%s -- turtle inventory EMPTY", label) else log("%s -- turtle inventory:\n%s", label, table.concat(lines, "\n")) end end --- Log a full plan (all steps) once, right after planning. local function logPlan(item, amount, plan) log("PLAN for %dx %s -> ok=%s", amount, item, tostring(plan.ok)) if not plan.ok then log("PLAN missing: %s", dump(plan.missing)) return end for i, step in ipairs(plan.steps) do log("PLAN step %d/%d: item=%s times=%s producedPerCraft=%s", i, #plan.steps, tostring(step.item), tostring(step.times), tostring(step.producedPerCraft)) log("PLAN step %d turtleSlots = %s", i, dump(step.turtleSlots)) -- also log it as a sorted slot list, easier to eyeball than the raw table dump local bySlot = {} for slot, wantItem in pairs(step.turtleSlots) do bySlot[#bySlot + 1] = { slot = slot, item = wantItem } end table.sort(bySlot, function(a, b) return a.slot < b.slot end) local parts = {} for _, e in ipairs(bySlot) do parts[#parts + 1] = ("slot %s = %s"):format(tostring(e.slot), tostring(e.item)) end log("PLAN step %d turtleSlots (sorted) = { %s }", i, table.concat(parts, ", ")) end end ---@class CraftController ---@field db table recipe database (recipes.decode result) ---@field inv table storage layer (modules.inv) local Controller = {} Controller.__index = Controller ---@param db table ---@param inv table ---@return CraftController function craft.new(db, inv) log("craft.new() called") return setmetatable({ db = db, inv = inv }, Controller) end --- Snapshot what the storage layer currently holds as { item = count }. ---@return table function Controller:snapshotStorage() local amounts = self.inv:listItemAmounts() local reshaped if amounts[1] and amounts[1].name then reshaped = {} for _, entry in ipairs(amounts) do reshaped[entry.name] = entry.amount end else reshaped = amounts end log("snapshotStorage() -> %d distinct items", (function() local n = 0 for _ in pairs(reshaped) do n = n + 1 end return n end)()) return reshaped end --- Plan a craft via autocraft, using the live storage snapshot. ---@param item string ---@param amount number ---@param opts table|nil ---@return table autocraft plan function Controller:plan(item, amount, opts) log("plan() called: item=%s amount=%s opts=%s", tostring(item), tostring(amount), dump(opts)) local storage = self:snapshotStorage() storage[item] = 0 local plan = autocraft.plan(self.db, storage, item, amount, opts) logPlan(item, amount, plan) -- NOTE: this used to error() out here on missing ingredients. ui.lua's -- craftFlow calls plan() expecting a normal return so it can build its -- own "missing: ..." status line -- since plan() was throwing instead, -- that error propagated out of pcall inside inv:_withMoveLock() and got -- re-raised, crashing the whole program on any missing-ingredients -- craft. Just return the plan (ok=false + missing) and let the caller -- decide what to do with it. if not plan.ok then log("plan() NOT ok, missing=%s", dump(plan.missing)) end return plan end --- Pull `amount` of `item` from storage into the turtle inventory. Keeps --- retrying as long as each call is making progress. ---@param item string ---@param amount integer ---@return integer obtained function Controller:withdraw(item, amount) log("withdraw() called: item=%s amount=%s", tostring(item), tostring(amount)) if amount <= 0 then return 0 end local obtained = 0 local staleAttempts = 0 local callNum = 0 while obtained < amount do callNum = callNum + 1 local remaining = amount - obtained local got = self.inv:sendItemToSelf(item, nil, remaining) or 0 log("withdraw() call #%d for %s: requested=%d got=%d (obtained so far=%d)", callNum, item, remaining, got, obtained + got) if got > 0 then obtained = obtained + got staleAttempts = 0 else staleAttempts = staleAttempts + 1 if staleAttempts >= 5 then log("withdraw() giving up on %s after %d stale attempts (obtained=%d of %d)", item, staleAttempts, obtained, amount) break end end end log("withdraw() DONE: item=%s requested=%d obtained=%d", item, amount, obtained) return obtained end --- Push everything currently in the turtle back into storage. ---@return integer moved function Controller:depositAll() log("depositAll() called") logTurtleInventory("depositAll() before") local slots = {} for slot = 1, 16 do if turtle.getItemDetail(slot) then slots[#slots + 1] = slot end end if #slots == 0 then log("depositAll(): nothing to deposit") return 0 end local moved = self.inv:sendItemAwayMultiple(slots) or 0 log("depositAll(): sendItemAwayMultiple(%s) -> moved=%d", dump(slots), moved) logTurtleInventory("depositAll() after") return moved end --- Arrange ingredients already withdrawn into the turtle into exact target slots. ---@param step table a single autocraft step ---@param batchSize integer function Controller:_placeIngredients(step, batchSize) log("_placeIngredients() called: item=%s batchSize=%d", tostring(step.item), batchSize) local isTargetSlot = {} for turtleSlot, _ in pairs(step.turtleSlots) do isTargetSlot[turtleSlot] = true end -- Step 1: Evict misplaced items out of target slots for targetSlot, wantItem in pairs(step.turtleSlots) do local cur = turtle.getItemDetail(targetSlot) if cur and cur.name ~= wantItem then local free = nil for s = 1, 16 do if not isTargetSlot[s] and not turtle.getItemDetail(s) then free = s break end end if not free then log("_placeIngredients() FATAL: no non-target free slot to evict %s from slot %d", cur.name, targetSlot) error("no free slot to arrange " .. wantItem) end log("_placeIngredients() evicting wrong item %s from slot %d -> slot %d", cur.name, targetSlot, free) fastTransfer(targetSlot, free) end end -- Step 2: Consolidate source items into non-target slots for targetSlot, wantItem in pairs(step.turtleSlots) do local cur = turtle.getItemDetail(targetSlot) if cur and cur.name == wantItem then local free = nil for s = 1, 16 do if not isTargetSlot[s] then local d = turtle.getItemDetail(s) if not d or (d.name == wantItem and d.count < 64) then free = s break end end end if free then fastTransfer(targetSlot, free) end end end -- Step 3: Distribute items into target slots strictly from non-target slots for targetSlot, wantItem in pairs(step.turtleSlots) do log("_placeIngredients() target turtleSlot=%d wantItem=%s batchSize=%d", targetSlot, wantItem, batchSize) local have = 0 local det = turtle.getItemDetail(targetSlot) if det and det.name == wantItem then have = det.count end local retries = 0 local iter = 0 while have < batchSize do iter = iter + 1 local src = nil for s = 1, 16 do if not isTargetSlot[s] then local d = turtle.getItemDetail(s) if d and d.name == wantItem then src = s break end end end if src then local wantToMove = batchSize - have log("_placeIngredients() [iter %d] fast-moving from non-target src slot %d -> target slot %d (amount: %d)", iter, src, targetSlot, wantToMove) fastTransfer(src, targetSlot, wantToMove) else retries = retries + 1 log("_placeIngredients() [iter %d] no non-target src slot found for %s! retry %d/5", iter, wantItem, retries) if retries > 5 then log("_placeIngredients() FATAL: giving up on slot %d, have=%d need=%d", targetSlot, have, batchSize) error(("missing %s for turtle slot %d (have %d, need %d)"):format(wantItem, targetSlot, have, batchSize)) end self:withdraw(wantItem, batchSize - have) end local nd = turtle.getItemDetail(targetSlot) have = (nd and nd.name == wantItem) and nd.count or 0 if iter > 200 then error("_placeIngredients: too many iterations, aborting") end end end logTurtleInventory("_placeIngredients() final state") end --- Execute a single planned step, crafting up to `step.times` in batches of <=64. ---@param step table ---@param onStatus fun(text:string)|nil ---@return boolean ok, string? err function Controller:runStep(step, onStatus) log("runStep() called: item=%s times=%s producedPerCraft=%s turtleSlots=%s", tostring(step.item), tostring(step.times), tostring(step.producedPerCraft), dump(step.turtleSlots)) local remaining = step.times local batchNum = 0 while remaining > 0 do batchNum = batchNum + 1 local batchSize = math.min(remaining, 64) log("runStep() batch #%d: remaining=%d batchSize=%d", batchNum, remaining, batchSize) if onStatus then onStatus(("crafting %dx %s (%d/%d)..."):format( step.producedPerCraft, step.item, step.times - remaining + 1, step.times)) end local slotsByItem = {} local itemOrder = {} for _, item in pairs(step.turtleSlots) do if not slotsByItem[item] then slotsByItem[item] = 0 itemOrder[#itemOrder + 1] = item end slotsByItem[item] = slotsByItem[item] + 1 end log("runStep() batch #%d: slotsByItem=%s itemOrder=%s", batchNum, dump(slotsByItem), dump(itemOrder)) local success, err = pcall(function() self:depositAll() for _, item in ipairs(itemOrder) do local needed = slotsByItem[item] * batchSize log("runStep() batch #%d: need %d x %s (%d slots x batchSize %d)", batchNum, needed, item, slotsByItem[item], batchSize) local obtained = self:withdraw(item, needed) if obtained < needed then log("runStep() batch #%d: SHORTFALL on %s: needed=%d obtained=%d", batchNum, item, needed, obtained) error(("missing %s: needed %d, got %d"):format(item, needed, obtained)) end end logTurtleInventory(("runStep() batch #%d after all withdrawals, before placement"):format(batchNum)) self:_placeIngredients(step, batchSize) turtle.select(1) logTurtleInventory(("runStep() batch #%d right before turtle.craft()"):format(batchNum)) local ok, craftErr = turtle.craft() log("runStep() batch #%d: turtle.craft() -> ok=%s err=%s", batchNum, tostring(ok), tostring(craftErr)) if not ok then error("turtle.craft failed for " .. step.item .. ": " .. tostring(craftErr)) end end) self:depositAll() if not success then log("runStep() batch #%d FAILED: %s", batchNum, tostring(err)) return false, err end log("runStep() batch #%d SUCCEEDED", batchNum) remaining = remaining - batchSize end log("runStep() DONE for item=%s", tostring(step.item)) return true end --- Run an entire plan. Returns ok + a list of missing items if it fails. ---@param plan table ---@param onStatus fun(text:string)|nil ---@return boolean ok, table? missing, string? err function Controller:run(plan, onStatus) log("run() called: plan.ok=%s, %d steps", tostring(plan.ok), plan.steps and #plan.steps or 0) if not plan.ok then log("run() aborting: plan not ok, missing=%s", dump(plan.missing)) return false, plan.missing end for i, step in ipairs(plan.steps) do log("run() starting step %d/%d: %s", i, #plan.steps, tostring(step.item)) local ok, err = self:runStep(step, onStatus) if not ok then log("run() FAILED at step %d/%d (%s): %s", i, #plan.steps, tostring(step.item), tostring(err)) return false, plan.missing, err end end log("run() completed all steps successfully") return true end return craft