diff --git a/src/components/journal/ComposePanel.tsx b/src/components/journal/ComposePanel.tsx index 2489bf7..5584052 100644 --- a/src/components/journal/ComposePanel.tsx +++ b/src/components/journal/ComposePanel.tsx @@ -10,7 +10,7 @@ import { DynamicForm } from "./DynamicForm"; export const ComposePanel: Component = () => { const stream = useJournalStream(); - const [selectedType, setSelectedType] = createSignal("narrative"); + const [selectedType, setSelectedType] = createSignal("chat"); const [formData, setFormData] = createSignal>({}); const [error, setError] = createSignal(null); const [sending, setSending] = createSignal(false); @@ -25,8 +25,8 @@ export const ComposePanel: Component = () => { types.push(type); } } - // Default to narrative if available, otherwise first - if (types.includes("narrative")) { + // Default to chat if available, otherwise first + if (types.includes("chat")) { return types; } return types; diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index b39825a..bc37075 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -2,9 +2,9 @@ * JournalInput — chat-style textarea with command dispatch and autocomplete. * * - Enter sends, Shift+Enter inserts newline - * - Plain text → "narrative" type - * - `/roll 3d6kh1` → "roll.request" type - * - `/link path#section` → "article.reveal" type + * - Plain text → "chat" type + * - `/roll 3d6kh1` → "roll" type (result resolved client-side by GM) + * - `/link path#section` → "link" type * - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json * in CLI mode, or client-side scan of the in-memory file index in dev mode) */ @@ -22,6 +22,7 @@ import { } from "solid-js"; import { sendMessage, useJournalStream } from "../stores/journalStream"; import { useJournalCompletions, ensureCompletions } from "./completions"; +import { resolveRollPayload } from "./types/roll"; // ---- Helpers ---- @@ -32,7 +33,7 @@ interface CompletionItem { } interface ParsedInput { - type: "narrative" | "roll.request" | "article.reveal"; + type: "chat" | "roll" | "link"; payload: Record; error?: string; } @@ -41,31 +42,26 @@ function parseInput(raw: string): ParsedInput { if (raw.startsWith("/roll ")) { const notation = raw.slice("/roll ".length).trim(); if (!notation) - return { - type: "roll.request", - payload: {}, - error: "Dice notation required", - }; - return { type: "roll.request", payload: { notation, label: notation } }; + return { type: "roll", payload: {}, error: "Dice notation required" }; + return { type: "roll", payload: { notation, label: notation } }; } if (raw.startsWith("/link ")) { const arg = raw.slice("/link ".length).trim(); - if (!arg) - return { type: "article.reveal", payload: {}, error: "Path required" }; + if (!arg) return { type: "link", payload: {}, error: "Path required" }; const hashIdx = arg.indexOf("#"); const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx); const section = hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined; - return { type: "article.reveal", payload: { path, section } }; + return { type: "link", payload: { path, section } }; } // /roll or /link with no space — need to complete, don't send if (raw === "/roll" || raw === "/link") { - return { type: "narrative", payload: {}, error: "Complete the command" }; + return { type: "chat", payload: {}, error: "Complete the command" }; } - return { type: "narrative", payload: { text: raw } }; + return { type: "chat", payload: { text: raw } }; } // ---- Component ---- @@ -98,9 +94,9 @@ export const JournalInput: Component = () => { const raw = text().trim(); if (!raw) return; - // Players can't use commands — everything is narrative + // Players / observers: everything is plain chat if (isPlayer() || isObserver()) { - const result = sendMessage("narrative", { text: raw }); + const result = sendMessage("chat", { text: raw }); if (!result.success) { setError(result.error); } else { @@ -119,6 +115,22 @@ export const JournalInput: Component = () => { setError(null); setSending(true); + // GM roll: resolve the dice result locally + if (parsed.type === "roll") { + const p = resolveRollPayload( + parsed.payload as { notation: string; label?: string }, + ); + const result = sendMessage("roll", p); + if (!result.success) { + setError(result.error); + } else { + setText(""); + } + setSending(false); + textareaRef?.focus(); + return; + } + const result = sendMessage(parsed.type, parsed.payload); if (!result.success) { setError(result.error); diff --git a/src/components/journal/registry.ts b/src/components/journal/registry.ts index 771dd25..8148a6b 100644 --- a/src/components/journal/registry.ts +++ b/src/components/journal/registry.ts @@ -42,7 +42,7 @@ export interface StreamMessage

