Files
sporel-lib-core.crafting/README.md
Calic ff95be19a8 crafting: actor-precondition (requires) + reward (grants) — Skills half of Phase H (ADR-0056)
lib-core.crafting v0.4.0: two optional domain-free recipe fields.
- `requires`: 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. Same predicate machinery as slots; distinct from is_known (discovery vs lock).
- `grants`: property->number reward, returned as `granted` from craft(); the consuming MODULE applies it to the actor (crafting stays container-scoped). Crafting knows no 'skill' vocabulary (ADR-0001).

vagrant-skeleton v0.21.0: player-actor carries skill.knapping (entity property, Phase-A lesson); knap_sharp_stone grants knapping XP, craft_stone_axe requires knapping>=3; module applies granted; skill-locked recipes show 'Skill too low'. Gate->earn->unlock loop headless-verified (axe locked @0 requires_unmet -> 3 knaps -> unlocks).

Quality-band reserved (Phase J): the requires threshold = the future quality floor (no re-authoring). Docs synced: ADR-0056, crafting-model.md (v0.4.0 + Actor-Precondition section), libraries.md, crafting README, vagrant README.
2026-07-31 10:00:26 +00:00

360 lines
15 KiB
Markdown
Raw 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.4.0
**Lib-ID:** lib-core.crafting
**Requires:** `lib-core.composition` 0.3.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.4.0 — ADR-0055 + ADR-0056)
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`).
**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}`
- 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`
**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)
- 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.
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
}
-- 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`
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) | `quality`-block: skill/ingredient/tool → output quality | crafting-model.md §Actor-Precondition |
| 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 |