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

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

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

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

389 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# lib-core.crafting
Recipe Registry + Match-Check + Craft Action. Headless data + logic layer.
A recipe SLOT is a property-constraint over an item's (possibly derived)
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.5.0
**Lib-ID:** lib-core.crafting
**Requires:** `lib-core.composition` 0.4.0, `lib-core.inventory-list` 0.1.0
**Tags:** crafting, recipe, registry
## Topology
```mermaid
graph LR
this["lib-core.crafting"]
composition["lib-core.composition"]
inventory["lib-core.inventory-list"]
this --> composition
this --> inventory
```
## 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`;
the pseudo-key `template` maps to `composition.template_of`, so old template-id
recipes are the trivial constraint through the SAME match-loop. Adds
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?}`
- `inputs` entries: `{template=..., count}` **or** `{match={k=v,...}, count}` (consumed)
- `tools` entries: `{template=...}` **or** `{match={...}}` (NON-consumed presence check)
- `outputs` array (plural) **or** `output` singular (bw-compat)
- `requires`: a `match` table evaluated against **`ctx.actor`** (hard gate;
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=<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
`">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`,
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)
- 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
recipe hits this)
- `density × volume` derived mass on the material substrate (v0.3 reads
whatever property keys the recipe names)
## API
### `crafting.define_recipe(def)`
**Syntax:** `crafting.define_recipe({id, inputs, tools?, outputs|output, requires?, grants?, name?, description?, is_known?}) -> void`
**Example (template-id, bw-compat):**
```lua
crafting.define_recipe{
id = "rock_pick",
inputs = { { template = "rock", count = 1 }, { template = "stick", count = 1 } },
output = { template = "rock_pick", count = 1 },
name = "Stone Pick",
}
```
**Example (property-constraint + tool + multi-output):**
```lua
crafting.define_recipe{
id = "saw_planks",
inputs = { { match = { category = "wood", mass = ">5" }, count = 1 } },
tools = { { match = { ["affordance.cutting"] = true } } }, -- NOT consumed
outputs = { { template = "plank", count = 4 } },
name = "Saw Planks",
}
```
**Example (actor gate + reward, ADR-0056):**
```lua
crafting.define_recipe{
id = "craft_stone_axe",
inputs = { { match = { ["affordance.cutting"] = true }, count = 1 }, ... },
requires = { ["skill.knapping"] = ">=3" }, -- gate vs ctx.actor
grants = { ["skill.knapping"] = 1 }, -- reward -> `granted`, module writes
outputs = { { template = "stone_axe", count = 1 } },
}
```
**Description:** Registers a recipe under `id`.
- `inputs` (required, non-empty): consumed slots. Each entry has a `count` plus
either `template = "<id>"` or `match = {key=constraint, ...}`.
- `tools` (optional): non-consumed presence checks. Each entry is `{template}`
or `{match}` (no count — a matching item need only be present).
- `outputs` (array) or `output` (single, bw-compat): items created into the sink.
- `requires` (optional): a `match` table evaluated against **`ctx.actor`** (not
container items) — a hard gate. Failing → `{ok=false, error="requires_unmet",
unmet={keys}}`. Fail-closed when `ctx.actor` is nil. Distinct from `is_known`
(discovery). Same predicate/`match` shape as slots.
- `grants` (optional): `property → number` map. Returned as `granted` from a
successful `craft`; the **consuming module** applies it to the actor (crafting
stays container-scoped). Domain-free: crafting knows no "skill" meaning.
- A `match` constraint value is an exact string/number/bool, or a comparison
string `">5"` / `">=0.2"` / `"<10"` / `"<=1"` / `"==x"`. Keys AND-combine.
The pseudo-key `template` matches `composition.template_of`; all other keys
match `get_property(key)` (read safely — an item lacking the property fails).
- `name` / `description` optional display strings; `is_known(ctx) -> bool`
optional discovery hook (default `true`).
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; 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>"`
so test-suites can pattern-match.
### `crafting.list_recipes()`
**Syntax:** `crafting.list_recipes() -> {recipe_def, ...}`
**Description:** Returns a shallow-copied list of all currently-registered
recipes. Mutating the returned list (or the entries themselves) does not
affect the registry. Order is unspecified.
### `crafting.get_recipe(id)`
**Syntax:** `crafting.get_recipe(id: string) -> recipe_def or nil`
**Description:** Returns a shallow-copy of the registered recipe with the
given `id`, or `nil` if no recipe is registered under that id.
### `crafting.is_known(recipe_id, ctx)`
**Syntax:** `crafting.is_known(recipe_id: string, ctx: table) -> bool`
**Description:** Returns the result of `recipe.is_known(ctx)` for the
named recipe, or `false` if the `recipe_id` is unregistered. `ctx`
must be a table (loud-error otherwise); empty `{}` is allowed. Used by
display libs to filter the recipe list to known recipes only.
### `crafting.can_craft(recipe_id, locale, ctx)`
**Syntax:** `crafting.can_craft(recipe_id, locale, ctx) -> result`
**Result-Shape:**
```lua
-- success
{ ok = true }
-- recipe missing / not known
{ ok = false, error = "unknown_recipe" }
-- actor precondition not met (ADR-0056)
{ ok = false, error = "requires_unmet", unmet = { "skill.knapping", ... } }
-- inputs insufficient
{
ok = false, error = "missing_inputs",
missing = {
{ template = "stick", needed = 1, have = 0 },
...
},
}
```
**Description:** Non-mutating check whether `recipe_id` can be crafted
right now. Returns `ok=true` if the recipe is registered, `is_known(ctx)`
returns `true`, the recipe's `requires` (if any) are satisfied by
`ctx.actor`, AND the union of `inventory.contents` across all
`locale.sources` supplies every input slot. Otherwise `ok=false` with an
`error` discriminator (checked in that order: `unknown_recipe`
`requires_unmet``missing_inputs``missing_tools`). For
`requires_unmet`, `unmet` lists the failing actor-precondition keys (a nil
`ctx.actor` fails all — fail-closed). For `missing_inputs`, `missing`
lists each under-supplied input with aggregated `needed`/`have`.
The `locale` parameter accepts two forms (see "Locale-Schema (v0.2.0)"
below for details): a bare container-entity (Form 1, bw-compat to v0.1)
or an explicit `{sources={c1, c2, ...}, sink=c_out}` table (Form 2).
Loud-Error: `ctx` must be a table (empty `{}` OK); `locale` must not be
`nil`; for Form 2, `locale.sources` must be a non-empty array and
`locale.sink` must not be `nil`.
### `crafting.craft(recipe_id, locale, ctx)`
**Syntax:** `crafting.craft(recipe_id, locale, ctx) -> result`
**Result-Shape:**
```lua
-- success
{
ok = true,
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)
{ ok = false, error = "unknown_recipe" }
{ ok = false, error = "requires_unmet", unmet = {...} }
{ ok = false, error = "missing_inputs", missing = {...} }
```
**Description:** Performs the same registration / `requires` / availability
checks as `can_craft` first; on failure returns the same result early
(sources untouched). `granted` echoes the recipe's `grants` (or nil) — the
**consuming module** writes it to `ctx.actor`; `craft` itself does not
mutate the actor. On success:
1. For each `input.need`, greedy-drain matching-template items from the
sources in array-order (`locale.sources[1]` first, then `[2]`, ...).
Each item is removed via `inventory.remove` from its source and then
`composition.destroy`d. The first source is fully drained of matching
items before moving on to the next.
2. Creates `output.count` new items via
`composition.create{template=output.template}`.
3. Adds each new output to `locale.sink` via `inventory.add`.
The greedy-drain-order is deterministic and stable: caller controls which
source supplies first by ordering the `sources` array. Common patterns:
put the workpiece-stash first to consume its leftovers; put the player
backpack first to leave the workbench-buffer for next time.
The returned `consumed` array references the input entity handles AFTER
they were destroyed; they're useful for debug logging but must not be
operated on (their composition meta-record is gone).
Loud-Error: `ctx` must be a table (empty `{}` OK); `locale` must not be
`nil`; for Form 2, `locale.sources` must be a non-empty array and
`locale.sink` must not be `nil`.
## Recipe-Schema
```lua
{
id = "rock_pick", -- string, unique, non-empty
inputs = { -- non-empty array
{ template = "rock", count = 1 }, -- count > 0, integer
{ template = "stick", count = 1 },
},
output = { template = "rock_pick", count = 1 },
requires = { ["skill.knapping"] = ">=3" }, -- optional (ADR-0056): gate
-- 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,
-- optional discovery hook
-- ctx is the caller-supplied table
-- default: returns true unconditionally
}
```
## Locale-Schema (v0.2.0)
The `locale` parameter to `can_craft` / `craft` describes WHERE inputs
come from and WHERE outputs go. Two forms are accepted:
### Form 1 (bw-compat, v0.1)
A bare container-entity-handle:
```lua
crafting.craft("rock_pick", player_backpack, ctx)
```
Internally treated as `{sources = {player_backpack}, sink = player_backpack}`
— the single container is both the sole input source AND the output sink.
This is the v0.1 single-container behavior and remains supported
unchanged.
### Form 2 (explicit, v0.2)
A table with `sources` (non-empty array) and `sink`:
```lua
crafting.craft("rock_pick",
{ sources = {workbench_buffer, player_backpack}, sink = workbench_buffer },
ctx)
```
- `sources` is an array of container-entity-handles. Inputs are
consumed greedy-left-to-right: the first source is fully drained of
matching items before moving to the next. Caller controls priority via
array order.
- `sink` is a single container-entity-handle. All outputs are added to
this container.
- The `sink` MAY appear in `sources` (e.g. the workbench buffer is both
an input source AND the output sink). It does not have to.
- `can_craft` aggregates `have` counts across ALL sources before
comparing against `needed`.
### Bw-Compat Guarantee
Any v0.1 call site that passed a bare container-entity-handle as the
second argument continues to work unchanged in v0.2; the shim wraps it
into `{sources = {h}, sink = h}` transparently. No call-site migration
is required.
### Loud-Error Conditions
| Condition | Error message |
|---|---|
| `locale == nil` | `crafting.<fn>: locale must not be nil` |
| Form 2, `sources` not a table or empty | `crafting.<fn>: locale.sources must be non-empty array` |
| Form 2, `sink == nil` | `crafting.<fn>: locale.sink must not be nil` |
## Test Backdoors
```lua
crafting._test_clear_all() -- wipe the recipe registry; for test isolation
crafting._test_get_recipes() -- raw internal recipes-by-id table
```
These are not part of the stable surface; they exist so test-libs can
re-initialize state between assertions.
## Conventions
- **Locale-Parameter:** v0.2 takes a `locale` describing input sources
and output sink. Single-container call sites stay simple via Form-1
bw-compat (bare-handle); workbench-style "merge inputs from N
containers into a sink-entity" call sites use Form-2 explicit table.
- **Discovery-Hook:** Per-recipe `is_known(ctx)` lets modders gate
recipes on schematic-store-lookups, skill-property-checks, etc.
without expanding the surface.
- **No silent fail:** `craft` always re-checks `can_craft` and returns
a structured error if conditions changed since the UI's last frame.
- **Greedy-drain-order is stable:** `craft` consumes from sources in
array-order, fully draining each matching template before moving on.
Callers can rely on this for sink-priority patterns.
## Future Phases
| Phase | Surface addition | Spec |
|---|---|---|
| ~~E (Property-driven)~~ | **Shipped v0.3.0** (`match` constraints, ADR-0055) | 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, progression) | XP→level curve, decay, `lib-core.skill` extraction | — |
| ~~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 |