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:
parent
59236f6dba
commit
0fc61e2f94
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: "" }),
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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: "/" }),
|
||||||
|
|
@ -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(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type RollRequestPayload = z.infer<typeof rollRequestSchema>;
|
|
||||||
|
|
||||||
registerMessageType<RollRequestPayload>({
|
|
||||||
type: "roll.request",
|
|
||||||
label: "Request Roll",
|
|
||||||
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>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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(),
|
total: z.number(),
|
||||||
requestId: z.string().optional(),
|
detail: z.string(),
|
||||||
|
pools: z.array(
|
||||||
|
z.object({
|
||||||
|
rolls: z.array(z.number()),
|
||||||
|
subtotal: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RollResultPayload = z.infer<typeof rollResultSchema>;
|
export type RollPayload = z.infer<typeof schema>;
|
||||||
|
|
||||||
registerMessageType<RollResultPayload>({
|
/** Compute the dice result on the GM's machine before send */
|
||||||
type: "roll.result",
|
export function resolveRollPayload(raw: {
|
||||||
label: "Roll Result",
|
notation: string;
|
||||||
emitters: ["player"],
|
label?: string;
|
||||||
schema: rollResultSchema,
|
}): 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"],
|
||||||
|
schema,
|
||||||
|
defaultPayload: () => ({
|
||||||
|
notation: "1d20",
|
||||||
|
result: { total: 0, detail: "", pools: [] },
|
||||||
|
}),
|
||||||
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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue