Files
sporel-lib-core.crafting/init.lua
Calic f938d11ca8 feat: lib-core.crafting v0.2.0 — Multi-Source / Single-Sink Locale-API
Extends can_craft + craft to take a `locale` parameter that describes
WHERE inputs come from and WHERE outputs go. Two forms are accepted;
the v0.1 single-container call signature continues to work unchanged
via a transparent bw-compat shim.

Locale-Schema:
  Form 1 (bw-compat): a bare container-entity-handle. Internally
                      wrapped to {sources={h}, sink=h}; the single
                      container is both the sole input source AND the
                      output sink. v0.1 call sites need no migration.
  Form 2 (explicit):  { 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. sink MAY appear in sources (e.g. workbench
                      buffer as both source and sink).

can_craft aggregates input counts across the union of all sources
before comparing against the recipe's needs. `missing` entries report
the aggregated `have` count across sources.

craft greedy-drains in array-order: for each input.need it walks
sources left-to-right, fully draining matching-template items from
the first source before moving on to the next. The order is stable
and deterministic so callers can encode priority via the array.

A file-local resolve_locale helper centralizes the Form-1-vs-Form-2
dispatch and validates Form 2 with loud errors:
  locale == nil                       → "locale must not be nil"
  Form 2, sources not array or empty  → "sources must be non-empty array"
  Form 2, sink == nil                 → "sink must not be nil"

The count_by_template helper now takes a sources array and walks each
container's inventory in turn, summing per-template counts.

Manifest bumped 0.1.0 → 0.2.0. Deps unchanged (composition 0.3.0,
inventory-list 0.1.0). README documents both locale forms, the
greedy-drain order, the bw-compat guarantee, and the loud-error
conditions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 18:48:04 +02:00

273 lines
8.4 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: akzeptiert entity_handle (bw-compat) ODER table
-- {sources={...}, sink=...}. Returns immer Form-2 mit validierten
-- Feldern. Loud-Error bei kaputter 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
return locale
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