commit 53384e2f372f66aea5688a167ee6fac6b02456f3 Author: Calic Date: Sun Jun 14 01:26:28 2026 +0200 initial: notify v0.1.0 — tag-based player-message routing Tag-based messages, channel registry, tag-filter routing (OR-list, empty=catch-all), per-channel history ring-buffer, subscriber callbacks, severity sugar wrappers (info/warn/error), and engine.print mirror. 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..68f829f --- /dev/null +++ b/README.md @@ -0,0 +1,232 @@ +# lib-core.notify + +Headless player-facing message routing. Tag-based messages with a channel +registry, tag-filter routing, subscriber callbacks, and an engine.print mirror. +No render code; no UI. + +**Version:** 0.1.0 +**Lib-ID:** lib-core.notify +**Requires:** engine.time.now (optional, for auto-timestamp), engine.print (optional, for log mirror) +**Tags:** notify, message, channel, routing, hud + +## Topology + + +```mermaid +graph LR + this["lib-core.notify"] + engine_time["engine.time.now (optional)"] + engine_print["engine.print (optional)"] + this -.->|optional| engine_time + this -.->|optional| engine_print +``` + + +## Scope (v0.1.0) + +v0.1 ships the data layer only: + +- Channel registry (create, destroy, list) +- Tag-filter routing: OR-match on shared tags; empty filter = catch-all +- Per-channel history ring-buffer (max_history capped, oldest dropped) +- Subscribe / unsubscribe callbacks (returns opaque handle) +- Severity sugar wrappers: `info`, `warn`, `error` +- Engine-log mirror (calls `engine.print` on every posted message; toggle via `set_engine_log_mirror`) + +**Intentional non-goals (deferred):** +- Glob/wildcard tag filters +- AND-combination or exclude-tag filters +- Persistent history (disk/savegame serialization) +- Display rendering (belongs in a future lib-core.notify-display) + +## Message Schema + +| Field | Type | Set by | Notes | +|-------------|--------------|-------------------|--------------------------------| +| `tags` | string[] | caller (required) | Non-empty; drives routing | +| `text` | string | caller (required) | Human-readable message body | +| `severity` | string | caller / auto | `"info"` / `"warn"` / `"error"`; defaults to `"info"` | +| `id` | number | auto | Monotonically increasing | +| `timestamp` | number / nil | auto | `engine.time.now()` if available | + +## Channel Schema + +| Field | Type | Required | Notes | +|----------------|----------|----------|----------------------------------------------------| +| `id` | string | yes | Unique channel identifier | +| `filter` | string[] | yes | Tag OR-list; `{}` = catch-all | +| `display_mode` | string | yes | `"log"` / `"toast"` / `"detail"` | +| `max_history` | number | no | Default: log=200, toast=20, detail=1 | +| `title` | string | no | Human-readable channel name | + +## Filter Semantics + +Routing uses an OR-list: a message is delivered to a channel if at least one +of the message's tags appears in the channel's filter array. + +An empty filter array (`{}`) is a catch-all — every message is delivered +regardless of its tags. + +``` +channel filter = {"combat", "loot"} message tags = {"loot", "vendor"} +→ match: "loot" in both lists +``` + +## API + +### `notify.create_channel(def)` + +**Syntax:** `notify.create_channel(def: table) -> void` + +**Example:** +```lua +notify.create_channel{ + id = "combat-log", + filter = {"combat", "damage"}, + display_mode = "log", + max_history = 100, + title = "Combat Log", +} +``` + +Registers a new channel. Loud-error on: non-table `def`, missing or empty +`def.id`, duplicate id, non-table `def.filter`, unknown `def.display_mode`. +`max_history` defaults to display-mode default (log=200, toast=20, detail=1). + +--- + +### `notify.destroy_channel(channel_id)` + +**Syntax:** `notify.destroy_channel(channel_id: string) -> void` + +Removes the channel and all its subscribers. Idempotent (no-op if channel +does not exist). + +--- + +### `notify.list_channels()` + +**Syntax:** `notify.list_channels() -> table[]` + +Returns a shallow-copy array of all registered channel definitions (without +internal history). Order is unspecified. + +--- + +### `notify.post(msg)` + +**Syntax:** `notify.post(msg: table) -> void` + +**Example:** +```lua +notify.post{ tags = {"combat", "damage"}, text = "You hit goblin for 12 damage.", severity = "info" } +``` + +Posts a message. Auto-fills `id`, `timestamp`, and `severity` (default +`"info"`). Routes to all channels whose filter matches at least one tag. +Appends to channel history (ring-buffered at `max_history`), invokes all +registered subscriber callbacks in insertion order, and mirrors to +`engine.print` if `engine_log_mirror` is enabled. + +Loud-error on: non-table `msg`, missing or non-array `msg.tags`, empty +`msg.tags`, non-string tag elements, non-string `msg.text`. + +--- + +### `notify.info(tags, text)` + +**Syntax:** `notify.info(tags: string[], text: string) -> void` + +Sugar for `M.post{ tags=tags, text=text, severity="info" }`. + +--- + +### `notify.warn(tags, text)` + +**Syntax:** `notify.warn(tags: string[], text: string) -> void` + +Sugar for `M.post{ tags=tags, text=text, severity="warn" }`. + +--- + +### `notify.error(tags, text)` + +**Syntax:** `notify.error(tags: string[], text: string) -> void` + +Sugar for `M.post{ tags=tags, text=text, severity="error" }`. + +--- + +### `notify.get_history(channel_id, n)` + +**Syntax:** `notify.get_history(channel_id: string, n?: number) -> table[]` + +**Example:** +```lua +local last5 = notify.get_history("combat-log", 5) +``` + +Returns a shallow-copy array of the last `n` messages delivered to the +channel, oldest-first within the returned slice. If `n` is nil or greater +than the number of stored messages, returns all stored messages. Returns +`{}` for unknown channels. + +--- + +### `notify.subscribe(channel_id, callback)` + +**Syntax:** `notify.subscribe(channel_id: string, callback: function) -> handle` + +**Example:** +```lua +local handle = notify.subscribe("combat-log", function(msg) + hud.append(msg.text) +end) +``` + +Registers `callback` to be invoked whenever a message is routed to +`channel_id`. Returns an opaque handle table required by `unsubscribe`. +Loud-error on unknown channel or non-function callback. + +--- + +### `notify.unsubscribe(handle)` + +**Syntax:** `notify.unsubscribe(handle: table) -> void` + +Removes the subscription identified by `handle`. Silent if the subscription +or channel no longer exists (idempotent). + +--- + +### `notify.set_engine_log_mirror(enable)` + +**Syntax:** `notify.set_engine_log_mirror(enable: bool) -> void` + +Enables or disables mirroring every posted message to `engine.print`. +Only `true` (boolean) enables the mirror; any other value disables it. +Enabled by default at module load. + +--- + +## Engine-Log Mirror + +When `engine_log_mirror` is enabled (default), every call to `notify.post` +also calls `engine.print(msg.text)` if `engine.print` is available. This +provides zero-config visibility in the engine console during development. + +Disable in production or when a channel subscriber handles its own output: + +```lua +notify.set_engine_log_mirror(false) +``` + +--- + +## Test Backdoors + +The following functions are for test-only use. Do not call them from game code. + +- `notify._test_clear_all()` — resets all state to initial values +- `notify._test_get_subscribers(channel_id)` — returns raw subscriber list +- `notify._test_get_log_mirror()` — returns current `engine_log_mirror` bool diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..3bc3035 --- /dev/null +++ b/init.lua @@ -0,0 +1,267 @@ +-- ===================================================================== +-- lib-core.notify v0.1.0 — Player-Facing Message Routing +-- Tag-based messages + channel registry + tag-filter routing. +-- Spec: meta/docs/superpowers/specs/2026-06-14-notification-system-design.md +-- ===================================================================== + +-- Module-table early so module-local closures can reference M.* +local M = {} + +-- ---------- module state (all local) ---------- +local channels = {} -- channel_id → channel_def +local subscribers = {} -- channel_id → array of {callback, sub_id} +local next_msg_id = 1 +local next_sub_id = 1 +local engine_log_mirror = true + +-- Valid display modes (capability-by-declaration) +local VALID_DISPLAY_MODES = { + log = true, + toast = true, + detail = true, +} + +-- Default max_history per display_mode +local DEFAULT_MAX_HISTORY = { + log = 200, + toast = 20, + detail = 1, +} + +-- ----------------------------------------------------------------------- +-- Internal helpers +-- ----------------------------------------------------------------------- + +local function tags_match(channel_filter, msg_tags) + -- Catch-all: empty filter matches everything + if #channel_filter == 0 then return true end + -- OR-list: ≥1 message-tag in channel_filter + for _, mtag in ipairs(msg_tags) do + for _, ftag in ipairs(channel_filter) do + if mtag == ftag then return true end + end + end + return false +end + +local function append_to_history(channel, msg) + table.insert(channel.history, msg) + while #channel.history > channel.max_history do + table.remove(channel.history, 1) -- drop oldest + end +end + +-- ----------------------------------------------------------------------- +-- Channel management +-- ----------------------------------------------------------------------- + +function M.create_channel(def) + if type(def) ~= "table" then + error("notify.create_channel: def must be a table") + end + if type(def.id) ~= "string" or def.id == "" then + error("notify.create_channel: def.id must be a non-empty string") + end + local id = def.id + if channels[id] then + error("notify.create_channel '" .. id .. "': duplicate channel id") + end + if type(def.filter) ~= "table" then + error("notify.create_channel '" .. id .. "': def.filter must be a table (array)") + end + if not VALID_DISPLAY_MODES[def.display_mode] then + error("notify.create_channel '" .. id .. "': unknown display_mode '" .. + tostring(def.display_mode) .. "'; valid: log, toast, detail") + end + local max_history = def.max_history + if max_history == nil then + max_history = DEFAULT_MAX_HISTORY[def.display_mode] + end + channels[id] = { + id = id, + filter = def.filter, + display_mode = def.display_mode, + max_history = max_history, + title = def.title, + history = {}, + } + subscribers[id] = {} +end + +function M.destroy_channel(channel_id) + channels[channel_id] = nil + subscribers[channel_id] = nil +end + +function M.list_channels() + local result = {} + for _, ch in pairs(channels) do + -- shallow copy, omit internal history + result[#result + 1] = { + id = ch.id, + filter = ch.filter, + display_mode = ch.display_mode, + max_history = ch.max_history, + title = ch.title, + } + end + return result +end + +-- ----------------------------------------------------------------------- +-- Post +-- ----------------------------------------------------------------------- + +function M.post(msg) + if type(msg) ~= "table" then + error("notify.post: msg must be a table") + end + if type(msg.tags) ~= "table" then + error("notify.post: msg.tags must be an array of strings") + end + if #msg.tags == 0 then + error("notify.post: msg.tags must be a non-empty array") + end + for i, tag in ipairs(msg.tags) do + if type(tag) ~= "string" then + error("notify.post: msg.tags[" .. i .. "] must be a string, got " .. type(tag)) + end + end + if type(msg.text) ~= "string" then + error("notify.post: msg.text must be a string") + end + + -- Shallow copy so caller's table is never mutated and history + -- entries can't alias across calls. + -- Note: tags and data are shared references; caller must not mutate + -- them after post(). + local stored = { + tags = msg.tags, + text = msg.text, + severity = msg.severity or "info", + ttl = msg.ttl, + source = msg.source, + data = msg.data, + } + stored.id = next_msg_id + next_msg_id = next_msg_id + 1 + stored.timestamp = (engine and engine.time and engine.time.now and engine.time.now()) or 0 + + -- Route to matching channels + for _, ch in pairs(channels) do + if tags_match(ch.filter, msg.tags) then + append_to_history(ch, stored) + for _, sub in ipairs(subscribers[ch.id]) do + sub.callback(stored) + end + end + end + + -- Engine-log mirror + if engine_log_mirror and engine and engine.print then + engine.print(stored.text) + end +end + +-- ----------------------------------------------------------------------- +-- Sugar wrappers +-- ----------------------------------------------------------------------- + +function M.info(tags, text) + M.post{ tags = tags, text = text, severity = "info" } +end + +function M.warn(tags, text) + M.post{ tags = tags, text = text, severity = "warn" } +end + +function M.error(tags, text) + M.post{ tags = tags, text = text, severity = "error" } +end + +-- ----------------------------------------------------------------------- +-- History +-- ----------------------------------------------------------------------- + +function M.get_history(channel_id, n) + local ch = channels[channel_id] + if not ch then return {} end + local history = ch.history + local total = #history + if n == nil or n >= total then + -- Return shallow copy of all + local copy = {} + for i = 1, total do copy[i] = history[i] end + return copy + end + -- Return last n entries (oldest-first within slice) + local copy = {} + local start = total - n + 1 + for i = start, total do + copy[#copy + 1] = history[i] + end + return copy +end + +-- ----------------------------------------------------------------------- +-- Subscribe / Unsubscribe +-- ----------------------------------------------------------------------- + +function M.subscribe(channel_id, callback) + if not channels[channel_id] then + error("notify.subscribe: unknown channel '" .. tostring(channel_id) .. "'") + end + if type(callback) ~= "function" then + error("notify.subscribe: callback for channel '" .. tostring(channel_id) .. "' must be a function") + end + local sub_id = next_sub_id + next_sub_id = next_sub_id + 1 + local entry = { callback = callback, sub_id = sub_id } + table.insert(subscribers[channel_id], entry) + return { channel_id = channel_id, sub_id = sub_id } +end + +function M.unsubscribe(handle) + if type(handle) ~= "table" or handle.channel_id == nil then + error("notify.unsubscribe: handle must be a table with channel_id field") + end + local list = subscribers[handle.channel_id] + if not list then return end -- channel destroyed, silent + for i, sub in ipairs(list) do + if sub.sub_id == handle.sub_id then + table.remove(list, i) + return + end + end + -- Not found: silent (idempotent) +end + +-- ----------------------------------------------------------------------- +-- Engine-log mirror toggle +-- ----------------------------------------------------------------------- + +function M.set_engine_log_mirror(enable) + engine_log_mirror = enable == true +end + +-- ----------------------------------------------------------------------- +-- Test backdoors +-- ----------------------------------------------------------------------- + +function M._test_clear_all() + channels = {} + subscribers = {} + next_msg_id = 1 + next_sub_id = 1 + engine_log_mirror = true +end + +function M._test_get_subscribers(channel_id) + return subscribers[channel_id] or {} +end + +function M._test_get_log_mirror() + return engine_log_mirror +end + +return M diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..1570169 --- /dev/null +++ b/manifest.lib @@ -0,0 +1 @@ +{"id":"lib-core.notify","version":"0.1.0","api_min":"0.1","deps":[]}