feat: implement recursive crafting engine with multi-step chain support
This commit is contained in:
325
lib/craft.lua
Normal file
325
lib/craft.lua
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user