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.
This commit is contained in:
Calic
2026-08-03 09:14:55 +00:00
parent ff95be19a8
commit ececf38b2c
3 changed files with 265 additions and 25 deletions

View File

@@ -6,9 +6,9 @@ properties; template-id is the trivial constraint `{template="rock"}`. Inputs
are consumed from the locale's source containers, non-consumed tools are are consumed from the locale's source containers, non-consumed tools are
presence-checked, and outputs are placed into the locale's sink container. presence-checked, and outputs are placed into the locale's sink container.
**Version:** 0.4.0 **Version:** 0.5.0
**Lib-ID:** lib-core.crafting **Lib-ID:** lib-core.crafting
**Requires:** `lib-core.composition` 0.3.0, `lib-core.inventory-list` 0.1.0 **Requires:** `lib-core.composition` 0.4.0, `lib-core.inventory-list` 0.1.0
**Tags:** crafting, recipe, registry **Tags:** crafting, recipe, registry
## Topology ## Topology
@@ -22,7 +22,7 @@ graph LR
this --> inventory this --> inventory
``` ```
## Scope (v0.4.0 — ADR-0055 + ADR-0056) ## Scope (v0.5.0 — ADR-0055 + ADR-0056 + Phase-J quality/condition)
Property-constraint recipe slots. A recipe slot matches items by a `match` Property-constraint recipe slots. A recipe slot matches items by a `match`
table of property-constraints (AND-combined), evaluated via `get_property`; table of property-constraints (AND-combined), evaluated via `get_property`;
@@ -32,6 +32,11 @@ non-consumed `tools` (presence checks) and multi-output. Multi-Source /
Single-Sink locale (Form 2) plus bw-compat bare-handle locale (Form 1). Single-Sink locale (Form 2) plus bw-compat bare-handle locale (Form 1).
v0.4.0 (ADR-0056): actor-side `requires` (precondition gate vs `ctx.actor`) + v0.4.0 (ADR-0056): actor-side `requires` (precondition gate vs `ctx.actor`) +
`grants` (reward returned as `granted`). `grants` (reward returned as `granted`).
v0.5.0 (Phase J, design 2026-08-03): optional slot `name`; a `quality` block
computing product quality from a skill-band + named ingredient/tool qualities
(the skill `min` re-read from the `requires` floor); tool-slot `wear_per_use`
decrementing the matched tool's `condition` (reported as `wear`). All additive:
a recipe with none of these behaves exactly as v0.4.0.
**Supported:** **Supported:**
- `define_recipe{id, inputs, tools?, outputs|output, requires?, grants?, name?, description?, is_known?}` - `define_recipe{id, inputs, tools?, outputs|output, requires?, grants?, name?, description?, is_known?}`
@@ -42,18 +47,29 @@ v0.4.0 (ADR-0056): actor-side `requires` (precondition gate vs `ctx.actor`) +
same predicate machinery as slots) — e.g. `{["skill.knapping"]=">=3"}` same predicate machinery as slots) — e.g. `{["skill.knapping"]=">=3"}`
- `grants`: `property → number` reward; returned as `granted`, the **module** - `grants`: `property → number` reward; returned as `granted`, the **module**
writes it to the actor — e.g. `{["skill.knapping"]=1}` writes it to the actor — e.g. `{["skill.knapping"]=1}`
- slot `name` (input or tool, optional): a label the `quality` formula
references — e.g. `{match={...}, count=1, name="head"}`
- `tools` entry `wear_per_use` (optional): decrements the matched tool's
`condition` on a successful craft (clamped 0..1; reported as `wear`)
- `quality = {contributors={...}}` (optional): product-quality formula;
each contributor is one of `{skill, max, weight}` /
`{ingredient=<slot-name>, weight}` / `{tool=<slot-name>, weight}`, weights
sum to 1; result 0..1 written onto every crafted item and returned as `quality`
- Constraint values: exact string/number/bool, or comparison string - Constraint values: exact string/number/bool, or comparison string
`">5"` / `">=0.2"` / `"<10"` / `"<=1"` / `"==x"` (numeric; string for `==`) `">5"` / `">=0.2"` / `"<10"` / `"<=1"` / `"==x"` (numeric; string for `==`)
- `list_recipes()`, `get_recipe(id)`, `is_known(recipe_id, ctx)` - `list_recipes()`, `get_recipe(id)`, `is_known(recipe_id, ctx)`
- `can_craft(recipe_id, locale, ctx)` — non-mutating; `craft(...)` — mutating - `can_craft(recipe_id, locale, ctx)` — non-mutating; `craft(...)` — mutating
- `locale`: bare container-entity (Form 1) or `{sources={...}, sink=...}` (Form 2) - `locale`: bare container-entity (Form 1) or `{sources={...}, sink=...}` (Form 2)
- Result `error ∈ {unknown_recipe, requires_unmet, missing_inputs, missing_tools}`; - Result `error ∈ {unknown_recipe, requires_unmet, missing_inputs, missing_tools}`;
on `requires_unmet` also `unmet={keys}`; on a successful `craft` also `granted` on `requires_unmet` also `unmet={keys}`; on a successful `craft` also `granted`,
plus `quality` (0..1, if a quality-block ran) and `wear` (per-tool new condition)
**Deferred:** **Deferred:**
- Skill XP→level curve, skill decay, `lib-core.skill` extraction (requires/grants - Skill XP→level curve, skill decay, `lib-core.skill` extraction (requires/grants
are the generic substrate; skill vocabulary is the module's — ADR-0056) are the generic substrate; skill vocabulary is the module's — ADR-0056)
- Quality-band coupling (skill/ingredient/tool quality → output quality, Phase J) - Graded affordances (magnitude → success chance): `affordance.*` stays boolean;
quality/condition are orthogonal scalars that scale OUTCOMES (design 2026-08-03 §7)
- Repair (condition-raising / in-place input mutation) — deferred (design §8)
- Workpiece-Model / multi-step / Batch / Time-coupled craft - Workpiece-Model / multi-step / Batch / Time-coupled craft
- True bipartite input↔item matching (v0.3 uses greedy first-fit — a solvable - True bipartite input↔item matching (v0.3 uses greedy first-fit — a solvable
recipe where one item satisfies two slots can be missed; no stone-age recipe where one item satisfies two slots can be missed; no stone-age
@@ -123,7 +139,11 @@ Loud `error(...)` on: non-table `def`; missing / non-string / empty `id`;
duplicate `id`; empty `inputs`; a slot with neither `template` nor `match`; duplicate `id`; empty `inputs`; a slot with neither `template` nor `match`;
an empty `match`; bad `count`; missing both `outputs` and `output`; an empty `match`; bad `count`; missing both `outputs` and `output`;
non-function `is_known`; non-table or empty `grants`; non-string `grants` key non-function `is_known`; non-table or empty `grants`; non-string `grants` key
or non-number `grants` value. or non-number `grants` value; non-string slot `name`; non-positive
`wear_per_use`; a `quality` block whose `contributors` is empty, whose weights
don't sum to 1, a contributor that isn't exactly one of skill/ingredient/tool,
a `skill` contributor missing `max`, or an ingredient/tool contributor naming a
slot that no `name` declares.
Error messages follow the pattern `"crafting.define_recipe '<id>': <reason>"` Error messages follow the pattern `"crafting.define_recipe '<id>': <reason>"`
so test-suites can pattern-match. so test-suites can pattern-match.
@@ -208,6 +228,9 @@ Loud-Error: `ctx` must be a table (empty `{}` OK); `locale` must not be
crafted_items = { entity_handle, ... }, -- new output entities crafted_items = { entity_handle, ... }, -- new output entities
consumed = { entity_handle, ... }, -- input entities (already destroyed) consumed = { entity_handle, ... }, -- input entities (already destroyed)
granted = { ["skill.knapping"] = 1 }, -- recipe.grants, or nil; MODULE applies granted = { ["skill.knapping"] = 1 }, -- recipe.grants, or nil; MODULE applies
quality = 0.7, -- 0..1 if a quality-block ran (else nil);
-- also written onto each crafted item
wear = { hammer = 0.8 }, -- new condition per worn tool-slot (else nil)
} }
-- failure (same shape as can_craft) -- failure (same shape as can_craft)
@@ -257,6 +280,11 @@ Loud-Error: `ctx` must be a table (empty `{}` OK); `locale` must not be
-- vs ctx.actor; same match shape -- vs ctx.actor; same match shape
grants = { ["skill.knapping"] = 1 }, -- optional (ADR-0056): reward, grants = { ["skill.knapping"] = 1 }, -- optional (ADR-0056): reward,
-- returned as `granted` -- returned as `granted`
quality = { contributors = { -- optional (Phase J): product-quality
{ skill = "skill.knapping", max = 5, weight = 0.4 }, -- min = requires-floor
{ ingredient = "head", weight = 0.3 }, -- a NAMED input slot's `quality`
{ tool = "hammer", weight = 0.3 }, -- a NAMED tool's `quality`×`condition`
} }, -- weights sum to 1; product 0..1
name = "Stone Pick", -- optional, display name = "Stone Pick", -- optional, display
description = "A pick for mining stone.", -- optional, used by Inspect description = "A pick for mining stone.", -- optional, used by Inspect
is_known = function(ctx) return true end, is_known = function(ctx) return true end,
@@ -353,7 +381,8 @@ re-initialize state between assertions.
| ~~H (Tools)~~ | **Shipped v0.3.0** (`tools` slot array) | crafting-model.md | | ~~H (Tools)~~ | **Shipped v0.3.0** (`tools` slot array) | crafting-model.md |
| ~~H (Skills, gate)~~ | **Shipped v0.4.0** (`requires`/`grants`, ADR-0056) | crafting-model.md | | ~~H (Skills, gate)~~ | **Shipped v0.4.0** (`requires`/`grants`, ADR-0056) | crafting-model.md |
| H (Skills, progression) | XP→level curve, decay, `lib-core.skill` extraction | — | | H (Skills, progression) | XP→level curve, decay, `lib-core.skill` extraction | — |
| J (Quality) | `quality`-block: skill/ingredient/tool → output quality | crafting-model.md §Actor-Precondition | | ~~J (Quality/Condition)~~ | **Shipped v0.5.0** (`quality`-block + `wear_per_use`, design 2026-08-03) | crafting-model.md §Quality |
| J+ (Repair / graded affordances) | condition-raising recipes; magnitude→success | design 2026-08-03 §7/§8 |
| D+ (Workpiece) | Multi-step crafting with intermediate workpiece-entities | crafting-model.md | | D+ (Workpiece) | Multi-step crafting with intermediate workpiece-entities | crafting-model.md |
| D+ (Batch / Time) | Batch parameter, time-system coupling, abort-decon | crafting-model.md | | D+ (Batch / Time) | Batch parameter, time-system coupling, abort-decon | crafting-model.md |
| Domain-Libs | `lib-core.metalwork` / `textile` / `woodwork` consume crafting substrate | libraries.md §8 Catalog | | Domain-Libs | `lib-core.metalwork` / `textile` / `woodwork` consume crafting substrate | libraries.md §8 Catalog |

245
init.lua
View File

@@ -1,9 +1,10 @@
-- ===================================================================== -- =====================================================================
-- lib-core.crafting v0.4.0 — Recipe Registry + Craft Action -- lib-core.crafting v0.5.0 — Recipe Registry + Craft Action
-- Spec: meta/docs/adrs/0055-recipe-slot-property-constraint.md -- Spec: meta/docs/adrs/0055-recipe-slot-property-constraint.md
-- meta/docs/adrs/0056-crafting-actor-precondition-reward.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-28-crafting-property-constraint-slots-design.md
-- meta/docs/design/2026-07-31-crafting-skill-gating-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: -- Surface:
-- crafting.define_recipe(recipe_def) -- register a recipe -- crafting.define_recipe(recipe_def) -- register a recipe
@@ -13,24 +14,43 @@
-- crafting.can_craft(recipe_id, locale, ctx) -> result (non-mutating) -- crafting.can_craft(recipe_id, locale, ctx) -> result (non-mutating)
-- crafting.craft(recipe_id, locale, ctx) -> result (mutating) -- crafting.craft(recipe_id, locale, ctx) -> result (mutating)
-- --
-- Recipe-Schema (v0.4.0 — ADR-0055 + ADR-0056): -- Recipe-Schema (v0.5.0 — ADR-0055 + ADR-0056 + Phase-J quality/condition):
-- { -- {
-- id = "saw_planks", -- id = "saw_planks",
-- inputs = { -- consumed; each entry is -- inputs = { -- consumed; each entry is
-- { match = {category="wood", mass=">5"}, count = 1 }, -- { match = {category="wood", mass=">5"}, count = 1, name="stock" },
-- { template = "rock", count = 1 }, -- template-id = trivial match -- { template = "rock", count = 1 }, -- template-id = trivial match
-- }, -- },
-- tools = { -- NON-consumed presence check -- tools = { -- NON-consumed presence check
-- { match = {["affordance.cutting"] = true} }, -- { match = {["affordance.cutting"] = true}, name="blade",
-- wear_per_use = 0.1 }, -- Phase J: condition decrement
-- }, -- },
-- outputs = { {template="plank", count=4} },-- plural; `output` singular ok -- outputs = { {template="plank", count=4} },-- plural; `output` singular ok
-- requires = { ["skill.knapping"] = ">=1" },-- ADR-0056: actor-precondition -- requires = { ["skill.knapping"] = ">=1" },-- ADR-0056: actor-precondition
-- -- gate, matched vs ctx.actor -- -- gate, matched vs ctx.actor
-- grants = { ["skill.knapping"] = 5 }, -- ADR-0056: reward returned as -- grants = { ["skill.knapping"] = 5 }, -- ADR-0056: reward returned as
-- -- `granted`; MODULE writes it -- -- `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 = "...", 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 -- `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 -- 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). -- recipe ("can this actor execute it?") — distinct from is_known (discovery).
@@ -58,11 +78,15 @@
-- --
-- Match-Result-Schema: -- Match-Result-Schema:
-- { ok, error?, missing?, missing_tools?, unmet?, crafted_items?, consumed?, -- { ok, error?, missing?, missing_tools?, unmet?, crafted_items?, consumed?,
-- granted? } -- granted?, quality?, wear? }
-- error ∈ { "unknown_recipe" | "requires_unmet" | "missing_inputs" -- error ∈ { "unknown_recipe" | "requires_unmet" | "missing_inputs"
-- | "missing_tools" } -- | "missing_tools" }
-- unmet = { "<requires-key>", ... } (which actor-preconditions failed) -- unmet = { "<requires-key>", ... } (which actor-preconditions failed)
-- granted = the recipe's `grants` table on a successful craft (module applies) -- 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), -- Deps: lib-core.composition (template_of, create, destroy),
-- lib-core.inventory-list (contents, add, remove) -- lib-core.inventory-list (contents, add, remove)
@@ -106,6 +130,27 @@ local function compile_value_test(val)
return function(x) return x == val end -- number / bool equality return function(x) return x == val end -- number / bool equality
end 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 -- Compile a `match` table into a list of {key, test}. A bare template-id
-- slot is normalized upstream into { template = "<id>" }. -- slot is normalized upstream into { template = "<id>" }.
local function compile_match(match_tbl, kind, recipe_id) local function compile_match(match_tbl, kind, recipe_id)
@@ -242,6 +287,7 @@ end
local function plan(r, sources) local function plan(r, sources)
local pool = build_pool(sources) local pool = build_pool(sources)
local consume = {} local consume = {}
local named = {} -- slot-name -> matched entity (for the quality formula)
for _, slot in ipairs(r.inputs) do for _, slot in ipairs(r.inputs) do
local found = 0 local found = 0
@@ -250,6 +296,8 @@ local function plan(r, sources)
if not p.claimed and entity_matches(p.ent, slot.fields) then if not p.claimed and entity_matches(p.ent, slot.fields) then
p.claimed = true p.claimed = true
consume[#consume + 1] = { ent = p.ent, src = p.src } 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 found = found + 1
end end
end end
@@ -260,18 +308,57 @@ local function plan(r, sources)
end end
-- Tools: presence only (not consumed, not claimed). Checked against the -- Tools: presence only (not consumed, not claimed). Checked against the
-- full pool, including items already claimed as inputs. -- full pool, including items already claimed as inputs. Record the matched
for _, slot in ipairs(r.tools) do -- entity per slot (for wear-decrement + named quality-references).
local present = false local tool_ents = {}
for i, slot in ipairs(r.tools) do
local match_ent = nil
for _, p in ipairs(pool) do for _, p in ipairs(pool) do
if entity_matches(p.ent, slot.fields) then present = true; break end if entity_matches(p.ent, slot.fields) then match_ent = p.ent; break end
end end
if not present then if not match_ent then
return { ok = false, error = "missing_tools", missing_tools = { {} } } return { ok = false, error = "missing_tools", missing_tools = { {} } }
end end
tool_ents[i] = match_ent
if slot.name and named[slot.name] == nil then named[slot.name] = match_ent end
end end
return { ok = true, consume = consume } 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 end
-- ---------- public API ---------- -- ---------- public API ----------
@@ -298,7 +385,11 @@ function M.define_recipe(def)
or entry.count ~= math.floor(entry.count) then 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) error(string.format("crafting.define_recipe '%s': inputs[%d].count must be positive int", id, i), 2)
end end
inputs[i] = { fields = normalize_slot(entry, "inputs[" .. i .. "]", id), count = entry.count } 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 end
-- tools (optional, non-consumed presence checks) -- tools (optional, non-consumed presence checks)
@@ -308,7 +399,18 @@ function M.define_recipe(def)
error(string.format("crafting.define_recipe '%s': tools must be array", id), 2) error(string.format("crafting.define_recipe '%s': tools must be array", id), 2)
end end
for i, entry in ipairs(def.tools) do for i, entry in ipairs(def.tools) do
tools[i] = { fields = normalize_slot(entry, "tools[" .. i .. "]", id) } 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
end end
@@ -367,11 +469,83 @@ function M.define_recipe(def)
end end
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] = { recipes[id] = {
id = id, id = id,
inputs = inputs, inputs = inputs,
tools = tools, tools = tools,
outputs = outputs, outputs = outputs,
quality = quality,
requires_floor = requires_floor,
-- bw-compat alias: consumers (e.g. UI icon resolvers) that read -- bw-compat alias: consumers (e.g. UI icon resolvers) that read
-- `recipe.output.template` keep working; points at the first output. -- `recipe.output.template` keep working; points at the first output.
output = outputs[1], output = outputs[1],
@@ -448,6 +622,13 @@ function M.craft(recipe_id, locale_arg, ctx)
return { ok = false, error = p.error, missing = p.missing, missing_tools = p.missing_tools } return { ok = false, error = p.error, missing = p.missing, missing_tools = p.missing_tools }
end 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). -- Consume claimed inputs (tools are left untouched).
local consumed = {} local consumed = {}
for _, c in ipairs(p.consume) do for _, c in ipairs(p.consume) do
@@ -456,19 +637,49 @@ function M.craft(recipe_id, locale_arg, ctx)
consumed[#consumed + 1] = c.ent consumed[#consumed + 1] = c.ent
end end
-- Create outputs into the sink. -- Create outputs into the sink. A computed quality is applied as a
-- per-instance override (composition auto-declares the property).
local crafted = {} local crafted = {}
for _, out_def in ipairs(r.outputs) do for _, out_def in ipairs(r.outputs) do
for _ = 1, out_def.count do for _ = 1, out_def.count do
local out = composition.create{ template = out_def.template } 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) inv.add(locale.sink, out)
crafted[#crafted + 1] = out crafted[#crafted + 1] = out
end end
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 -- `granted` is static reward data (ADR-0056); the module writes it onto the
-- actor. nil when the recipe has no grants. -- actor. `quality` (nil unless a quality-block ran) and `wear` (nil unless a
return { ok = true, crafted_items = crafted, consumed = consumed, granted = r.grants } -- tool wore) round out the result.
return { ok = true, crafted_items = crafted, consumed = consumed,
granted = r.grants, quality = quality_val, wear = wear }
end end
-- ---------- test backdoors ---------- -- ---------- test backdoors ----------

View File

@@ -1 +1 @@
{"id":"lib-core.crafting","version":"0.4.0","api_min":"0.1","deps":[{"id":"lib-core.composition","version":"0.3.0"},{"id":"lib-core.inventory-list","version":"0.1.0"}]} {"id":"lib-core.crafting","version":"0.5.0","api_min":"0.1","deps":[{"id":"lib-core.composition","version":"0.4.0"},{"id":"lib-core.inventory-list","version":"0.1.0"}]}