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:27 +00:00
parent b28afd5290
commit f93fef5990
4 changed files with 53 additions and 6 deletions

View File

@@ -8,11 +8,20 @@ callback, foot-body-orientation ("tactical twist"), scale-deformation walk
cycle, and inheritance-decoupled foot sprites. End-to-end inventory pickup/drop cycle, and inheritance-decoupled foot sprites. End-to-end inventory pickup/drop
demo: pick up a rock into a backpack container, drop it back into the world. demo: pick up a rock into a backpack container, drop it back into the world.
**Version:** 0.20.0 **Version:** 0.21.0
**Module-ID:** vagrant-skeleton **Module-ID:** vagrant-skeleton
**Requires:** lib-core.input >=0.4.0, lib-core.camera >=0.3.0, lib-core.render >=0.2.0, lib-core.maps >=0.1.2, lib-core.puppet >=0.4.4, lib-core.interaction >=0.1.0, lib-core.composition >=0.3.0, lib-core.actor >=0.1.0, lib-core.inventory-list >=0.1.0, lib-core.panel >=0.2.0, lib-core.inventory-list-display >=0.1.0, lib-core.notify >=0.1.0, lib-core.notify-display >=0.1.0, lib-core.world-overlay >=0.1.0, lib-core.crafting >=0.3.0, lib-core.crafting-display >=0.2.0, lib-asset.prototype-subterrain >=0.2.0, lib-asset.prototype-tc-basics >=0.1.0 **Requires:** lib-core.input >=0.4.0, lib-core.camera >=0.3.0, lib-core.render >=0.2.0, lib-core.maps >=0.1.2, lib-core.puppet >=0.4.4, lib-core.interaction >=0.1.0, lib-core.composition >=0.3.0, lib-core.actor >=0.1.0, lib-core.inventory-list >=0.1.0, lib-core.panel >=0.2.0, lib-core.inventory-list-display >=0.1.0, lib-core.notify >=0.1.0, lib-core.notify-display >=0.1.0, lib-core.world-overlay >=0.1.0, lib-core.crafting >=0.4.0, lib-core.crafting-display >=0.2.0, lib-asset.prototype-subterrain >=0.2.0, lib-asset.prototype-tc-basics >=0.1.0
**Tags:** test-chamber, puppet, walking-sim, interaction **Tags:** test-chamber, puppet, walking-sim, interaction
> **v0.21.0:** **Skill-gating (crafting v0.4.0 / ADR-0056).** Recipes can now gate
> on and reward the ACTOR: the player-actor carries `skill.knapping` (an entity
> property), `knap_sharp_stone` **grants** knapping-XP, and `craft_stone_axe`
> **requires** `skill.knapping>=3`. The module applies the craft's returned
> `granted` to the actor. Demonstrates the gate→earn→unlock loop (gate downstream,
> grant upstream). Headless-verified: axe locked at skill 0 (`requires_unmet`) →
> 3 knaps → axe unlocks. Skill-locked recipes show "Skill too low" instead of a
> generic failure.
>
> **v0.20.0:** **`form` retired → `affordance` is the one capability axis** > **v0.20.0:** **`form` retired → `affordance` is the one capability axis**
> (ADR-0055 amendment). The `form` role-tag mixed geometry, identity and > (ADR-0055 amendment). The `form` role-tag mixed geometry, identity and
> capability; it is gone. Its functional members folded into `affordance.*` > capability; it is gone. Its functional members folded into `affordance.*`

View File

