407 lines
15 KiB
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
|