crafting: actor-precondition (requires) + reward (grants) — Skills half of Phase H (ADR-0056)

lib-core.crafting v0.4.0: two optional domain-free recipe fields.
- `requires`: a match-table evaluated against ctx.actor (not container items) — a hard gate. Failing -> {ok=false, error="requires_unmet", unmet={keys}}; fail-closed when ctx.actor is nil. Same predicate machinery as slots; distinct from is_known (discovery vs lock).
- `grants`: property->number reward, returned as `granted` from craft(); the consuming MODULE applies it to the actor (crafting stays container-scoped). Crafting knows no 'skill' vocabulary (ADR-0001).

vagrant-skeleton v0.21.0: player-actor carries skill.knapping (entity property, Phase-A lesson); knap_sharp_stone grants knapping XP, craft_stone_axe requires knapping>=3; module applies granted; skill-locked recipes show 'Skill too low'. Gate->earn->unlock loop headless-verified (axe locked @0 requires_unmet -> 3 knaps -> unlocks).

Quality-band reserved (Phase J): the requires threshold = the future quality floor (no re-authoring). Docs synced: ADR-0056, crafting-model.md (v0.4.0 + Actor-Precondition section), libraries.md, crafting README, vagrant README.
This commit is contained in:
Calic
2026-07-31 10:00:26 +00:00
parent b75a2432f6
commit ff95be19a8
3 changed files with 145 additions and 24 deletions

View File

