293 lines
11 KiB
Markdown
293 lines
11 KiB
Markdown
# lib-core.crafting
|
|
|
|
Recipe Registry + Match-Check + Craft Action. Headless data + logic layer.
|
|
Recipes reference item-template-ids; inputs are consumed from the locale's
|
|
source containers, outputs are placed into the locale's sink container.
|
|
|
|
**Version:** 0.2.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.2.0)
|
|
|
|
Solid-Cut: recipe registry + match-check + craft-action + per-recipe
|
|
discovery hook. Multi-Source / Single-Sink locale (Form 2) plus
|
|
bw-compat bare-handle locale (Form 1). Count-form recipe-inputs.
|
|
|
|
**Supported:**
|
|
- `define_recipe{id, inputs, output, name?, description?, is_known?}`
|
|
- `list_recipes()`, `get_recipe(id)`
|
|
- `is_known(recipe_id, ctx)` — per-recipe discovery gating
|
|
- `can_craft(recipe_id, locale, ctx)` — non-mutating availability check
|
|
- `craft(recipe_id, locale, ctx)` — mutating action
|
|
- `locale` parameter accepts either a bare container-entity (Form 1,
|
|
bw-compat) or an explicit `{sources={...}, sink=...}` table (Form 2)
|
|
|
|
**Deferred (Phase D+ / E):**
|
|
- Workpiece-Model / multi-step crafting / Batch / Time-coupled craft
|
|
- Tool-Requirements (Hammer-required, Workbench-required)
|
|
- Skill-Checks (skill-property-min for recipe)
|
|
- Property-driven recipes (`composition.iron ≥ 0.8`) — Phase E
|
|
- Multi-Container-UI (input from A, output to B)
|
|
- Action-Property-Tags (`crafting.cutting`, `crafting.hammering`) — Domain-Lib substrate
|
|
|
|
## API
|
|
|
|
### `crafting.define_recipe(def)`
|
|
|
|
**Syntax:** `crafting.define_recipe({id, inputs, output, name?, description?, is_known?}) -> void`
|
|
|
|
**Example:**
|
|
```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",
|
|
description = "A pick for mining stone.",
|
|
is_known = function(ctx) return true end,
|
|
}
|
|
```
|
|
|
|
**Description:** Registers a recipe under `id`. `inputs` is a non-empty
|
|
array of `{template, count}` entries; `output` is a single
|
|
`{template, count}`. `name` / `description` are optional player-facing
|
|
display strings. `is_known(ctx) -> bool` is an optional discovery hook;
|
|
default is `function() return true end`. The hook receives the same `ctx`
|
|
table the caller passes to `is_known` / `can_craft` / `craft`.
|
|
|
|
Loud `error(...)` on: non-table `def`; missing / non-string / empty `id`;
|
|
duplicate `id`; non-table / empty `inputs`; any input or output entry
|
|
that isn't a `{template: string, count: positive int}` table; non-table
|
|
`output`; non-function `is_known` if provided.
|
|
|
|
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 |
|