feat(crafting): lib-core.crafting v0.3.0 — property-constraint slots + non-consumed tools + multi-output (ADR-0055); vagrant-skeleton debug-drive hook for headless scenario testing

This commit is contained in:
Calic
2026-07-28 10:35:54 +00:00
parent ee56ecaabf
commit b60364df84
2 changed files with 320 additions and 185 deletions

View File

@@ -1,10 +1,12 @@
# lib-core.crafting
Recipe Registry + Match-Check + Craft Action. Headless data + logic layer.
Recipes reference item-template-ids; inputs are consumed from the locale's
source containers, outputs are placed into the locale's sink container.
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.2.0
**Version:** 0.3.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,61 +22,80 @@ graph LR
this --> inventory
```
## Scope (v0.2.0)
## Scope (v0.3.0 — ADR-0055)
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.
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).
**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)
- `define_recipe{id, inputs, tools?, outputs|output, 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)
- 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, missing_inputs, missing_tools}`
**Deferred (Phase D+ / E):**
- Workpiece-Model / multi-step crafting / Batch / Time-coupled craft
- Tool-Requirements (Hammer-required, Workbench-required)
**Deferred (Phase E+):**
- 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
- 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, output, name?, description?, is_known?}) -> void`
**Syntax:** `crafting.define_recipe({id, inputs, tools?, outputs|output, name?, description?, is_known?}) -> void`
**Example:**
**Example (template-id, bw-compat):**
```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,
inputs = { { template = "rock", count = 1 }, { template = "stick", count = 1 } },
output = { template = "rock_pick", count = 1 },
name = "Stone Pick",
}
```
**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`.
**Example (property-constraint + tool + multi-output):**
```lua
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",
}
```
**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.
- 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`; 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.
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`.
Error messages follow the pattern `"crafting.define_recipe '<id>': <reason>"`
so test-suites can pattern-match.

406
init.lua
View File

@@ -1,6 +1,7 @@
-- =====================================================================
-- lib-core.crafting v0.2.0 — Recipe Registry + Craft Action
-- Spec: meta/docs/superpowers/specs/2026-06-14-phase-D-workbench-design.md
-- lib-core.crafting v0.3.0 — Recipe Registry + Craft Action
-- Spec: meta/docs/adrs/0055-recipe-slot-property-constraint.md
-- meta/docs/design/2026-07-28-crafting-property-constraint-slots-design.md
--
-- Surface:
-- crafting.define_recipe(recipe_def) -- register a recipe
@@ -10,31 +11,44 @@
-- 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:
-- Recipe-Schema (v0.3.0 — ADR-0055):
-- {
-- id = "rock_pick",
-- inputs = { {template, count}, ... },
-- output = {template, count},
-- name = "Stone Pick",
-- description = "...",
-- is_known = function(ctx) return true end,
-- id = "saw_planks",
-- inputs = { -- consumed; each entry is
-- { match = {category="wood", mass=">5"}, count = 1 },
-- { template = "rock", count = 1 }, -- template-id = trivial match
-- },
-- tools = { -- NON-consumed presence check
-- { match = {["affordance.cutting"] = true} },
-- },
-- outputs = { {template="plank", count=4} },-- plural; `output` singular ok
-- name = "...", description = "...", is_known = function(ctx) ... end,
-- }
--
-- Match-Result-Schema:
-- { ok, error?, missing?, crafted_items?, consumed? }
-- error ∈ { "unknown_recipe" | "missing_inputs" }
-- A recipe SLOT is a property-CONSTRAINT over an item's (possibly derived)
-- properties, evaluated via ent:get_property. The pseudo-key "template" maps
-- to composition.template_of, so an old {template="rock"} slot is just the
-- trivial constraint {template="rock"} — one match-loop, no second code path.
--
-- Deps:
-- lib-core.composition (template_of, create, destroy)
-- lib-core.inventory-list (contents, add, remove)
-- Constraint values: exact string/number/bool → equality; or a comparison
-- string ">5" / ">=0.2" / "<10" / "<=1" / "==x" (numeric, or string for ==).
-- All keys in a `match` table are AND-combined.
--
-- Input↔item assignment is greedy first-fit (a claimed item can't fill a
-- second slot). Documented Sackgasse: greedy can miss a solvable recipe when
-- one item satisfies two slots; true bipartite matching is deferred. No
-- stone-age recipe hits this.
--
-- Locale-Param (unchanged from v0.2.0):
-- Form 1 (bw-compat): bare entity_handle → both sole source AND sink.
-- Form 2 (explicit): { sources = {c1,...}, sink = c_out }.
--
-- Match-Result-Schema:
-- { ok, error?, missing?, missing_tools?, crafted_items?, consumed? }
-- error ∈ { "unknown_recipe" | "missing_inputs" | "missing_tools" }
--
-- Deps: lib-core.composition (template_of, create, destroy),
-- lib-core.inventory-list (contents, add, remove)
-- =====================================================================
local composition = require("lib-core.composition")
@@ -49,90 +63,187 @@ local function default_is_known(_ctx)
return true
end
-- ---------- helpers ----------
-- ---------- constraint compilation ----------
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)
-- Compile a predicate VALUE into a test function `fn(x) -> bool`.
-- Comparison strings: ">n" ">=n" "<n" "<=n" "==v". Anything else = equality.
local function compile_value_test(val)
if type(val) == "string" then
local op, rhs = val:match("^(<=)%s*(.+)$")
if not op then op, rhs = val:match("^(>=)%s*(.+)$") end
if not op then op, rhs = val:match("^(==)%s*(.+)$") end
if not op then op, rhs = val:match("^([<>])%s*(.+)$") end
if op then
local num = tonumber(rhs)
if op == ">" then return function(x) return type(x) == "number" and x > num end end
if op == ">=" then return function(x) return type(x) == "number" and x >= num end end
if op == "<" then return function(x) return type(x) == "number" and x < num end end
if op == "<=" then return function(x) return type(x) == "number" and x <= num end end
if op == "==" then
if num ~= nil then return function(x) return x == num end
else return function(x) return tostring(x) == rhs end end
end
end
return function(x) return x == val end -- plain string equality
end
return function(x) return x == val end -- number / bool equality
end
-- Compile a `match` table into a list of {key, test}. A bare template-id
-- slot is normalized upstream into { template = "<id>" }.
local function compile_match(match_tbl, kind, recipe_id)
if type(match_tbl) ~= "table" then
error(string.format("crafting.define_recipe '%s': %s.match must be table",
recipe_id, kind), 3)
end
local fields = {}
for key, val in pairs(match_tbl) do
if type(key) ~= "string" then
error(string.format("crafting.define_recipe '%s': %s.match keys must be strings",
recipe_id, kind), 3)
end
fields[#fields + 1] = { key = key, test = compile_value_test(val) }
end
if #fields == 0 then
error(string.format("crafting.define_recipe '%s': %s.match must be non-empty",
recipe_id, kind), 3)
end
return fields
end
-- Read a property for matching. "template" is the derived-property pseudo-key
-- (composition.template_of); everything else is a plain get_property, read
-- safely so matching an item that simply lacks the property fails the test
-- instead of erroring.
local function read_prop(ent, key)
if key == "template" then return composition.template_of(ent) end
local ok, v = pcall(function() return ent:get_property(key) end)
if ok then return v end
return nil
end
local function entity_matches(ent, fields)
for _, f in ipairs(fields) do
if not f.test(read_prop(ent, f.key)) then return false end
end
return true
end
-- ---------- helpers ----------
local function shallow_copy(t)
local out = {}
for k, v in pairs(t) do out[k] = v end
return out
end
-- Locale resolver: accepts entity_handle (bw-compat) OR table
-- {sources={...}, sink=...}. Always returns Form-2 with validated
-- fields. Loud-error on malformed Form-2.
local function validate_output_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
-- Normalize a consumed/tool slot into compiled match-fields. Accepts either
-- {template=...} (bw-compat) or {match={...}}.
local function normalize_slot(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 entry.template ~= nil then
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
return compile_match({ template = entry.template }, kind, recipe_id)
elseif entry.match ~= nil then
return compile_match(entry.match, kind, recipe_id)
end
error(string.format("crafting.define_recipe '%s': %s entry needs 'template' or 'match'",
recipe_id, kind), 3)
end
-- Locale resolver: entity_handle (bw-compat) OR {sources={...}, sink=...}.
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)
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)
error(string.format("crafting.%s: locale.sink must not be nil", fn_name), 3)
end
-- De-duplicate sources by identity, preserving first-occurrence order.
-- A caller building sources programmatically (e.g. {workbench_buffer,
-- player_backpack} where both alias the same entity) should not have
-- count_by_template double-count nor craft silent-fail.
local seen, deduped = {}, {}
for _, s in ipairs(locale.sources) do
if not seen[s] then
seen[s] = true
deduped[#deduped + 1] = s
end
if not seen[s] then seen[s] = true; deduped[#deduped + 1] = s end
end
return { sources = deduped, sink = locale.sink }
end
-- Form 2 malformed: a table that's not Form-2 (no sources key) is almost
-- certainly a caller typo. Loud-error explicitly instead of silently
-- falling through to the Form-1 bw-compat path (which would then crash
-- deep inside inventory-list with an opaque error).
if type(locale) == "table" then
error(string.format(
"crafting.%s: locale table must contain 'sources' field",
fn_name), 3)
error(string.format("crafting.%s: locale table must contain 'sources' field", fn_name), 3)
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)
error(string.format("crafting.%s: locale must not be nil", fn_name), 3)
end
return { sources = {locale}, sink = locale }
return { sources = { locale }, sink = locale }
end
local function count_by_template(sources)
local out = {}
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
-- Build the flat pool of {ent, src} across all sources.
local function build_pool(sources)
local pool = {}
for _, src in ipairs(sources) do
for _, ent in ipairs(inv.contents(src)) do
pool[#pool + 1] = { ent = ent, src = src, claimed = false }
end
end
return out
return pool
end
-- Plan a craft against a source-pool: greedy-claim inputs, presence-check
-- tools. Returns { ok=true, consume={ {ent,src}, ... } } or
-- { ok=false, error=..., missing?/missing_tools? }.
local function plan(r, sources)
local pool = build_pool(sources)
local consume = {}
for _, slot in ipairs(r.inputs) do
local found = 0
for _, p in ipairs(pool) do
if found >= slot.count then break end
if not p.claimed and entity_matches(p.ent, slot.fields) then
p.claimed = true
consume[#consume + 1] = { ent = p.ent, src = p.src }
found = found + 1
end
end
if found < slot.count then
return { ok = false, error = "missing_inputs",
missing = { { needed = slot.count, have = found } } }
end
end
-- Tools: presence only (not consumed, not claimed). Checked against the
-- full pool, including items already claimed as inputs.
for _, slot in ipairs(r.tools) do
local present = false
for _, p in ipairs(pool) do
if entity_matches(p.ent, slot.fields) then present = true; break end
end
if not present then
return { ok = false, error = "missing_tools", missing_tools = { {} } }
end
end
return { ok = true, consume = consume }
end
-- ---------- public API ----------
@@ -146,36 +257,62 @@ function M.define_recipe(def)
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)
error(string.format("crafting.define_recipe '%s': duplicate id", id), 2)
end
-- inputs (required, non-empty)
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)
error(string.format("crafting.define_recipe '%s': inputs must be non-empty array", id), 2)
end
local inputs = {}
for i, entry in ipairs(def.inputs) do
validate_io_entry(entry, "inputs[" .. i .. "]", id)
if type(entry.count) ~= "number" or entry.count <= 0
or entry.count ~= math.floor(entry.count) then
error(string.format("crafting.define_recipe '%s': inputs[%d].count must be positive int", id, i), 2)
end
inputs[i] = { fields = normalize_slot(entry, "inputs[" .. i .. "]", id), count = entry.count }
end
if type(def.output) ~= "table" then
error(string.format(
"crafting.define_recipe '%s': output must be table", id), 2)
-- tools (optional, non-consumed presence checks)
local tools = {}
if def.tools ~= nil then
if type(def.tools) ~= "table" then
error(string.format("crafting.define_recipe '%s': tools must be array", id), 2)
end
for i, entry in ipairs(def.tools) do
tools[i] = { fields = normalize_slot(entry, "tools[" .. i .. "]", id) }
end
end
-- outputs (plural) OR output (singular bw-compat) — at least one required
local outputs = {}
if def.outputs ~= nil then
if type(def.outputs) ~= "table" or #def.outputs == 0 then
error(string.format("crafting.define_recipe '%s': outputs must be non-empty array", id), 2)
end
for i, entry in ipairs(def.outputs) do
validate_output_entry(entry, "outputs[" .. i .. "]", id)
outputs[i] = { template = entry.template, count = entry.count }
end
elseif def.output ~= nil then
validate_output_entry(def.output, "output", id)
outputs[1] = { template = def.output.template, count = def.output.count }
else
error(string.format("crafting.define_recipe '%s': needs 'outputs' or 'output'", 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)
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,
inputs = inputs,
tools = tools,
outputs = outputs,
name = def.name,
description = def.description,
is_known = is_known,
@@ -210,73 +347,50 @@ function M.can_craft(recipe_id, locale_arg, ctx)
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
if r == nil or r.is_known(ctx) ~= true then
return { ok = false, error = "unknown_recipe" }
end
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
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 }
local p = plan(r, locale.sources)
if not p.ok then
return { ok = false, error = p.error, missing = p.missing, missing_tools = p.missing_tools }
end
return { ok = true }
end
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")
if type(ctx) ~= "table" then
error("crafting.craft: ctx must be table", 2)
end
local r = recipes[recipe_id]
if r == nil or r.is_known(ctx) ~= true then
return { ok = false, error = "unknown_recipe" }
end
local locale = resolve_locale(locale_arg, "craft")
local p = plan(r, locale.sources)
if not p.ok then
return { ok = false, error = p.error, missing = p.missing, missing_tools = p.missing_tools }
end
-- 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.
-- Consume claimed inputs (tools are left untouched).
local consumed = {}
for _, need in ipairs(r.inputs) do
local remaining = need.count
for _, source in ipairs(locale.sources) do
if remaining == 0 then break end
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
for _, c in ipairs(p.consume) do
inv.remove(c.src, c.ent)
composition.destroy(c.ent)
consumed[#consumed + 1] = c.ent
end
-- Create outputs into the sink.
local crafted = {}
for _, out_def in ipairs(r.outputs) do
for _ = 1, out_def.count do
local out = composition.create{ template = out_def.template }
inv.add(locale.sink, out)
crafted[#crafted + 1] = out
end
end
-- 2. Create outputs + add to sink.
local crafted = {}
for _ = 1, r.output.count do
local out = composition.create{ template = r.output.template }
inv.add(locale.sink, out)
crafted[#crafted + 1] = out
end
return {
ok = true,
crafted_items = crafted,
consumed = consumed,
}
return { ok = true, crafted_items = crafted, consumed = consumed }
end
-- ---------- test backdoors ----------