@@ -6,7 +6,7 @@ properties; template-id is the trivial constraint `{template="rock"}`. Inputs
are consumed from the locale's source containers, non-consumed tools are are consumed from the locale's source containers, non-consumed tools are
presence-checked, and outputs are placed into the locale's sink container. presence-checked, and outputs are placed into the locale's sink container.
**Version:** 0.3.0 **Version:** 0.4.0
**Lib-ID:** lib-core.crafting **Lib-ID:** lib-core.crafting
**Requires:** `lib-core.composition` 0.3.0, `lib-core.inventory-list` 0.1.0 **Requires:** `lib-core.composition` 0.3.0, `lib-core.inventory-list` 0.1.0
**Tags:** crafting, recipe, registry **Tags:** crafting, recipe, registry
@@ -22,7 +22,7 @@ graph LR
this --> inventory this --> inventory
``` ```
## Scope (v0.3.0 — ADR-0055) ## Scope (v0.4.0 — ADR-0055 + ADR-0056)
Property-constraint recipe slots. A recipe slot matches items by a `match` Property-constraint recipe slots. A recipe slot matches items by a `match`
table of property-constraints (AND-combined), evaluated via `get_property`; table of property-constraints (AND-combined), evaluated via `get_property`;
@@ -30,21 +30,30 @@ the pseudo-key `template` maps to `composition.template_of`, so old template-id
recipes are the trivial constraint through the SAME match-loop. Adds recipes are the trivial constraint through the SAME match-loop. Adds
non-consumed `tools` (presence checks) and multi-output. Multi-Source / non-consumed `tools` (presence checks) and multi-output. Multi-Source /
Single-Sink locale (Form 2) plus bw-compat bare-handle locale (Form 1). Single-Sink locale (Form 2) plus bw-compat bare-handle locale (Form 1).
v0.4.0 (ADR-0056): actor-side `requires` (precondition gate vs `ctx.actor`) +
`grants` (reward returned as `granted`).
**Supported:** **Supported:**
- `define_recipe{id, inputs, tools?, outputs|output, name?, description?, is_known?}` - `define_recipe{id, inputs, tools?, outputs|output, requires?, grants?, name?, description?, is_known?}`
- `inputs` entries: `{template=..., count}` **or** `{match={k=v,...}, count}` (consumed) - `inputs` entries: `{template=..., count}` **or** `{match={k=v,...}, count}` (consumed)
- `tools` entries: `{template=...}` **or** `{match={...}}` (NON-consumed presence check) - `tools` entries: `{template=...}` **or** `{match={...}}` (NON-consumed presence check)
- `outputs` array (plural) **or** `output` singular (bw-compat) - `outputs` array (plural) **or** `output` singular (bw-compat)
- `requires`: a `match` table evaluated against **`ctx.actor`** (hard gate;
same predicate machinery as slots) — e.g. `{["skill.knapping"]=">=3"}`
- `grants`: `property → number` reward; returned as `granted`, the **module**
writes it to the actor — e.g. `{["skill.knapping"]=1}`
- Constraint values: exact string/number/bool, or comparison string - Constraint values: exact string/number/bool, or comparison string
`">5"` / `">=0.2"` / `"<10"` / `"<=1"` / `"==x"` (numeric; string for `==`) `">5"` / `">=0.2"` / `"<10"` / `"<=1"` / `"==x"` (numeric; string for `==`)
- `list_recipes()`, `get_recipe(id)`, `is_known(recipe_id, ctx)` - `list_recipes()`, `get_recipe(id)`, `is_known(recipe_id, ctx)`
- `can_craft(recipe_id, locale, ctx)` — non-mutating; `craft(...)` — mutating - `can_craft(recipe_id, locale, ctx)` — non-mutating; `craft(...)` — mutating
- `locale`: bare container-entity (Form 1) or `{sources={...}, sink=...}` (Form 2) - `locale`: bare container-entity (Form 1) or `{sources={...}, sink=...}` (Form 2)
- Result `error ∈ {unknown_recipe, missing_inputs, missing_tools}` - Result `error ∈ {unknown_recipe, requires_unmet, missing_inputs, missing_tools}`;
on `requires_unmet` also `unmet={keys}`; on a successful `craft` also `granted`
**Deferred (Phase E+):** **Deferred:**
- Skill-Checks (skill-property-min for recipe) - Skill XP→level curve, skill decay, `lib-core.skill` extraction (requires/grants
are the generic substrate; skill vocabulary is the module's — ADR-0056)
- Quality-band coupling (skill/ingredient/tool quality → output quality, Phase J)
- Workpiece-Model / multi-step / Batch / Time-coupled craft - Workpiece-Model / multi-step / Batch / Time-coupled craft
- True bipartite input↔item matching (v0.3 uses greedy first-fit — a solvable - 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 where one item satisfies two slots can be missed; no stone-age
@@ -56,7 +65,7 @@ Single-Sink locale (Form 2) plus bw-compat bare-handle locale (Form 1).
### `crafting.define_recipe(def)` ### `crafting.define_recipe(def)`
**Syntax:** `crafting.define_recipe({id, inputs, tools?, outputs|output, name?, description?, is_known?}) -> void` **Syntax:** `crafting.define_recipe({id, inputs, tools?, outputs|output, requires?, grants?, name?, description?, is_known?}) -> void`
**Example (template-id, bw-compat):** **Example (template-id, bw-compat):**
```lua ```lua
@@ -79,12 +88,30 @@ crafting.define_recipe{
} }
``` ```
**Example (actor gate + reward, ADR-0056):**
```lua
crafting.define_recipe{
id = "craft_stone_axe",
inputs = { { match = { ["affordance.cutting"] = true }, count = 1 }, ... },
requires = { ["skill.knapping"] = ">=3" }, -- gate vs ctx.actor
grants = { ["skill.knapping"] = 1 }, -- reward -> `granted`, module writes
outputs = { { template = "stone_axe", count = 1 } },
}
```
**Description:** Registers a recipe under `id`. **Description:** Registers a recipe under `id`.
- `inputs` (required, non-empty): consumed slots. Each entry has a `count` plus - `inputs` (required, non-empty): consumed slots. Each entry has a `count` plus
either `template = "<id>"` or `match = {key=constraint, ...}`. either `template = "<id>"` or `match = {key=constraint, ...}`.
- `tools` (optional): non-consumed presence checks. Each entry is `{template}` - `tools` (optional): non-consumed presence checks. Each entry is `{template}`
or `{match}` (no count — a matching item need only be present). or `{match}` (no count — a matching item need only be present).
- `outputs` (array) or `output` (single, bw-compat): items created into the sink. - `outputs` (array) or `output` (single, bw-compat): items created into the sink.
- `requires` (optional): a `match` table evaluated against **`ctx.actor`** (not
container items) — a hard gate. Failing → `{ok=false, error="requires_unmet",
unmet={keys}}`. Fail-closed when `ctx.actor` is nil. Distinct from `is_known`
(discovery). Same predicate/`match` shape as slots.
- `grants` (optional): `property → number` map. Returned as `granted` from a
successful `craft`; the **consuming module** applies it to the actor (crafting
stays container-scoped). Domain-free: crafting knows no "skill" meaning.
- A `match` constraint value is an exact string/number/bool, or a comparison - A `match` constraint value is an exact string/number/bool, or a comparison
string `">5"` / `">=0.2"` / `"<10"` / `"<=1"` / `"==x"`. Keys AND-combine. string `">5"` / `">=0.2"` / `"<10"` / `"<=1"` / `"==x"`. Keys AND-combine.
The pseudo-key `template` matches `composition.template_of`; all other keys The pseudo-key `template` matches `composition.template_of`; all other keys
@@ -95,7 +122,8 @@ crafting.define_recipe{
Loud `error(...)` on: non-table `def`; missing / non-string / empty `id`; Loud `error(...)` on: non-table `def`; missing / non-string / empty `id`;
duplicate `id`; empty `inputs`; a slot with neither `template` nor `match`; duplicate `id`; empty `inputs`; a slot with neither `template` nor `match`;
an empty `match`; bad `count`; missing both `outputs` and `output`; an empty `match`; bad `count`; missing both `outputs` and `output`;
non-function `is_known`. non-function `is_known`; non-table or empty `grants`; non-string `grants` key
or non-number `grants` value.
Error messages follow the pattern `"crafting.define_recipe '<id>': <reason>"` Error messages follow the pattern `"crafting.define_recipe '<id>': <reason>"`
so test-suites can pattern-match. so test-suites can pattern-match.
@@ -136,6 +164,9 @@ display libs to filter the recipe list to known recipes only.
-- recipe missing / not known -- recipe missing / not known
{ ok = false, error = "unknown_recipe" } { ok = false, error = "unknown_recipe" }
-- actor precondition not met (ADR-0056)
{ ok = false, error = "requires_unmet", unmet = { "skill.knapping", ... } }
-- inputs insufficient -- inputs insufficient
{ {
ok = false, error = "missing_inputs", ok = false, error = "missing_inputs",
@@ -147,13 +178,15 @@ display libs to filter the recipe list to known recipes only.
``` ```
**Description:** Non-mutating check whether `recipe_id` can be crafted **Description:** Non-mutating check whether `recipe_id` can be crafted
right now from items in the locale's sources. Returns `ok=true` if the right now. Returns `ok=true` if the recipe is registered, `is_known(ctx)`
recipe is registered, `is_known(ctx)` returns `true`, AND the union of returns `true`, the recipe's `requires` (if any) are satisfied by
`inventory.contents` across all `locale.sources` contains at least `ctx.actor`, AND the union of `inventory.contents` across all
`input.count` items per `input.template` for every input. Otherwise `locale.sources` supplies every input slot. Otherwise `ok=false` with an
returns `ok=false` with an `error` discriminator. For `missing_inputs`, `error` discriminator (checked in that order: `unknown_recipe`
the `missing` array lists each input that's under-supplied with its `requires_unmet``missing_inputs``missing_tools`). For
aggregated `needed` and `have` count (summed across all sources). `requires_unmet`, `unmet` lists the failing actor-precondition keys (a nil
`ctx.actor` fails all — fail-closed). For `missing_inputs`, `missing`
lists each under-supplied input with aggregated `needed`/`have`.
The `locale` parameter accepts two forms (see "Locale-Schema (v0.2.0)" 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) below for details): a bare container-entity (Form 1, bw-compat to v0.1)
@@ -174,15 +207,20 @@ Loud-Error: `ctx` must be a table (empty `{}` OK); `locale` must not be
ok = true, ok = true,
crafted_items = { entity_handle, ... }, -- new output entities crafted_items = { entity_handle, ... }, -- new output entities
consumed = { entity_handle, ... }, -- input entities (already destroyed) consumed = { entity_handle, ... }, -- input entities (already destroyed)
granted = { ["skill.knapping"] = 1 }, -- recipe.grants, or nil; MODULE applies
} }
-- failure (same shape as can_craft) -- failure (same shape as can_craft)
{ ok = false, error = "unknown_recipe" } { ok = false, error = "unknown_recipe" }
{ ok = false, error = "requires_unmet", unmet = {...} }
{ ok = false, error = "missing_inputs", missing = {...} } { ok = false, error = "missing_inputs", missing = {...} }
``` ```
**Description:** Performs an internal `can_craft` check first; on failure **Description:** Performs the same registration / `requires` / availability
returns the same result early (sources untouched). On success: checks as `can_craft` first; on failure returns the same result early
(sources untouched). `granted` echoes the recipe's `grants` (or nil) — the
**consuming module** writes it to `ctx.actor`; `craft` itself does not
mutate the actor. On success:
1. For each `input.need`, greedy-drain matching-template items from the 1. For each `input.need`, greedy-drain matching-template items from the
sources in array-order (`locale.sources[1]` first, then `[2]`, ...). sources in array-order (`locale.sources[1]` first, then `[2]`, ...).
Each item is removed via `inventory.remove` from its source and then Each item is removed via `inventory.remove` from its source and then
@@ -215,6 +253,10 @@ Loud-Error: `ctx` must be a table (empty `{}` OK); `locale` must not be
{ template = "stick", count = 1 }, { template = "stick", count = 1 },
}, },
output = { template = "rock_pick", count = 1 }, output = { template = "rock_pick", count = 1 },
requires = { ["skill.knapping"] = ">=3" }, -- optional (ADR-0056): gate
-- vs ctx.actor; same match shape
grants = { ["skill.knapping"] = 1 }, -- optional (ADR-0056): reward,
-- returned as `granted`
name = "Stone Pick", -- optional, display name = "Stone Pick", -- optional, display
description = "A pick for mining stone.", -- optional, used by Inspect description = "A pick for mining stone.", -- optional, used by Inspect
is_known = function(ctx) return true end, is_known = function(ctx) return true end,
@@ -307,7 +349,11 @@ re-initialize state between assertions.
| Phase | Surface addition | Spec | | Phase | Surface addition | Spec |
|---|---|---| |---|---|---|
| ~~E (Property-driven)~~ | **Shipped v0.3.0** (`match` constraints, ADR-0055) | crafting-model.md |
| ~~H (Tools)~~ | **Shipped v0.3.0** (`tools` slot array) | crafting-model.md |
| ~~H (Skills, gate)~~ | **Shipped v0.4.0** (`requires`/`grants`, ADR-0056) | crafting-model.md |
| H (Skills, progression) | XP→level curve, decay, `lib-core.skill` extraction | — |
| J (Quality) | `quality`-block: skill/ingredient/tool → output quality | crafting-model.md §Actor-Precondition |
| D+ (Workpiece) | Multi-step crafting with intermediate workpiece-entities | 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 | | 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 | | Domain-Libs | `lib-core.metalwork` / `textile` / `woodwork` consume crafting substrate | libraries.md §8 Catalog |

