refactor: simplify journal message types and implement GM rolls

Refactor journal message types to use flatter, more intuitive names:
- `narrative` becomes `chat`
- `article.reveal` becomes `reveal`
- `roll.request`/`roll.result` becomes a single `roll` type

Implement client-side dice roll resolution for GMs. When a GM uses
the `/roll` command, the result is now calculated locally using
`resolveRollPayload` before being sent to the stream, rather than
requesting a roll from the server.
This commit is contained in:
hypercross 2026-07-06 18:06:32 +08:00
parent 59236f6dba
commit 0fc61e2f94
8 changed files with 99 additions and 96 deletions

View File

@ -10,7 +10,7 @@ import { DynamicForm } from "./DynamicForm";
export const ComposePanel: Component = () => { export const ComposePanel: Component = () => {
const stream = useJournalStream(); const stream = useJournalStream();
const [selectedType, setSelectedType] = createSignal<string>("narrative"); const [selectedType, setSelectedType] = createSignal<string>("chat");
const [formData, setFormData] = createSignal<Record<string, unknown>>({}); const [formData, setFormData] = createSignal<Record<string, unknown>>({});
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
const [sending, setSending] = createSignal(false); const [sending, setSending] = createSignal(false);
@ -25,8 +25,8 @@ export const ComposePanel: Component = () => {
types.push(type); types.push(type);
} }
} }
// Default to narrative if available, otherwise first // Default to chat if available, otherwise first
if (types.includes("narrative")) { if (types.includes("chat")) {
return types; return types;
} }
return types; return types;

View File