@@ -21,6 +21,12 @@
-- `inputs` and non-consumed `tools`; the inputs/tools split (not a separate -- `inputs` and non-consumed `tools`; the inputs/tools split (not a separate
-- axis) says whether the item is used up. -- axis) says whether the item is used up.
-- --
-- ACTOR-SIDE (crafting v0.4 / ADR-0056): a recipe may also gate on / reward the
-- ACTOR, not the ingredients:
-- * `requires = { ["skill.knapping"]=">=3" }` — hard gate vs ctx.actor
-- * `grants = { ["skill.knapping"]=1 }` — XP given on successful craft
-- Same predicate machine; the module applies `grants` to the actor.
--
-- `form` was RETIRED (ADR-0055 amendment): a physical-shape axis mixed -- `form` was RETIRED (ADR-0055 amendment): a physical-shape axis mixed
-- geometry, identity and capability. Its functional members became -- geometry, identity and capability. Its functional members became
-- affordances; its identity members (a "tree") stay template-id. -- affordances; its identity members (a "tree") stay template-id.
@@ -86,10 +92,13 @@ return function(c)
-- ---- recipes (match by CAPABILITY, not by item id) -------------- -- ---- recipes (match by CAPABILITY, not by item id) --------------
-- knap: any KNAPPABLE stone + a HAMMERING tool -> a cutting edge. -- knap: any KNAPPABLE stone + a HAMMERING tool -> a cutting edge.
-- (rock is knappable; sharp_stone is NOT → no self-recursion.) -- (rock is knappable; sharp_stone is NOT → no self-recursion.)
-- Skill-ungated but GRANTS knapping XP (ADR-0056) — the accessible "grind"
-- recipe that bootstraps the gated craft_stone_axe below.
c.R{ id = "knap_sharp_stone", c.R{ id = "knap_sharp_stone",
inputs = { { match = { ["affordance.knappable"] = true }, count = 1 } }, inputs = { { match = { ["affordance.knappable"] = true }, count = 1 } },
tools = { { match = { ["affordance.hammering"] = true } } }, tools = { { match = { ["affordance.hammering"] = true } } },
outputs = { { template = "sharp_stone", count = 1 } }, outputs = { { template = "sharp_stone", count = 1 } },
grants = { ["skill.knapping"] = 1 },
name = "Knap Sharp Stone", name = "Knap Sharp Stone",
description = "Strike a stone with a hammerstone to flake off a sharp edge." } description = "Strike a stone with a hammerstone to flake off a sharp edge." }
@@ -115,15 +124,18 @@ return function(c)
description = "Lash a stone to a haft for a pounding tool (enables knapping)." } description = "Lash a stone to a haft for a pounding tool (enables knapping)." }
-- axe: a CUTTING head + a LEVERAGE haft + a BINDING. -- axe: a CUTTING head + a LEVERAGE haft + a BINDING.
-- Skill-GATED (ADR-0056): needs knapping >=3, earned by knap_sharp_stone.
-- Demonstrates the gate+earn+unlock loop (gate downstream, grant upstream).
c.R{ id = "craft_stone_axe", c.R{ id = "craft_stone_axe",
inputs = { inputs = {
{ match = { ["affordance.cutting"] = true }, count = 1 }, -- head (a sharp stone) { match = { ["affordance.cutting"] = true }, count = 1 }, -- head (a sharp stone)
{ match = { ["affordance.leverage"] = true }, count = 1 }, -- haft { match = { ["affordance.leverage"] = true }, count = 1 }, -- haft
{ match = { ["affordance.binding"] = true }, count = 1 }, -- binding { match = { ["affordance.binding"] = true }, count = 1 }, -- binding
}, },
requires = { ["skill.knapping"] = ">=3" },
outputs = { { template = "stone_axe", count = 1 } }, outputs = { { template = "stone_axe", count = 1 } },
name = "Haft Stone Axe", name = "Haft Stone Axe",
description = "Bind a sharp stone to a haft with cordage." } description = "Bind a sharp stone to a haft with cordage. (Needs knapping skill 3.)" }
-- break: a HEAVY leverage-thing (branch, not a light stick) -> two sticks. -- break: a HEAVY leverage-thing (branch, not a light stick) -> two sticks.
c.R{ id = "break_branch", c.R{ id = "break_branch",

View File

@@ -239,6 +239,10 @@ composition.define_template{
properties = { properties = {
position = {x = 0, y = 0}, position = {x = 0, y = 0},
movement_speed = 120, -- WALK_SPEED_NORMAL movement_speed = 120, -- WALK_SPEED_NORMAL
-- Skill as an actor entity-property (ADR-0056 / Phase-A lesson: NOT a
-- Lua-local, so the derivation machinery + get_property reach it).
-- Raw XP counter; recipe `requires`/`grants` gate/reward against it.
["skill.knapping"] = 0,
}, },
tags = {}, -- intentionally untagged: not renderable tags = {}, -- intentionally untagged: not renderable
} }
@@ -492,6 +496,22 @@ local function update_lower_anim(moving, move_x, move_y)
end end
end end
-- Apply a craft's `granted` rewards (ADR-0056) to the actor: additive numeric
-- accumulation (skill XP, etc.). crafting returns the reward; the module owns
-- the actor-write (crafting stays container-scoped). Granted keys must be
-- declared actor properties (see the vagrant_player template).
local function apply_grants(actor_handle, granted)
if not granted then return end
for key, amount in pairs(granted) do
local cur = actor_handle:get_property(key) or 0
actor_handle:set_property(key, cur + amount)
if CI_FRAME_PERF then
engine.print(string.format("vagrant: grant %s +%s -> %s",
key, tostring(amount), tostring(cur + amount)))
end
end
end
function M.init(ctx) function M.init(ctx)
local aliases = engine.module.asset_aliases() local aliases = engine.module.asset_aliases()
@@ -910,6 +930,7 @@ function M.init(ctx)
local r = crafting.craft(recipe_id, c.container, local r = crafting.craft(recipe_id, c.container,
{ actor = state.actor }) { actor = state.actor })
if r.ok then if r.ok then
apply_grants(state.actor, r.granted)
local recipe = crafting.get_recipe(recipe_id) local recipe = crafting.get_recipe(recipe_id)
notify.post{ notify.post{
tags = {"craft", "event"}, tags = {"craft", "event"},
@@ -923,6 +944,8 @@ function M.init(ctx)
local msg local msg
if r.error == "missing_inputs" then if r.error == "missing_inputs" then
msg = "Missing inputs" msg = "Missing inputs"
elseif r.error == "requires_unmet" then
msg = "Skill too low: " .. table.concat(r.unmet or {}, ", ")
else else
msg = "Cannot craft: " .. tostring(r.error) msg = "Cannot craft: " .. tostring(r.error)
end end
@@ -993,6 +1016,7 @@ function M.init(ctx)
local r = crafting.craft(recipe_id, ctx_inner.locale, local r = crafting.craft(recipe_id, ctx_inner.locale,
{ actor = state.actor, at_workbench = true }) { actor = state.actor, at_workbench = true })
if r.ok then if r.ok then
apply_grants(state.actor, r.granted)
local recipe = crafting.get_recipe(recipe_id) local recipe = crafting.get_recipe(recipe_id)
notify.post{ notify.post{
tags = {"craft", "event"}, tags = {"craft", "event"},
@@ -1007,6 +1031,8 @@ function M.init(ctx)
local msg local msg
if r.error == "missing_inputs" then if r.error == "missing_inputs" then
msg = "Missing inputs" msg = "Missing inputs"
elseif r.error == "requires_unmet" then
msg = "Skill too low: " .. table.concat(r.unmet or {}, ", ")
else else
msg = "Cannot craft: " .. tostring(r.error) msg = "Cannot craft: " .. tostring(r.error)
end end

View File

@@ -1,6 +1,6 @@
{ {
"id": "vagrant-skeleton", "id": "vagrant-skeleton",
"version": "0.20.0", "version": "0.21.0",
"kind": "module", "kind": "module",
"api": "^0.1", "api": "^0.1",
"ci_frames": 60, "ci_frames": 60,
@@ -26,7 +26,7 @@
{"id": "lib-core.puppet", "version": "0.5.0"}, {"id": "lib-core.puppet", "version": "0.5.0"},
{"id": "lib-core.interaction", "version": "0.1.0"}, {"id": "lib-core.interaction", "version": "0.1.0"},
{"id": "lib-core.inventory-list", "version": "0.1.0"}, {"id": "lib-core.inventory-list", "version": "0.1.0"},
{"id": "lib-core.crafting", "version": "0.3.0"}, {"id": "lib-core.crafting", "version": "0.4.0"},
{"id": "lib-core.crafting-display", "version": "0.3.0"}, {"id": "lib-core.crafting-display", "version": "0.3.0"},
{"id": "lib-asset.prototype-subterrain", "version": "0.2.0"}, {"id": "lib-asset.prototype-subterrain", "version": "0.2.0"},
{"id": "lib-asset.prototype-blob-geom", "version": "0.1.0"}, {"id": "lib-asset.prototype-blob-geom", "version": "0.1.0"},