326 lines
11 KiB
Lua
326 lines
11 KiB
Lua
-- 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
|