feat(crafting): lib-core.crafting v0.3.0 — property-constraint slots + non-consumed tools + multi-output (ADR-0055); vagrant-skeleton debug-drive hook for headless scenario testing
This commit is contained in:
406
init.lua
406
init.lua
@@ -1,6 +1,7 @@
|
||||
-- =====================================================================
|
||||
-- lib-core.crafting v0.2.0 — Recipe Registry + Craft Action
|
||||
-- Spec: meta/docs/superpowers/specs/2026-06-14-phase-D-workbench-design.md
|
||||
-- lib-core.crafting v0.3.0 — Recipe Registry + Craft Action
|
||||
-- Spec: meta/docs/adrs/0055-recipe-slot-property-constraint.md
|
||||
-- meta/docs/design/2026-07-28-crafting-property-constraint-slots-design.md
|
||||
--
|
||||
-- Surface:
|
||||
-- crafting.define_recipe(recipe_def) -- register a recipe
|
||||
@@ -10,31 +11,44 @@
|
||||
-- crafting.can_craft(recipe_id, locale, ctx) -> result (non-mutating)
|
||||
-- crafting.craft(recipe_id, locale, ctx) -> result (mutating)
|
||||
--
|
||||
-- Locale-Param (v0.2.0):
|
||||
-- Form 1 (bw-compat): a bare entity_handle. Treated as both the sole
|
||||
-- source AND the sink.
|
||||
-- Form 2 (explicit): a table { sources = {c1, c2, ...}, sink = c_out }
|
||||
-- where `sources` is a non-empty array of
|
||||
-- container-entity-handles and `sink` is a single
|
||||
-- container-entity-handle.
|
||||
--
|
||||
-- Recipe-Schema:
|
||||
-- Recipe-Schema (v0.3.0 — ADR-0055):
|
||||
-- {
|
||||
-- id = "rock_pick",
|
||||
-- inputs = { {template, count}, ... },
|
||||
-- output = {template, count},
|
||||
-- name = "Stone Pick",
|
||||
-- description = "...",
|
||||
-- is_known = function(ctx) return true end,
|
||||
-- id = "saw_planks",
|
||||
-- inputs = { -- consumed; each entry is
|
||||
-- { match = {category="wood", mass=">5"}, count = 1 },
|
||||
-- { template = "rock", count = 1 }, -- template-id = trivial match
|
||||
-- },
|
||||
-- tools = { -- NON-consumed presence check
|
||||
-- { match = {["affordance.cutting"] = true} },
|
||||
-- },
|
||||
-- outputs = { {template="plank", count=4} },-- plural; `output` singular ok
|
||||
-- name = "...", description = "...", is_known = function(ctx) ... end,
|
||||
-- }
|
||||
--
|
||||
-- Match-Result-Schema:
|
||||
-- { ok, error?, missing?, crafted_items?, consumed? }
|
||||
-- error ∈ { "unknown_recipe" | "missing_inputs" }
|
||||
-- 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
|
||||
-- trivial constraint {template="rock"} — one match-loop, no second code path.
|
||||
--
|
||||
-- Deps:
|
||||
-- lib-core.composition (template_of, create, destroy)
|
||||
-- lib-core.inventory-list (contents, add, remove)
|
||||
-- Constraint values: exact string/number/bool → equality; or a comparison
|
||||
-- string ">5" / ">=0.2" / "<10" / "<=1" / "==x" (numeric, or string for ==).
|
||||
-- All keys in a `match` table are AND-combined.
|
||||
--
|
||||
-- Input↔item assignment is greedy first-fit (a claimed item can't fill a
|
||||
-- second slot). Documented Sackgasse: greedy can miss a solvable recipe when
|
||||
-- one item satisfies two slots; true bipartite matching is deferred. No
|
||||
-- stone-age recipe hits this.
|
||||
--
|
||||
-- Locale-Param (unchanged from v0.2.0):
|
||||
-- Form 1 (bw-compat): bare entity_handle → both sole source AND sink.
|
||||
-- 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" }
|
||||
--
|
||||
-- Deps: lib-core.composition (template_of, create, destroy),
|
||||
-- lib-core.inventory-list (contents, add, remove)
|
||||
-- =====================================================================
|
||||
|
||||
local composition = require("lib-core.composition")
|
||||
@@ -49,90 +63,187 @@ local function default_is_known(_ctx)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ---------- helpers ----------
|
||||
-- ---------- constraint compilation ----------
|
||||
|
||||
local function validate_io_entry(entry, kind, recipe_id)
|
||||
if type(entry) ~= "table" then
|
||||
error(string.format(
|
||||
"crafting.define_recipe '%s': %s entry must be table",
|
||||
recipe_id, kind), 3)
|
||||
end
|
||||
if type(entry.template) ~= "string" or entry.template == "" then
|
||||
error(string.format(
|
||||
"crafting.define_recipe '%s': %s.template must be string",
|
||||
recipe_id, kind), 3)
|
||||
end
|
||||
if type(entry.count) ~= "number" or entry.count <= 0
|
||||
or entry.count ~= math.floor(entry.count) then
|
||||
error(string.format(
|
||||
"crafting.define_recipe '%s': %s.count must be positive int",
|
||||
recipe_id, kind), 3)
|
||||
-- Compile a predicate VALUE into a test function `fn(x) -> bool`.
|
||||
-- Comparison strings: ">n" ">=n" "<n" "<=n" "==v". Anything else = equality.
|
||||
local function compile_value_test(val)
|
||||
if type(val) == "string" then
|
||||
local op, rhs = val:match("^(<=)%s*(.+)$")
|
||||
if not op then op, rhs = val:match("^(>=)%s*(.+)$") end
|
||||
if not op then op, rhs = val:match("^(==)%s*(.+)$") end
|
||||
if not op then op, rhs = val:match("^([<>])%s*(.+)$") end
|
||||
if op then
|
||||
local num = tonumber(rhs)
|
||||
if op == ">" then return function(x) return type(x) == "number" and x > num end end
|
||||
if op == ">=" then return function(x) return type(x) == "number" and x >= num end end
|
||||
if op == "<" then return function(x) return type(x) == "number" and x < num end end
|
||||
if op == "<=" then return function(x) return type(x) == "number" and x <= num end end
|
||||
if op == "==" then
|
||||
if num ~= nil then return function(x) return x == num end
|
||||
else return function(x) return tostring(x) == rhs end end
|
||||
end
|
||||
end
|
||||
return function(x) return x == val end -- plain string equality
|
||||
end
|
||||
return function(x) return x == val end -- number / bool equality
|
||||
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)
|
||||
if type(match_tbl) ~= "table" then
|
||||
error(string.format("crafting.define_recipe '%s': %s.match must be table",
|
||||
recipe_id, kind), 3)
|
||||
end
|
||||
local fields = {}
|
||||
for key, val in pairs(match_tbl) do
|
||||
if type(key) ~= "string" then
|
||||
error(string.format("crafting.define_recipe '%s': %s.match keys must be strings",
|
||||
recipe_id, kind), 3)
|
||||
end
|
||||
fields[#fields + 1] = { key = key, test = compile_value_test(val) }
|
||||
end
|
||||
if #fields == 0 then
|
||||
error(string.format("crafting.define_recipe '%s': %s.match must be non-empty",
|
||||
recipe_id, kind), 3)
|
||||
end
|
||||
return fields
|
||||
end
|
||||
|
||||
-- Read a property for matching. "template" is the derived-property pseudo-key
|
||||
-- (composition.template_of); everything else is a plain get_property, read
|
||||
-- safely so matching an item that simply lacks the property fails the test
|
||||
-- instead of erroring.
|
||||
local function read_prop(ent, key)
|
||||
if key == "template" then return composition.template_of(ent) end
|
||||
local ok, v = pcall(function() return ent:get_property(key) end)
|
||||
if ok then return v end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function entity_matches(ent, fields)
|
||||
for _, f in ipairs(fields) do
|
||||
if not f.test(read_prop(ent, f.key)) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ---------- helpers ----------
|
||||
|
||||
local function shallow_copy(t)
|
||||
local out = {}
|
||||
for k, v in pairs(t) do out[k] = v end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Locale resolver: accepts entity_handle (bw-compat) OR table
|
||||
-- {sources={...}, sink=...}. Always returns Form-2 with validated
|
||||
-- fields. Loud-error on malformed Form-2.
|
||||
local function validate_output_entry(entry, kind, recipe_id)
|
||||
if type(entry) ~= "table" then
|
||||
error(string.format("crafting.define_recipe '%s': %s entry must be table",
|
||||
recipe_id, kind), 3)
|
||||
end
|
||||
if type(entry.template) ~= "string" or entry.template == "" then
|
||||
error(string.format("crafting.define_recipe '%s': %s.template must be string",
|
||||
recipe_id, kind), 3)
|
||||
end
|
||||
if type(entry.count) ~= "number" or entry.count <= 0
|
||||
or entry.count ~= math.floor(entry.count) then
|
||||
error(string.format("crafting.define_recipe '%s': %s.count must be positive int",
|
||||
recipe_id, kind), 3)
|
||||
end
|
||||
end
|
||||
|
||||
-- Normalize a consumed/tool slot into compiled match-fields. Accepts either
|
||||
-- {template=...} (bw-compat) or {match={...}}.
|
||||
local function normalize_slot(entry, kind, recipe_id)
|
||||
if type(entry) ~= "table" then
|
||||
error(string.format("crafting.define_recipe '%s': %s entry must be table",
|
||||
recipe_id, kind), 3)
|
||||
end
|
||||
if entry.template ~= nil then
|
||||
if type(entry.template) ~= "string" or entry.template == "" then
|
||||
error(string.format("crafting.define_recipe '%s': %s.template must be string",
|
||||
recipe_id, kind), 3)
|
||||
end
|
||||
return compile_match({ template = entry.template }, kind, recipe_id)
|
||||
elseif entry.match ~= nil then
|
||||
return compile_match(entry.match, kind, recipe_id)
|
||||
end
|
||||
error(string.format("crafting.define_recipe '%s': %s entry needs 'template' or 'match'",
|
||||
recipe_id, kind), 3)
|
||||
end
|
||||
|
||||
-- Locale resolver: entity_handle (bw-compat) OR {sources={...}, sink=...}.
|
||||
local function resolve_locale(locale, fn_name)
|
||||
-- Form 2: explicit table
|
||||
if type(locale) == "table" and locale.sources ~= nil then
|
||||
if type(locale.sources) ~= "table" or #locale.sources == 0 then
|
||||
error(string.format(
|
||||
"crafting.%s: locale.sources must be non-empty array",
|
||||
fn_name), 3)
|
||||
error(string.format("crafting.%s: locale.sources must be non-empty array", fn_name), 3)
|
||||
end
|
||||
if locale.sink == nil then
|
||||
error(string.format(
|
||||
"crafting.%s: locale.sink must not be nil",
|
||||
fn_name), 3)
|
||||
error(string.format("crafting.%s: locale.sink must not be nil", fn_name), 3)
|
||||
end
|
||||
-- De-duplicate sources by identity, preserving first-occurrence order.
|
||||
-- A caller building sources programmatically (e.g. {workbench_buffer,
|
||||
-- player_backpack} where both alias the same entity) should not have
|
||||
-- count_by_template double-count nor craft silent-fail.
|
||||
local seen, deduped = {}, {}
|
||||
for _, s in ipairs(locale.sources) do
|
||||
if not seen[s] then
|
||||
seen[s] = true
|
||||
deduped[#deduped + 1] = s
|
||||
end
|
||||
if not seen[s] then seen[s] = true; deduped[#deduped + 1] = s end
|
||||
end
|
||||
return { sources = deduped, sink = locale.sink }
|
||||
end
|
||||
-- Form 2 malformed: a table that's not Form-2 (no sources key) is almost
|
||||
-- certainly a caller typo. Loud-error explicitly instead of silently
|
||||
-- falling through to the Form-1 bw-compat path (which would then crash
|
||||
-- deep inside inventory-list with an opaque error).
|
||||
if type(locale) == "table" then
|
||||
error(string.format(
|
||||
"crafting.%s: locale table must contain 'sources' field",
|
||||
fn_name), 3)
|
||||
error(string.format("crafting.%s: locale table must contain 'sources' field", fn_name), 3)
|
||||
end
|
||||
-- Form 1 (bw-compat): bare entity_handle
|
||||
if locale == nil then
|
||||
error(string.format(
|
||||
"crafting.%s: locale must not be nil", fn_name), 3)
|
||||
error(string.format("crafting.%s: locale must not be nil", fn_name), 3)
|
||||
end
|
||||
return { sources = {locale}, sink = locale }
|
||||
return { sources = { locale }, sink = locale }
|
||||
end
|
||||
|
||||
local function count_by_template(sources)
|
||||
local out = {}
|
||||
for _, source in ipairs(sources) do
|
||||
for _, ent in ipairs(inv.contents(source)) do
|
||||
local tpl = composition.template_of(ent)
|
||||
if tpl ~= nil then
|
||||
out[tpl] = (out[tpl] or 0) + 1
|
||||
end
|
||||
-- Build the flat pool of {ent, src} across all sources.
|
||||
local function build_pool(sources)
|
||||
local pool = {}
|
||||
for _, src in ipairs(sources) do
|
||||
for _, ent in ipairs(inv.contents(src)) do
|
||||
pool[#pool + 1] = { ent = ent, src = src, claimed = false }
|
||||
end
|
||||
end
|
||||
return out
|
||||
return pool
|
||||
end
|
||||
|
||||
-- Plan a craft against a source-pool: greedy-claim inputs, presence-check
|
||||
-- tools. Returns { ok=true, consume={ {ent,src}, ... } } or
|
||||
-- { ok=false, error=..., missing?/missing_tools? }.
|
||||
local function plan(r, sources)
|
||||
local pool = build_pool(sources)
|
||||
local consume = {}
|
||||
|
||||
for _, slot in ipairs(r.inputs) do
|
||||
local found = 0
|
||||
for _, p in ipairs(pool) do
|
||||
if found >= slot.count then break end
|
||||
if not p.claimed and entity_matches(p.ent, slot.fields) then
|
||||
p.claimed = true
|
||||
consume[#consume + 1] = { ent = p.ent, src = p.src }
|
||||
found = found + 1
|
||||
end
|
||||
end
|
||||
if found < slot.count then
|
||||
return { ok = false, error = "missing_inputs",
|
||||
missing = { { needed = slot.count, have = found } } }
|
||||
end
|
||||
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
|
||||
for _, p in ipairs(pool) do
|
||||
if entity_matches(p.ent, slot.fields) then present = true; break end
|
||||
end
|
||||
if not present then
|
||||
return { ok = false, error = "missing_tools", missing_tools = { {} } }
|
||||
end
|
||||
end
|
||||
|
||||
return { ok = true, consume = consume }
|
||||
end
|
||||
|
||||
-- ---------- public API ----------
|
||||
@@ -146,36 +257,62 @@ function M.define_recipe(def)
|
||||
error("crafting.define_recipe: id must be non-empty string", 2)
|
||||
end
|
||||
if recipes[id] then
|
||||
error(string.format(
|
||||
"crafting.define_recipe '%s': duplicate id", id), 2)
|
||||
error(string.format("crafting.define_recipe '%s': duplicate id", id), 2)
|
||||
end
|
||||
|
||||
-- inputs (required, non-empty)
|
||||
if type(def.inputs) ~= "table" or #def.inputs == 0 then
|
||||
error(string.format(
|
||||
"crafting.define_recipe '%s': inputs must be non-empty array",
|
||||
id), 2)
|
||||
error(string.format("crafting.define_recipe '%s': inputs must be non-empty array", id), 2)
|
||||
end
|
||||
local inputs = {}
|
||||
for i, entry in ipairs(def.inputs) do
|
||||
validate_io_entry(entry, "inputs[" .. i .. "]", id)
|
||||
if type(entry.count) ~= "number" or entry.count <= 0
|
||||
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 }
|
||||
end
|
||||
if type(def.output) ~= "table" then
|
||||
error(string.format(
|
||||
"crafting.define_recipe '%s': output must be table", id), 2)
|
||||
|
||||
-- tools (optional, non-consumed presence checks)
|
||||
local tools = {}
|
||||
if def.tools ~= nil then
|
||||
if type(def.tools) ~= "table" then
|
||||
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) }
|
||||
end
|
||||
end
|
||||
|
||||
-- outputs (plural) OR output (singular bw-compat) — at least one required
|
||||
local outputs = {}
|
||||
if def.outputs ~= nil then
|
||||
if type(def.outputs) ~= "table" or #def.outputs == 0 then
|
||||
error(string.format("crafting.define_recipe '%s': outputs must be non-empty array", id), 2)
|
||||
end
|
||||
for i, entry in ipairs(def.outputs) do
|
||||
validate_output_entry(entry, "outputs[" .. i .. "]", id)
|
||||
outputs[i] = { template = entry.template, count = entry.count }
|
||||
end
|
||||
elseif def.output ~= nil then
|
||||
validate_output_entry(def.output, "output", id)
|
||||
outputs[1] = { template = def.output.template, count = def.output.count }
|
||||
else
|
||||
error(string.format("crafting.define_recipe '%s': needs 'outputs' or 'output'", id), 2)
|
||||
end
|
||||
validate_io_entry(def.output, "output", id)
|
||||
|
||||
local is_known = def.is_known
|
||||
if is_known == nil then
|
||||
is_known = default_is_known
|
||||
elseif type(is_known) ~= "function" then
|
||||
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
|
||||
|
||||
recipes[id] = {
|
||||
id = id,
|
||||
inputs = def.inputs,
|
||||
output = def.output,
|
||||
inputs = inputs,
|
||||
tools = tools,
|
||||
outputs = outputs,
|
||||
name = def.name,
|
||||
description = def.description,
|
||||
is_known = is_known,
|
||||
@@ -210,73 +347,50 @@ function M.can_craft(recipe_id, locale_arg, ctx)
|
||||
error("crafting.can_craft: ctx must be table", 2)
|
||||
end
|
||||
local r = recipes[recipe_id]
|
||||
if r == nil then
|
||||
return { ok = false, error = "unknown_recipe" }
|
||||
end
|
||||
if r.is_known(ctx) ~= true then
|
||||
if r == nil or r.is_known(ctx) ~= true then
|
||||
return { ok = false, error = "unknown_recipe" }
|
||||
end
|
||||
local locale = resolve_locale(locale_arg, "can_craft")
|
||||
local have = count_by_template(locale.sources)
|
||||
local missing = {}
|
||||
for _, need in ipairs(r.inputs) do
|
||||
local have_n = have[need.template] or 0
|
||||
if have_n < need.count then
|
||||
missing[#missing + 1] = {
|
||||
template = need.template,
|
||||
needed = need.count,
|
||||
have = have_n,
|
||||
}
|
||||
end
|
||||
end
|
||||
if #missing > 0 then
|
||||
return { ok = false, error = "missing_inputs", missing = missing }
|
||||
local p = plan(r, locale.sources)
|
||||
if not p.ok then
|
||||
return { ok = false, error = p.error, missing = p.missing, missing_tools = p.missing_tools }
|
||||
end
|
||||
return { ok = true }
|
||||
end
|
||||
|
||||
function M.craft(recipe_id, locale_arg, ctx)
|
||||
local pre = M.can_craft(recipe_id, locale_arg, ctx)
|
||||
if not pre.ok then return pre end
|
||||
|
||||
local locale = resolve_locale(locale_arg, "craft")
|
||||
if type(ctx) ~= "table" then
|
||||
error("crafting.craft: ctx must be table", 2)
|
||||
end
|
||||
local r = recipes[recipe_id]
|
||||
if r == nil or r.is_known(ctx) ~= true then
|
||||
return { ok = false, error = "unknown_recipe" }
|
||||
end
|
||||
local locale = resolve_locale(locale_arg, "craft")
|
||||
local p = plan(r, locale.sources)
|
||||
if not p.ok then
|
||||
return { ok = false, error = p.error, missing = p.missing, missing_tools = p.missing_tools }
|
||||
end
|
||||
|
||||
-- 1. Greedy-drain from sources in array-order. Per input-need, walk
|
||||
-- sources left-to-right and snapshot each source's contents
|
||||
-- BEFORE the inner loop because we mutate via inv.remove during
|
||||
-- iteration.
|
||||
-- Consume claimed inputs (tools are left untouched).
|
||||
local consumed = {}
|
||||
for _, need in ipairs(r.inputs) do
|
||||
local remaining = need.count
|
||||
for _, source in ipairs(locale.sources) do
|
||||
if remaining == 0 then break end
|
||||
local snapshot = inv.contents(source)
|
||||
for _, ent in ipairs(snapshot) do
|
||||
if remaining == 0 then break end
|
||||
if composition.template_of(ent) == need.template then
|
||||
inv.remove(source, ent)
|
||||
consumed[#consumed + 1] = ent
|
||||
composition.destroy(ent)
|
||||
remaining = remaining - 1
|
||||
end
|
||||
end
|
||||
for _, c in ipairs(p.consume) do
|
||||
inv.remove(c.src, c.ent)
|
||||
composition.destroy(c.ent)
|
||||
consumed[#consumed + 1] = c.ent
|
||||
end
|
||||
|
||||
-- Create outputs into the sink.
|
||||
local crafted = {}
|
||||
for _, out_def in ipairs(r.outputs) do
|
||||
for _ = 1, out_def.count do
|
||||
local out = composition.create{ template = out_def.template }
|
||||
inv.add(locale.sink, out)
|
||||
crafted[#crafted + 1] = out
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. Create outputs + add to sink.
|
||||
local crafted = {}
|
||||
for _ = 1, r.output.count do
|
||||
local out = composition.create{ template = r.output.template }
|
||||
inv.add(locale.sink, out)
|
||||
crafted[#crafted + 1] = out
|
||||
end
|
||||
|
||||
return {
|
||||
ok = true,
|
||||
crafted_items = crafted,
|
||||
consumed = consumed,
|
||||
}
|
||||
return { ok = true, crafted_items = crafted, consumed = consumed }
|
||||
end
|
||||
|
||||
-- ---------- test backdoors ----------
|
||||
|
||||
Reference in New Issue
Block a user