feat(journal): implement journal UI components

Introduce a complete set of journal components including:
- JournalPanel: main container with mobile/desktop support
- ConnectionBar: MQTT connection and session management
- StreamView and StreamMessage: message log and individual message cards
- ComposePanel: message composition with type selection
- DynamicForm: recursive form generator based on Zod schemas
This commit is contained in:
hypercross 2026-07-06 15:06:19 +08:00
parent 8194438816
commit 53c33587fb
12 changed files with 917 additions and 40 deletions

View File

@ -11,12 +11,14 @@ import {
DataSourceDialog,
} from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal";
const App: Component = () => {
const location = useLocation();
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
const [isDocOpen, setIsDocOpen] = createSignal(false);
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
// Sidebar and TOC data — reload when source changes
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
@ -75,6 +77,15 @@ const App: Component = () => {
</button>
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
<div class="flex-1" />
<button
onClick={() => setIsJournalOpen((v) => !v)}
class={`text-sm px-2 py-1 rounded hover:bg-gray-100 ${
isJournalOpen() ? "text-blue-600 font-medium" : "text-gray-500"
}`}
title="Journal Stream"
>
📋 Journal
</button>
<button
onClick={() => setIsDataSourceOpen(true)}
class="text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100"
@ -106,6 +117,10 @@ const App: Component = () => {
onClose={() => setIsDataSourceOpen(false)}
onSourceChanged={handleSourceChanged}
/>
<JournalPanel
open={isJournalOpen()}
onClose={() => setIsJournalOpen(false)}
/>
</div>
);
};

View File

@ -0,0 +1,108 @@
/**
* ComposePanel type picker + dynamic form + send button
*/
import { Component, createSignal, For, Show, createMemo } from "solid-js";
import { registeredTypes, canEmit } from "./registry";
import {
sendMessage,
revertLatest,
canRevert,
useJournalStream,
} from "../stores/journalStream";
import type { MessageTypeDef } from "./registry";
import { DynamicForm } from "./DynamicForm";
export const ComposePanel: Component = () => {
const stream = useJournalStream();
const [selectedType, setSelectedType] = createSignal<string>("narrative");
const [formData, setFormData] = createSignal<Record<string, unknown>>({});
const [error, setError] = createSignal<string | null>(null);
const [sending, setSending] = createSignal(false);
const role = () => stream.myName;
// Available types filtered by role
const availableTypes = createMemo(() => {
const types: string[] = [];
for (const type of registeredTypes()) {
if (canEmit(type, role())) {
types.push(type);
}
}
// Default to narrative if available, otherwise first
if (types.includes("narrative")) {
return types;
}
return types;
});
const handleSend = () => {
setError(null);
setSending(true);
const result = sendMessage(selectedType(), formData());
if (!result.success) {
setError(result.error);
} else {
setFormData({}); // Reset on success
}
setSending(false);
};
const handleRevert = () => {
revertLatest();
};
const showRevert = () => canRevert() && stream.myName === "gm";
return (
<div class="border-t border-gray-200 p-2 space-y-2 bg-white">
{/* Type picker */}
<div class="flex items-center gap-2">
<select
value={selectedType()}
onChange={(e) => {
setSelectedType(e.currentTarget.value);
setFormData({});
}}
class="text-xs border border-gray-300 rounded px-2 py-1 bg-white"
>
<For each={availableTypes()}>
{(type) => <option value={type}>{type}</option>}
</For>
</select>
<button
onClick={handleSend}
disabled={sending()}
class="ml-auto bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
>
{sending() ? "..." : "Send"}
</button>
<Show when={showRevert()}>
<button
onClick={handleRevert}
class="text-xs text-gray-400 hover:text-red-500"
title="Revert last message"
>
</button>
</Show>
</div>
{/* Dynamic form */}
<DynamicForm
type={selectedType()}
value={formData()}
onChange={setFormData}
/>
<Show when={error()}>
<p class="text-red-500 text-xs">{error()}</p>
</Show>
</div>
);
};

View File

@ -0,0 +1,225 @@
/**
* ConnectionBar connect form, session picker, player name
*
* Three states: disconnected connecting connected
*/
import { Component, createSignal, Show, For, onMount } from "solid-js";
import {
connectStream,
hydrateFromServer,
useJournalStream,
sessions,
setMyName,
createSession,
deleteSession,
} from "../stores/journalStream";
import type { SessionMeta } from "../stores/journalStream";
import { createMemo } from "solid-js";
// Direct access to the store's setter for session switching
import { journalSetState } from "../stores/journalStream";
export const ConnectionBar: Component = () => {
const stream = useJournalStream();
const [brokerUrl, setBrokerUrl] = createSignal("");
const [newSessionName, setNewSessionName] = createSignal("");
const [playerName, setPlayerName] = createSignal("");
const [connecting, setConnecting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [showNewSession, setShowNewSession] = createSignal(false);
onMount(() => {
setBrokerUrl(stream.brokerUrl ?? "");
setPlayerName(stream.myName);
});
const manifest = sessions;
const handleConnect = async () => {
const url = brokerUrl().trim();
if (!url) return;
setConnecting(true);
setError(null);
try {
// Save name first
if (playerName().trim()) {
setMyName(playerName().trim());
}
// Connect
const sessionId = stream.sessionId || "default";
await hydrateFromServer(sessionId);
await connectStream(sessionId, url);
} catch (e) {
setError(e instanceof Error ? e.message : "Connection failed");
} finally {
setConnecting(false);
}
};
const handleCreateSession = () => {
const name = newSessionName().trim();
if (!name) return;
createSession(name);
setNewSessionName("");
setShowNewSession(false);
};
const handleDeleteSession = (id: string) => {
if (confirm(`Delete session "${id}"? This cannot be undone.`)) {
deleteSession(id);
}
};
const handleSessionSelect = async (id: string) => {
if (!stream.connected) return;
try {
await hydrateFromServer(id);
journalSetState("sessionId", id);
} catch (e) {
setError("Failed to load session");
}
};
const statusColor = () =>
stream.connected
? "bg-green-500"
: connecting()
? "bg-yellow-400"
: "bg-gray-400";
return (
<div class="border-b border-gray-200 p-3 space-y-2 text-sm">
{/* Status + session name */}
<div class="flex items-center gap-2">
<span class={`w-2 h-2 rounded-full ${statusColor()}`} />
<span class="text-xs text-gray-500">
{stream.connected
? "connected"
: connecting()
? "connecting..."
: "disconnected"}
</span>
<Show when={stream.sessionId}>
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
{stream.sessionId}
</span>
</Show>
<div class="flex-1" />
<Show when={stream.connected}>
<button
onClick={() => {} /* disconnect */}
class="text-xs text-gray-400 hover:text-red-500"
title="Disconnect"
>
</button>
</Show>
</div>
{/* Disconnected: connect form */}
<Show when={!stream.connected}>
<div class="space-y-2">
<input
type="text"
value={brokerUrl()}
onInput={(e) => setBrokerUrl(e.currentTarget.value)}
placeholder="MQTT broker (e.g. tcp://192.168.1.5:1883)"
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
/>
<input
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
placeholder="Your name (GM or player)"
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
/>
<button
onClick={handleConnect}
disabled={connecting() || !brokerUrl().trim()}
class="w-full bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
>
{connecting() ? "Connecting..." : "Connect"}
</button>
<Show when={error()}>
<p class="text-red-500 text-xs">{error()}</p>
</Show>
</div>
</Show>
{/* Connected: session management (GM only) */}
<Show when={stream.connected && stream.myName === "gm"}>
<div class="space-y-1">
<div class="flex items-center gap-1">
<span class="text-xs text-gray-500">Sessions:</span>
<button
onClick={() => setShowNewSession((v) => !v)}
class="text-xs text-blue-600 hover:underline"
>
+ new
</button>
</div>
<Show when={showNewSession()}>
<div class="flex gap-1">
<input
type="text"
value={newSessionName()}
onInput={(e) => setNewSessionName(e.currentTarget.value)}
placeholder="Session name"
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
/>
<button
onClick={handleCreateSession}
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
>
Create
</button>
</div>
</Show>
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<div
class={`flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs ${
id === stream.sessionId
? "bg-blue-100 text-blue-800"
: "hover:bg-gray-100"
}`}
>
<span
class="flex-1 truncate"
onClick={() => handleSessionSelect(id)}
>
{meta.name || id}
</span>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteSession(id);
}}
class="text-gray-400 hover:text-red-500 text-xs"
title="Delete session"
>
</button>
</div>
)}
</For>
</div>
</Show>
{/* Connected: player info (player only) */}
<Show when={stream.connected && stream.myName !== "gm"}>
<div class="flex items-center gap-1 text-xs text-gray-500">
<span>Playing as:</span>
<span class="font-medium text-gray-700">{stream.myName}</span>
</div>
</Show>
</div>
);
};

