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:
parent
8194438816
commit
53c33587fb
15
src/App.tsx
15
src/App.tsx
|
|
@ -11,12 +11,14 @@ import {
|
||||||
DataSourceDialog,
|
DataSourceDialog,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||||
|
import { JournalPanel } from "./components/journal";
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||||
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
||||||
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
||||||
|
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
|
||||||
|
|
||||||
// Sidebar and TOC data — reload when source changes
|
// Sidebar and TOC data — reload when source changes
|
||||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||||
|
|
@ -75,6 +77,15 @@ const App: Component = () => {
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
||||||
<div class="flex-1" />
|
<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
|
<button
|
||||||
onClick={() => setIsDataSourceOpen(true)}
|
onClick={() => setIsDataSourceOpen(true)}
|
||||||
class="text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100"
|
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)}
|
onClose={() => setIsDataSourceOpen(false)}
|
||||||
onSourceChanged={handleSourceChanged}
|
onSourceChanged={handleSourceChanged}
|
||||||
/>
|
/>
|
||||||
|
<JournalPanel
|
||||||
|
open={isJournalOpen()}
|
||||||
|
onClose={() => setIsJournalOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -33,3 +33,12 @@ export type {
|
||||||
SessionMeta,
|
SessionMeta,
|
||||||
SessionManifest,
|
SessionManifest,
|
||||||
} from "../stores/journalStream";
|
} 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";
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Built-in message type: article.reveal
|
* 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 { z } from "zod";
|
||||||
|
|
@ -11,11 +8,8 @@ import { journalSetState } from "../../stores/journalStream";
|
||||||
import { produce } from "solid-js/store";
|
import { produce } from "solid-js/store";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
/** Path relative to content root, e.g. "/adventures/dungeon.md" */
|
|
||||||
path: z.string().min(1),
|
path: z.string().min(1),
|
||||||
/** Optional display label, defaults to the path */
|
|
||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
/** Optional section heading to deep-link */
|
|
||||||
section: z.string().optional(),
|
section: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -27,6 +21,19 @@ registerMessageType<ArticleRevealPayload>({
|
||||||
emitters: ["gm"],
|
emitters: ["gm"],
|
||||||
schema,
|
schema,
|
||||||
defaultPayload: () => ({ path: "/" }),
|
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) => {
|
reducer: (p) => {
|
||||||
journalSetState(
|
journalSetState(
|
||||||
produce((s) => {
|
produce((s) => {
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Built-in message types: intent / resolution
|
* 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 { z } from "zod";
|
||||||
|
|
@ -13,12 +10,7 @@ import { registerMessageType } from "../registry";
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const intentSchema = z.object({
|
const intentSchema = z.object({
|
||||||
/** Plain-text description of what the player wants to do */
|
|
||||||
description: z.string().min(1),
|
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(),
|
context: z.record(z.string(), z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -30,6 +22,20 @@ registerMessageType<IntentPayload>({
|
||||||
emitters: ["player"],
|
emitters: ["player"],
|
||||||
schema: intentSchema,
|
schema: intentSchema,
|
||||||
defaultPayload: () => ({ description: "" }),
|
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({
|
const resolutionSchema = z.object({
|
||||||
/** The id of the intent message being resolved */
|
|
||||||
intentId: z.string().min(1),
|
intentId: z.string().min(1),
|
||||||
/** Freeform outcome text */
|
|
||||||
outcome: z.string().min(1),
|
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(),
|
statChanges: z.record(z.string(), z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -55,4 +55,23 @@ registerMessageType<ResolutionPayload>({
|
||||||
label: "Resolve Intent",
|
label: "Resolve Intent",
|
||||||
emitters: ["gm"],
|
emitters: ["gm"],
|
||||||
schema: resolutionSchema,
|
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>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Built-in message type: narrative
|
* Built-in message type: narrative
|
||||||
*
|
|
||||||
* Freeform text from GM or players. Renders as a chat bubble.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
|
|
||||||
const schema = z.object({
|
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>;
|
export type NarrativePayload = z.infer<typeof schema>;
|
||||||
|
|
@ -18,9 +19,5 @@ registerMessageType<NarrativePayload>({
|
||||||
label: "Narrative",
|
label: "Narrative",
|
||||||
schema,
|
schema,
|
||||||
defaultPayload: () => ({ text: "" }),
|
defaultPayload: () => ({ text: "" }),
|
||||||
render: (p, msg) => {
|
render: (p) => <p class="text-sm whitespace-pre-wrap">{p.text}</p>,
|
||||||
// Placeholder — real render will be in StreamMessage component
|
|
||||||
// Returning a simple representation for now
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Built-in message types: roll.request / roll.result
|
* 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 { z } from "zod";
|
||||||
|
|
@ -13,13 +10,9 @@ import { registerMessageType } from "../registry";
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const rollRequestSchema = z.object({
|
const rollRequestSchema = z.object({
|
||||||
/** Dice notation, e.g. "2d6", "1d20+5", "3d6kh1" */
|
|
||||||
notation: z.string().min(1),
|
notation: z.string().min(1),
|
||||||
/** Human label, e.g. "Fear save", "Attack roll" */
|
|
||||||
label: z.string().min(1),
|
label: z.string().min(1),
|
||||||
/** Optional target number to compare against */
|
|
||||||
target: z.number().int().optional(),
|
target: z.number().int().optional(),
|
||||||
/** Optional extra context */
|
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -31,6 +24,19 @@ registerMessageType<RollRequestPayload>({
|
||||||
emitters: ["gm"],
|
emitters: ["gm"],
|
||||||
schema: rollRequestSchema,
|
schema: rollRequestSchema,
|
||||||
defaultPayload: () => ({ notation: "1d20", label: "" }),
|
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({
|
const rollResultSchema = z.object({
|
||||||
/** The dice notation that was rolled */
|
|
||||||
notation: z.string().min(1),
|
notation: z.string().min(1),
|
||||||
/** The raw dice results, e.g. [6, 4] for 2d6 */
|
|
||||||
dice: z.array(z.number().int().min(1)),
|
dice: z.array(z.number().int().min(1)),
|
||||||
/** Modifiers, e.g. +5 */
|
|
||||||
modifier: z.number().int().default(0),
|
modifier: z.number().int().default(0),
|
||||||
/** Final total after modifiers and keep/drop rules */
|
|
||||||
total: z.number(),
|
total: z.number(),
|
||||||
/** Optional: which roll.request message this is responding to (by id) */
|
|
||||||
requestId: z.string().optional(),
|
requestId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -57,4 +58,24 @@ registerMessageType<RollResultPayload>({
|
||||||
label: "Roll Result",
|
label: "Roll Result",
|
||||||
emitters: ["player"],
|
emitters: ["player"],
|
||||||
schema: rollResultSchema,
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
Loading…
Reference in New Issue