local autocraft = require("modules.autocraft") local craft = {} ---@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) 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 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) local storage = self:snapshotStorage() storage[item] = 0 local plan = autocraft.plan(self.db, storage, item, amount, opts) return plan end --- Pull `amount` of `item` from storage directly into a specified slot. --- If the wrong item is pulled due to timing/race conditions, the slot is cleared and retried. ---@param item string ---@param amount integer ---@param targetSlot integer|nil ---@return integer obtained function Controller:withdrawToSlot(item, amount, targetSlot) 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, nil, targetSlot) or 0 local checkSlot = targetSlot or 1 local detail = turtle.getItemDetail(checkSlot) if detail then if detail.name ~= item then self.inv:sendItemAwayMultiple({ checkSlot }) obtained = 0 staleAttempts = staleAttempts + 1 else obtained = detail.count staleAttempts = 0 end else staleAttempts = staleAttempts + 1 end if staleAttempts >= 5 then break end end return obtained end --- Push everything currently in the turtle back into storage. ---@return integer moved function Controller:depositAll() local slots = {} for slot = 1, 16 do if turtle.getItemDetail(slot) then slots[#slots + 1] = slot end end if #slots == 0 then return 0 end local moved = self.inv:sendItemAwayMultiple(slots) or 0 return moved 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) local remaining = step.times local batchNum = 0 while remaining > 0 do batchNum = batchNum + 1 local batchSize = math.min(remaining, 64) if onStatus then onStatus(("crafting %dx %s (%d/%d)..."):format( step.producedPerCraft, step.item, step.times - remaining + 1, step.times)) end local success, err = pcall(function() self:depositAll() for targetSlot, item in pairs(step.turtleSlots) do local obtained = self:withdrawToSlot(item, batchSize, targetSlot) if obtained < batchSize then error(("missing %s for slot %d: needed %d, got %d"):format(item, targetSlot, batchSize, obtained)) end end turtle.select(1) local ok, craftErr = turtle.craft() if not ok then error("turtle.craft failed for " .. step.item .. ": " .. tostring(craftErr)) end end) self:depositAll() if not success then return false, err end remaining = remaining - batchSize end 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) if not plan.ok then return false, plan.missing end for i, step in ipairs(plan.steps) do local ok, err = self:runStep(step, onStatus) if not ok then return false, plan.missing, err end end return true end return craft