View File

@ -0,0 +1,299 @@
/**
* DynamicForm renders form inputs from a Zod schema
*
* Walks the schema's shape and renders appropriate inputs for each field.
* Supports: string, number, boolean, enum, array, and nested objects.
* Optional fields get a checkbox to toggle inclusion.
*/
import { Component, For, Show, createMemo } from "solid-js";
import { z } from "zod";
import { getMessageType } from "./registry";
import type { MessageTypeDef } from "./registry";
type FormData = Record<string, unknown>;
interface DynamicFormProps {
type: string;
value: FormData;
onChange: (data: FormData) => void;
}
export const DynamicForm: Component<DynamicFormProps> = (props) => {
const def = createMemo(() => getMessageType(props.type));
// Generate default on type change
const schema = createMemo(() => def()?.schema);
return (
<div class="space-y-1.5">
<Show when={def()}>
{(d) => (
<SchemaFields
schema={d().schema}
value={props.value}
onChange={props.onChange}
/>
)}
</Show>
</div>
);
};
// ---------------------------------------------------------------------------
// SchemaFields — recursive
// ---------------------------------------------------------------------------
const SchemaFields: Component<{
schema: z.ZodTypeAny;
value: FormData;
onChange: (data: FormData) => void;
prefix?: string;
}> = (props) => {
const shape = unwrapShape(props.schema);
if (!shape) {
return <p class="text-xs text-gray-400">No fields</p>;
}
return (
<For each={Object.entries(shape)}>
{([key, fieldSchema]) => {
const def = (fieldSchema as z.ZodDefault<any>)._def;
const isOptional = fieldSchema.isOptional?.() ?? false;
const fieldType = getFieldType(fieldSchema);
// Nested object
if (fieldType === "object" && fieldSchema instanceof z.ZodObject) {
const nestedValue = (props.value[key] as FormData) ?? {};
return (
<fieldset class="border border-gray-200 rounded px-2 py-1">
<legend class="text-xs text-gray-500 px-1">{key}</legend>
<SchemaFields
schema={fieldSchema}
value={nestedValue}
onChange={(v) => updateField(props, key, v)}
/>
</fieldset>
);
}
// Optional toggle
const isSet = () => props.value[key] !== undefined;
const toggleOptional = () => {
if (isSet()) {
const next = { ...props.value };
delete next[key];
props.onChange(next);
} else {
props.onChange({ ...props.value, [key]: getDefault(fieldSchema) });
}
};
return (
<div class="flex items-start gap-1.5">
<Show when={isOptional}>
<input
type="checkbox"
checked={isSet()}
onChange={toggleOptional}
class="mt-1.5"
title={`Include ${key}`}
/>
</Show>
<div class={`flex-1 ${isOptional && !isSet() ? "opacity-40" : ""}`}>
<FieldInput
label={key}
schema={fieldSchema}
value={isSet() ? props.value[key] : getDefault(fieldSchema)}
onChange={(v) => updateField(props, key, v)}
disabled={isOptional && !isSet()}
/>
</div>
</div>
);
}}
</For>
);
};
// ---------------------------------------------------------------------------
// FieldInput
// ---------------------------------------------------------------------------
const FieldInput: Component<{
label: string;
schema: z.ZodTypeAny;
value: unknown;
onChange: (v: unknown) => void;
disabled?: boolean;
}> = (props) => {
const fieldType = createMemo(() => getFieldType(props.schema));
const inputClass =
"w-full border border-gray-300 rounded px-2 py-1 text-xs disabled:bg-gray-100 disabled:text-gray-400";
return (
<div class="flex items-center gap-1">
<label
class="text-xs text-gray-500 w-20 flex-shrink-0 truncate"
title={props.label}
>
{props.label}
</label>
<Show when={fieldType() === "string"}>
<input
type="text"
value={(props.value as string) ?? ""}
onInput={(e) => props.onChange(e.currentTarget.value)}
disabled={props.disabled}
class={inputClass}
/>
</Show>
<Show when={fieldType() === "number"}>
<input
type="number"
value={(props.value as number) ?? ""}
onInput={(e) =>
props.onChange(parseFloat(e.currentTarget.value) || 0)
}
disabled={props.disabled}
class={inputClass}
/>
</Show>
<Show when={fieldType() === "boolean"}>
<input
type="checkbox"
checked={(props.value as boolean) ?? false}
onChange={(e) => props.onChange(e.currentTarget.checked)}
disabled={props.disabled}
class="mt-1"
/>
</Show>
<Show when={fieldType() === "enum"}>
<select
value={(props.value as string) ?? ""}
onChange={(e) => props.onChange(e.currentTarget.value)}
disabled={props.disabled}
class={inputClass}
>
<For each={getEnumValues(props.schema)}>
{(v) => <option value={v}>{v}</option>}
</For>
</select>
</Show>
<Show when={fieldType() === "array"}>
<input
type="text"
value={
Array.isArray(props.value) ? (props.value as any[]).join(", ") : ""
}
onInput={(e) => {
const raw = e.currentTarget.value;
const parsed = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((s) => (isNaN(Number(s)) ? s : Number(s)));
props.onChange(parsed);
}}
disabled={props.disabled}
class={inputClass}
placeholder="comma-separated"
/>
</Show>
</div>
);
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function unwrapShape(
schema: z.ZodTypeAny,
): Record<string, z.ZodTypeAny> | null {
try {
if (schema instanceof z.ZodObject) {
return schema.shape as Record<string, z.ZodTypeAny>;
}
// Unwrap optional/branded/etc
const inner = (schema as any)?._def?.innerType;
if (inner) return unwrapShape(inner);
return null;
} catch {
return null;
}
}
type FieldType =
"string" | "number" | "boolean" | "enum" | "array" | "object" | "unknown";
function getFieldType(schema: z.ZodTypeAny): FieldType {
if (schema instanceof z.ZodString) return "string";
if (schema instanceof z.ZodNumber) return "number";
if (schema instanceof z.ZodBoolean) return "boolean";
if (schema instanceof z.ZodEnum) return "enum";
// Zod v4: nativeEnum returns ZodEnum, catch with duck-type check
if (isZodEnum(schema)) return "enum";
if (schema instanceof z.ZodArray) return "array";
if (schema instanceof z.ZodObject) return "object";
// Unwrap optional
const inner = (schema as any)?._def?.innerType;
if (inner) return getFieldType(inner);
return "unknown";
}
/** Duck-type check: Zod v4's nativeEnum returns a ZodEnum instance */
function isZodEnum(schema: z.ZodTypeAny): boolean {
return (schema as any)?._def?.typeName === "ZodEnum";
}
function getEnumValues(schema: z.ZodTypeAny): string[] {
try {
if (schema instanceof z.ZodEnum) return schema.options as string[];
if (isZodEnum(schema) && (schema as any).enum) {
return Object.values((schema as any).enum).filter(
(v: unknown) => typeof v === "string",
) as string[];
}
const inner = (schema as any)?._def?.innerType;
if (inner) return getEnumValues(inner);
} catch {
/* ignore */
}
return [];
}
function getDefault(schema: z.ZodTypeAny): unknown {
const ft = getFieldType(schema);
switch (ft) {
case "string":
return "";
case "number":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object":
return {};
case "enum":
return getEnumValues(schema)[0] ?? "";
default:
return "";
}
}
function updateField(
parent: { value: FormData; onChange: (data: FormData) => void },
key: string,
value: unknown,
): void {
parent.onChange({ ...parent.value, [key]: value });
}

View File

@ -0,0 +1,61 @@
/**
* JournalPanel right panel container for the journal stream
*
* Collapsible, overlays on mobile, pushes content on desktop.
*/
import { Component, Show, createSignal } from "solid-js";
import { useJournalStream } from "../stores/journalStream";
import { ConnectionBar } from "./ConnectionBar";
import { StreamView } from "./StreamView";
import { ComposePanel } from "./ComposePanel";
export interface JournalPanelProps {
open: boolean;
onClose: () => void;
}
export const JournalPanel: Component<JournalPanelProps> = (props) => {
const stream = useJournalStream();
return (
<Show when={props.open}>
{/* Mobile overlay */}
<div
class="fixed inset-0 bg-black/30 z-40 md:hidden"
onClick={props.onClose}
/>
<aside
class={`
fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
flex flex-col
w-full max-w-md md:w-[420px]
md:relative md:top-0 md:z-0
`}
>
{/* Header */}
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-700">Journal</h2>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm"
>
</button>
</div>
<ConnectionBar />
{/* Stream */}
<div class="flex-1 min-h-0">
<StreamView />
</div>
{/* Compose */}
<Show when={stream.connected}>
<ComposePanel />
</Show>
</aside>
</Show>
);
};

View File

@ -0,0 +1,75 @@
/**
* StreamMessage single message card in the stream
*/
import { Component, Show, createMemo } from "solid-js";
import type { StreamMessage as StreamMessageType } from "./registry";
import { getMessageType } from "./registry";
export const StreamMessageCard: Component<{
message: StreamMessageType;
}> = (props) => {
const def = createMemo(() => getMessageType(props.message.type));
const rendered = createMemo(() =>
def()?.render?.(props.message.payload, props.message as any),
);
const time = createMemo(() => {
const d = new Date(props.message.timestamp);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
});
const senderClass = () =>
props.message.sender === "gm"
? "text-purple-600 font-semibold"
: "text-blue-600 font-semibold";
return (
<Show
when={!props.message.reverted}
fallback={<RevealedCard msg={props.message} />}
>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
{/* Header */}
<div class="flex items-center gap-1.5 px-2.5 py-1 bg-gray-50 border-b border-gray-100">
<span class={`text-xs ${senderClass()}`}>{props.message.sender}</span>
<span class="text-xs text-gray-400">{time()}</span>
<span class="text-xs text-gray-300 bg-gray-100 px-1 rounded ml-auto">
{props.message.type}
</span>
</div>
{/* Body */}
<div class="px-2.5 py-1.5 text-sm text-gray-800">
<Show
when={rendered()}
fallback={<JsonBody payload={props.message.payload} />}
>
{rendered()}
</Show>
</div>
</div>
</Show>
);
};
/** Fallback: render payload as formatted JSON */
const JsonBody: Component<{ payload: unknown }> = (props) => {
const text = () => JSON.stringify(props.payload, null, 2);
return (
<pre class="text-xs text-gray-600 whitespace-pre-wrap font-mono">
{text()}
</pre>
);
};
/** Reverted card: struck-through, grey */
const RevealedCard: Component<{ msg: StreamMessageType }> = (props) => {
return (
<div class="bg-gray-100 rounded-lg border border-gray-200 opacity-50 px-2.5 py-1.5">
<span class="text-xs text-gray-400 line-through">
{props.msg.sender} reverted
</span>
</div>
);
};

View File

@ -0,0 +1,41 @@
/**
* StreamView scrollable message log
*/
import { Component, For, createEffect, onMount } from "solid-js";
import { visibleMessages, useJournalStream } from "../stores/journalStream";
import { StreamMessageCard } from "./StreamMessage";
export const StreamView: Component = () => {
const stream = useJournalStream();
let containerRef: HTMLDivElement | undefined;
// Auto-scroll to bottom on new messages
const messages = () => visibleMessages();
createEffect(() => {
void messages().length; // track
if (!containerRef) return;
containerRef.scrollTop = containerRef.scrollHeight;
});
return (
<div
ref={containerRef}
class="h-full overflow-y-auto px-3 py-2 space-y-2 bg-gray-50"
>
<For each={messages()}>
{(msg) => <StreamMessageCard message={msg} />}
</For>
<Show when={messages().length === 0}>
<p class="text-center text-gray-400 text-xs py-8">
{stream.connected
? "No messages yet. Start by posting something."
: "Connect to a session to see messages."}
</p>
</Show>
</div>
);
};
import { Show } from "solid-js";

View File

@ -33,3 +33,12 @@ export type {
SessionMeta,
SessionManifest,
} from "../stores/journalStream";
// UI components
export { JournalPanel } from "./JournalPanel";
export type { JournalPanelProps } from "./JournalPanel";
export { ConnectionBar } from "./ConnectionBar";
export { StreamView } from "./StreamView";
export { StreamMessageCard } from "./StreamMessage";
export { ComposePanel } from "./ComposePanel";
export { DynamicForm } from "./DynamicForm";

View File

@ -1,8 +1,5 @@
/**
* 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";
@ -11,11 +8,8 @@ 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(),
});
@ -27,6 +21,19 @@ registerMessageType<ArticleRevealPayload>({
emitters: ["gm"],
schema,
defaultPayload: () => ({ path: "/" }),
render: (p) => (
<div class="flex items-center gap-2">
<span class="text-lg">📄</span>
<div>
<a href={p.path} class="text-blue-600 underline text-sm">
{p.label || p.path}
</a>
{p.section && (
<span class="text-xs text-gray-400 ml-1">§ {p.section}</span>
)}
</div>
</div>
),
reducer: (p) => {
journalSetState(
produce((s) => {

View File

@ -1,8 +1,5 @@
/**
* 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";
@ -13,12 +10,7 @@ import { registerMessageType } from "../registry";
// ---------------------------------------------------------------------------
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(),
});
@ -30,6 +22,20 @@ registerMessageType<IntentPayload>({
emitters: ["player"],
schema: intentSchema,
defaultPayload: () => ({ description: "" }),
render: (p) => (
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="text-lg">🙋</span>
<span class="text-sm font-medium text-yellow-700">Intent</span>
</div>
<p class="text-sm">{p.description}</p>
{p.context && Object.keys(p.context).length > 0 && (
<pre class="text-xs bg-gray-100 rounded px-2 py-1 text-gray-600">
{JSON.stringify(p.context, null, 2)}
</pre>
)}
</div>
),
});
// ---------------------------------------------------------------------------
@ -37,14 +43,8 @@ registerMessageType<IntentPayload>({
// ---------------------------------------------------------------------------
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(),
});
@ -55,4 +55,23 @@ registerMessageType<ResolutionPayload>({
label: "Resolve Intent",
emitters: ["gm"],
schema: resolutionSchema,
render: (p) => (
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="text-lg"></span>
<span class="text-sm font-medium text-green-700">Resolution</span>
<span class="text-xs text-gray-400">re: {p.intentId}</span>
</div>
<p class="text-sm">{p.outcome}</p>
{p.statChanges && Object.keys(p.statChanges).length > 0 && (
<div class="flex flex-wrap gap-1">
{Object.entries(p.statChanges).map(([k, v]) => (
<span class="text-xs bg-green-50 text-green-800 px-1.5 py-0.5 rounded border border-green-200">
{k}: {String(v)}
</span>
))}
</div>
)}
</div>
),
});

View File

@ -1,14 +1,15 @@
/**
* 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"),
text: z
.string()
.min(1, "Message cannot be empty")
.max(2000, "Message too long"),
});
export type NarrativePayload = z.infer<typeof schema>;
@ -18,9 +19,5 @@ registerMessageType<NarrativePayload>({
label: "Narrative",
schema,
defaultPayload: () => ({ text: "" }),
render: (p, msg) => {
// Placeholder — real render will be in StreamMessage component
// Returning a simple representation for now
return undefined;
},
render: (p) => <p class="text-sm whitespace-pre-wrap">{p.text}</p>,
});

View File

@ -1,8 +1,5 @@
/**
* 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";
@ -13,13 +10,9 @@ import { registerMessageType } from "../registry";
// ---------------------------------------------------------------------------
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(),
});
@ -31,6 +24,19 @@ registerMessageType<RollRequestPayload>({
emitters: ["gm"],
schema: rollRequestSchema,
defaultPayload: () => ({ notation: "1d20", label: "" }),
render: (p) => (
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="text-lg">🎲</span>
<span class="font-bold">{p.notation}</span>
<span class="text-gray-600">{p.label}</span>
</div>
{p.description && <p class="text-xs text-gray-500">{p.description}</p>}
{p.target !== undefined && (
<p class="text-xs text-gray-500">Target: {p.target}</p>
)}
</div>
),
});
// ---------------------------------------------------------------------------
@ -38,15 +44,10 @@ registerMessageType<RollRequestPayload>({
// ---------------------------------------------------------------------------
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(),
});
@ -57,4 +58,24 @@ registerMessageType<RollResultPayload>({
label: "Roll Result",
emitters: ["player"],
schema: rollResultSchema,
render: (p) => {
const diceStr = p.dice.join(", ");
const modStr =
p.modifier !== 0
? p.modifier > 0
? ` + ${p.modifier}`
: ` - ${Math.abs(p.modifier)}`
: "";
return (
<div class="space-y-0.5">
<div class="flex items-center gap-1.5">
<span class="text-lg">🎲</span>
<span class="font-mono text-sm">
{p.notation} [{diceStr}]{modStr} ={" "}
<span class="font-bold text-base">{p.total}</span>
</span>
</div>
</div>
);
},
});