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.
This commit is contained in:
267
init.lua
Normal file
267
init.lua
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user