Files
sporel-lib-core.crafting/init.lua
Calic ececf38b2c crafting v0.5.0 + composition v0.4.0: Quality & Condition (Phase J)
lib-core.composition v0.4.0: un-defer quality/condition as inert numeric
properties (loud-error removed); composition stores them without semantics.

lib-core.crafting v0.5.0: quality-block (multi-contributor product-quality
formula: skill-band with min=requires-floor + named ingredient/tool qualities),
optional slot name, tool wear_per_use (condition decrement + wear report).
affordance stays boolean. Additive to v0.4.0.

vagrant-skeleton v0.22.0: branch quality-band RNG at spawn; stone_hammer
condition=1.0; knap+axe quality-blocks; hammer wears out. Headless-verified 20/20.

Design: meta/docs/design/2026-08-03-crafting-quality-condition-design.md.
Docs synced: crafting-model.md, composition-model.md, libraries.md, READMEs.
2026-08-03 09:14:55 +00:00

696 lines
30 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- =====================================================================
-- 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 = { "<requires-key>", ... } (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 = { <tool-slot-name-or-index> = <new condition 0..1>, ... } (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" "<n" "<=n" "==v". Anything else = equality.
local function compile_value_test(val)
if type(val) == "string" then
local op, rhs = val:match("^(<=)%s*(.+)$")
if not op then op, rhs = val:match("^(>=)%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 = "<id>" }.
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