Calic ececf38b2c crafting v0.5.0 + composition v0.4.0: Quality & Condition (Phase J)
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.
2026-08-03 09:14:55 +00:00

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?}
    • 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}
    • slot name (input or tool, optional): a label the quality formula references — e.g. {match={...}, count=1, name="head"}
    • tools entry wear_per_use (optional): decrements the matched tool's condition on a successful craft (clamped 0..1; reported as wear)
    • 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 as quality
  • 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, plus quality (0..1, if a quality-block ran) and wear (per-tool new condition)

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)
  • 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 × 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):

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 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; 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_reciperequires_unmetmissing_inputsmissing_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:

  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.destroyd. 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

{
    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)
  • 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

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/Condition) 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
Description
No description provided
Readme 96 KiB
Languages
Lua 100%