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

@@ -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 = { "<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),
-- 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 ----------