diff --git a/lib/craft.lua b/lib/craft.lua new file mode 100644 index 0000000..e883c34 --- /dev/null +++ b/lib/craft.lua @@ -0,0 +1,325 @@ +-- lib/craft.lua — Recursive crafting engine +-- Adapted from opus-apps milo/apis/craft2.lua for Inventory Manager. +-- Supports multi-step crafting chains with cycle detection. +-- +-- Usage: +-- local craft = dofile("lib/craft.lua") +-- craft.init(recipeBook, ops.getItemTotal) +-- local ok, err = craft.executeChain("minecraft:chest", 1, ctx) + +local craft = {} + +local recipeBook = nil -- lib/recipeBook instance +local getItemTotal = nil -- function(itemName) -> number + +--- Initialize the crafting engine. +-- @param rb recipeBook instance (lib/recipeBook.lua) +-- @param getTotal function(itemName) returning stock count +function craft.init(rb, getTotal) + recipeBook = rb + getItemTotal = getTotal +end + +------------------------------------------------- +-- Ingredient analysis +------------------------------------------------- + +--- Sum ingredients across a recipe grid. +-- @return { [itemName] = count } +function craft.sumIngredients(recipe) + return recipeBook.getIngredients(recipe) +end + +--- Recursively determine how many items can be crafted. +-- Follows sub-recipe chains with cycle detection. +-- @param recipe crafting recipe table +-- @param count desired output quantity +-- @return craftable amount (may be less than count) +function craft.getCraftableAmount(recipe, count) + local path = { [recipe.output] = true } + + local function check(r, summed, cnt, p) + local can = 0 + for _ = 1, cnt do + for ingr, needed in pairs(craft.sumIngredients(r)) do + local avail = summed[ingr] or getItemTotal(ingr) + -- Try sub-crafting if we don't have enough + if avail < needed then + local sub = recipeBook.getCraftingRecipe(ingr) + if sub and not p[ingr] then + local sp = {} + for k, v in pairs(p) do sp[k] = v end + sp[sub.output] = true + avail = avail + check(sub, summed, needed - avail, sp) + end + end + if avail < needed then return can end + summed[ingr] = avail - needed + end + can = can + r.count + end + return can + end + + return check(recipe, {}, math.ceil(count / recipe.count), path) +end + +--- Build a full resource list for a recipe (what's needed, available, missing). +-- Recursively expands sub-recipes. +-- @param recipe crafting recipe table +-- @param count desired output quantity +-- @return { [itemName] = { name, have, total, used, need, craftable } } +function craft.getResourceList(recipe, count) + local summed = {} + + local function sum(r, cnt, path) + for ingr, per in pairs(craft.sumIngredients(r)) do + local need = per * cnt + if not summed[ingr] then + summed[ingr] = { + name = ingr, + have = getItemTotal(ingr), + total = 0, + used = 0, + need = 0, + craftable = recipeBook.isCraftable(ingr), + } + end + local e = summed[ingr] + e.total = e.total + need + local canUse = math.min(e.have, need) + e.used = e.used + canUse + e.have = e.have - canUse + local short = need - canUse + if short > 0 then + local sub = recipeBook.getCraftingRecipe(ingr) + if sub and not path[ingr] then + local p = {} + for k, v in pairs(path) do p[k] = v end + p[sub.output] = true + sum(sub, math.ceil(short / sub.count), p) + else + e.need = e.need + short + end + end + end + end + + sum(recipe, math.ceil(count / recipe.count), { [recipe.output] = true }) + return summed +end + +------------------------------------------------- +-- Crafting chain planning +------------------------------------------------- + +--- Plan a crafting chain (bottom-up order: sub-ingredients first). +-- @param targetItem string — output item name +-- @param count number — desired quantity +-- @return array of steps { recipe, count (batches), output, outputCount } or nil, error +function craft.planChain(targetItem, count) + local recipe = recipeBook.getCraftingRecipe(targetItem) + if not recipe then return nil, "No recipe for " .. targetItem end + + local steps = {} + local visited = {} + + local function plan(r, cnt, depth) + if depth > 20 then return false, "Recipe chain too deep (cycle?)" end + local batches = math.ceil(cnt / r.count) + + -- Plan sub-ingredients first (depth-first) + for ingr, per in pairs(craft.sumIngredients(r)) do + local need = per * batches + local have = getItemTotal(ingr) + if have < need and not visited[ingr] then + local sub = recipeBook.getCraftingRecipe(ingr) + if sub then + local ok, err = plan(sub, need - have, depth + 1) + if not ok then return false, err end + end + end + end + + -- Add this step after all sub-steps + if not visited[r.output] then + visited[r.output] = true + table.insert(steps, { + recipe = r, + count = batches, + output = r.output, + outputCount = batches * r.count, + }) + end + return true + end + + local ok, err = plan(recipe, count, 0) + if not ok then return nil, err end + return steps +end + +------------------------------------------------- +-- Craft execution (remote turtle protocol) +------------------------------------------------- + +--- Execute a single craft batch via remote turtle. +-- Uses the existing IM craft_request/craft_result modem protocol. +-- @param recipe crafting recipe table +-- @param ctx context table with ops, cfg, state, log, craftTurtleName, networkModem +-- @return success, error_message +function craft.executeSingleCraft(recipe, ctx) + local ops = ctx.ops + local cfg = ctx.cfg + local st = ctx.state + + if not ctx.craftTurtleName then return false, "No turtle" end + if not ctx.networkModem then return false, "No modem" end + if not peripheral.isPresent(ctx.craftTurtleName) then return false, "Turtle offline" end + + local chests = ops.getChests() + local slotMap = {} + local reserved = {} + + -- Map each grid position to a chest slot + for gridPos = 1, 9 do + local itemName = recipe.grid[gridPos] + if itemName then + local tSlot = cfg.GRID_TO_SLOT[gridPos] + local found = false + + if st.cache.catalogue[itemName] then + for _, src in ipairs(st.cache.catalogue[itemName]) do + local chest = ops.wrapCached(src.chest) + if chest then + for slot, si in pairs(chest.list()) do + local key = src.chest .. ":" .. slot + if si.name == itemName and not reserved[key] then + slotMap[tostring(tSlot)] = { + chestName = src.chest, + chestSlot = slot, + itemName = itemName, + count = 1, + } + reserved[key] = true + found = true + break + end + end + end + if found then break end + end + end + + if not found then + return false, "Missing ingredient: " .. itemName + end + end + end + + -- Send craft request to turtle + ctx.networkModem.transmit(cfg.CRAFT_CHANNEL, cfg.CRAFT_REPLY_CHANNEL, { + type = "craft_request", + output = recipe.output, + slots = slotMap, + returnChests = chests, + }) + + -- Optimistically update cache + for _, info in pairs(slotMap) do + st.adjustCache(info.itemName, info.chestName, -info.count) + end + + -- Wait for turtle reply + local deadline = os.clock() + cfg.CRAFT_TIMEOUT + local result + local buffered = {} + + 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 + if p2 == cfg.CRAFT_REPLY_CHANNEL and type(p4) == "table" and p4.type == "craft_result" then + result = p4 + break + elseif p2 == cfg.ORDER_CHANNEL then + table.insert(buffered, { event, p1, p2, p3, p4, p5 }) + end + end + end + + -- Re-queue any buffered messages + for _, msg in ipairs(buffered) do + os.queueEvent(table.unpack(msg)) + end + + if not result then + 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 + if #chests > 0 then + st.adjustCache(r.name, chests[1], r.count) + end + end + elseif #chests > 0 then + st.adjustCache(recipe.output, chests[1], totalOutput) + end + return true + else + -- Restore cache on failure + for _, info in pairs(slotMap) do + st.adjustCache(info.itemName, info.chestName, info.count) + end + return false, result.error or "Craft failed" + end +end + +--- Execute a full crafting chain (all steps, bottom-up). +-- @param targetItem string — desired output item name +-- @param count number — desired quantity +-- @param ctx context table +-- @return success, error_message +function craft.executeChain(targetItem, count, ctx) + local steps, err = craft.planChain(targetItem, count) + if not steps then return false, err end + + ctx.log.info("CRAFT", "Chain: %d steps for %s x%d", #steps, targetItem, count) + + for i, step in ipairs(steps) do + ctx.log.info("CRAFT", "Step %d/%d: %s x%d (%d batches)", + i, #steps, step.output, step.outputCount, step.count) + + ctx.state.activity.crafting = true + ctx.state.needsRedraw = true + ctx.state.smelterNeedsRedraw = true + + for batch = 1, step.count do + local ok, batchErr = craft.executeSingleCraft(step.recipe, ctx) + if not ok then + ctx.state.activity.crafting = false + ctx.state.needsRedraw = true + ctx.log.error("CRAFT", "Chain failed at step %d batch %d: %s", i, batch, batchErr) + return false, string.format("Step %d/%d failed: %s", i, #steps, batchErr) + end + -- Brief pause between batches to let turtle finish + if batch < step.count then os.sleep(0.3) end + end + + ctx.log.info("CRAFT", "Step %d/%d complete: %s x%d", i, #steps, step.output, step.outputCount) + -- Pause between steps to let items settle in storage + if i < #steps then os.sleep(0.5) end + end + + ctx.state.activity.crafting = false + ctx.state.needsRedraw = true + ctx.state.smelterNeedsRedraw = true + ctx.log.info("CRAFT", "Chain complete: %s x%d", targetItem, count) + return true +end + +return craft