feat: lib-core.crafting-display v0.1.0 — Recipe Panel-Widget

UI layer for recipe-based crafting. Reads recipes from
lib-core.crafting, renders the known-subset as a vertical row-list
inside a panel managed by lib-core.panel, and dispatches right-click
context-menu actions registered by the consuming module.

Surface:
  create(container_entity, opts) -> widget_def
  register_action(widget, label, callback)
  unregister_action(widget, label)
  set_icon_resolver(widget, fn)
  set_label_resolver(widget, fn)
  set_summary_resolver(widget, fn)

Row-build is rebuilt per render frame from crafting.list_recipes():
  - Visibility gate: recipe.is_known(ctx); errors hide row + emit [WARN]
  - Availability gate: crafting.can_craft(id, container, ctx).ok
    drives row colour (full vs dim text_color).

ctx comes from opts.ctx_factory() — default returns {}; override to
feed actor/skill/faction state into both predicates. label_resolver
defaults to recipe.name or recipe.id; summary_resolver joins input
template-ids with " + " and suffixes "xN" for count>1.

Right-click hit-tests row-rects captured during render, then opens
the menu via panel.show_context_menu(x, y, actions). Action
callbacks receive (recipe_id, {container, close_menu, refresh}).
Registration order is preserved (array-based, not pairs-based) so
context-menu rendering matches registration sequence.

Depends on lib-core.crafting 0.1.0, lib-core.panel 0.1.1,
lib-core.inventory-list 0.1.0, lib-core.composition 0.3.0. The last
two are not called directly but are required as direct deps so
modules consuming this widget satisfy crafting's transitive needs
(engine resolver is per-module non-transitive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Calic
2026-06-14 12:34:32 +02:00
commit eb8f72bcbd
4 changed files with 546 additions and 0 deletions

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
Copyright (c) 2026 Calic. All rights reserved.
This software is part of the Sporel platform — **Tier 1 (Official /
Proprietary)** content per the Three-Tier Licensing Model documented in
`meta/docs/archive/design/vision.md §Licensing Model` (current source;
migration to `meta/docs/architecture/licensing-model.md` pending).
⚠ **WIP — Legal review required before public launch.** The terms below
reflect design intent only; the formalized license framework will be
finalized through legal counsel before the first public release. Until
then, this notice serves as a placeholder defending the platform owner's
rights against unintentional re-licensing.
No license is granted to copy, modify, distribute, sublicense, or otherwise
use this software in any form without prior written permission from the
copyright holder.
References:
- Tier 1 (this file): all rights reserved, proprietary, sold/distributed
via official channels (Steam, etc.)
- Tier 2 (Semi-Commercial Co-Development): bilateral contracts, revenue-
share — see vision.md §Licensing Model
- Tier 3 (Community Content): CC BY-NC-SA 4.0 + asymmetric CLA — applies
to community-uploaded libs/modules/assets, not this repo

245
README.md Normal file
View File

@@ -0,0 +1,245 @@
# lib-core.crafting-display
Recipe panel-widget. Reads recipes via `lib-core.crafting` and renders
the known-subset as a vertical row-list inside a panel managed by
`lib-core.panel`. Each row shows label + summary; row colour reflects
per-row availability (full-colour when craftable, dim when blocked).
Right-clicking a row opens a context-menu populated from actions
registered via `register_action`.
**Version:** 0.1.0
**Lib-ID:** lib-core.crafting-display
**Requires:** lib-core.crafting v0.1.0, lib-core.panel v0.1.1, lib-core.inventory-list v0.1.0, lib-core.composition v0.3.0
**Tags:** crafting, ui, panel, widget, recipes
## Topology
<!-- topology:start (auto-generated; do not edit) -->
```mermaid
graph LR
this["lib-core.crafting-display"]
crafting["lib-core.crafting"]
panel["lib-core.panel"]
inv["lib-core.inventory-list"]
comp["lib-core.composition"]
this --> crafting
this --> panel
this --> inv
this --> comp
```
<!-- topology:end -->
`inventory-list` and `composition` are declared as direct deps for the
engine's per-module non-transitive resolver: this lib does not call
them directly, but `crafting` does, and modules consuming this widget
must satisfy them.
## Scope (v0.1.0)
v0.1 ships a minimal recipe-list widget:
- `create(container_entity, opts)` — builds a panel-compatible widget_def
- `register_action` / `unregister_action` — context-menu actions (right-click on row)
- `set_icon_resolver` / `set_label_resolver` / `set_summary_resolver` — override default row content
- Render: label (left-aligned) + summary (right-aligned)
- Row colour: full when `can_craft.ok==true`, dim otherwise
- Input: right-click row opens context-menu via `panel.show_context_menu`
**Intentional non-goals (deferred):**
- Row scrolling
- Icon-column rendering (resolver returns nil by default)
- Tooltip / hover-detail
- Stack-count display
## Row Visibility + Availability rules
For every frame, the widget rebuilds its row-list by walking
`crafting.list_recipes()` and applying two independent gates:
| Gate | Source | Effect on row |
|-----------------|------------------------------------------|----------------------------------------------|
| **Visibility** | `recipe.is_known(ctx)` | `false` → row is **hidden** entirely |
| **Availability**| `crafting.can_craft(id, container, ctx)` | `ok=false` → row visible but **dim colour** |
`ctx` is built from `opts.ctx_factory()` (default `function() return {} end`).
Override `ctx_factory` to wire in actor-state, skill-level, faction-membership,
etc. — whatever the consuming module needs `is_known` and `can_craft` to see.
If `recipe.is_known(ctx)` raises, the row is hidden and a `[WARN]`
message is emitted via `engine.print` (defensive — visibility predicate
must not crash UI). No equivalent guard around `can_craft`: errors
there propagate (consistent with rest of the lib stack).
## API
### `display.create(container_entity, opts)`
**Syntax:** `display.create(container_entity: entity, opts: table|nil) -> widget_def`
**Example:**
```lua
local display = require("lib-core.crafting-display")
local panel = require("lib-core.panel")
local widget = display.create(workbench_entity, {
title = "Workbench",
widget_id = "crafting",
pause_on_open = true,
ctx_factory = function()
return { actor = current_actor() }
end,
})
panel.register(widget.widget_id, widget)
panel.bind_default_trigger("c", widget.widget_id)
```
Creates a widget_def bound to `container_entity` (used as the input
source for `can_craft` availability checks). `opts` keys:
- `title` (string, default `"Crafting"`) — panel title bar text
- `widget_id` (string, default `"crafting"`) — key for `panel.register`
- `pause_on_open` (bool, default `false`) — passed to panel for `is_pausing()`
- `ctx_factory` (function, default `function() return {} end`) — per-frame ctx builder
- `icon_resolver` (function) — overrides default icon resolver at creation time
- `label_resolver` (function) — overrides default label resolver at creation time
- `summary_resolver` (function) — overrides default summary resolver at creation time
Loud-error if `container_entity` is `nil`.
---
### `display.register_action(widget_def, label, callback)`
**Syntax:** `display.register_action(widget_def: table, label: string, callback: function) -> void`
**Example:**
```lua
display.register_action(widget, "Craft", function(recipe_id, ctx)
crafting.craft(recipe_id, ctx.container, {})
ctx.close_menu()
end)
```
Registers a context-menu action shown on right-click of any row. `callback`
receives `(recipe_id, context)` where
`context = { container, close_menu, refresh }`.
Loud-error on duplicate `label` or if `callback` is not a function.
The default action-set is empty — all actions must be registered explicitly.
Registration order is preserved (entries are kept in an array). Identical
order is reflected in the context-menu rendering.
---
### `display.unregister_action(widget_def, label)`
**Syntax:** `display.unregister_action(widget_def: table, label: string) -> void`
Removes a context-menu action. Idempotent: no error if `label` was never registered.
---
### `display.set_icon_resolver(widget_def, fn)`
**Syntax:** `display.set_icon_resolver(widget_def: table, fn: function) -> void`
Replaces the icon resolver. `fn(recipe) -> any|nil` — return value is
opaque to v0.1 (default icon column not yet rendered; resolver is wired
in for v0.2 atlas-icon support and for tests).
---
### `display.set_label_resolver(widget_def, fn)`
**Syntax:** `display.set_label_resolver(widget_def: table, fn: function) -> void`
**Example:**
```lua
display.set_label_resolver(widget, function(recipe)
return localize("recipe." .. recipe.id) or recipe.name or recipe.id
end)
```
Replaces the label resolver. `fn(recipe) -> string`. Called each render frame per row.
Default returns `recipe.name or recipe.id`.
---
### `display.set_summary_resolver(widget_def, fn)`
**Syntax:** `display.set_summary_resolver(widget_def: table, fn: function) -> void`
**Example:**
```lua
display.set_summary_resolver(widget, function(recipe)
return string.format("%d ingredients", #recipe.inputs)
end)
```
Replaces the summary resolver. `fn(recipe) -> string`. Called each render
frame per row. Default joins `recipe.inputs` template-ids with `" + "`,
suffixing `"×N"` when `count>1`.
---
## Default Resolvers
| Resolver | Default behavior |
|--------------------|-------------------------------------------------------------|
| `label_resolver` | `recipe.name or recipe.id` |
| `summary_resolver` | `inputs[*].template` joined with `" + "`, `"×N"` when `count>1` |
| `icon_resolver` | returns `nil` (no icon in v0.1) |
| `ctx_factory` | returns `{}` (empty table; replace to feed actor/skill ctx) |
## Test backdoors
- `display._test_get_rows(widget_def)` — returns the per-frame row list
(`{recipe, available}` pairs after is_known filtering)
- `display._test_resolve_icon(widget_def, recipe)` — invokes current icon resolver
- `display._test_resolve_label(widget_def, recipe)` — invokes current label resolver
- `display._test_resolve_summary(widget_def, recipe)` — invokes current summary resolver
All four are for test modules only and should not be called in
production code.
## Glue-Pattern
Minimal module setup:
```lua
local display = require("lib-core.crafting-display")
local crafting = require("lib-core.crafting")
local panel = require("lib-core.panel")
-- 1. Define recipes
crafting.define_recipe{
id = "rock_pick",
inputs = {
{ template = "items.rock", count = 1 },
{ template = "items.stick", count = 1 },
},
output = { template = "items.rock_pick", count = 1 },
name = "Rock Pick",
}
-- 2. Create widget (workbench_entity is a list-container)
local widget = display.create(workbench_entity, {
title = "Workbench",
widget_id = "crafting",
pause_on_open = true,
})
-- 3. Register actions
display.register_action(widget, "Craft", function(recipe_id, ctx)
crafting.craft(recipe_id, ctx.container, {})
ctx.close_menu()
end)
-- 4. Register with panel + bind toggle key
panel.register(widget.widget_id, widget)
panel.bind_default_trigger("c", widget.widget_id)
-- 5. Wire into update + render
function M.update(dt) panel.update(dt) end
function M.render() panel.render() end
```

276
init.lua Normal file
View File

@@ -0,0 +1,276 @@
-- =====================================================================
-- lib-core.crafting-display v0.1.0 — Recipe Panel-Widget
--
-- Sits on top of lib-core.panel and reads lib-core.crafting +
-- lib-core.inventory-list + lib-core.composition. Provides a ready-to-
-- register panel widget that lists recipes known to the actor and shows
-- per-row availability based on container contents.
--
-- Right-clicking a row opens a context-menu populated from actions
-- registered via M.register_action.
--
-- Public API:
-- display.create(container_entity, opts) -> widget_def
-- display.register_action(widget_def, label, callback)
-- display.unregister_action(widget_def, label)
-- display.set_icon_resolver(widget_def, fn)
-- display.set_label_resolver(widget_def, fn)
-- display.set_summary_resolver(widget_def, fn)
--
-- widget_def conforms to the panel widget contract:
-- widget_def.render(theme, x, y, w, h)
-- widget_def.handle_input(input_state, theme, x, y, w, h)
-- widget_def.title (string)
--
-- DEFERRED (v0.1 non-goals):
-- - Row scrolling
-- - Custom row layouts (icon column, summary column, etc.)
-- - Tooltip / hover-detail
-- - Stack-count display
-- =====================================================================
local crafting = require("lib-core.crafting")
local panel = require("lib-core.panel")
local inventory = require("lib-core.inventory-list")
local composition = require("lib-core.composition")
-- inventory + composition are required for transitive completeness (engine
-- resolver is per-module non-transitive). The lib does not call into them
-- directly; crafting handles container access internally.
local _ = inventory
local _ = composition
local M = {}
-- ---------------------------------------------------------------------
-- Default resolvers
-- ---------------------------------------------------------------------
local function default_label(recipe)
return recipe.name or recipe.id
end
local function default_summary(recipe)
local parts = {}
for _, inp in ipairs(recipe.inputs) do
if inp.count == 1 then
parts[#parts + 1] = inp.template
else
parts[#parts + 1] = inp.template .. "\xc3\x97" .. inp.count -- UTF-8 "×"
end
end
return table.concat(parts, " + ")
end
local function default_icon(_recipe)
return nil -- v0.1: module must override icon_resolver for sprites
end
local function default_ctx_factory()
return {}
end
-- ---------------------------------------------------------------------
-- Internal: build row-list per frame
-- ---------------------------------------------------------------------
local function build_rows(widget)
local ctx = widget._ctx_factory()
if type(ctx) ~= "table" then ctx = {} end
local out = {}
for _, recipe in ipairs(crafting.list_recipes()) do
local known_ok, known = pcall(recipe.is_known, ctx)
if not known_ok then
if engine and engine.print then
engine.print(string.format(
"[WARN] crafting-display: is_known('%s') errored: %s",
recipe.id, tostring(known)))
end
elseif known == true then
local match = crafting.can_craft(recipe.id, widget._container, ctx)
out[#out + 1] = {
recipe = recipe,
available = match.ok == true,
}
end
end
return out
end
-- ---------------------------------------------------------------------
-- Public API: create
-- ---------------------------------------------------------------------
--- M.create(container_entity, opts) -> widget_def
--- Creates a crafting-recipe widget bound to `container_entity` (used as
--- the input source for can_craft availability checks).
--- opts = {
--- title = string, default "Crafting"
--- widget_id = string, default "crafting"
--- pause_on_open = bool, default false
--- ctx_factory = function() -> table, default returns {}
--- icon_resolver = function(recipe) -> any|nil
--- label_resolver = function(recipe) -> string
--- summary_resolver = function(recipe) -> string
--- }
--- Loud-error if container_entity is nil.
function M.create(container_entity, opts)
if container_entity == nil then
error("crafting-display.create: container must not be nil", 2)
end
opts = opts or {}
local widget = {
_container = container_entity,
_opts = opts,
_actions = {},
_ctx_factory = opts.ctx_factory or default_ctx_factory,
_icon_resolver = opts.icon_resolver or default_icon,
_label_resolver = opts.label_resolver or default_label,
_summary_resolver = opts.summary_resolver or default_summary,
title = opts.title or "Crafting",
widget_id = opts.widget_id or "crafting",
pause_on_open = opts.pause_on_open == true,
}
function widget.render(theme, x, y, w, h)
M._render_widget(widget, theme, x, y, w, h)
end
function widget.handle_input(input_state, theme, x, y, w, h)
return M._handle_input_widget(widget, input_state, theme, x, y, w, h)
end
return widget
end
-- ---------------------------------------------------------------------
-- Public API: actions
-- ---------------------------------------------------------------------
--- M.register_action(widget, label, callback)
--- Registers a context-menu action shown on right-click of any row.
--- callback(recipe_id, context) where context = { container, close_menu, refresh }.
--- Loud-error on duplicate label or non-function callback.
function M.register_action(widget, label, callback)
if type(label) ~= "string" or label == "" then
error("crafting-display.register_action: label must be non-empty string", 2)
end
if type(callback) ~= "function" then
error("crafting-display.register_action: callback must be function", 2)
end
for _, a in ipairs(widget._actions) do
if a.label == label then
error(string.format(
"crafting-display.register_action: duplicate label '%s'", label), 2)
end
end
widget._actions[#widget._actions + 1] = { label = label, callback = callback }
end
--- M.unregister_action(widget, label)
--- Removes a previously-registered context-menu action.
--- Idempotent: no error if `label` was never registered.
function M.unregister_action(widget, label)
for i, a in ipairs(widget._actions) do
if a.label == label then
table.remove(widget._actions, i)
return
end
end
end
-- ---------------------------------------------------------------------
-- Public API: resolver overrides
-- ---------------------------------------------------------------------
function M.set_icon_resolver(widget, fn) widget._icon_resolver = fn end
function M.set_label_resolver(widget, fn) widget._label_resolver = fn end
function M.set_summary_resolver(widget, fn) widget._summary_resolver = fn end
-- ---------------------------------------------------------------------
-- Render
-- ---------------------------------------------------------------------
function M._render_widget(widget, theme, x, y, w, h)
local rows = build_rows(widget)
local row_h = (theme and theme.row_height) or 28
local pad = (theme and theme.padding) or 8
local txt_col_full = (theme and theme.text_color) or {1.0, 1.0, 1.0, 1.0}
local txt_col_dim = (theme and theme.text_color_dim) or {0.6, 0.6, 0.6, 1.0}
local cy = y + pad
widget._render_rows = {}
for i, row in ipairs(rows) do
local color = row.available and txt_col_full or txt_col_dim
local label = widget._label_resolver(row.recipe)
local summary = widget._summary_resolver(row.recipe)
if engine and engine.render and engine.render.draw_text then
engine.render.draw_text(label, x + pad, cy, color)
engine.render.draw_text(summary, x + w - pad - 100, cy, color)
end
widget._render_rows[i] = {
recipe_id = row.recipe.id,
x = x, y = cy, w = w, h = row_h,
}
cy = cy + row_h
end
end
-- ---------------------------------------------------------------------
-- Input
-- ---------------------------------------------------------------------
function M._handle_input_widget(widget, input_state, _theme, _x, _y, _w, _h)
if not widget._render_rows then return false end
local mx, my = input_state.mouse_x, input_state.mouse_y
if not (mx and my) then return false end
for _, r in ipairs(widget._render_rows) do
if mx >= r.x and mx <= r.x + r.w and my >= r.y and my <= r.y + r.h then
if input_state.right_clicked then
M._invoke_context_menu(widget, r.recipe_id, mx, my)
return true
end
end
end
return false
end
function M._invoke_context_menu(widget, recipe_id, mx, my)
local entries = {}
for _, a in ipairs(widget._actions) do
local cb = a.callback -- capture for closure
entries[#entries + 1] = {
label = a.label,
callback = function(menu_ctx)
cb(recipe_id, {
container = widget._container,
close_menu = menu_ctx.close_menu,
refresh = function() end, -- v0.1: free (next frame re-reads)
})
end,
}
end
if #entries == 0 then return end
if panel.show_context_menu then
panel.show_context_menu(mx or 0, my or 0, entries)
end
end
-- ---------------------------------------------------------------------
-- Test-backdoors
-- ---------------------------------------------------------------------
function M._test_get_rows(widget)
return build_rows(widget)
end
function M._test_resolve_icon(widget, recipe)
return widget._icon_resolver(recipe)
end
function M._test_resolve_label(widget, recipe)
return widget._label_resolver(recipe)
end
function M._test_resolve_summary(widget, recipe)
return widget._summary_resolver(recipe)
end
return M

1
manifest.lib Normal file
View File

@@ -0,0 +1 @@
{"id":"lib-core.crafting-display","version":"0.1.0","api_min":"0.1","deps":[{"id":"lib-core.crafting","version":"0.1.0"},{"id":"lib-core.panel","version":"0.1.1"},{"id":"lib-core.inventory-list","version":"0.1.0"},{"id":"lib-core.composition","version":"0.3.0"}]}