From eb8f72bcbd8daeeb1ceb8e7e9f4bdc3ad4e4eca9 Mon Sep 17 00:00:00 2001 From: Calic Date: Sun, 14 Jun 2026 12:34:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lib-core.crafting-display=20v0.1.0=20?= =?UTF-8?q?=E2=80=94=20Recipe=20Panel-Widget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- LICENSE | 24 +++++ README.md | 245 +++++++++++++++++++++++++++++++++++++++++++++ init.lua | 276 +++++++++++++++++++++++++++++++++++++++++++++++++++ manifest.lib | 1 + 4 files changed, 546 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 init.lua create mode 100644 manifest.lib diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81c9d65 --- /dev/null +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a73f2e --- /dev/null +++ b/README.md @@ -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 + + +```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 +``` + + +`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 +``` diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..bd1fa29 --- /dev/null +++ b/init.lua @@ -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 diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..12deed0 --- /dev/null +++ b/manifest.lib @@ -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"}]}