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

11 KiB
Raw Blame History

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

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

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):

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):

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):

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:

{
    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:

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:

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:

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:

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