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.
696 lines
30 KiB
Lua
696 lines
30 KiB
Lua
-- =====================================================================
|
||
-- 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
|