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) <noreply@anthropic.com>
This commit is contained in:
24
LICENSE
Normal file
24
LICENSE
Normal file
@@ -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
|
||||
216
README.md
Normal file
216
README.md
Normal file
@@ -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 '<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, 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 |
|
||||
229
init.lua
Normal file
229
init.lua
Normal file
@@ -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
|
||||
1
manifest.lib
Normal file
1
manifest.lib
Normal file
@@ -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"}]}
|
||||
Reference in New Issue
Block a user