View File

@@ -1,7 +1,9 @@
-- ===================================================================== -- =====================================================================
-- lib-core.crafting v0.3.0 — Recipe Registry + Craft Action -- lib-core.crafting v0.4.0 — Recipe Registry + Craft Action
-- Spec: meta/docs/adrs/0055-recipe-slot-property-constraint.md -- Spec: meta/docs/adrs/0055-recipe-slot-property-constraint.md
-- meta/docs/adrs/0056-crafting-actor-precondition-reward.md
-- meta/docs/design/2026-07-28-crafting-property-constraint-slots-design.md -- meta/docs/design/2026-07-28-crafting-property-constraint-slots-design.md
-- meta/docs/design/2026-07-31-crafting-skill-gating-design.md
-- --
-- Surface: -- Surface:
-- crafting.define_recipe(recipe_def) -- register a recipe -- crafting.define_recipe(recipe_def) -- register a recipe
@@ -11,7 +13,7 @@
-- crafting.can_craft(recipe_id, locale, ctx) -> result (non-mutating) -- crafting.can_craft(recipe_id, locale, ctx) -> result (non-mutating)
-- crafting.craft(recipe_id, locale, ctx) -> result (mutating) -- crafting.craft(recipe_id, locale, ctx) -> result (mutating)
-- --
-- Recipe-Schema (v0.3.0 — ADR-0055): -- Recipe-Schema (v0.4.0 — ADR-0055 + ADR-0056):
-- { -- {
-- id = "saw_planks", -- id = "saw_planks",
-- inputs = { -- consumed; each entry is -- inputs = { -- consumed; each entry is
@@ -22,9 +24,20 @@
-- { match = {["affordance.cutting"] = true} }, -- { match = {["affordance.cutting"] = true} },
-- }, -- },
-- outputs = { {template="plank", count=4} },-- plural; `output` singular ok -- outputs = { {template="plank", count=4} },-- plural; `output` singular ok
-- requires = { ["skill.knapping"] = ">=1" },-- ADR-0056: actor-precondition
-- -- gate, matched vs ctx.actor
-- grants = { ["skill.knapping"] = 5 }, -- ADR-0056: reward returned as
-- -- `granted`; MODULE writes it
-- name = "...", description = "...", is_known = function(ctx) ... end, -- name = "...", description = "...", is_known = function(ctx) ... end,
-- } -- }
-- --
-- `requires` is a match-table (same predicate machinery as slots) evaluated
-- against ctx.actor, NOT against container items. It is a hard gate on a KNOWN
-- recipe ("can this actor execute it?") — distinct from is_known (discovery).
-- `grants` (property -> number) is static reward data; craft() returns it as
-- `granted` and the consuming module applies it to the actor (crafting stays
-- container-scoped). Both are domain-free: crafting knows no "skill" vocabulary.
--
-- A recipe SLOT is a property-CONSTRAINT over an item's (possibly derived) -- A recipe SLOT is a property-CONSTRAINT over an item's (possibly derived)
-- properties, evaluated via ent:get_property. The pseudo-key "template" maps -- properties, evaluated via ent:get_property. The pseudo-key "template" maps
-- to composition.template_of, so an old {template="rock"} slot is just the -- to composition.template_of, so an old {template="rock"} slot is just the
@@ -44,8 +57,12 @@
-- Form 2 (explicit): { sources = {c1,...}, sink = c_out }. -- Form 2 (explicit): { sources = {c1,...}, sink = c_out }.
-- --
-- Match-Result-Schema: -- Match-Result-Schema:
-- { ok, error?, missing?, missing_tools?, crafted_items?, consumed? } -- { ok, error?, missing?, missing_tools?, unmet?, crafted_items?, consumed?,
-- error ∈ { "unknown_recipe" | "missing_inputs" | "missing_tools" } -- granted? }
-- error ∈ { "unknown_recipe" | "requires_unmet" | "missing_inputs"
-- | "missing_tools" }
-- unmet = { "<requires-key>", ... } (which actor-preconditions failed)
-- granted = the recipe's `grants` table on a successful craft (module applies)
-- --
-- Deps: lib-core.composition (template_of, create, destroy), -- Deps: lib-core.composition (template_of, create, destroy),
-- lib-core.inventory-list (contents, add, remove) -- lib-core.inventory-list (contents, add, remove)
@@ -129,6 +146,17 @@ local function entity_matches(ent, fields)
return true return true
end end
-- Evaluate `requires` fields against the actor (ADR-0056). Returns the list of
-- unmet keys ({} = all satisfied). A nil actor fails every field (fail-closed).
local function eval_requires(actor, fields)
local unmet = {}
for _, f in ipairs(fields) do
local v = (actor ~= nil) and read_prop(actor, f.key) or nil
if not f.test(v) then unmet[#unmet + 1] = f.key end
end
return unmet
end
-- ---------- helpers ---------- -- ---------- helpers ----------
local function shallow_copy(t) local function shallow_copy(t)
@@ -308,6 +336,37 @@ function M.define_recipe(def)
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 end
-- requires (optional, ADR-0056): actor-precondition gate. Same match-table
-- shape as a slot; compiled here, evaluated against ctx.actor at craft-time.
local requires = nil
if def.requires ~= nil then
requires = compile_match(def.requires, "requires", id)
end
-- grants (optional, ADR-0056): property -> number reward, returned as
-- `granted`; the module applies it to the actor.
local grants = nil
if def.grants ~= nil then
if type(def.grants) ~= "table" then
error(string.format("crafting.define_recipe '%s': grants must be table", id), 2)
end
grants = {}
local n = 0
for k, v in pairs(def.grants) do
if type(k) ~= "string" then
error(string.format("crafting.define_recipe '%s': grants keys must be strings", id), 2)
end
if type(v) ~= "number" then
error(string.format("crafting.define_recipe '%s': grants['%s'] must be number", id, k), 2)
end
grants[k] = v
n = n + 1
end
if n == 0 then
error(string.format("crafting.define_recipe '%s': grants must be non-empty", id), 2)
end
end
recipes[id] = { recipes[id] = {
id = id, id = id,
inputs = inputs, inputs = inputs,
@@ -316,6 +375,8 @@ function M.define_recipe(def)
-- bw-compat alias: consumers (e.g. UI icon resolvers) that read -- bw-compat alias: consumers (e.g. UI icon resolvers) that read
-- `recipe.output.template` keep working; points at the first output. -- `recipe.output.template` keep working; points at the first output.
output = outputs[1], output = outputs[1],
requires = requires,
grants = grants,
name = def.name, name = def.name,
description = def.description, description = def.description,
is_known = is_known, is_known = is_known,
@@ -353,6 +414,12 @@ function M.can_craft(recipe_id, locale_arg, ctx)
if r == nil or r.is_known(ctx) ~= true then if r == nil or r.is_known(ctx) ~= true then
return { ok = false, error = "unknown_recipe" } return { ok = false, error = "unknown_recipe" }
end end
if r.requires then
local unmet = eval_requires(ctx.actor, r.requires)
if #unmet > 0 then
return { ok = false, error = "requires_unmet", unmet = unmet }
end
end
local locale = resolve_locale(locale_arg, "can_craft") local locale = resolve_locale(locale_arg, "can_craft")
local p = plan(r, locale.sources) local p = plan(r, locale.sources)
if not p.ok then if not p.ok then
@@ -369,6 +436,12 @@ function M.craft(recipe_id, locale_arg, ctx)
if r == nil or r.is_known(ctx) ~= true then if r == nil or r.is_known(ctx) ~= true then
return { ok = false, error = "unknown_recipe" } return { ok = false, error = "unknown_recipe" }
end end
if r.requires then
local unmet = eval_requires(ctx.actor, r.requires)
if #unmet > 0 then
return { ok = false, error = "requires_unmet", unmet = unmet }
end
end
local locale = resolve_locale(locale_arg, "craft") local locale = resolve_locale(locale_arg, "craft")
local p = plan(r, locale.sources) local p = plan(r, locale.sources)
if not p.ok then if not p.ok then
@@ -393,7 +466,9 @@ function M.craft(recipe_id, locale_arg, ctx)
end end
end end
return { ok = true, crafted_items = crafted, consumed = consumed } -- `granted` is static reward data (ADR-0056); the module writes it onto the
-- actor. nil when the recipe has no grants.
return { ok = true, crafted_items = crafted, consumed = consumed, granted = r.grants }
end end
-- ---------- test backdoors ---------- -- ---------- test backdoors ----------

View File

@@ -1 +1 @@
{"id":"lib-core.crafting","version":"0.3.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.4.0","api_min":"0.1","deps":[{"id":"lib-core.composition","version":"0.3.0"},{"id":"lib-core.inventory-list","version":"0.1.0"}]}