314 lines
12 KiB
Markdown
314 lines
12 KiB
Markdown
# 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.3.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.3.0 — ADR-0055)
|
||
|
||
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).
|
||
|
||
**Supported:**
|
||
- `define_recipe{id, inputs, tools?, outputs|output, 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)
|
||
- 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, missing_inputs, missing_tools}`
|
||
|
||
**Deferred (Phase E+):**
|
||
- Skill-Checks (skill-property-min for recipe)
|
||
- 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, 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",
|
||
}
|
||
```
|
||
|
||
**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.
|
||
- 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`.
|
||
|
||
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" }
|
||
|
||
-- 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 from items in the locale's sources. Returns `ok=true` if the
|
||
recipe is registered, `is_known(ctx)` returns `true`, AND the union of
|
||
`inventory.contents` across all `locale.sources` contains at least
|
||
`input.count` items per `input.template` for every input. Otherwise
|
||
returns `ok=false` with an `error` discriminator. For `missing_inputs`,
|
||
the `missing` array lists each input that's under-supplied with its
|
||
aggregated `needed` and `have` count (summed across all sources).
|
||
|
||
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)
|
||
}
|
||
|
||
-- failure (same shape as can_craft)
|
||
{ ok = false, error = "unknown_recipe" }
|
||
{ ok = false, error = "missing_inputs", missing = {...} }
|
||
```
|
||
|
||
**Description:** Performs an internal `can_craft` check first; on failure
|
||
returns the same result early (sources untouched). 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 },
|
||
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 |
|
||
|---|---|---|
|
||
| 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 |
|
||
| E (Property-driven) | `composition.iron ≥ 0.8` as input matcher | composition-model.md, crafting-model.md |
|
||
| Domain-Libs | `lib-core.metalwork` / `textile` / `woodwork` consume crafting substrate | libraries.md §8 Catalog |
|