325 lines
11 KiB
Markdown
325 lines
11 KiB
Markdown
# 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.4.0, 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.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
|
||
```
|