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:
43
README.md
43
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
|
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
245
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
|
-- 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 ----------
|
||||||
|
|||||||
@@ -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"}]}
|
||||||
|
|||||||
Reference in New Issue
Block a user