Files
sporel-lib-core.crafting/init.lua

407 lines
15 KiB
Lua

-- =====================================================================
-- 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
-- crafting.list_recipes() -> {recipe_def, ...}
-- crafting.get_recipe(id) -> recipe_def or nil
-- crafting.is_known(recipe_id, ctx) -> bool
-- 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):
-- {
-- 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,
-- }
--
-- 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.
--
-- 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")
local inv = require("lib-core.inventory-list")
local M = {}
-- ---------- module state (all local) ----------
local recipes = {}
local function default_is_known(_ctx)
return true
end
-- ---------- constraint compilation ----------
-- 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
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)
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)
end
if locale.sink == nil then
error(string.format("crafting.%s: locale.sink must not be nil", fn_name), 3)
end
local seen, deduped = {}, {}
for _, s in ipairs(locale.sources) do
if not seen[s] then seen[s] = true; deduped[#deduped + 1] = s end
end
return { sources = deduped, sink = locale.sink }
end
if type(locale) == "table" then
error(string.format("crafting.%s: locale table must contain 'sources' field", fn_name), 3)
end
if locale == nil then
error(string.format("crafting.%s: locale must not be nil", fn_name), 3)
end
return { sources = { locale }, sink = locale }
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 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 ----------
function M.define_recipe(def)
if type(def) ~= "table" then
error("crafting.define_recipe: def must be table", 2)
end
local id = def.id
if type(id) ~= "string" or id == "" then
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)
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)
end
local inputs = {}
for i, entry in ipairs(def.inputs) do
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
-- 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
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)
end
recipes[id] = {
id = id,
inputs = inputs,
tools = tools,
outputs = outputs,
name = def.name,
description = def.description,
is_known = is_known,
}
end
function M.list_recipes()
local out = {}
for _, r in pairs(recipes) do
out[#out + 1] = shallow_copy(r)
end
return out
end
function M.get_recipe(id)
local r = recipes[id]
if r == nil then return nil end
return shallow_copy(r)
end
function M.is_known(recipe_id, ctx)
if type(ctx) ~= "table" then
error("crafting.is_known: ctx must be table", 2)
end
local r = recipes[recipe_id]
if r == nil then return false end
return r.is_known(ctx) == true
end
function M.can_craft(recipe_id, locale_arg, ctx)
if type(ctx) ~= "table" then
error("crafting.can_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, "can_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
return { ok = true }
end
function M.craft(recipe_id, locale_arg, ctx)
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
-- Consume claimed inputs (tools are left untouched).
local consumed = {}
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
return { ok = true, crafted_items = crafted, consumed = consumed }
end
-- ---------- test backdoors ----------
function M._test_clear_all()
recipes = {}
end
function M._test_get_recipes()
return recipes
end
return M