-- 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, batches) batches = batches or 1 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 -- Clamp batches to 64 (max stack size per slot) batches = math.min(batches, 64) local chests = ops.getChests() local slotMap = {} local reserved = {} -- Map each grid position to a chest slot, pulling `batches` items per 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 local pullCount = math.min(batches, si.count) slotMap[tostring(tSlot)] = { chestName = src.chest, chestSlot = slot, itemName = itemName, count = pullCount, } 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 -- Batch in chunks of 64 (max stack per turtle slot) local remaining = step.count while remaining > 0 do local chunk = math.min(remaining, 64) local ok, batchErr = craft.executeSingleCraft(step.recipe, ctx, chunk) if not ok then ctx.state.activity.crafting = false ctx.state.needsRedraw = true ctx.log.error("CRAFT", "Chain failed at step %d: %s", i, batchErr) return false, string.format("Step %d/%d failed: %s", i, #steps, batchErr) end remaining = remaining - chunk -- Brief pause between chunks to let turtle finish if remaining > 0 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