From b60364df8454b2f5d40cbec96931cbcc990033df Mon Sep 17 00:00:00 2001 From: Calic Date: Tue, 28 Jul 2026 10:35:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(crafting):=20lib-core.crafting=20v0.3.0=20?= =?UTF-8?q?=E2=80=94=20property-constraint=20slots=20+=20non-consumed=20to?= =?UTF-8?q?ols=20+=20multi-output=20(ADR-0055);=20vagrant-skeleton=20debug?= =?UTF-8?q?-drive=20hook=20for=20headless=20scenario=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 99 +++++++------ init.lua | 406 ++++++++++++++++++++++++++++++++++-------------------- 2 files changed, 320 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index 41799f1..a35259e 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # lib-core.crafting Recipe Registry + Match-Check + Craft Action. Headless data + logic layer. -Recipes reference item-template-ids; inputs are consumed from the locale's -source containers, outputs are placed into the locale's sink container. +A recipe SLOT is a property-constraint over an item's (possibly derived) +properties; template-id is the trivial constraint `{template="rock"}`. Inputs +are consumed from the locale's source containers, non-consumed tools are +presence-checked, and outputs are placed into the locale's sink container. -**Version:** 0.2.0 +**Version:** 0.3.0 **Lib-ID:** lib-core.crafting **Requires:** `lib-core.composition` 0.3.0, `lib-core.inventory-list` 0.1.0 **Tags:** crafting, recipe, registry @@ -20,61 +22,80 @@ graph LR this --> inventory ``` -## Scope (v0.2.0) +## Scope (v0.3.0 — ADR-0055) -Solid-Cut: recipe registry + match-check + craft-action + per-recipe -discovery hook. Multi-Source / Single-Sink locale (Form 2) plus -bw-compat bare-handle locale (Form 1). Count-form recipe-inputs. +Property-constraint recipe slots. A recipe slot matches items by a `match` +table of property-constraints (AND-combined), evaluated via `get_property`; +the pseudo-key `template` maps to `composition.template_of`, so old template-id +recipes are the trivial constraint through the SAME match-loop. Adds +non-consumed `tools` (presence checks) and multi-output. Multi-Source / +Single-Sink locale (Form 2) plus bw-compat bare-handle locale (Form 1). **Supported:** -- `define_recipe{id, inputs, output, name?, description?, is_known?}` -- `list_recipes()`, `get_recipe(id)` -- `is_known(recipe_id, ctx)` — per-recipe discovery gating -- `can_craft(recipe_id, locale, ctx)` — non-mutating availability check -- `craft(recipe_id, locale, ctx)` — mutating action -- `locale` parameter accepts either a bare container-entity (Form 1, - bw-compat) or an explicit `{sources={...}, sink=...}` table (Form 2) +- `define_recipe{id, inputs, tools?, outputs|output, name?, description?, is_known?}` + - `inputs` entries: `{template=..., count}` **or** `{match={k=v,...}, count}` (consumed) + - `tools` entries: `{template=...}` **or** `{match={...}}` (NON-consumed presence check) + - `outputs` array (plural) **or** `output` singular (bw-compat) +- Constraint values: exact string/number/bool, or comparison string + `">5"` / `">=0.2"` / `"<10"` / `"<=1"` / `"==x"` (numeric; string for `==`) +- `list_recipes()`, `get_recipe(id)`, `is_known(recipe_id, ctx)` +- `can_craft(recipe_id, locale, ctx)` — non-mutating; `craft(...)` — mutating +- `locale`: bare container-entity (Form 1) or `{sources={...}, sink=...}` (Form 2) +- Result `error ∈ {unknown_recipe, missing_inputs, missing_tools}` -**Deferred (Phase D+ / E):** -- Workpiece-Model / multi-step crafting / Batch / Time-coupled craft -- Tool-Requirements (Hammer-required, Workbench-required) +**Deferred (Phase E+):** - Skill-Checks (skill-property-min for recipe) -- Property-driven recipes (`composition.iron ≥ 0.8`) — Phase E -- Multi-Container-UI (input from A, output to B) -- Action-Property-Tags (`crafting.cutting`, `crafting.hammering`) — Domain-Lib substrate +- Workpiece-Model / multi-step / Batch / Time-coupled craft +- True bipartite input↔item matching (v0.3 uses greedy first-fit — a solvable + recipe where one item satisfies two slots can be missed; no stone-age + recipe hits this) +- `density × volume` derived mass on the material substrate (v0.3 reads + whatever property keys the recipe names) ## API ### `crafting.define_recipe(def)` -**Syntax:** `crafting.define_recipe({id, inputs, output, name?, description?, is_known?}) -> void` +**Syntax:** `crafting.define_recipe({id, inputs, tools?, outputs|output, name?, description?, is_known?}) -> void` -**Example:** +**Example (template-id, bw-compat):** ```lua crafting.define_recipe{ id = "rock_pick", - inputs = { - { template = "rock", count = 1 }, - { template = "stick", count = 1 }, - }, - output = { template = "rock_pick", count = 1 }, - name = "Stone Pick", - description = "A pick for mining stone.", - is_known = function(ctx) return true end, + inputs = { { template = "rock", count = 1 }, { template = "stick", count = 1 } }, + output = { template = "rock_pick", count = 1 }, + name = "Stone Pick", } ``` -**Description:** Registers a recipe under `id`. `inputs` is a non-empty -array of `{template, count}` entries; `output` is a single -`{template, count}`. `name` / `description` are optional player-facing -display strings. `is_known(ctx) -> bool` is an optional discovery hook; -default is `function() return true end`. The hook receives the same `ctx` -table the caller passes to `is_known` / `can_craft` / `craft`. +**Example (property-constraint + tool + multi-output):** +```lua +crafting.define_recipe{ + id = "saw_planks", + inputs = { { match = { category = "wood", mass = ">5" }, count = 1 } }, + tools = { { match = { ["affordance.cutting"] = true } } }, -- NOT consumed + outputs = { { template = "plank", count = 4 } }, + name = "Saw Planks", +} +``` + +**Description:** Registers a recipe under `id`. +- `inputs` (required, non-empty): consumed slots. Each entry has a `count` plus + either `template = ""` or `match = {key=constraint, ...}`. +- `tools` (optional): non-consumed presence checks. Each entry is `{template}` + or `{match}` (no count — a matching item need only be present). +- `outputs` (array) or `output` (single, bw-compat): items created into the sink. +- A `match` constraint value is an exact string/number/bool, or a comparison + string `">5"` / `">=0.2"` / `"<10"` / `"<=1"` / `"==x"`. Keys AND-combine. + The pseudo-key `template` matches `composition.template_of`; all other keys + match `get_property(key)` (read safely — an item lacking the property fails). +- `name` / `description` optional display strings; `is_known(ctx) -> bool` + optional discovery hook (default `true`). Loud `error(...)` on: non-table `def`; missing / non-string / empty `id`; -duplicate `id`; non-table / empty `inputs`; any input or output entry -that isn't a `{template: string, count: positive int}` table; non-table -`output`; non-function `is_known` if provided. +duplicate `id`; empty `inputs`; a slot with neither `template` nor `match`; +an empty `match`; bad `count`; missing both `outputs` and `output`; +non-function `is_known`. Error messages follow the pattern `"crafting.define_recipe '': "` so test-suites can pattern-match. diff --git a/init.lua b/init.lua index 48673b5..ba68976 100644 --- a/init.lua +++ b/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" "=)%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 = "" }. +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 ----------