crafting v0.5.0 + composition v0.4.0: Quality & Condition (Phase J)
lib-core.composition v0.4.0: un-defer quality/condition as inert numeric properties (loud-error removed); composition stores them without semantics. lib-core.crafting v0.5.0: quality-block (multi-contributor product-quality formula: skill-band with min=requires-floor + named ingredient/tool qualities), optional slot name, tool wear_per_use (condition decrement + wear report). affordance stays boolean. Additive to v0.4.0. vagrant-skeleton v0.22.0: branch quality-band RNG at spawn; stone_hammer condition=1.0; knap+axe quality-blocks; hammer wears out. Headless-verified 20/20. Design: meta/docs/design/2026-08-03-crafting-quality-condition-design.md. Docs synced: crafting-model.md, composition-model.md, libraries.md, READMEs.
This commit is contained in:
245
init.lua
245
init.lua
@@ -1,9 +1,10 @@
|
||||
-- =====================================================================
|
||||
-- lib-core.crafting v0.4.0 — Recipe Registry + Craft Action
|
||||
-- lib-core.crafting v0.5.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
|
||||
-- meta/docs/design/2026-08-03-crafting-quality-condition-design.md
|
||||
--
|
||||
-- Surface:
|
||||
-- crafting.define_recipe(recipe_def) -- register a recipe
|
||||
@@ -13,24 +14,43 @@
|
||||
-- crafting.can_craft(recipe_id, locale, ctx) -> result (non-mutating)
|
||||
-- crafting.craft(recipe_id, locale, ctx) -> result (mutating)
|
||||
--
|
||||
-- Recipe-Schema (v0.4.0 — ADR-0055 + ADR-0056):
|
||||
-- Recipe-Schema (v0.5.0 — ADR-0055 + ADR-0056 + Phase-J quality/condition):
|
||||
-- {
|
||||
-- id = "saw_planks",
|
||||
-- inputs = { -- consumed; each entry is
|
||||
-- { match = {category="wood", mass=">5"}, count = 1 },
|
||||
-- { match = {category="wood", mass=">5"}, count = 1, name="stock" },
|
||||
-- { template = "rock", count = 1 }, -- template-id = trivial match
|
||||
-- },
|
||||
-- tools = { -- NON-consumed presence check
|
||||
-- { match = {["affordance.cutting"] = true} },
|
||||
-- { match = {["affordance.cutting"] = true}, name="blade",
|
||||
-- wear_per_use = 0.1 }, -- Phase J: condition decrement
|
||||
-- },
|
||||
-- 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
|
||||
-- quality = { contributors = { -- Phase J: product-quality formula
|
||||
-- { skill = "skill.knapping", max = 5, weight = 0.5 }, -- min=requires-floor
|
||||
-- { ingredient = "stock", weight = 0.3 }, -- named input's `quality`
|
||||
-- { tool = "blade", weight = 0.2 }, -- named tool's `quality`×`condition`
|
||||
-- } }, -- weights sum to 1
|
||||
-- name = "...", description = "...", is_known = function(ctx) ... end,
|
||||
-- }
|
||||
--
|
||||
-- `name` on a slot (input or tool) is optional; the `quality` formula references
|
||||
-- inputs/tools by that name. `wear_per_use` on a tool slot decrements the matched
|
||||
-- tool's `condition` (clamped 0..1) on a successful craft; the tool's affordance
|
||||
-- is unchanged — author `condition = ">0"` into the tool match to make a fully
|
||||
-- worn tool stop qualifying. Both fields are additive to v0.4.0; a recipe with
|
||||
-- neither behaves exactly as before.
|
||||
--
|
||||
-- The `quality` formula is domain-free: it references named slots + a skill
|
||||
-- property key, never a domain vocabulary. The skill-band `min` is re-read from
|
||||
-- the recipe's own `requires` floor (`">=3"` → 3), so a gate value is authored
|
||||
-- once and reused as the quality floor. Absent `quality`/`condition` on a matched
|
||||
-- item counts as a NEUTRAL 1.0 (absence ≠ zero; only an explicit low value bites).
|
||||
--
|
||||
-- `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).
|
||||
@@ -58,11 +78,15 @@
|
||||
--
|
||||
-- Match-Result-Schema:
|
||||
-- { ok, error?, missing?, missing_tools?, unmet?, crafted_items?, consumed?,
|
||||
-- granted? }
|
||||
-- granted?, quality?, wear? }
|
||||
-- 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)
|
||||
-- quality = computed product quality 0..1 (nil unless a `quality` block ran);
|
||||
-- also written onto every crafted item as its `quality` property
|
||||
-- wear = { <tool-slot-name-or-index> = <new condition 0..1>, ... } (nil
|
||||
-- unless a tool with `wear_per_use` was used)
|
||||
--
|
||||
-- Deps: lib-core.composition (template_of, create, destroy),
|
||||
-- lib-core.inventory-list (contents, add, remove)
|
||||
@@ -106,6 +130,27 @@ local function compile_value_test(val)
|
||||
return function(x) return x == val end -- number / bool equality
|
||||
end
|
||||
|
||||
-- Clamp x into [lo, hi]. Used by the quality-formula and wear-decrement
|
||||
-- (0..1 is a crafting convention, not a Core one — ADR-0001).
|
||||
local function clamp(x, lo, hi)
|
||||
if x < lo then return lo elseif x > hi then return hi else return x end
|
||||
end
|
||||
|
||||
-- Extract a numeric floor from a predicate VALUE, for the quality-band `min`
|
||||
-- (design 2026-08-03 §4). ">=n" / ">n" → n; a bare number (exact equality) →
|
||||
-- that number; anything else → nil (no floor, treated as 0 by the formula).
|
||||
-- This is the forward-compat promise: the skill-`min` = the `requires`-gate
|
||||
-- value, re-read here, never re-authored.
|
||||
local function extract_floor(val)
|
||||
if type(val) == "string" then
|
||||
local rhs = val:match("^>=%s*(.+)$") or val:match("^>%s*(.+)$")
|
||||
if rhs then return tonumber(rhs) end
|
||||
elseif type(val) == "number" then
|
||||
return val
|
||||
end
|
||||
return nil
|
||||
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)
|
||||
@@ -242,6 +287,7 @@ end
|
||||
local function plan(r, sources)
|
||||
local pool = build_pool(sources)
|
||||
local consume = {}
|
||||
local named = {} -- slot-name -> matched entity (for the quality formula)
|
||||
|
||||
for _, slot in ipairs(r.inputs) do
|
||||
local found = 0
|
||||
@@ -250,6 +296,8 @@ local function plan(r, sources)
|
||||
if not p.claimed and entity_matches(p.ent, slot.fields) then
|
||||
p.claimed = true
|
||||
consume[#consume + 1] = { ent = p.ent, src = p.src }
|
||||
-- Named ingredient = the FIRST claimed item of that slot.
|
||||
if slot.name and named[slot.name] == nil then named[slot.name] = p.ent end
|
||||
found = found + 1
|
||||
end
|
||||
end
|
||||
@@ -260,18 +308,57 @@ local function plan(r, sources)
|
||||
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
|
||||
-- full pool, including items already claimed as inputs. Record the matched
|
||||
-- entity per slot (for wear-decrement + named quality-references).
|
||||
local tool_ents = {}
|
||||
for i, slot in ipairs(r.tools) do
|
||||
local match_ent = nil
|
||||
for _, p in ipairs(pool) do
|
||||
if entity_matches(p.ent, slot.fields) then present = true; break end
|
||||
if entity_matches(p.ent, slot.fields) then match_ent = p.ent; break end
|
||||
end
|
||||
if not present then
|
||||
if not match_ent then
|
||||
return { ok = false, error = "missing_tools", missing_tools = { {} } }
|
||||
end
|
||||
tool_ents[i] = match_ent
|
||||
if slot.name and named[slot.name] == nil then named[slot.name] = match_ent end
|
||||
end
|
||||
|
||||
return { ok = true, consume = consume }
|
||||
return { ok = true, consume = consume, named = named, tool_ents = tool_ents }
|
||||
end
|
||||
|
||||
-- Evaluate the quality-formula (design 2026-08-03 §4). Each contributor yields a
|
||||
-- normalized 0..1 term; product quality = Σ(weightᵢ · termᵢ), clamped to 0..1.
|
||||
-- skill: clamp((actor[key] - min) / (max - min)) min = requires-floor
|
||||
-- ingredient: named item's `quality` property
|
||||
-- tool: named tool's `quality` × `condition`
|
||||
-- Absent `quality`/`condition` on an item counts as a NEUTRAL 1.0 (absence ≠
|
||||
-- zero-quality; only an explicit low value penalizes).
|
||||
local function num_or(v, default)
|
||||
if type(v) == "number" then return v end
|
||||
return default
|
||||
end
|
||||
|
||||
local function eval_quality(qblock, actor, named, requires_floor)
|
||||
local q = 0
|
||||
for _, c in ipairs(qblock.contributors) do
|
||||
local term
|
||||
if c.kind == "skill" then
|
||||
local min = (requires_floor and requires_floor[c.key]) or 0
|
||||
local sv = num_or(actor ~= nil and read_prop(actor, c.key) or nil, 0)
|
||||
local span = c.max - min
|
||||
if span <= 0 then term = 1 else term = clamp((sv - min) / span, 0, 1) end
|
||||
elseif c.kind == "ingredient" then
|
||||
local ent = named[c.slot]
|
||||
term = ent and num_or(read_prop(ent, "quality"), 1) or 1
|
||||
else -- "tool": quality × condition (a worn tool makes worse products)
|
||||
local ent = named[c.slot]
|
||||
local tq = ent and num_or(read_prop(ent, "quality"), 1) or 1
|
||||
local tc = ent and num_or(read_prop(ent, "condition"), 1) or 1
|
||||
term = tq * tc
|
||||
end
|
||||
q = q + c.weight * clamp(term, 0, 1)
|
||||
end
|
||||
return clamp(q, 0, 1)
|
||||
end
|
||||
|
||||
-- ---------- public API ----------
|
||||
@@ -298,7 +385,11 @@ function M.define_recipe(def)
|
||||
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 }
|
||||
if entry.name ~= nil and type(entry.name) ~= "string" then
|
||||
error(string.format("crafting.define_recipe '%s': inputs[%d].name must be string", id, i), 2)
|
||||
end
|
||||
inputs[i] = { fields = normalize_slot(entry, "inputs[" .. i .. "]", id),
|
||||
count = entry.count, name = entry.name }
|
||||
end
|
||||
|
||||
-- tools (optional, non-consumed presence checks)
|
||||
@@ -308,7 +399,18 @@ function M.define_recipe(def)
|
||||
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) }
|
||||
if entry.name ~= nil and type(entry.name) ~= "string" then
|
||||
error(string.format("crafting.define_recipe '%s': tools[%d].name must be string", id, i), 2)
|
||||
end
|
||||
if entry.wear_per_use ~= nil then
|
||||
if type(entry.wear_per_use) ~= "number" or entry.wear_per_use <= 0 then
|
||||
error(string.format(
|
||||
"crafting.define_recipe '%s': tools[%d].wear_per_use must be positive number",
|
||||
id, i), 2)
|
||||
end
|
||||
end
|
||||
tools[i] = { fields = normalize_slot(entry, "tools[" .. i .. "]", id),
|
||||
name = entry.name, wear_per_use = entry.wear_per_use }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -367,11 +469,83 @@ function M.define_recipe(def)
|
||||
end
|
||||
end
|
||||
|
||||
-- requires_floor (Phase J): numeric floor per requires-key, re-read from the
|
||||
-- gate predicate so the quality-band `min` never needs re-authoring (§4).
|
||||
local requires_floor = nil
|
||||
if def.requires ~= nil then
|
||||
requires_floor = {}
|
||||
for k, v in pairs(def.requires) do
|
||||
local f = extract_floor(v)
|
||||
if f ~= nil then requires_floor[k] = f end
|
||||
end
|
||||
end
|
||||
|
||||
-- quality (optional, Phase J / design 2026-08-03 §4): a multi-contributor
|
||||
-- formula that computes the product's `quality` at craft-time. Each
|
||||
-- contributor is exactly ONE of skill / ingredient / tool, plus a numeric
|
||||
-- weight; weights must sum to 1. ingredient/tool reference a NAMED slot.
|
||||
local quality = nil
|
||||
if def.quality ~= nil then
|
||||
if type(def.quality) ~= "table" or type(def.quality.contributors) ~= "table"
|
||||
or #def.quality.contributors == 0 then
|
||||
error(string.format(
|
||||
"crafting.define_recipe '%s': quality.contributors must be non-empty array", id), 2)
|
||||
end
|
||||
local input_names, tool_names = {}, {}
|
||||
for _, s in ipairs(inputs) do if s.name then input_names[s.name] = true end end
|
||||
for _, s in ipairs(tools) do if s.name then tool_names[s.name] = true end end
|
||||
local contributors, wsum = {}, 0
|
||||
for ci, c in ipairs(def.quality.contributors) do
|
||||
if type(c) ~= "table" then
|
||||
error(string.format("crafting.define_recipe '%s': quality.contributors[%d] must be table", id, ci), 2)
|
||||
end
|
||||
if type(c.weight) ~= "number" or c.weight < 0 then
|
||||
error(string.format("crafting.define_recipe '%s': quality.contributors[%d].weight must be non-negative number", id, ci), 2)
|
||||
end
|
||||
local nc
|
||||
if c.skill ~= nil then
|
||||
if type(c.skill) ~= "string" then
|
||||
error(string.format("crafting.define_recipe '%s': quality.contributors[%d].skill must be string", id, ci), 2)
|
||||
end
|
||||
if type(c.max) ~= "number" then
|
||||
error(string.format("crafting.define_recipe '%s': quality.contributors[%d].max must be number", id, ci), 2)
|
||||
end
|
||||
nc = { kind = "skill", key = c.skill, max = c.max, weight = c.weight }
|
||||
elseif c.ingredient ~= nil then
|
||||
if type(c.ingredient) ~= "string" then
|
||||
error(string.format("crafting.define_recipe '%s': quality.contributors[%d].ingredient must be string", id, ci), 2)
|
||||
end
|
||||
if not input_names[c.ingredient] then
|
||||
error(string.format("crafting.define_recipe '%s': quality.contributors[%d] references unknown input slot name '%s'", id, ci, c.ingredient), 2)
|
||||
end
|
||||
nc = { kind = "ingredient", slot = c.ingredient, weight = c.weight }
|
||||
elseif c.tool ~= nil then
|
||||
if type(c.tool) ~= "string" then
|
||||
error(string.format("crafting.define_recipe '%s': quality.contributors[%d].tool must be string", id, ci), 2)
|
||||
end
|
||||
if not tool_names[c.tool] then
|
||||
error(string.format("crafting.define_recipe '%s': quality.contributors[%d] references unknown tool slot name '%s'", id, ci, c.tool), 2)
|
||||
end
|
||||
nc = { kind = "tool", slot = c.tool, weight = c.weight }
|
||||
else
|
||||
error(string.format("crafting.define_recipe '%s': quality.contributors[%d] needs one of skill/ingredient/tool", id, ci), 2)
|
||||
end
|
||||
contributors[ci] = nc
|
||||
wsum = wsum + c.weight
|
||||
end
|
||||
if math.abs(wsum - 1) > 1e-9 then
|
||||
error(string.format("crafting.define_recipe '%s': quality contributor weights must sum to 1 (got %s)", id, tostring(wsum)), 2)
|
||||
end
|
||||
quality = { contributors = contributors }
|
||||
end
|
||||
|
||||
recipes[id] = {
|
||||
id = id,
|
||||
inputs = inputs,
|
||||
tools = tools,
|
||||
outputs = outputs,
|
||||
quality = quality,
|
||||
requires_floor = requires_floor,
|
||||
-- bw-compat alias: consumers (e.g. UI icon resolvers) that read
|
||||
-- `recipe.output.template` keep working; points at the first output.
|
||||
output = outputs[1],
|
||||
@@ -448,6 +622,13 @@ function M.craft(recipe_id, locale_arg, ctx)
|
||||
return { ok = false, error = p.error, missing = p.missing, missing_tools = p.missing_tools }
|
||||
end
|
||||
|
||||
-- Compute product quality BEFORE consuming inputs — the formula reads the
|
||||
-- ingredient/tool entities, which the consume step is about to destroy.
|
||||
local quality_val = nil
|
||||
if r.quality then
|
||||
quality_val = eval_quality(r.quality, ctx.actor, p.named, r.requires_floor)
|
||||
end
|
||||
|
||||
-- Consume claimed inputs (tools are left untouched).
|
||||
local consumed = {}
|
||||
for _, c in ipairs(p.consume) do
|
||||
@@ -456,19 +637,49 @@ function M.craft(recipe_id, locale_arg, ctx)
|
||||
consumed[#consumed + 1] = c.ent
|
||||
end
|
||||
|
||||
-- Create outputs into the sink.
|
||||
-- Create outputs into the sink. A computed quality is applied as a
|
||||
-- per-instance override (composition auto-declares the property).
|
||||
local crafted = {}
|
||||
for _, out_def in ipairs(r.outputs) do
|
||||
for _ = 1, out_def.count do
|
||||
local out = composition.create{ template = out_def.template }
|
||||
local out
|
||||
if quality_val ~= nil then
|
||||
out = composition.create{ template = out_def.template,
|
||||
properties = { quality = quality_val } }
|
||||
else
|
||||
out = composition.create{ template = out_def.template }
|
||||
end
|
||||
inv.add(locale.sink, out)
|
||||
crafted[#crafted + 1] = out
|
||||
end
|
||||
end
|
||||
|
||||
-- Wear (design 2026-08-03 §6): decrement each tool slot that declares
|
||||
-- wear_per_use, directly on the matched tool entity. Tools live in the
|
||||
-- container (crafting-scope), so — unlike the actor-side `grants` — crafting
|
||||
-- may mutate them here. Reported as `wear` (slot-name or index -> new
|
||||
-- condition) for transparency/testability. condition is clamped to 0..1.
|
||||
local wear = nil
|
||||
for i, slot in ipairs(r.tools) do
|
||||
if slot.wear_per_use and p.tool_ents[i] then
|
||||
local ent = p.tool_ents[i]
|
||||
local newc = clamp(num_or(read_prop(ent, "condition"), 1) - slot.wear_per_use, 0, 1)
|
||||
-- Snap float residue to exactly 0 so a tool that has mathematically
|
||||
-- reached the bottom reads as 0 (a `condition > 0` tool-gate then
|
||||
-- correctly rejects it, and it displays cleanly). Legit low
|
||||
-- conditions are far above this epsilon.
|
||||
if newc < 1e-9 then newc = 0 end
|
||||
ent:set_property("condition", newc)
|
||||
wear = wear or {}
|
||||
wear[slot.name or ("tools[" .. i .. "]")] = newc
|
||||
end
|
||||
end
|
||||
|
||||
-- `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 }
|
||||
-- actor. `quality` (nil unless a quality-block ran) and `wear` (nil unless a
|
||||
-- tool wore) round out the result.
|
||||
return { ok = true, crafted_items = crafted, consumed = consumed,
|
||||
granted = r.grants, quality = quality_val, wear = wear }
|
||||
end
|
||||
|
||||
-- ---------- test backdoors ----------
|
||||
|
||||
Reference in New Issue
Block a user