commit a4f60b4825ce5be758b6884c66f104a970d03147 Author: Calic Date: Sun Jun 14 12:20:42 2026 +0200 feat: lib-core.crafting v0.1.0 — Recipe Registry + Craft Action Headless data + logic layer for recipe-based crafting. Manages a recipe registry, performs match-checks against a container's inventory contents, and atomically consumes inputs + creates outputs. Surface: define_recipe{id, inputs, output, name?, description?, is_known?} list_recipes(), get_recipe(id) is_known(recipe_id, ctx) -- per-recipe discovery gate can_craft(recipe_id, container, ctx) -- non-mutating availability craft(recipe_id, container, ctx) -- mutating action Recipe-Schema: count-form inputs (array of {template, count}) + single output {template, count} + optional is_known(ctx) hook (default returns true). Match-result schema: { ok=true, crafted_items, consumed } { ok=false, error='unknown_recipe' } { ok=false, error='missing_inputs', missing={{template, needed, have}} } Container is both inputs-source and output-destination (single-container v0.1; multi-container deferred). craft re-runs can_craft internally and returns the structured error if state changed since the last frame. Depends on lib-core.composition 0.3.0 (template_of, create, destroy) and lib-core.inventory-list 0.1.0 (contents, add, remove). Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81c9d65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2026 Calic. All rights reserved. + +This software is part of the Sporel platform — **Tier 1 (Official / +Proprietary)** content per the Three-Tier Licensing Model documented in +`meta/docs/archive/design/vision.md §Licensing Model` (current source; +migration to `meta/docs/architecture/licensing-model.md` pending). + +⚠ **WIP — Legal review required before public launch.** The terms below +reflect design intent only; the formalized license framework will be +finalized through legal counsel before the first public release. Until +then, this notice serves as a placeholder defending the platform owner's +rights against unintentional re-licensing. + +No license is granted to copy, modify, distribute, sublicense, or otherwise +use this software in any form without prior written permission from the +copyright holder. + +References: +- Tier 1 (this file): all rights reserved, proprietary, sold/distributed + via official channels (Steam, etc.) +- Tier 2 (Semi-Commercial Co-Development): bilateral contracts, revenue- + share — see vision.md §Licensing Model +- Tier 3 (Community Content): CC BY-NC-SA 4.0 + asymmetric CLA — applies + to community-uploaded libs/modules/assets, not this repo diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d4d239 --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ +# 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.1.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.1.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. + +**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 + +**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 '': "` +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, container, ctx)` + +**Syntax:** `crafting.can_craft(recipe_id, container, 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 `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. + +Loud-Error: `ctx` must be a table (empty `{}` OK). + +### `crafting.craft(recipe_id, container, ctx)` + +**Syntax:** `crafting.craft(recipe_id, container, 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 (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`. + +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). + +## 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 +} +``` + +## 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 + +- **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. +- **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. +- **No silent fail:** `craft` always re-checks `can_craft` and returns + a structured error if conditions changed since the UI's last frame. + +## 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 | +| Domain-Libs | `lib-core.metalwork` / `textile` / `woodwork` consume crafting substrate | libraries.md §8 Catalog | diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..3c56cd1 --- /dev/null +++ b/init.lua @@ -0,0 +1,229 @@ +-- ===================================================================== +-- lib-core.crafting v0.1.0 — Recipe Registry + Craft Action +-- Spec: meta/docs/superpowers/specs/2026-06-14-phase-C-crafting-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) +-- +-- Recipe-Schema: +-- { +-- id = "rock_pick", +-- inputs = { {template, count}, ... }, +-- output = {template, count}, +-- name = "Stone Pick", +-- description = "...", +-- is_known = function(ctx) return true end, +-- } +-- +-- Match-Result-Schema: +-- { ok, error?, missing?, crafted_items?, consumed? } +-- error ∈ { "unknown_recipe" | "missing_inputs" } +-- +-- Deps: +-- lib-core.composition (template_of, create, destroy) +-- lib-core.inventory-list (contents, add, remove) +-- ===================================================================== + +local composition = require("lib-core.composition") +local inv = require("lib-core.inventory-list") + +local M = {} + +-- ---------- module state (all local) ---------- +local recipes = {} + +local function default_is_known(_ctx) + return true +end + +-- ---------- helpers ---------- + +local function validate_io_entry(entry, kind, recipe_id) + if type(entry) ~= "table" then + error(string.format( + "crafting.define_recipe '%s': %s entry must be table", + recipe_id, kind), 3) + end + if type(entry.template) ~= "string" or entry.template == "" then + error(string.format( + "crafting.define_recipe '%s': %s.template must be string", + recipe_id, kind), 3) + end + if type(entry.count) ~= "number" or entry.count <= 0 + or entry.count ~= math.floor(entry.count) then + error(string.format( + "crafting.define_recipe '%s': %s.count must be positive int", + recipe_id, kind), 3) + end +end + +local function shallow_copy(t) + local out = {} + for k, v in pairs(t) do out[k] = v end + return out +end + +local function count_by_template(container) + 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 + end + end + return out +end + +-- ---------- public API ---------- + +function M.define_recipe(def) + if type(def) ~= "table" then + error("crafting.define_recipe: def must be table", 2) + end + local id = def.id + if type(id) ~= "string" or id == "" then + error("crafting.define_recipe: id must be non-empty string", 2) + end + if recipes[id] then + error(string.format( + "crafting.define_recipe '%s': duplicate id", id), 2) + end + if type(def.inputs) ~= "table" or #def.inputs == 0 then + error(string.format( + "crafting.define_recipe '%s': inputs must be non-empty array", + id), 2) + end + for i, entry in ipairs(def.inputs) do + validate_io_entry(entry, "inputs[" .. i .. "]", id) + end + if type(def.output) ~= "table" then + error(string.format( + "crafting.define_recipe '%s': output must be table", id), 2) + end + validate_io_entry(def.output, "output", id) + + local is_known = def.is_known + if is_known == nil then + is_known = default_is_known + elseif type(is_known) ~= "function" then + error(string.format( + "crafting.define_recipe '%s': is_known must be function", + id), 2) + end + + recipes[id] = { + id = id, + inputs = def.inputs, + output = def.output, + name = def.name, + description = def.description, + is_known = is_known, + } +end + +function M.list_recipes() + local out = {} + for _, r in pairs(recipes) do + out[#out + 1] = shallow_copy(r) + end + return out +end + +function M.get_recipe(id) + local r = recipes[id] + if r == nil then return nil end + return shallow_copy(r) +end + +function M.is_known(recipe_id, ctx) + if type(ctx) ~= "table" then + error("crafting.is_known: ctx must be table", 2) + end + local r = recipes[recipe_id] + if r == nil then return false end + return r.is_known(ctx) == true +end + +function M.can_craft(recipe_id, container, ctx) + if type(ctx) ~= "table" then + error("crafting.can_craft: ctx must be table", 2) + end + local r = recipes[recipe_id] + if r == nil then + return { ok = false, error = "unknown_recipe" } + end + if r.is_known(ctx) ~= true then + return { ok = false, error = "unknown_recipe" } + end + local have = count_by_template(container) + local missing = {} + for _, need in ipairs(r.inputs) do + local have_n = have[need.template] or 0 + if have_n < need.count then + missing[#missing + 1] = { + template = need.template, + needed = need.count, + have = have_n, + } + end + end + if #missing > 0 then + return { ok = false, error = "missing_inputs", missing = missing } + end + return { ok = true } +end + +function M.craft(recipe_id, container, ctx) + local pre = M.can_craft(recipe_id, container, ctx) + if not pre.ok then return pre end + + local r = recipes[recipe_id] + + -- Consume inputs: per-input-need, snapshot 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 + 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 + end + end + end + + -- Produce outputs: composition.create + inv.add per output count. + local crafted = {} + for _ = 1, r.output.count do + local out = composition.create{ template = r.output.template } + inv.add(container, out) + crafted[#crafted + 1] = out + end + + return { + ok = true, + crafted_items = crafted, + consumed = consumed, + } +end + +-- ---------- test backdoors ---------- + +function M._test_clear_all() + recipes = {} +end + +function M._test_get_recipes() + return recipes +end + +return M diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..2997fa6 --- /dev/null +++ b/manifest.lib @@ -0,0 +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"}]}