/** * Command dispatcher — shared logic for parsing & dispatching commands * from text input or :cmd[] directive spans. * * Used by both JournalInput (manual typing) and CommandLinkManager (click). */ import { sendMessage } from "../stores/journalStream"; import { createSignal } from "solid-js"; import { parseInput } from "./command-parser"; import { resolveRollPayload } from "./types/roll"; import { resolveSparkPayload } from "./types/spark"; import { evaluateExpression, expressionIsTag } from "./variable-expression"; import { computeCascade } from "./var-reactivity"; import type { VarDeclaration, TagModifier } from "./declare-parser"; // --------------------------------------------------------------------------- // Result // --------------------------------------------------------------------------- export type DispatchResult = | { ok: true } | { ok: false; error: string }; /** * Shared signal for dispatch errors from any source (typed or cmd-link clicks). * Components that show errors (JournalInput) read from here; callers that * want errors surfaced (CommandLinkManager) write to it. */ export const [dispatchError, setDispatchError] = createSignal(null); // --------------------------------------------------------------------------- // Main dispatch // --------------------------------------------------------------------------- export interface DispatchContext { /** The role of the current user */ role: "gm" | "player" | "observer"; /** The user's name */ myName: string; /** The raw text to dispatch (with or without leading `/`) */ command: string; /** Spark table lookup data (from completions) */ sparkTables: { slug: string; csvPath?: string; remix?: boolean }[]; /** Current runtime variable values */ variables: Record; /** Variable declarations (from role=declare blocks) */ declarations: VarDeclaration[]; /** Tag modifiers (from role=declare blocks) */ tagModifiers: TagModifier[]; } /** * Dispatch a command string. Works for both typed input and * :cmd[] directive clicks. */ export async function dispatchCommand( ctx: DispatchContext, ): Promise { const raw = ctx.command.trim(); if (!raw) return { ok: false, error: "Empty command" }; // Observers can only send chat if (ctx.role === "observer") { const result = sendMessage("chat", { text: raw }); return finish(unwrap(result)); } const prefixed = raw.startsWith("/") ? raw : "/" + raw; const parsed = parseInput(prefixed); if (parsed.error) return finish({ ok: false, error: parsed.error }); // Players: chat + set commands only if (ctx.role === "player") { if (parsed.type === "chat") { const result = sendMessage("chat", { text: raw }); return finish(unwrap(result)); } if (parsed.type === "set" || parsed.type === "rolltag") { return finish(dispatchSet(parsed.payload as Record, ctx)); } return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" }); } // GM: all commands if (parsed.type === "roll") { const p = resolveRollPayload( parsed.payload as { notation: string; label?: string }, ); const result = sendMessage("roll", p); return finish(unwrap(result)); } if (parsed.type === "spark") { try { const key = (parsed.payload as { key: string }).key; const match = ctx.sparkTables.find((s) => s.slug === key); const csvPath = match?.csvPath ?? ""; const remix = match?.remix ?? false; const p = await resolveSparkPayload({ key, csvPath, remix }); const result = sendMessage("spark", p); return finish(unwrap(result)); } catch (e) { return finish({ ok: false, error: e instanceof Error ? e.message : "种子表掷骰失败", }); } } if (parsed.type === "set" || parsed.type === "rolltag") { return finish(dispatchSet(parsed.payload as Record, ctx)); } const result = sendMessage(parsed.type, parsed.payload); return finish(unwrap(result)); } /** Update the shared error signal and return the result (pass-through). */ function finish(r: DispatchResult): DispatchResult { setDispatchError(r.ok ? null : r.error); return r; } // --------------------------------------------------------------------------- // Set dispatch // --------------------------------------------------------------------------- function dispatchSet( payload: Record, ctx: DispatchContext, ): DispatchResult { const p = payload as { key?: string; expr?: string; tags?: string[] }; if (!p.key) { return { ok: false, error: "缺少变量名" }; } const key = p.key; const oldValue = ctx.variables[key] ?? undefined; let newValue: string; try { if (p.tags && p.tags.length > 0) { // Rolltag: pick random tag const idx = Math.floor(Math.random() * p.tags.length); newValue = p.tags[idx]; } else if (p.expr && expressionIsTag(p.expr)) { // Bare tag value newValue = p.expr.trim(); } else if (p.expr) { // Numeric expression — evaluate const result = evaluateExpression(p.expr, { lookup: (name: string) => { const k = "$" + name; return ctx.variables[k] ?? undefined; }, }); newValue = String(result.value); } else { return { ok: false, error: "缺少表达式" }; } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : "表达式求值失败", }; } // Send the direct set const r1 = sendMessage("var", { action: "set", key, value: newValue }); const u1 = unwrap(r1); if (!u1.ok) return u1; // Build working variable store for cascade const workingVars = { ...ctx.variables, [key]: newValue }; // Compute cascade (tag activation/deactivation + declaration re-eval) try { const cascade = computeCascade(key, oldValue, workingVars); for (const change of cascade) { sendMessage("var", { action: "set", key: change.key, value: change.value }); } } catch (e) { // Cascade errors are non-fatal — the direct set already succeeded console.warn("[dispatch] cascade error:", e); } return { ok: true }; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Unwrap a sendMessage result into DispatchResult. */ function unwrap( r: { success: true; msg: R } | { success: false; error: string }, ): DispatchResult { return r.success ? { ok: true } : { ok: false, error: r.error }; }