-- ===================================================================== -- lib-core.crafting v0.2.0 — Recipe Registry + Craft Action -- Spec: meta/docs/superpowers/specs/2026-06-14-phase-D-workbench-design.md -- -- Surface: -- crafting.define_recipe(recipe_def) -- register a recipe -- crafting.list_recipes() -> {recipe_def, ...} -- crafting.get_recipe(id) -> recipe_def or nil -- crafting.is_known(recipe_id, ctx) -> bool -- crafting.can_craft(recipe_id, locale, ctx) -> result (non-mutating) -- crafting.craft(recipe_id, locale, ctx) -> result (mutating) -- -- Locale-Param (v0.2.0): -- Form 1 (bw-compat): a bare entity_handle. Treated as both the sole -- source AND the sink. -- Form 2 (explicit): a table { sources = {c1, c2, ...}, sink = c_out } -- where `sources` is a non-empty array of -- container-entity-handles and `sink` is a single -- container-entity-handle. -- -- Recipe-Schema: -- { -- id = "rock_pick", -- inputs = { {template, count}, ... }, -- output = {template, count}, -- name = "Stone Pick", -- description = "...", -- is_known = function(ctx) return true end, -- } -- -- Match-Result-Schema: -- { ok, error?, missing?, crafted_items?, consumed? } -- error ∈ { "unknown_recipe" | "missing_inputs" } -- -- Deps: -- lib-core.composition (template_of, create, destroy) -- lib-core.inventory-list (contents, add, remove) -- ===================================================================== local composition = require("lib-core.composition") local inv = require("lib-core.inventory-list") local M = {} -- ---------- module state (all local) ---------- local recipes = {} local function default_is_known(_ctx) return true end -- ---------- helpers ---------- local function validate_io_entry(entry, kind, recipe_id) if type(entry) ~= "table" then error(string.format( "crafting.define_recipe '%s': %s entry must be table", recipe_id, kind), 3) end if type(entry.template) ~= "string" or entry.template == "" then error(string.format( "crafting.define_recipe '%s': %s.template must be string", recipe_id, kind), 3) end if type(entry.count) ~= "number" or entry.count <= 0 or entry.count ~= math.floor(entry.count) then error(string.format( "crafting.define_recipe '%s': %s.count must be positive int", recipe_id, kind), 3) end end local function shallow_copy(t) local out = {} for k, v in pairs(t) do out[k] = v end return out end -- Locale resolver: accepts entity_handle (bw-compat) OR table -- {sources={...}, sink=...}. Always returns Form-2 with validated -- fields. Loud-error on malformed Form-2. local function resolve_locale(locale, fn_name) -- Form 2: explicit table if type(locale) == "table" and locale.sources ~= nil then if type(locale.sources) ~= "table" or #locale.sources == 0 then error(string.format( "crafting.%s: locale.sources must be non-empty array", fn_name), 3) end if locale.sink == nil then error(string.format( "crafting.%s: locale.sink must not be nil", fn_name), 3) end -- De-duplicate sources by identity, preserving first-occurrence order. -- A caller building sources programmatically (e.g. {workbench_buffer, -- player_backpack} where both alias the same entity) should not have -- count_by_template double-count nor craft silent-fail. local seen, deduped = {}, {} for _, s in ipairs(locale.sources) do if not seen[s] then seen[s] = true deduped[#deduped + 1] = s end end return { sources = deduped, sink = locale.sink } end -- Form 2 malformed: a table that's not Form-2 (no sources key) is almost -- certainly a caller typo. Loud-error explicitly instead of silently -- falling through to the Form-1 bw-compat path (which would then crash -- deep inside inventory-list with an opaque error). if type(locale) == "table" then error(string.format( "crafting.%s: locale table must contain 'sources' field", fn_name), 3) end -- Form 1 (bw-compat): bare entity_handle if locale == nil then error(string.format( "crafting.%s: locale must not be nil", fn_name), 3) end return { sources = {locale}, sink = locale } end local function count_by_template(sources) local out = {} for _, source in ipairs(sources) do for _, ent in ipairs(inv.contents(source)) do local tpl = composition.template_of(ent) if tpl ~= nil then out[tpl] = (out[tpl] or 0) + 1 end end end return out end -- ---------- public API ---------- function M.define_recipe(def) if type(def) ~= "table" then error("crafting.define_recipe: def must be table", 2) end local id = def.id if type(id) ~= "string" or id == "" then error("crafting.define_recipe: id must be non-empty string", 2) end if recipes[id] then error(string.format( "crafting.define_recipe '%s': duplicate id", id), 2) end if type(def.inputs) ~= "table" or #def.inputs == 0 then error(string.format( "crafting.define_recipe '%s': inputs must be non-empty array", id), 2) end for i, entry in ipairs(def.inputs) do validate_io_entry(entry, "inputs[" .. i .. "]", id) end if type(def.output) ~= "table" then error(string.format( "crafting.define_recipe '%s': output must be table", id), 2) end validate_io_entry(def.output, "output", id) local is_known = def.is_known if is_known == nil then is_known = default_is_known elseif type(is_known) ~= "function" then error(string.format( "crafting.define_recipe '%s': is_known must be function", id), 2) end recipes[id] = { id = id, inputs = def.inputs, output = def.output, name = def.name, description = def.description, is_known = is_known, } end function M.list_recipes() local out = {} for _, r in pairs(recipes) do out[#out + 1] = shallow_copy(r) end return out end function M.get_recipe(id) local r = recipes[id] if r == nil then return nil end return shallow_copy(r) end function M.is_known(recipe_id, ctx) if type(ctx) ~= "table" then error("crafting.is_known: ctx must be table", 2) end local r = recipes[recipe_id] if r == nil then return false end return r.is_known(ctx) == true end function M.can_craft(recipe_id, locale_arg, ctx) if type(ctx) ~= "table" then error("crafting.can_craft: ctx must be table", 2) end local r = recipes[recipe_id] if r == nil then return { ok = false, error = "unknown_recipe" } end if r.is_known(ctx) ~= true then return { ok = false, error = "unknown_recipe" } end local locale = resolve_locale(locale_arg, "can_craft") local have = count_by_template(locale.sources) local missing = {} for _, need in ipairs(r.inputs) do local have_n = have[need.template] or 0 if have_n < need.count then missing[#missing + 1] = { template = need.template, needed = need.count, have = have_n, } end end if #missing > 0 then return { ok = false, error = "missing_inputs", missing = missing } end return { ok = true } end function M.craft(recipe_id, locale_arg, ctx) local pre = M.can_craft(recipe_id, locale_arg, ctx) if not pre.ok then return pre end local locale = resolve_locale(locale_arg, "craft") local r = recipes[recipe_id] -- 1. Greedy-drain from sources in array-order. Per input-need, walk -- sources left-to-right and snapshot each source's contents -- BEFORE the inner loop because we mutate via inv.remove during -- iteration. local consumed = {} for _, need in ipairs(r.inputs) do local remaining = need.count for _, source in ipairs(locale.sources) do if remaining == 0 then break end local snapshot = inv.contents(source) for _, ent in ipairs(snapshot) do if remaining == 0 then break end if composition.template_of(ent) == need.template then inv.remove(source, ent) consumed[#consumed + 1] = ent composition.destroy(ent) remaining = remaining - 1 end end end end -- 2. Create outputs + add to sink. local crafted = {} for _ = 1, r.output.count do local out = composition.create{ template = r.output.template } inv.add(locale.sink, out) crafted[#crafted + 1] = out end return { ok = true, crafted_items = crafted, consumed = consumed, } end -- ---------- test backdoors ---------- function M._test_clear_all() recipes = {} end function M._test_get_recipes() return recipes end return M