feat(journal): implement message registry and stream store
Implement a robust journal system featuring a type-safe message registry and an MQTT-backed stream store. - Add `registry.ts` to manage message type definitions, Zod schemas, and custom reducers. - Implement `journalStream.ts` to manage MQTT connections, session lifecycle, and reactive message state. - Add built-in message types for `narrative`, `intent`, `roll`, and `article`. - Provide utilities for message validation, optimistic local updates, and message reversion.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Journal module — barrel export
|
||||
*/
|
||||
|
||||
// Trigger message type registration
|
||||
import "./types";
|
||||
|
||||
export {
|
||||
registerMessageType,
|
||||
getMessageType,
|
||||
registeredTypes,
|
||||
validatePayload,
|
||||
canEmit,
|
||||
} from "./registry";
|
||||
export type { MessageTypeDef, StreamMessage } from "./registry";
|
||||
export {
|
||||
connectStream,
|
||||
disconnectStream,
|
||||
hydrateFromServer,
|
||||
sendMessage,
|
||||
revertLatest,
|
||||
canRevert,
|
||||
visibleMessages,
|
||||
useJournalStream,
|
||||
journalStreamState,
|
||||
createSession,
|
||||
deleteSession,
|
||||
sessions,
|
||||
setMyName,
|
||||
} from "../stores/journalStream";
|
||||
export type {
|
||||
JournalStreamState,
|
||||
SessionMeta,
|
||||
SessionManifest,
|
||||
} from "../stores/journalStream";
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Built-in message type: article.reveal
|
||||
*
|
||||
* GM reveals a document/article path to players. The reducer populates
|
||||
* the revealedPaths set so the Article component can gate visibility.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { registerMessageType } from "../registry";
|
||||
import { journalSetState } from "../../stores/journalStream";
|
||||
import { produce } from "solid-js/store";
|
||||
|
||||
const schema = z.object({
|
||||
/** Path relative to content root, e.g. "/adventures/dungeon.md" */
|
||||
path: z.string().min(1),
|
||||
/** Optional display label, defaults to the path */
|
||||
label: z.string().optional(),
|
||||
/** Optional section heading to deep-link */
|
||||
section: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ArticleRevealPayload = z.infer<typeof schema>;
|
||||
|
||||
registerMessageType<ArticleRevealPayload>({
|
||||
type: "article.reveal",
|
||||
label: "Reveal Article",
|
||||
emitters: ["gm"],
|
||||
schema,
|
||||
defaultPayload: () => ({ path: "/" }),
|
||||
reducer: (p) => {
|
||||
journalSetState(
|
||||
produce((s) => {
|
||||
s.revealedPaths.add(p.path);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Journal message types — barrel export
|
||||
*
|
||||
* Importing this module registers all built-in message types.
|
||||
* Game-specific widgets should create their own barrel and import it.
|
||||
*/
|
||||
|
||||
import "./narrative";
|
||||
import "./roll";
|
||||
import "./article";
|
||||
import "./intent";
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Built-in message types: intent / resolution
|
||||
*
|
||||
* intent — Player declares an action or resource usage for GM to resolve
|
||||
* resolution — GM resolves a player intent (outcome, stat changes, etc.)
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { registerMessageType } from "../registry";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// intent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const intentSchema = z.object({
|
||||
/** Plain-text description of what the player wants to do */
|
||||
description: z.string().min(1),
|
||||
/**
|
||||
* Optional JSON-serializable context (item name, skill name, target, etc.)
|
||||
* Parsed by game-specific widgets.
|
||||
*/
|
||||
context: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type IntentPayload = z.infer<typeof intentSchema>;
|
||||
|
||||
registerMessageType<IntentPayload>({
|
||||
type: "intent",
|
||||
label: "Declare Intent",
|
||||
emitters: ["player"],
|
||||
schema: intentSchema,
|
||||
defaultPayload: () => ({ description: "" }),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolutionSchema = z.object({
|
||||
/** The id of the intent message being resolved */
|
||||
intentId: z.string().min(1),
|
||||
/** Freeform outcome text */
|
||||
outcome: z.string().min(1),
|
||||
/**
|
||||
* Stat changes to apply. Game-specific widgets interpret these keys.
|
||||
* Example: { "hp": -3, "stress": 1, "status.add": "bleeding" }
|
||||
*/
|
||||
statChanges: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type ResolutionPayload = z.infer<typeof resolutionSchema>;
|
||||
|
||||
registerMessageType<ResolutionPayload>({
|
||||
type: "resolution",
|
||||
label: "Resolve Intent",
|
||||
emitters: ["gm"],
|
||||
schema: resolutionSchema,
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Built-in message type: narrative
|
||||
*
|
||||
* Freeform text from GM or players. Renders as a chat bubble.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { registerMessageType } from "../registry";
|
||||
|
||||
const schema = z.object({
|
||||
text: z.string().min(1, "Message cannot be empty").max(2000, "Message too long"),
|
||||
});
|
||||
|
||||
export type NarrativePayload = z.infer<typeof schema>;
|
||||
|
||||
registerMessageType<NarrativePayload>({
|
||||
type: "narrative",
|
||||
label: "Narrative",
|
||||
schema,
|
||||
defaultPayload: () => ({ text: "" }),
|
||||
render: (p, msg) => {
|
||||
// Placeholder — real render will be in StreamMessage component
|
||||
// Returning a simple representation for now
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Built-in message types: roll.request / roll.result
|
||||
*
|
||||
* roll.request — GM asks for a roll, renders a prompt with dice roller
|
||||
* roll.result — Player responds with their roll outcome
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { registerMessageType } from "../registry";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// roll.request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rollRequestSchema = z.object({
|
||||
/** Dice notation, e.g. "2d6", "1d20+5", "3d6kh1" */
|
||||
notation: z.string().min(1),
|
||||
/** Human label, e.g. "Fear save", "Attack roll" */
|
||||
label: z.string().min(1),
|
||||
/** Optional target number to compare against */
|
||||
target: z.number().int().optional(),
|
||||
/** Optional extra context */
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RollRequestPayload = z.infer<typeof rollRequestSchema>;
|
||||
|
||||
registerMessageType<RollRequestPayload>({
|
||||
type: "roll.request",
|
||||
label: "Request Roll",
|
||||
emitters: ["gm"],
|
||||
schema: rollRequestSchema,
|
||||
defaultPayload: () => ({ notation: "1d20", label: "" }),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// roll.result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rollResultSchema = z.object({
|
||||
/** The dice notation that was rolled */
|
||||
notation: z.string().min(1),
|
||||
/** The raw dice results, e.g. [6, 4] for 2d6 */
|
||||
dice: z.array(z.number().int().min(1)),
|
||||
/** Modifiers, e.g. +5 */
|
||||
modifier: z.number().int().default(0),
|
||||
/** Final total after modifiers and keep/drop rules */
|
||||
total: z.number(),
|
||||
/** Optional: which roll.request message this is responding to (by id) */
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RollResultPayload = z.infer<typeof rollResultSchema>;
|
||||
|
||||
registerMessageType<RollResultPayload>({
|
||||
type: "roll.result",
|
||||
label: "Roll Result",
|
||||
emitters: ["player"],
|
||||
schema: rollResultSchema,
|
||||
});
|
||||
Reference in New Issue
Block a user