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:
2026-07-06 18:06:32 +08:00
parent 59236f6dba
commit 0fc61e2f94
8 changed files with 99 additions and 96 deletions
+29 -17
View File
@@ -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<string, unknown>;
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);