{ // --------------------------------------------------------------------------- export interface MessageTypeDef

{ - /** Unique type key, e.g. "roll.request", "article.reveal" */ + /** Unique type key, e.g. "roll", "link" */ type: string; /** Human-readable label shown in the compose panel dropdown */ label: string; diff --git a/src/components/journal/types/narrative.tsx b/src/components/journal/types/chat.tsx similarity index 63% rename from src/components/journal/types/narrative.tsx rename to src/components/journal/types/chat.tsx index aaf203c..dfd1f58 100644 --- a/src/components/journal/types/narrative.tsx +++ b/src/components/journal/types/chat.tsx @@ -1,5 +1,8 @@ /** - * Built-in message type: narrative + * Built-in message type: chat + * + * Emitters: gm, player + * Command: plain text (or press Enter) */ import { z } from "zod"; @@ -12,11 +15,11 @@ const schema = z.object({ .max(2000, "Message too long"), }); -export type NarrativePayload = z.infer; +export type ChatPayload = z.infer; -registerMessageType({ - type: "narrative", - label: "Narrative", +registerMessageType({ + type: "chat", + label: "Chat", emitters: ["gm", "player"], schema, defaultPayload: () => ({ text: "" }), diff --git a/src/components/journal/types/index.ts b/src/components/journal/types/index.ts index 3ff6a0e..7efbae3 100644 --- a/src/components/journal/types/index.ts +++ b/src/components/journal/types/index.ts @@ -5,7 +5,7 @@ * Game-specific widgets should create their own barrel and import it. */ -import "./narrative"; +import "./chat"; import "./roll"; -import "./article"; +import "./link"; import "./intent"; diff --git a/src/components/journal/types/article.tsx b/src/components/journal/types/link.tsx similarity index 80% rename from src/components/journal/types/article.tsx rename to src/components/journal/types/link.tsx index 8555f05..682e6a2 100644 --- a/src/components/journal/types/article.tsx +++ b/src/components/journal/types/link.tsx @@ -1,5 +1,8 @@ /** - * Built-in message type: article.reveal + * Built-in message type: link (article reveal) + * + * Emitters: gm + * Command: /link path#section */ import { Component } from "solid-js"; @@ -15,14 +18,14 @@ const schema = z.object({ section: z.string().optional(), }); -export type ArticleRevealPayload = z.infer; +export type LinkPayload = z.infer; function fileNameFromPath(path: string): string { const parts = path.split("/").filter(Boolean); return parts[parts.length - 1] || path; } -/** Section slug → human-readable title (e.g. "attack-rules" → "Attack Rules") */ +/** Section slug → human-readable title */ function slugToTitle(slug: string): string { return slug .split("-") @@ -30,8 +33,7 @@ function slugToTitle(slug: string): string { .join(" "); } -/** Clickable link that navigates without dropping URL params */ -const RevealLink: Component = (p) => { +const RevealLink: Component = (p) => { const navigate = useNavigate(); const target = () => { @@ -71,9 +73,9 @@ const RevealLink: Component = (p) => { ); }; -registerMessageType({ - type: "article.reveal", - label: "Reveal Article", +registerMessageType({ + type: "link", + label: "Link Article", emitters: ["gm"], schema, defaultPayload: () => ({ path: "/" }), diff --git a/src/components/journal/types/roll.tsx b/src/components/journal/types/roll.tsx index 0f78924..c2b13a2 100644 --- a/src/components/journal/types/roll.tsx +++ b/src/components/journal/types/roll.tsx @@ -1,80 +1,66 @@ /** - * Built-in message types: roll.request / roll.result + * Built-in message type: roll + * + * Emitters: gm + * Command: /roll formula + * The GM's browser rolls the formula and publishes the resolved result. */ import { z } from "zod"; import { registerMessageType } from "../registry"; +import { rollFormula } from "../../md-commander/hooks"; -// --------------------------------------------------------------------------- -// roll.request -// --------------------------------------------------------------------------- - -const rollRequestSchema = z.object({ +const schema = z.object({ notation: z.string().min(1), - label: z.string().min(1), - target: z.number().int().optional(), - description: z.string().optional(), + label: z.string().optional(), + result: z.object({ + total: z.number(), + detail: z.string(), + pools: z.array( + z.object({ + rolls: z.array(z.number()), + subtotal: z.number(), + }), + ), + }), }); -export type RollRequestPayload = z.infer; +export type RollPayload = z.infer; -registerMessageType({ - type: "roll.request", - label: "Request Roll", +/** Compute the dice result on the GM's machine before send */ +export function resolveRollPayload(raw: { + notation: string; + label?: string; +}): RollPayload { + const roll = rollFormula(raw.notation); + return { + notation: raw.notation, + label: raw.label, + result: roll.result, + }; +} + +registerMessageType({ + type: "roll", + label: "Roll Dice", emitters: ["gm"], - schema: rollRequestSchema, - defaultPayload: () => ({ notation: "1d20", label: "" }), - render: (p) => ( -

-
- 🎲 - {p.notation} - {p.label} -
- {p.description &&

{p.description}

} - {p.target !== undefined && ( -

Target: {p.target}

- )} -
- ), -}); - -// --------------------------------------------------------------------------- -// roll.result -// --------------------------------------------------------------------------- - -const rollResultSchema = z.object({ - notation: z.string().min(1), - dice: z.array(z.number().int().min(1)), - modifier: z.number().int().default(0), - total: z.number(), - requestId: z.string().optional(), -}); - -export type RollResultPayload = z.infer; - -registerMessageType({ - type: "roll.result", - label: "Roll Result", - emitters: ["player"], - schema: rollResultSchema, + schema, + defaultPayload: () => ({ + notation: "1d20", + result: { total: 0, detail: "", pools: [] }, + }), render: (p) => { - const diceStr = p.dice.join(", "); - const modStr = - p.modifier !== 0 - ? p.modifier > 0 - ? ` + ${p.modifier}` - : ` - ${Math.abs(p.modifier)}` - : ""; + const { result } = p; return (
🎲 - {p.notation} → [{diceStr}]{modStr} ={" "} - {p.total} + {p.notation} →{" "} + {result.total}
+ {result.detail &&

{result.detail}

}
); }, diff --git a/src/components/stores/journalStream.ts b/src/components/stores/journalStream.ts index 800ef01..eedd319 100644 --- a/src/components/stores/journalStream.ts +++ b/src/components/stores/journalStream.ts @@ -3,7 +3,7 @@ * * Reactive state for a single session's message stream. Manages MQTT * connection, local message log, per-sender sequence tracking, and the - * revealed-paths set (populated by the article.reveal reducer). + * revealed-paths set (populated by the link reducer). * * Session lifecycle (create/list/delete) is handled via MQTT retained * topics — ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session. @@ -29,7 +29,7 @@ export interface JournalStreamState { /** Last sequence number per sender */ senderSeq: Record; /** - * Paths revealed by article.reveal messages. + * Paths revealed by link messages. * Populated during hydration and live receipt via the type's reducer. */ revealedPaths: Set;