Two silent-fail scenarios closed:
- sources={c, c} would double-count in can_craft then partial-
consume in craft. resolve_locale now de-duplicates by handle
identity, preserving first-occurrence order.
- {sink=x} without sources would wrap the table as Form-1 and
crash deep inside inventory-list. resolve_locale now loud-
errors with "locale table must contain 'sources' field"
before reaching the bw-compat fallback.
Helper comment translated to English per code-language convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
293 lines
9.3 KiB
Lua
293 lines
9.3 KiB
Lua
-- =====================================================================
|
|
-- lib-core.crafting v0.2.0 — Recipe Registry + Craft Action
|
|
-- Spec: meta/docs/superpowers/specs/2026-06-14-phase-D-workbench-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)
|
|
--
|
|
-- 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:
|
|
-- {
|
|
-- id = "rock_pick",
|
|
-- inputs = { {template, count}, ... },
|
|
-- output = {template, count},
|
|
-- name = "Stone Pick",
|
|
-- description = "...",
|
|
-- is_known = function(ctx) return true end,
|
|
-- }
|
|
--
|
|
-- Match-Result-Schema:
|
|
-- { ok, error?, missing?, crafted_items?, consumed? }
|
|
-- error ∈ { "unknown_recipe" | "missing_inputs" }
|
|
--
|
|
-- 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
|
|
|
|
-- ---------- helpers ----------
|
|
|
|
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)
|
|
end
|
|
end
|
|
|
|
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 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)
|
|
end
|
|
if locale.sink == nil then
|
|
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
|
|
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)
|
|
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)
|
|
end
|
|
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
|
|
end
|
|
end
|
|
return out
|
|
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
|
|
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
|
|
for i, entry in ipairs(def.inputs) do
|
|
validate_io_entry(entry, "inputs[" .. i .. "]", id)
|
|
end
|
|
if type(def.output) ~= "table" then
|
|
error(string.format(
|
|
"crafting.define_recipe '%s': output must be table", 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)
|
|
end
|
|
|
|
recipes[id] = {
|
|
id = id,
|
|
inputs = def.inputs,
|
|
output = def.output,
|
|
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 then
|
|
return { ok = false, error = "unknown_recipe" }
|
|
end
|
|
if 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 }
|
|
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")
|
|
local r = recipes[recipe_id]
|
|
|
|
-- 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.
|
|
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
|
|
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,
|
|
}
|
|
end
|
|
|
|
-- ---------- test backdoors ----------
|
|
|
|
function M._test_clear_all()
|
|
recipes = {}
|
|
end
|
|
|
|
function M._test_get_recipes()
|
|
return recipes
|
|
end
|
|
|
|
return M
|