@ -2,9 +2,9 @@
* JournalInput chat-style textarea with command dispatch and autocomplete. * JournalInput chat-style textarea with command dispatch and autocomplete.
* *
* - Enter sends, Shift+Enter inserts newline * - Enter sends, Shift+Enter inserts newline
* - Plain text "narrative" type * - Plain text "chat" type
* - `/roll 3d6kh1` "roll.request" type * - `/roll 3d6kh1` "roll" type (result resolved client-side by GM)
* - `/link path#section` "article.reveal" type * - `/link path#section` "link" type
* - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json * - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json
* in CLI mode, or client-side scan of the in-memory file index in dev mode) * in CLI mode, or client-side scan of the in-memory file index in dev mode)
*/ */
@ -22,6 +22,7 @@ import {
} from "solid-js"; } from "solid-js";
import { sendMessage, useJournalStream } from "../stores/journalStream"; import { sendMessage, useJournalStream } from "../stores/journalStream";
import { useJournalCompletions, ensureCompletions } from "./completions"; import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll";
// ---- Helpers ---- // ---- Helpers ----
@ -32,7 +33,7 @@ interface CompletionItem {
} }
interface ParsedInput { interface ParsedInput {
type: "narrative" | "roll.request" | "article.reveal"; type: "chat" | "roll" | "link";
payload: Record<string, unknown>; payload: Record<string, unknown>;
error?: string; error?: string;
} }
@ -41,31 +42,26 @@ function parseInput(raw: string): ParsedInput {
if (raw.startsWith("/roll ")) { if (raw.startsWith("/roll ")) {
const notation = raw.slice("/roll ".length).trim(); const notation = raw.slice("/roll ".length).trim();
if (!notation) if (!notation)
return { return { type: "roll", payload: {}, error: "Dice notation required" };
type: "roll.request", return { type: "roll", payload: { notation, label: notation } };
payload: {},
error: "Dice notation required",
};
return { type: "roll.request", payload: { notation, label: notation } };
} }
if (raw.startsWith("/link ")) { if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim(); const arg = raw.slice("/link ".length).trim();
if (!arg) if (!arg) return { type: "link", payload: {}, error: "Path required" };
return { type: "article.reveal", payload: {}, error: "Path required" };
const hashIdx = arg.indexOf("#"); const hashIdx = arg.indexOf("#");
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx); const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
const section = const section =
hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined; 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 // /roll or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/link") { 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 ---- // ---- Component ----
@ -98,9 +94,9 @@ export const JournalInput: Component = () => {
const raw = text().trim(); const raw = text().trim();
if (!raw) return; if (!raw) return;
// Players can't use commands — everything is narrative // Players / observers: everything is plain chat
if (isPlayer() || isObserver()) { if (isPlayer() || isObserver()) {
const result = sendMessage("narrative", { text: raw }); const result = sendMessage("chat", { text: raw });
if (!result.success) { if (!result.success) {
setError(result.error); setError(result.error);
} else { } else {
@ -119,6 +115,22 @@ export const JournalInput: Component = () => {
setError(null); setError(null);
setSending(true); 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); const result = sendMessage(parsed.type, parsed.payload);
if (!result.success) { if (!result.success) {
setError(result.error); setError(result.error);

View File

@ -42,7 +42,7 @@ export interface StreamMessage<P = unknown> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface MessageTypeDef<P = unknown> { export interface MessageTypeDef<P = unknown> {
/** Unique type key, e.g. "roll.request", "article.reveal" */ /** Unique type key, e.g. "roll", "link" */
type: string; type: string;
/** Human-readable label shown in the compose panel dropdown */ /** Human-readable label shown in the compose panel dropdown */
label: string; label: string;

View File

@ -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"; import { z } from "zod";
@ -12,11 +15,11 @@ const schema = z.object({
.max(2000, "Message too long"), .max(2000, "Message too long"),
}); });
export type NarrativePayload = z.infer<typeof schema>; export type ChatPayload = z.infer<typeof schema>;
registerMessageType<NarrativePayload>({ registerMessageType<ChatPayload>({
type: "narrative", type: "chat",
label: "Narrative", label: "Chat",
emitters: ["gm", "player"], emitters: ["gm", "player"],
schema, schema,
defaultPayload: () => ({ text: "" }), defaultPayload: () => ({ text: "" }),

View File

@ -5,7 +5,7 @@
* Game-specific widgets should create their own barrel and import it. * Game-specific widgets should create their own barrel and import it.
*/ */
import "./narrative"; import "./chat";
import "./roll"; import "./roll";
import "./article"; import "./link";
import "./intent"; import "./intent";

View File

@ -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"; import { Component } from "solid-js";
@ -15,14 +18,14 @@ const schema = z.object({
section: z.string().optional(), section: z.string().optional(),
}); });
export type ArticleRevealPayload = z.infer<typeof schema>; export type LinkPayload = z.infer<typeof schema>;
function fileNameFromPath(path: string): string { function fileNameFromPath(path: string): string {
const parts = path.split("/").filter(Boolean); const parts = path.split("/").filter(Boolean);
return parts[parts.length - 1] || path; 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 { function slugToTitle(slug: string): string {
return slug return slug
.split("-") .split("-")
@ -30,8 +33,7 @@ function slugToTitle(slug: string): string {
.join(" "); .join(" ");
} }
/** Clickable link that navigates without dropping URL params */ const RevealLink: Component<LinkPayload> = (p) => {
const RevealLink: Component<ArticleRevealPayload> = (p) => {
const navigate = useNavigate(); const navigate = useNavigate();
const target = () => { const target = () => {
@ -71,9 +73,9 @@ const RevealLink: Component<ArticleRevealPayload> = (p) => {
); );
}; };
registerMessageType<ArticleRevealPayload>({ registerMessageType<LinkPayload>({
type: "article.reveal", type: "link",
label: "Reveal Article", label: "Link Article",
emitters: ["gm"], emitters: ["gm"],
schema, schema,
defaultPayload: () => ({ path: "/" }), defaultPayload: () => ({ path: "/" }),

View File

@ -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 { z } from "zod";
import { registerMessageType } from "../registry"; import { registerMessageType } from "../registry";
import { rollFormula } from "../../md-commander/hooks";
// --------------------------------------------------------------------------- const schema = z.object({
// roll.request
// ---------------------------------------------------------------------------
const rollRequestSchema = z.object({
notation: z.string().min(1), notation: z.string().min(1),
label: z.string().min(1), label: z.string().optional(),
target: z.number().int().optional(), result: z.object({
description: z.string().optional(), total: z.number(),
detail: z.string(),
pools: z.array(
z.object({
rolls: z.array(z.number()),
subtotal: z.number(),
}),
),
}),
}); });
export type RollRequestPayload = z.infer<typeof rollRequestSchema>; export type RollPayload = z.infer<typeof schema>;
registerMessageType<RollRequestPayload>({ /** Compute the dice result on the GM's machine before send */
type: "roll.request", export function resolveRollPayload(raw: {
label: "Request Roll", notation: string;
label?: string;
}): RollPayload {
const roll = rollFormula(raw.notation);
return {
notation: raw.notation,
label: raw.label,
result: roll.result,
};
}
registerMessageType<RollPayload>({
type: "roll",
label: "Roll Dice",
emitters: ["gm"], emitters: ["gm"],
schema: rollRequestSchema, schema,
defaultPayload: () => ({ notation: "1d20", label: "" }), defaultPayload: () => ({
render: (p) => ( notation: "1d20",
<div class="space-y-1"> result: { total: 0, detail: "", pools: [] },
<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>
),
});
// ---------------------------------------------------------------------------
// 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<typeof rollResultSchema>;
registerMessageType<RollResultPayload>({
type: "roll.result",
label: "Roll Result",
emitters: ["player"],
schema: rollResultSchema,
render: (p) => { render: (p) => {
const diceStr = p.dice.join(", "); const { result } = p;
const modStr =
p.modifier !== 0
? p.modifier > 0
? ` + ${p.modifier}`
: ` - ${Math.abs(p.modifier)}`
: "";
return ( return (
<div class="space-y-0.5"> <div class="space-y-0.5">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="text-lg">🎲</span> <span class="text-lg">🎲</span>
<span class="font-mono text-sm"> <span class="font-mono text-sm">
{p.notation} [{diceStr}]{modStr} ={" "} {p.notation} {" "}
<span class="font-bold text-base">{p.total}</span> <span class="font-bold text-base">{result.total}</span>
</span> </span>
</div> </div>
{result.detail && <p class="text-xs text-gray-400">{result.detail}</p>}
</div> </div>
); );
}, },

View File

@ -3,7 +3,7 @@
* *
* Reactive state for a single session's message stream. Manages MQTT * Reactive state for a single session's message stream. Manages MQTT
* connection, local message log, per-sender sequence tracking, and the * 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 * Session lifecycle (create/list/delete) is handled via MQTT retained
* topics ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session. * topics ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session.
@ -29,7 +29,7 @@ export interface JournalStreamState {
/** Last sequence number per sender */ /** Last sequence number per sender */
senderSeq: Record<string, number>; senderSeq: Record<string, number>;
/** /**
* Paths revealed by article.reveal messages. * Paths revealed by link messages.
* Populated during hydration and live receipt via the type's reducer. * Populated during hydration and live receipt via the type's reducer.
*/ */
revealedPaths: Set<string>; revealedPaths: Set<string>;