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 = () => {
|
||||
const stream = useJournalStream();
|
||||
const [selectedType, setSelectedType] = createSignal<string>("narrative");
|
||||
const [selectedType, setSelectedType] = createSignal<string>("chat");
|
||||
const [formData, setFormData] = createSignal<Record<string, unknown>>({});
|
||||
const [error, setError] = createSignal<string | null>(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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export interface StreamMessage<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;
|
||||
/** Human-readable label shown in the compose panel dropdown */
|
||||
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";
|
||||
|
|
@ -12,11 +15,11 @@ const schema = z.object({
|
|||
.max(2000, "Message too long"),
|
||||
});
|
||||
|
||||
export type NarrativePayload = z.infer<typeof schema>;
|
||||
export type ChatPayload = z.infer<typeof schema>;
|
||||
|
||||
registerMessageType<NarrativePayload>({
|
||||
type: "narrative",
|
||||
label: "Narrative",
|
||||
registerMessageType<ChatPayload>({
|
||||
type: "chat",
|
||||
label: "Chat",
|
||||
emitters: ["gm", "player"],
|
||||
schema,
|
||||
defaultPayload: () => ({ text: "" }),
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<typeof schema>;
|
||||
export type LinkPayload = z.infer<typeof schema>;
|
||||
|
||||
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<ArticleRevealPayload> = (p) => {
|
||||
const RevealLink: Component<LinkPayload> = (p) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const target = () => {
|
||||
|
|
@ -71,9 +73,9 @@ const RevealLink: Component<ArticleRevealPayload> = (p) => {
|
|||
);
|
||||
};
|
||||
|
||||
registerMessageType<ArticleRevealPayload>({
|
||||
type: "article.reveal",
|
||||
label: "Reveal Article",
|
||||
registerMessageType<LinkPayload>({
|
||||
type: "link",
|
||||
label: "Link Article",
|
||||
emitters: ["gm"],
|
||||
schema,
|
||||
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 { 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<typeof rollRequestSchema>;
|
||||
export type RollPayload = z.infer<typeof schema>;
|
||||
|
||||
registerMessageType<RollRequestPayload>({
|
||||
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<RollPayload>({
|
||||
type: "roll",
|
||||
label: "Roll Dice",
|
||||
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(),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RollResultPayload = z.infer<typeof rollResultSchema>;
|
||||
|
||||
registerMessageType<RollResultPayload>({
|
||||
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 (
|
||||
<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>
|
||||
{p.notation} →{" "}
|
||||
<span class="font-bold text-base">{result.total}</span>
|
||||
</span>
|
||||
</div>
|
||||
{result.detail && <p class="text-xs text-gray-400">{result.detail}</p>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
/**
|
||||
* Paths revealed by article.reveal messages.
|
||||
* Paths revealed by link messages.
|
||||
* Populated during hydration and live receipt via the type's reducer.
|
||||
*/
|
||||
revealedPaths: Set<string>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue