Files
sporel-lib-core.crafting-di…/README.md
Calic 31e51ee8b7 feat: v0.2.0 locale_factory replaces single container
M.create now accepts either a function (locale_factory, called per
frame to resolve the current crafting locale) or a bare entity-handle
(wrapped internally as a constant factory for v0.1 bw-compat). The
factory's return value is forwarded to crafting.can_craft as the new
multi-source / single-sink locale, enabling dynamic topologies
(adjacent-container sets that change as the actor moves) without
recreating the widget.

Action callbacks now receive ctx_inner with both `locale` (the
currently-resolved locale, either the Form-2 table or the Form-1 bare
handle) and `container` as a bw-compat alias pointing at locale.sink
(Form-2) or the handle itself (Form-1). Existing v0.1 callers using
ctx.container keep working unchanged.

New backdoor _test_get_locale exposes the locale_factory's current
return value. README documents both Form-1 and Form-2 with examples
plus the updated callback contract.

Depends on lib-core.crafting v0.2.0.

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

324 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.2.0
**Lib-ID:** lib-core.crafting-display
**Requires:** lib-core.crafting v0.2.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
**v0.2 hardening notes (deferred):**
- `is_known(ctx)` is called per recipe per frame; no per-frame memoization.
Large recipe-registries may want a cache layer.
- `is_known(ctx)` errors emit one `[WARN]` per failure per frame; no rate-
limit (spammy if a recipe is permanently broken).
- Right-click on a row with an empty action-set is silently dropped; a
diagnostic warning would help modders catch missing `register_action`
calls.
## Widget Contract
The widget conforms to `lib-core.panel`'s widget-lifecycle contract (see
`lib-core/panel/README.md` §Widget-Lifecycle-Contract):
```lua
widget.render(ctx)
widget.handle_input(ctx, event)
```
where `ctx = {bounds = {x,y,w,h}, theme = table, is_focused = bool}` and
the panel dispatches input events as `{kind = "click", x, y, button}` or
`{kind = "wheel", dy}`. Theme colours are packed-RGBA integers (e.g.
`0xE0E0E0FF`), matching the `panel.DEFAULT_THEME` schema.
Right-click on a row invokes `panel.show_context_menu(x, y, actions)`
with the registered actions; left-click and wheel are ignored in v0.1.
## 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, locale, ctx)` | `ok=false` → row visible but **dim colour** |
The `locale` here is whatever the widget's locale-factory returns for
the current frame (v0.2: per-frame fresh; v0.1 bw-compat: constant
single-container).
`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(arg, opts) -> widget_def`
Creates a crafting widget. **`arg`** ist eines von:
- **Form 1 (Bw-Compat v0.1):** bare container-entity-handle. Intern
als constant locale_factory gewrappt (`function() return arg end`).
- **Form 2 (v0.2 NEU):** `function() -> locale` — wird pro Frame
aufgerufen. Locale ist entweder ein entity-handle (Form-1 sub-
semantik) oder eine Table `{sources={...}, sink=...}`. Forward-
compat zu dynamischen Topologien (player-bewegt-sich →
adjacent-Container-Set ändert sich, ohne widget-Recreate).
**Syntax:** `display.create(arg: entity | function() -> locale, opts: table|nil) -> widget_def`
**Example (Form 1, bw-compat):**
```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)
```
**Example (Form 2, locale-factory):**
```lua
local widget = display.create(function()
return {
sources = adjacent_containers(player),
sink = player_backpack,
}
end, { title = "Workbench" })
```
`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 `arg` is `nil`.
---
### Action-Callback Signatur (v0.2.0 Update)
Callbacks erhalten `(recipe_id, ctx_inner)` wo `ctx_inner`:
```lua
{
locale = <currently-resolved locale>, -- v0.2 NEU
container = <sink (Form-2) or handle (Form-1)>, -- bw-compat alias
close_menu = function() ... end,
refresh = function() ... end,
}
```
Modul-Aktion ruft typisch `crafting.craft(recipe_id,
ctx_inner.locale, ctx)`. Existing v0.1 callers die
`ctx_inner.container` benutzen funktionieren weiter via Form-1
bw-compat-shim (sink-handle wird intern als single-container
behandelt).
---
### `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)
-- v0.2: prefer ctx.locale; ctx.container is a bw-compat alias.
crafting.craft(recipe_id, ctx.locale, {})
ctx.close_menu()
end)
```
Registers a context-menu action shown on right-click of any row. `callback`
receives `(recipe_id, ctx_inner)` where
`ctx_inner = { locale, container, close_menu, refresh }`.
`locale` carries the currently-resolved locale (table for Form-2, bare
handle for Form-1). `container` is a bw-compat alias pointing at
`locale.sink` (Form-2) or the handle itself (Form-1).
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_get_locale(widget_def)` — invokes the configured
locale_factory and returns whatever it produces (v0.2)
- `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 five 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)
-- v0.2: prefer ctx.locale (ctx.container is a bw-compat alias).
crafting.craft(recipe_id, ctx.locale, {})
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
```