# 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 = ""` 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 '': "` 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.: locale must not be nil` | | Form 2, `sources` not a table or empty | `crafting.: locale.sources must be non-empty array` | | Form 2, `sink == nil` | `crafting.: 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 |