Files
Inventory-Manager-CC/lib/craft.lua

335 lines
12 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, 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