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.
18 KiB
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
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?}inputsentries:{template=..., count}or{match={k=v,...}, count}(consumed)toolsentries:{template=...}or{match={...}}(NON-consumed presence check)outputsarray (plural) oroutputsingular (bw-compat)requires: amatchtable evaluated againstctx.actor(hard gate; same predicate machinery as slots) — e.g.{["skill.knapping"]=">=3"}grants:property → numberreward; returned asgranted, the module writes it to the actor — e.g.{["skill.knapping"]=1}- slot
name(input or tool, optional): a label thequalityformula references — e.g.{match={...}, count=1, name="head"} toolsentrywear_per_use(optional): decrements the matched tool'sconditionon a successful craft (clamped 0..1; reported aswear)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 asquality
- 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(...)— mutatinglocale: bare container-entity (Form 1) or{sources={...}, sink=...}(Form 2)- Result
error ∈ {unknown_recipe, requires_unmet, missing_inputs, missing_tools}; onrequires_unmetalsounmet={keys}; on a successfulcraftalsogranted, plusquality(0..1, if a quality-block ran) andwear(per-tool new condition)
Deferred:
- Skill XP→level curve, skill decay,
lib-core.skillextraction (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 × volumederived 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):
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):
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):
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 acountplus eithertemplate = "<id>"ormatch = {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) oroutput(single, bw-compat): items created into the sink.requires(optional): amatchtable evaluated againstctx.actor(not container items) — a hard gate. Failing →{ok=false, error="requires_unmet", unmet={keys}}. Fail-closed whenctx.actoris nil. Distinct fromis_known(discovery). Same predicate/matchshape as slots.grants(optional):property → numbermap. Returned asgrantedfrom a successfulcraft; the consuming module applies it to the actor (crafting stays container-scoped). Domain-free: crafting knows no "skill" meaning.- A
matchconstraint value is an exact string/number/bool, or a comparison string">5"/">=0.2"/"<10"/"<=1"/"==x". Keys AND-combine. The pseudo-keytemplatematchescomposition.template_of; all other keys matchget_property(key)(read safely — an item lacking the property fails). name/descriptionoptional display strings;is_known(ctx) -> booloptional discovery hook (defaulttrue).
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:
-- 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:
-- 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:
- 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 viainventory.removefrom its source and thencomposition.destroyd. The first source is fully drained of matching items before moving on to the next. - Creates
output.countnew items viacomposition.create{template=output.template}. - Adds each new output to
locale.sinkviainventory.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
{
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:
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:
crafting.craft("rock_pick",
{ sources = {workbench_buffer, player_backpack}, sink = workbench_buffer },
ctx)
sourcesis 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.sinkis a single container-entity-handle. All outputs are added to this container.- The
sinkMAY appear insources(e.g. the workbench buffer is both an input source AND the output sink). It does not have to. can_craftaggregateshavecounts across ALL sources before comparing againstneeded.
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
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
localedescribing 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:
craftalways re-checkscan_craftand returns a structured error if conditions changed since the UI's last frame. - Greedy-drain-order is stable:
craftconsumes 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 |
|---|---|---|
Shipped v0.3.0 (match constraints, ADR-0055) |
crafting-model.md | |
Shipped v0.3.0 (tools slot array) |
crafting-model.md | |
Shipped v0.4.0 (requires/grants, ADR-0056) |
crafting-model.md | |
| H (Skills, progression) | XP→level curve, decay, lib-core.skill extraction |
— |
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 |