# 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"` | | `ttl` | number / nil | caller (optional) | Display timeout in seconds; toast mode falls back to channel/display default if unset | | `source` | any / nil | caller (optional) | Optional source reference (e.g. originating entity) | | `data` | table / nil | caller (optional) | Structured key/value payload; detail mode renders `msg.data.*` | | `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