Calic 357e7554a5 fix: resolve_locale de-dups sources and rejects malformed tables
Two silent-fail scenarios closed:
  - sources={c, c} would double-count in can_craft then partial-
    consume in craft. resolve_locale now de-duplicates by handle
    identity, preserving first-occurrence order.
  - {sink=x} without sources would wrap the table as Form-1 and
    crash deep inside inventory-list. resolve_locale now loud-
    errors with "locale table must contain 'sources' field"
    before reaching the bw-compat fallback.

Helper comment translated to English per code-language convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 19:29:46 +02:00

lib-core.crafting

Recipe Registry + Match-Check + Craft Action. Headless data + logic layer. Recipes reference item-template-ids; inputs are consumed from a container, outputs are placed back into the same 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

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:

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:

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

-- 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.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 },
    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
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
Description
No description provided
Readme 96 KiB
Languages
Lua 100%