127 lines
4.3 KiB
TypeScript
127 lines
4.3 KiB
TypeScript
/**
|
|
* 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<P = unknown> {
|
|
/** 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<P = unknown> {
|
|
/** 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<P>;
|
|
/** 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<P>) => 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<P>) => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Registry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const registry = new Map<string, MessageTypeDef<any>>();
|
|
|
|
/**
|
|
* 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<P>(def: MessageTypeDef<P>): 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<string> {
|
|
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<P>(
|
|
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");
|
|
}
|