diff --git a/README.md b/README.md index a35259e..689ddbc 100644 --- a/README.md +++ b/README.md @@ -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 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 **Requires:** `lib-core.composition` 0.3.0, `lib-core.inventory-list` 0.1.0 **Tags:** crafting, recipe, registry @@ -22,7 +22,7 @@ graph LR 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` 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 non-consumed `tools` (presence checks) and multi-output. Multi-Source / 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:** -- `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) - `tools` entries: `{template=...}` **or** `{match={...}}` (NON-consumed presence check) - `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 `">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}` +- 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+):** -- Skill-Checks (skill-property-min for recipe) +**Deferred:** +- 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 - 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 @@ -56,7 +65,7 @@ Single-Sink locale (Form 2) plus bw-compat bare-handle locale (Form 1). ### `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):** ```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`. - `inputs` (required, non-empty): consumed slots. Each entry has a `count` plus either `template = ""` 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. +- `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 string `">5"` / `">=0.2"` / `"<10"` / `"<=1"` / `"==x"`. Keys AND-combine. 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`; 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`. +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 '': "` 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 { ok = false, error = "unknown_recipe" } +-- actor precondition not met (ADR-0056) +{ ok = false, error = "requires_unmet", unmet = { "skill.knapping", ... } } + -- inputs insufficient { 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 -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). +right now. Returns `ok=true` if the recipe is registered, `is_known(ctx)` +returns `true`, the recipe's `requires` (if any) are satisfied by +`ctx.actor`, AND the union of `inventory.contents` across all +`locale.sources` supplies every input slot. Otherwise `ok=false` with an +`error` discriminator (checked in that order: `unknown_recipe` → +`requires_unmet` → `missing_inputs` → `missing_tools`). For +`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)" 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, crafted_items = { entity_handle, ... }, -- new output entities consumed = { entity_handle, ... }, -- input entities (already destroyed) + granted = { ["skill.knapping"] = 1 }, -- recipe.grants, or nil; MODULE applies } -- failure (same shape as can_craft) { ok = false, error = "unknown_recipe" } +{ ok = false, error = "requires_unmet", unmet = {...} } { ok = false, error = "missing_inputs", missing = {...} } ``` -**Description:** Performs an internal `can_craft` check first; on failure -returns the same result early (sources untouched). On success: +**Description:** Performs the same registration / `requires` / availability +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 sources in array-order (`locale.sources[1]` first, then `[2]`, ...). 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 }, }, 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 description = "A pick for mining stone.", -- optional, used by Inspect is_known = function(ctx) return true end, @@ -307,7 +349,11 @@ re-initialize state between assertions. | 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+ (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 index 3b3c4d1..9adb577 100644 --- a/init.lua +++ b/init.lua @@ -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 +-- 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-31-crafting-skill-gating-design.md -- -- Surface: -- crafting.define_recipe(recipe_def) -- register a recipe @@ -11,7 +13,7 @@ -- crafting.can_craft(recipe_id, locale, ctx) -> result (non-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", -- inputs = { -- consumed; each entry is @@ -22,9 +24,20 @@ -- { match = {["affordance.cutting"] = true} }, -- }, -- 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, -- } -- +-- `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) -- properties, evaluated via ent:get_property. The pseudo-key "template" maps -- 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 }. -- -- Match-Result-Schema: --- { ok, error?, missing?, missing_tools?, crafted_items?, consumed? } --- error ∈ { "unknown_recipe" | "missing_inputs" | "missing_tools" } +-- { ok, error?, missing?, missing_tools?, unmet?, crafted_items?, consumed?, +-- granted? } +-- error ∈ { "unknown_recipe" | "requires_unmet" | "missing_inputs" +-- | "missing_tools" } +-- unmet = { "", ... } (which actor-preconditions failed) +-- granted = the recipe's `grants` table on a successful craft (module applies) -- -- Deps: lib-core.composition (template_of, create, destroy), -- lib-core.inventory-list (contents, add, remove) @@ -129,6 +146,17 @@ local function entity_matches(ent, fields) return true 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 ---------- 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) 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] = { id = id, inputs = inputs, @@ -316,6 +375,8 @@ function M.define_recipe(def) -- bw-compat alias: consumers (e.g. UI icon resolvers) that read -- `recipe.output.template` keep working; points at the first output. output = outputs[1], + requires = requires, + grants = grants, name = def.name, description = def.description, 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 return { ok = false, error = "unknown_recipe" } 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 p = plan(r, locale.sources) 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 return { ok = false, error = "unknown_recipe" } 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 p = plan(r, locale.sources) if not p.ok then @@ -393,7 +466,9 @@ function M.craft(recipe_id, locale_arg, ctx) 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 -- ---------- test backdoors ---------- diff --git a/manifest.lib b/manifest.lib index 52619c5..048564b 100644 --- a/manifest.lib +++ b/manifest.lib @@ -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"}]} \ No newline at end of file