Files
sporel-lib-core.crafting-di…/README.md
calic 318de44cc9 fix(deps): cascade composition 0.4.0 + crafting 0.5.0 pins
Phase J (2026-08-03) bumped lib-core.composition 0.3.0->0.4.0 and
lib-core.crafting 0.3.0->0.5.0, but only vagrant-skeleton and
lib-core.crafting themselves were updated. Dependent manifests kept the
old pins, so dep-fetcher saw disagreeing pins on the same lib and
refused to start both spine-prototype (ENGINE_ERR_LIB_DEP_MISSING,
rc=19) and vagrant-skeleton ("dep check failed", conflict on
lib-core.composition + lib-core.crafting).

Unify all pins on the on-disk versions. No code changes required:
composition 0.4.0 is purely additive (the quality/condition loud-error
was removed; no signature changed), and crafting-display only consumes
crafting.can_craft + crafting.list_recipes, both still present, and
never referenced the `form` field retired by ADR-0055.

README "Requires:" lines synced to match the manifests (Gate 3).

milestone-check.sh: GREEN (build, ctest, smoke, devwrap-ci,
test-all-modules + anomaly scan). vagrant-skeleton headless run clean
at 60 CI frames.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:27:20 +02:00

325 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.5.0, lib-core.panel v0.4.0, lib-core.inventory-list v0.1.0, lib-core.composition v0.4.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.2.0)
v0.2 ships a recipe-list widget:
- `create(arg, opts)` — builds a panel-compatible widget_def; `arg` is a
bare container-entity-handle (Form 1, bw-compat) or a
`function() -> locale` factory (Form 2)
- `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
**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.
## 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`** is one of:
- **Form 1 (bw-compat from v0.1):** bare container-entity-handle. Wrapped
internally as a constant locale_factory (`function() return arg end`).
- **Form 2 (NEW in v0.2):** `function() -> locale` — invoked per frame.
Locale is either an entity-handle (Form-1 sub-semantics) or a table
`{sources={...}, sink=...}`. Forward-compatible with dynamic
topologies (player moves → adjacent-container set changes, no widget
recreate required).
**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 Signature (v0.2.0 Update)
Callbacks receive `(recipe_id, ctx_inner)` where `ctx_inner` is:
```lua
{
locale = <currently-resolved locale>, -- NEW in v0.2
container = <sink (Form-2) or handle (Form-1)>, -- bw-compat alias
close_menu = function() ... end,
refresh = function() ... end,
}
```
Module action typically calls `crafting.craft(recipe_id,
ctx_inner.locale, ctx)`. Existing v0.1 callers that use
`ctx_inner.container` continue to work via the Form-1 bw-compat shim
(sink-handle is treated internally as a single-container).
---
### `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` — the return value is
opaque to the widget (the icon column is not rendered yet; resolver is
wired in for 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` (icon column not rendered yet) |
| `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
```