-- ===================================================================== -- lib-core.crafting v0.5.0 — Recipe Registry + Craft Action -- Spec: meta/docs/adrs/0055-recipe-slot-property-constraint.md -- meta/docs/adrs/0056-crafting-actor-precondition-reward.md -- meta/docs/design/2026-07-28-crafting-property-constraint-slots-design.md -- meta/docs/design/2026-07-31-crafting-skill-gating-design.md -- meta/docs/design/2026-08-03-crafting-quality-condition-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) -- -- Recipe-Schema (v0.5.0 — ADR-0055 + ADR-0056 + Phase-J quality/condition): -- { -- id = "saw_planks", -- inputs = { -- consumed; each entry is -- { match = {category="wood", mass=">5"}, count = 1, name="stock" }, -- { template = "rock", count = 1 }, -- template-id = trivial match -- }, -- tools = { -- NON-consumed presence check -- { match = {["affordance.cutting"] = true}, name="blade", -- wear_per_use = 0.1 }, -- Phase J: condition decrement -- }, -- outputs = { {template="plank", count=4} },-- plural; `output` singular ok -- requires = { ["skill.knapping"] = ">=1" },-- ADR-0056: actor-precondition -- -- gate, matched vs ctx.actor -- grants = { ["skill.knapping"] = 5 }, -- ADR-0056: reward returned as -- -- `granted`; MODULE writes it -- quality = { contributors = { -- Phase J: product-quality formula -- { skill = "skill.knapping", max = 5, weight = 0.5 }, -- min=requires-floor -- { ingredient = "stock", weight = 0.3 }, -- named input's `quality` -- { tool = "blade", weight = 0.2 }, -- named tool's `quality`×`condition` -- } }, -- weights sum to 1 -- name = "...", description = "...", is_known = function(ctx) ... end, -- } -- -- `name` on a slot (input or tool) is optional; the `quality` formula references -- inputs/tools by that name. `wear_per_use` on a tool slot decrements the matched -- tool's `condition` (clamped 0..1) on a successful craft; the tool's affordance -- is unchanged — author `condition = ">0"` into the tool match to make a fully -- worn tool stop qualifying. Both fields are additive to v0.4.0; a recipe with -- neither behaves exactly as before. -- -- The `quality` formula is domain-free: it references named slots + a skill -- property key, never a domain vocabulary. The skill-band `min` is re-read from -- the recipe's own `requires` floor (`">=3"` → 3), so a gate value is authored -- once and reused as the quality floor. Absent `quality`/`condition` on a matched -- item counts as a NEUTRAL 1.0 (absence ≠ zero; only an explicit low value bites). -- -- `requires` is a match-table (same predicate machinery as slots) evaluated -- against ctx.actor, NOT against container items. It is a hard gate on a KNOWN -- recipe ("can this actor execute it?") — distinct from is_known (discovery). -- `grants` (property -> number) is static reward data; craft() returns it as -- `granted` and the consuming module applies it to the actor (crafting stays -- container-scoped). Both are domain-free: crafting knows no "skill" vocabulary. -- -- A recipe SLOT is a property-CONSTRAINT over an item's (possibly derived) -- properties, evaluated via ent:get_property. The pseudo-key "template" maps -- to composition.template_of, so an old {template="rock"} slot is just the -- trivial constraint {template="rock"} — one match-loop, no second code path. -- -- Constraint values: exact string/number/bool → equality; or a comparison -- string ">5" / ">=0.2" / "<10" / "<=1" / "==x" (numeric, or string for ==). -- All keys in a `match` table are AND-combined. -- -- Input↔item assignment is greedy first-fit (a claimed item can't fill a -- second slot). Documented Sackgasse: greedy can miss a solvable recipe when -- one item satisfies two slots; true bipartite matching is deferred. No -- stone-age recipe hits this. -- -- Locale-Param (unchanged from v0.2.0): -- Form 1 (bw-compat): bare entity_handle → both sole source AND sink. -- Form 2 (explicit): { sources = {c1,...}, sink = c_out }. -- -- Match-Result-Schema: -- { ok, error?, missing?, missing_tools?, unmet?, crafted_items?, consumed?, -- granted?, quality?, wear? } -- error ∈ { "unknown_recipe" | "requires_unmet" | "missing_inputs" -- | "missing_tools" } -- unmet = { "", ... } (which actor-preconditions failed) -- granted = the recipe's `grants` table on a successful craft (module applies) -- quality = computed product quality 0..1 (nil unless a `quality` block ran); -- also written onto every crafted item as its `quality` property -- wear = { = , ... } (nil -- unless a tool with `wear_per_use` was used) -- -- 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 -- ---------- constraint compilation ---------- -- Compile a predicate VALUE into a test function `fn(x) -> bool`. -- Comparison strings: ">n" ">=n" "=)%s*(.+)$") end if not op then op, rhs = val:match("^(==)%s*(.+)$") end if not op then op, rhs = val:match("^([<>])%s*(.+)$") end if op then local num = tonumber(rhs) if op == ">" then return function(x) return type(x) == "number" and x > num end end if op == ">=" then return function(x) return type(x) == "number" and x >= num end end if op == "<" then return function(x) return type(x) == "number" and x < num end end if op == "<=" then return function(x) return type(x) == "number" and x <= num end end if op == "==" then if num ~= nil then return function(x) return x == num end else return function(x) return tostring(x) == rhs end end end end return function(x) return x == val end -- plain string equality end return function(x) return x == val end -- number / bool equality end -- Clamp x into [lo, hi]. Used by the quality-formula and wear-decrement -- (0..1 is a crafting convention, not a Core one — ADR-0001). local function clamp(x, lo, hi) if x < lo then return lo elseif x > hi then return hi else return x end end -- Extract a numeric floor from a predicate VALUE, for the quality-band `min` -- (design 2026-08-03 §4). ">=n" / ">n" → n; a bare number (exact equality) → -- that number; anything else → nil (no floor, treated as 0 by the formula). -- This is the forward-compat promise: the skill-`min` = the `requires`-gate -- value, re-read here, never re-authored. local function extract_floor(val) if type(val) == "string" then local rhs = val:match("^>=%s*(.+)$") or val:match("^>%s*(.+)$") if rhs then return tonumber(rhs) end elseif type(val) == "number" then return val end return nil end -- Compile a `match` table into a list of {key, test}. A bare template-id -- slot is normalized upstream into { template = "" }. local function compile_match(match_tbl, kind, recipe_id) if type(match_tbl) ~= "table" then error(string.format("crafting.define_recipe '%s': %s.match must be table", recipe_id, kind), 3) end local fields = {} for key, val in pairs(match_tbl) do if type(key) ~= "string" then error(string.format("crafting.define_recipe '%s': %s.match keys must be strings", recipe_id, kind), 3) end fields[#fields + 1] = { key = key, test = compile_value_test(val) } end if #fields == 0 then error(string.format("crafting.define_recipe '%s': %s.match must be non-empty", recipe_id, kind), 3) end return fields end -- Read a property for matching. "template" is the derived-property pseudo-key -- (composition.template_of); everything else is a plain get_property, read -- safely so matching an item that simply lacks the property fails the test -- instead of erroring. local function read_prop(ent, key) if key == "template" then return composition.template_of(ent) end local ok, v = pcall(function() return ent:get_property(key) end) if ok then return v end return nil end local function entity_matches(ent, fields) for _, f in ipairs(fields) do if not f.test(read_prop(ent, f.key)) then return false end end return true end -- Evaluate `requires` fields against the actor (ADR-0056). Returns the list of -- unmet keys ({} = all satisfied). A nil actor fails every field (fail-closed). local function eval_requires(actor, fields) local unmet = {} for _, f in ipairs(fields) do local v = (actor ~= nil) and read_prop(actor, f.key) or nil if not f.test(v) then unmet[#unmet + 1] = f.key end end return unmet end -- ---------- helpers ---------- local function shallow_copy(t) local out = {} for k, v in pairs(t) do out[k] = v end return out end local function validate_output_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 -- Normalize a consumed/tool slot into compiled match-fields. Accepts either -- {template=...} (bw-compat) or {match={...}}. local function normalize_slot(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 entry.template ~= nil then 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 return compile_match({ template = entry.template }, kind, recipe_id) elseif entry.match ~= nil then return compile_match(entry.match, kind, recipe_id) end error(string.format("crafting.define_recipe '%s': %s entry needs 'template' or 'match'", recipe_id, kind), 3) end -- Locale resolver: entity_handle (bw-compat) OR {sources={...}, sink=...}. local function resolve_locale(locale, fn_name) 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 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 if type(locale) == "table" then error(string.format("crafting.%s: locale table must contain 'sources' field", fn_name), 3) end if locale == nil then error(string.format("crafting.%s: locale must not be nil", fn_name), 3) end return { sources = { locale }, sink = locale } end -- Build the flat pool of {ent, src} across all sources. local function build_pool(sources) local pool = {} for _, src in ipairs(sources) do for _, ent in ipairs(inv.contents(src)) do pool[#pool + 1] = { ent = ent, src = src, claimed = false } end end return pool end -- Plan a craft against a source-pool: greedy-claim inputs, presence-check -- tools. Returns { ok=true, consume={ {ent,src}, ... } } or -- { ok=false, error=..., missing?/missing_tools? }. local function plan(r, sources) local pool = build_pool(sources) local consume = {} local named = {} -- slot-name -> matched entity (for the quality formula) for _, slot in ipairs(r.inputs) do local found = 0 for _, p in ipairs(pool) do if found >= slot.count then break end if not p.claimed and entity_matches(p.ent, slot.fields) then p.claimed = true consume[#consume + 1] = { ent = p.ent, src = p.src } -- Named ingredient = the FIRST claimed item of that slot. if slot.name and named[slot.name] == nil then named[slot.name] = p.ent end found = found + 1 end end if found < slot.count then return { ok = false, error = "missing_inputs", missing = { { needed = slot.count, have = found } } } end end -- Tools: presence only (not consumed, not claimed). Checked against the -- full pool, including items already claimed as inputs. Record the matched -- entity per slot (for wear-decrement + named quality-references). local tool_ents = {} for i, slot in ipairs(r.tools) do local match_ent = nil for _, p in ipairs(pool) do if entity_matches(p.ent, slot.fields) then match_ent = p.ent; break end end if not match_ent then return { ok = false, error = "missing_tools", missing_tools = { {} } } end tool_ents[i] = match_ent if slot.name and named[slot.name] == nil then named[slot.name] = match_ent end end return { ok = true, consume = consume, named = named, tool_ents = tool_ents } end -- Evaluate the quality-formula (design 2026-08-03 §4). Each contributor yields a -- normalized 0..1 term; product quality = Σ(weightᵢ · termᵢ), clamped to 0..1. -- skill: clamp((actor[key] - min) / (max - min)) min = requires-floor -- ingredient: named item's `quality` property -- tool: named tool's `quality` × `condition` -- Absent `quality`/`condition` on an item counts as a NEUTRAL 1.0 (absence ≠ -- zero-quality; only an explicit low value penalizes). local function num_or(v, default) if type(v) == "number" then return v end return default end local function eval_quality(qblock, actor, named, requires_floor) local q = 0 for _, c in ipairs(qblock.contributors) do local term if c.kind == "skill" then local min = (requires_floor and requires_floor[c.key]) or 0 local sv = num_or(actor ~= nil and read_prop(actor, c.key) or nil, 0) local span = c.max - min if span <= 0 then term = 1 else term = clamp((sv - min) / span, 0, 1) end elseif c.kind == "ingredient" then local ent = named[c.slot] term = ent and num_or(read_prop(ent, "quality"), 1) or 1 else -- "tool": quality × condition (a worn tool makes worse products) local ent = named[c.slot] local tq = ent and num_or(read_prop(ent, "quality"), 1) or 1 local tc = ent and num_or(read_prop(ent, "condition"), 1) or 1 term = tq * tc end q = q + c.weight * clamp(term, 0, 1) end return clamp(q, 0, 1) 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 -- inputs (required, non-empty) 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 local inputs = {} for i, entry in ipairs(def.inputs) do if type(entry.count) ~= "number" or entry.count <= 0 or entry.count ~= math.floor(entry.count) then error(string.format("crafting.define_recipe '%s': inputs[%d].count must be positive int", id, i), 2) end if entry.name ~= nil and type(entry.name) ~= "string" then error(string.format("crafting.define_recipe '%s': inputs[%d].name must be string", id, i), 2) end inputs[i] = { fields = normalize_slot(entry, "inputs[" .. i .. "]", id), count = entry.count, name = entry.name } end -- tools (optional, non-consumed presence checks) local tools = {} if def.tools ~= nil then if type(def.tools) ~= "table" then error(string.format("crafting.define_recipe '%s': tools must be array", id), 2) end for i, entry in ipairs(def.tools) do if entry.name ~= nil and type(entry.name) ~= "string" then error(string.format("crafting.define_recipe '%s': tools[%d].name must be string", id, i), 2) end if entry.wear_per_use ~= nil then if type(entry.wear_per_use) ~= "number" or entry.wear_per_use <= 0 then error(string.format( "crafting.define_recipe '%s': tools[%d].wear_per_use must be positive number", id, i), 2) end end tools[i] = { fields = normalize_slot(entry, "tools[" .. i .. "]", id), name = entry.name, wear_per_use = entry.wear_per_use } end end -- outputs (plural) OR output (singular bw-compat) — at least one required local outputs = {} if def.outputs ~= nil then if type(def.outputs) ~= "table" or #def.outputs == 0 then error(string.format("crafting.define_recipe '%s': outputs must be non-empty array", id), 2) end for i, entry in ipairs(def.outputs) do validate_output_entry(entry, "outputs[" .. i .. "]", id) outputs[i] = { template = entry.template, count = entry.count } end elseif def.output ~= nil then validate_output_entry(def.output, "output", id) outputs[1] = { template = def.output.template, count = def.output.count } else error(string.format("crafting.define_recipe '%s': needs 'outputs' or 'output'", id), 2) end 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 -- requires (optional, ADR-0056): actor-precondition gate. Same match-table -- shape as a slot; compiled here, evaluated against ctx.actor at craft-time. local requires = nil if def.requires ~= nil then requires = compile_match(def.requires, "requires", id) end -- grants (optional, ADR-0056): property -> number reward, returned as -- `granted`; the module applies it to the actor. local grants = nil if def.grants ~= nil then if type(def.grants) ~= "table" then error(string.format("crafting.define_recipe '%s': grants must be table", id), 2) end grants = {} local n = 0 for k, v in pairs(def.grants) do if type(k) ~= "string" then error(string.format("crafting.define_recipe '%s': grants keys must be strings", id), 2) end if type(v) ~= "number" then error(string.format("crafting.define_recipe '%s': grants['%s'] must be number", id, k), 2) end grants[k] = v n = n + 1 end if n == 0 then error(string.format("crafting.define_recipe '%s': grants must be non-empty", id), 2) end end -- requires_floor (Phase J): numeric floor per requires-key, re-read from the -- gate predicate so the quality-band `min` never needs re-authoring (§4). local requires_floor = nil if def.requires ~= nil then requires_floor = {} for k, v in pairs(def.requires) do local f = extract_floor(v) if f ~= nil then requires_floor[k] = f end end end -- quality (optional, Phase J / design 2026-08-03 §4): a multi-contributor -- formula that computes the product's `quality` at craft-time. Each -- contributor is exactly ONE of skill / ingredient / tool, plus a numeric -- weight; weights must sum to 1. ingredient/tool reference a NAMED slot. local quality = nil if def.quality ~= nil then if type(def.quality) ~= "table" or type(def.quality.contributors) ~= "table" or #def.quality.contributors == 0 then error(string.format( "crafting.define_recipe '%s': quality.contributors must be non-empty array", id), 2) end local input_names, tool_names = {}, {} for _, s in ipairs(inputs) do if s.name then input_names[s.name] = true end end for _, s in ipairs(tools) do if s.name then tool_names[s.name] = true end end local contributors, wsum = {}, 0 for ci, c in ipairs(def.quality.contributors) do if type(c) ~= "table" then error(string.format("crafting.define_recipe '%s': quality.contributors[%d] must be table", id, ci), 2) end if type(c.weight) ~= "number" or c.weight < 0 then error(string.format("crafting.define_recipe '%s': quality.contributors[%d].weight must be non-negative number", id, ci), 2) end local nc if c.skill ~= nil then if type(c.skill) ~= "string" then error(string.format("crafting.define_recipe '%s': quality.contributors[%d].skill must be string", id, ci), 2) end if type(c.max) ~= "number" then error(string.format("crafting.define_recipe '%s': quality.contributors[%d].max must be number", id, ci), 2) end nc = { kind = "skill", key = c.skill, max = c.max, weight = c.weight } elseif c.ingredient ~= nil then if type(c.ingredient) ~= "string" then error(string.format("crafting.define_recipe '%s': quality.contributors[%d].ingredient must be string", id, ci), 2) end if not input_names[c.ingredient] then error(string.format("crafting.define_recipe '%s': quality.contributors[%d] references unknown input slot name '%s'", id, ci, c.ingredient), 2) end nc = { kind = "ingredient", slot = c.ingredient, weight = c.weight } elseif c.tool ~= nil then if type(c.tool) ~= "string" then error(string.format("crafting.define_recipe '%s': quality.contributors[%d].tool must be string", id, ci), 2) end if not tool_names[c.tool] then error(string.format("crafting.define_recipe '%s': quality.contributors[%d] references unknown tool slot name '%s'", id, ci, c.tool), 2) end nc = { kind = "tool", slot = c.tool, weight = c.weight } else error(string.format("crafting.define_recipe '%s': quality.contributors[%d] needs one of skill/ingredient/tool", id, ci), 2) end contributors[ci] = nc wsum = wsum + c.weight end if math.abs(wsum - 1) > 1e-9 then error(string.format("crafting.define_recipe '%s': quality contributor weights must sum to 1 (got %s)", id, tostring(wsum)), 2) end quality = { contributors = contributors } end recipes[id] = { id = id, inputs = inputs, tools = tools, outputs = outputs, quality = quality, requires_floor = requires_floor, -- bw-compat alias: consumers (e.g. UI icon resolvers) that read -- `recipe.output.template` keep working; points at the first output. output = outputs[1], requires = requires, grants = grants, 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 or r.is_known(ctx) ~= true then return { ok = false, error = "unknown_recipe" } end if r.requires then local unmet = eval_requires(ctx.actor, r.requires) if #unmet > 0 then return { ok = false, error = "requires_unmet", unmet = unmet } end end local locale = resolve_locale(locale_arg, "can_craft") local p = plan(r, locale.sources) if not p.ok then return { ok = false, error = p.error, missing = p.missing, missing_tools = p.missing_tools } end return { ok = true } end function M.craft(recipe_id, locale_arg, ctx) if type(ctx) ~= "table" then error("crafting.craft: ctx must be table", 2) end local r = recipes[recipe_id] if r == nil or r.is_known(ctx) ~= true then return { ok = false, error = "unknown_recipe" } end if r.requires then local unmet = eval_requires(ctx.actor, r.requires) if #unmet > 0 then return { ok = false, error = "requires_unmet", unmet = unmet } end end local locale = resolve_locale(locale_arg, "craft") local p = plan(r, locale.sources) if not p.ok then return { ok = false, error = p.error, missing = p.missing, missing_tools = p.missing_tools } end -- Compute product quality BEFORE consuming inputs — the formula reads the -- ingredient/tool entities, which the consume step is about to destroy. local quality_val = nil if r.quality then quality_val = eval_quality(r.quality, ctx.actor, p.named, r.requires_floor) end -- Consume claimed inputs (tools are left untouched). local consumed = {} for _, c in ipairs(p.consume) do inv.remove(c.src, c.ent) composition.destroy(c.ent) consumed[#consumed + 1] = c.ent end -- Create outputs into the sink. A computed quality is applied as a -- per-instance override (composition auto-declares the property). local crafted = {} for _, out_def in ipairs(r.outputs) do for _ = 1, out_def.count do local out if quality_val ~= nil then out = composition.create{ template = out_def.template, properties = { quality = quality_val } } else out = composition.create{ template = out_def.template } end inv.add(locale.sink, out) crafted[#crafted + 1] = out end end -- Wear (design 2026-08-03 §6): decrement each tool slot that declares -- wear_per_use, directly on the matched tool entity. Tools live in the -- container (crafting-scope), so — unlike the actor-side `grants` — crafting -- may mutate them here. Reported as `wear` (slot-name or index -> new -- condition) for transparency/testability. condition is clamped to 0..1. local wear = nil for i, slot in ipairs(r.tools) do if slot.wear_per_use and p.tool_ents[i] then local ent = p.tool_ents[i] local newc = clamp(num_or(read_prop(ent, "condition"), 1) - slot.wear_per_use, 0, 1) -- Snap float residue to exactly 0 so a tool that has mathematically -- reached the bottom reads as 0 (a `condition > 0` tool-gate then -- correctly rejects it, and it displays cleanly). Legit low -- conditions are far above this epsilon. if newc < 1e-9 then newc = 0 end ent:set_property("condition", newc) wear = wear or {} wear[slot.name or ("tools[" .. i .. "]")] = newc end end -- `granted` is static reward data (ADR-0056); the module writes it onto the -- actor. `quality` (nil unless a quality-block ran) and `wear` (nil unless a -- tool wore) round out the result. return { ok = true, crafted_items = crafted, consumed = consumed, granted = r.grants, quality = quality_val, wear = wear } end -- ---------- test backdoors ---------- function M._test_clear_all() recipes = {} end function M._test_get_recipes() return recipes end return M