/** * Journal Stream — Message Type Registry * * An open, add-only registry of message types. Game-specific widgets * call `registerMessageType()` at module import time. The stream core * uses this to validate, render, and reduce messages. */ import type { ZodType } from "zod"; import type { JSX } from "solid-js"; // --------------------------------------------------------------------------- // StreamMessage // --------------------------------------------------------------------------- /** * The typed envelope carried on the stream topic. * `P` is narrowed by the `type` key at the receiving side. */ export interface StreamMessage

{ /** Deterministic id: `{sender}-{seq}` */ id: string; /** Sender identifier — "gm" or a player name */ sender: string; /** Monotonic sequence number, per sender */ seq: number; /** Registered type key — determines which payload schema & widget are used */ type: string; /** Type-specific data, validated against the registry's Zod schema */ payload: P; /** Epoch millis, set by the sender at publish time */ timestamp: number; /** * Soft-delete. Only the sender's latest (highest seq) message can be * reverted. Sending a new message locks the previous one permanently. */ reverted: boolean; } // --------------------------------------------------------------------------- // MessageTypeDef // --------------------------------------------------------------------------- export interface MessageTypeDef

{ /** Unique type key, e.g. "roll.request", "article.reveal" */ type: string; /** Human-readable label shown in the compose panel dropdown */ label: string; /** Which roles can emit this type. Empty array = all roles allowed. */ emitters?: ("gm" | "player")[]; /** Zod schema used to validate payload before publish */ schema: ZodType

; /** Returns a default payload for compose panel initialization */ defaultPayload?: () => P; /** * Render the message body in the stream. * Receives the full message for context (sender, timestamp, reverted). * Return undefined to suppress inline rendering (e.g. pure state mutations). */ render?: (payload: P, msg: StreamMessage

) => JSX.Element | undefined; /** * Apply side-effects to local state when this message is received. * Runs once per message, at initial hydration and on live receipt. */ reducer?: (payload: P, msg: StreamMessage

) => void; } // --------------------------------------------------------------------------- // Registry // --------------------------------------------------------------------------- const registry = new Map>(); /** * Register a message type. Calling module should import and call this at * top-level so the registry is populated before any stream operations. */ export function registerMessageType

(def: MessageTypeDef

): void { if (registry.has(def.type)) { throw new Error(`Message type "${def.type}" is already registered.`); } registry.set(def.type, def); } /** Retrieve a registered type definition. */ export function getMessageType(type: string): MessageTypeDef | undefined { return registry.get(type); } /** Iterate over all registered type keys. */ export function registeredTypes(): IterableIterator { return registry.keys(); } /** * Validate a payload against the registered schema for `type`. * Returns the parsed (narrowed) payload on success, or a ZodError. */ export function validatePayload

( type: string, payload: P, ): { success: true; data: P } | { success: false; error: string } { const def = registry.get(type); if (!def) { return { success: false, error: `Unknown message type: "${type}"` }; } const result = def.schema.safeParse(payload); if (result.success) { return { success: true, data: result.data as P }; } return { success: false, error: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), }; } /** * Check whether a role (sender) is allowed to emit a given message type. */ export function canEmit(type: string, role: string): boolean { const def = registry.get(type); if (!def) return false; const emitters = def.emitters; if (!emitters || emitters.length === 0) return true; return emitters.includes(role as "gm" | "player"); }