From f938d11ca8640771e1207a60f5e4332ad932eb66 Mon Sep 17 00:00:00 2001 From: Calic Date: Sun, 14 Jun 2026 18:48:04 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lib-core.crafting=20v0.2.0=20=E2=80=94?= =?UTF-8?q?=20Multi-Source=20/=20Single-Sink=20Locale-API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends can_craft + craft to take a `locale` parameter that describes WHERE inputs come from and WHERE outputs go. Two forms are accepted; the v0.1 single-container call signature continues to work unchanged via a transparent bw-compat shim. Locale-Schema: Form 1 (bw-compat): a bare container-entity-handle. Internally wrapped to {sources={h}, sink=h}; the single container is both the sole input source AND the output sink. v0.1 call sites need no migration. Form 2 (explicit): { sources = {c1, c2, ...}, sink = c_out } where sources is a non-empty array of container-entity- handles and sink is a single container-entity- handle. sink MAY appear in sources (e.g. workbench buffer as both source and sink). can_craft aggregates input counts across the union of all sources before comparing against the recipe's needs. `missing` entries report the aggregated `have` count across sources. craft greedy-drains in array-order: for each input.need it walks sources left-to-right, fully draining matching-template items from the first source before moving on to the next. The order is stable and deterministic so callers can encode priority via the array. A file-local resolve_locale helper centralizes the Form-1-vs-Form-2 dispatch and validates Form 2 with loud errors: locale == nil → "locale must not be nil" Form 2, sources not array or empty → "sources must be non-empty array" Form 2, sink == nil → "sink must not be nil" The count_by_template helper now takes a sources array and walks each container's inventory in turn, summing per-template counts. Manifest bumped 0.1.0 → 0.2.0. Deps unchanged (composition 0.3.0, inventory-list 0.1.0). README documents both locale forms, the greedy-drain order, the bw-compat guarantee, and the loud-error conditions. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 134 ++++++++++++++++++++++++++++++++++++++++----------- init.lua | 91 +++++++++++++++++++++++++--------- manifest.lib | 2 +- 3 files changed, 173 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 6d4d239..7bf3496 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 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.1.0 +**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 @@ -20,18 +20,20 @@ graph LR this --> inventory ``` -## Scope (v0.1.0) +## Scope (v0.2.0) Solid-Cut: recipe registry + match-check + craft-action + per-recipe -discovery hook. Single-container parameter (inputs source AND output -destination). Count-form recipe-inputs. +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, container, ctx)` — non-mutating availability check -- `craft(recipe_id, container, ctx)` — mutating action +- `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 @@ -101,9 +103,9 @@ 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, container, ctx)` +### `crafting.can_craft(recipe_id, locale, ctx)` -**Syntax:** `crafting.can_craft(recipe_id, container, ctx) -> result` +**Syntax:** `crafting.can_craft(recipe_id, locale, ctx) -> result` **Result-Shape:** ```lua @@ -124,18 +126,25 @@ display libs to filter the recipe list to known recipes only. ``` **Description:** Non-mutating check whether `recipe_id` can be crafted -right now from items in `container`. Returns `ok=true` if the recipe is -registered, `is_known(ctx)` returns `true`, AND `inventory.contents(container)` -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 `needed` and `have` count. +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). -Loud-Error: `ctx` must be a table (empty `{}` OK). +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). -### `crafting.craft(recipe_id, container, ctx)` +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`. -**Syntax:** `crafting.craft(recipe_id, container, ctx) -> result` +### `crafting.craft(recipe_id, locale, ctx)` + +**Syntax:** `crafting.craft(recipe_id, locale, ctx) -> result` **Result-Shape:** ```lua @@ -152,17 +161,28 @@ Loud-Error: `ctx` must be a table (empty `{}` OK). ``` **Description:** Performs an internal `can_craft` check first; on failure -returns the same result early (container untouched). On success: -1. Removes `input.count` matching-template items from the container via - `inventory.remove`, then `composition.destroy`s each consumed item. -2. Creates `output.count` new items via `composition.create{template=output.template}`. -3. Adds each new output to the container via `inventory.add`. +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). +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 @@ -183,6 +203,60 @@ Loud-Error: `ctx` must be a table (empty `{}` OK). } ``` +## 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.: locale must not be nil` | +| Form 2, `sources` not a table or empty | `crafting.: locale.sources must be non-empty array` | +| Form 2, `sink == nil` | `crafting.: locale.sink must not be nil` | + ## Test Backdoors ```lua @@ -195,21 +269,23 @@ re-initialize state between assertions. ## Conventions -- **Container-Parameter:** v0.1 uses a single container for both inputs - and output. Modules pass the appropriate container (e.g. the player's - backpack). Workbench-Entity / multi-container splits are deferred to - Phase D. +- **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 v0.1 surface. + 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 (Workbench) | Workbench-Entity-Container parameter; post-craft-relocate callback | crafting-model.md | | 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 | diff --git a/init.lua b/init.lua index 3c56cd1..64d48e9 100644 --- a/init.lua +++ b/init.lua @@ -1,14 +1,22 @@ -- ===================================================================== --- lib-core.crafting v0.1.0 — Recipe Registry + Craft Action --- Spec: meta/docs/superpowers/specs/2026-06-14-phase-C-crafting-design.md +-- lib-core.crafting v0.2.0 — Recipe Registry + Craft Action +-- Spec: meta/docs/superpowers/specs/2026-06-14-phase-D-workbench-design.md -- -- Surface: -- crafting.define_recipe(recipe_def) -- register a recipe -- crafting.list_recipes() -> {recipe_def, ...} -- crafting.get_recipe(id) -> recipe_def or nil -- crafting.is_known(recipe_id, ctx) -> bool --- crafting.can_craft(recipe_id, container, ctx) -> result (non-mutating) --- crafting.craft(recipe_id, container, ctx) -> result (mutating) +-- crafting.can_craft(recipe_id, locale, ctx) -> result (non-mutating) +-- crafting.craft(recipe_id, locale, ctx) -> result (mutating) +-- +-- Locale-Param (v0.2.0): +-- Form 1 (bw-compat): a bare entity_handle. Treated as both the sole +-- source AND the sink. +-- Form 2 (explicit): a table { sources = {c1, c2, ...}, sink = c_out } +-- where `sources` is a non-empty array of +-- container-entity-handles and `sink` is a single +-- container-entity-handle. -- -- Recipe-Schema: -- { @@ -68,12 +76,40 @@ local function shallow_copy(t) return out end -local function count_by_template(container) +-- Locale-Resolver: akzeptiert entity_handle (bw-compat) ODER table +-- {sources={...}, sink=...}. Returns immer Form-2 mit validierten +-- Feldern. Loud-Error bei kaputter Form-2. +local function resolve_locale(locale, fn_name) + -- Form 2: explicit table + if type(locale) == "table" and locale.sources ~= nil then + if type(locale.sources) ~= "table" or #locale.sources == 0 then + error(string.format( + "crafting.%s: locale.sources must be non-empty array", + fn_name), 3) + end + if locale.sink == nil then + error(string.format( + "crafting.%s: locale.sink must not be nil", + fn_name), 3) + end + return locale + end + -- Form 1 (bw-compat): bare entity_handle + if locale == nil then + error(string.format( + "crafting.%s: locale must not be nil", fn_name), 3) + end + return { sources = {locale}, sink = locale } +end + +local function count_by_template(sources) local out = {} - for _, ent in ipairs(inv.contents(container)) do - local tpl = composition.template_of(ent) - if tpl ~= nil then - out[tpl] = (out[tpl] or 0) + 1 + for _, source in ipairs(sources) do + for _, ent in ipairs(inv.contents(source)) do + local tpl = composition.template_of(ent) + if tpl ~= nil then + out[tpl] = (out[tpl] or 0) + 1 + end end end return out @@ -149,7 +185,7 @@ function M.is_known(recipe_id, ctx) return r.is_known(ctx) == true end -function M.can_craft(recipe_id, container, ctx) +function M.can_craft(recipe_id, locale_arg, ctx) if type(ctx) ~= "table" then error("crafting.can_craft: ctx must be table", 2) end @@ -160,7 +196,8 @@ function M.can_craft(recipe_id, container, ctx) if r.is_known(ctx) ~= true then return { ok = false, error = "unknown_recipe" } end - local have = count_by_template(container) + local locale = resolve_locale(locale_arg, "can_craft") + local have = count_by_template(locale.sources) local missing = {} for _, need in ipairs(r.inputs) do local have_n = have[need.template] or 0 @@ -178,34 +215,40 @@ function M.can_craft(recipe_id, container, ctx) return { ok = true } end -function M.craft(recipe_id, container, ctx) - local pre = M.can_craft(recipe_id, container, ctx) +function M.craft(recipe_id, locale_arg, ctx) + local pre = M.can_craft(recipe_id, locale_arg, ctx) if not pre.ok then return pre end + local locale = resolve_locale(locale_arg, "craft") local r = recipes[recipe_id] - -- Consume inputs: per-input-need, snapshot contents BEFORE the - -- inner loop because we mutate via inv.remove during iteration. + -- 1. Greedy-drain from sources in array-order. Per input-need, walk + -- sources left-to-right and snapshot each source's contents + -- BEFORE the inner loop because we mutate via inv.remove during + -- iteration. local consumed = {} for _, need in ipairs(r.inputs) do local remaining = need.count - local snapshot = inv.contents(container) - for _, ent in ipairs(snapshot) do + for _, source in ipairs(locale.sources) do if remaining == 0 then break end - if composition.template_of(ent) == need.template then - inv.remove(container, ent) - consumed[#consumed + 1] = ent - composition.destroy(ent) - remaining = remaining - 1 + local snapshot = inv.contents(source) + for _, ent in ipairs(snapshot) do + if remaining == 0 then break end + if composition.template_of(ent) == need.template then + inv.remove(source, ent) + consumed[#consumed + 1] = ent + composition.destroy(ent) + remaining = remaining - 1 + end end end end - -- Produce outputs: composition.create + inv.add per output count. + -- 2. Create outputs + add to sink. local crafted = {} for _ = 1, r.output.count do local out = composition.create{ template = r.output.template } - inv.add(container, out) + inv.add(locale.sink, out) crafted[#crafted + 1] = out end diff --git a/manifest.lib b/manifest.lib index 2997fa6..a80a967 100644 --- a/manifest.lib +++ b/manifest.lib @@ -1 +1 @@ -{"id":"lib-core.crafting","version":"0.1.0","api_min":"0.1","deps":[{"id":"lib-core.composition","version":"0.3.0"},{"id":"lib-core.inventory-list","version":"0.1.0"}]} +{"id":"lib-core.crafting","version":"0.2.0","api_min":"0.1","deps":[{"id":"lib-core.composition","version":"0.3.0"},{"id":"lib-core.inventory-list","version":"0.1.0"}]}