diff --git a/README.md b/README.md index 689ddbc..63119e0 100644 --- a/README.md +++ b/README.md @@ -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 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 -**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 ## Topology @@ -22,7 +22,7 @@ graph LR 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` 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). v0.4.0 (ADR-0056): actor-side `requires` (precondition gate vs `ctx.actor`) + `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:** - `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"}` - `grants`: `property → number` reward; returned as `granted`, the **module** 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=, weight}` / `{tool=, 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 `">5"` / `">=0.2"` / `"<10"` / `"<=1"` / `"==x"` (numeric; string for `==`) - `list_recipes()`, `get_recipe(id)`, `is_known(recipe_id, ctx)` - `can_craft(recipe_id, locale, ctx)` — non-mutating; `craft(...)` — mutating - `locale`: bare container-entity (Form 1) or `{sources={...}, sink=...}` (Form 2) - 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:** - Skill XP→level curve, skill decay, `lib-core.skill` extraction (requires/grants 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 - 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 @@ -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`; an empty `match`; bad `count`; missing both `outputs` and `output`; 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 '': "` 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 consumed = { entity_handle, ... }, -- input entities (already destroyed) 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) @@ -257,6 +280,11 @@ Loud-Error: `ctx` must be a table (empty `{}` OK); `locale` must not be -- vs ctx.actor; same match shape grants = { ["skill.knapping"] = 1 }, -- optional (ADR-0056): reward, -- 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 description = "A pick for mining stone.", -- optional, used by Inspect 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 (Skills, gate)~~ | **Shipped v0.4.0** (`requires`/`grants`, ADR-0056) | crafting-model.md | | 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+ (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 | diff --git a/init.lua b/init.lua index 9adb577..ec28f68 100644 --- a/init.lua +++ b/init.lua @@ -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 -- 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 @@ -13,24 +14,43 @@ -- crafting.can_craft(recipe_id, locale, ctx) -> result (non-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", -- 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 -- }, -- 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 -- 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). @@ -58,11 +78,15 @@ -- -- Match-Result-Schema: -- { ok, error?, missing?, missing_tools?, unmet?, crafted_items?, consumed?, --- granted? } +-- 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) @@ -106,6 +130,27 @@ local function compile_value_test(val) 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) @@ -242,6 +287,7 @@ end 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 @@ -250,6 +296,8 @@ local function plan(r, sources) 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 @@ -260,18 +308,57 @@ local function plan(r, sources) end -- Tools: presence only (not consumed, not claimed). Checked against the - -- full pool, including items already claimed as inputs. - for _, slot in ipairs(r.tools) do - local present = false + -- 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 present = true; break end + if entity_matches(p.ent, slot.fields) then match_ent = p.ent; break end end - if not present then + 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 } + 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 ---------- @@ -298,7 +385,11 @@ function M.define_recipe(def) 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 - 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 -- 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) end 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 @@ -367,11 +469,83 @@ function M.define_recipe(def) 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], @@ -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 } 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 @@ -456,19 +637,49 @@ function M.craft(recipe_id, locale_arg, ctx) consumed[#consumed + 1] = c.ent 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 = {} for _, out_def in ipairs(r.outputs) 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) 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. nil when the recipe has no grants. - return { ok = true, crafted_items = crafted, consumed = consumed, granted = r.grants } + -- 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 ---------- diff --git a/manifest.lib b/manifest.lib index 048564b..0ed53c8 100644 --- a/manifest.lib +++ b/manifest.lib @@ -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"}]} \ No newline at end of file +{"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"}]}