/** * 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, exprValueToString } from "./variable-expression"; import { computeCascade, getCombined, resolveVariable, setBase } from "./var-reactivity"; import { ensureReactivity } from "./completions"; // Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand. const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/; function normalizeTagMap(expr: string): string { const t = expr.trim(); if (BARE_TAG_PATTERN.test(t)) return t + ":1"; return t; } // --------------------------------------------------------------------------- // 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; docPath?: string; remix?: boolean; }[]; /** Current runtime variable values */ variables: Record; } /** * 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" }; // Make sure the engine reflects the currently loaded declarations; this // re-initializes only when they changed since the last dispatch. ensureReactivity(); // 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 arg = (parsed.payload as { arg: string }).arg; // Try spark table first: if arg matches a known spark table slug, roll it const match = ctx.sparkTables.find((s) => s.slug === arg); if (match) { try { const csvPath = match.csvPath ?? ""; const docPath = match.docPath; const remix = match.remix ?? false; const p = await resolveSparkPayload({ key: arg, csvPath, docPath, remix, }); const result = sendMessage("spark", p); return finish(unwrap(result)); } catch (e) { return finish({ ok: false, error: e instanceof Error ? e.message : "种子表掷骰失败", }); } } // Variable expression (e.g. "2d6 + $mod" or "$hp / 2") if (arg.includes("$")) { try { const ev = evaluateExpression(arg, { // Strict: referencing an undefined variable is an error, not 0. lookup: (name) => resolveVariable(name, ctx.variables), }); if (ev.value.kind !== "number") { return finish({ ok: false, error: "Roll expression must evaluate to a number" }); } const payload = { notation: arg, label: arg, result: { total: ev.value.value, detail: "", plainDetail: "", pools: [] as { rolls: number[]; subtotal: number }[], }, }; const result = sendMessage("roll", payload); return finish(unwrap(result)); } catch (e) { return finish({ ok: false, error: e instanceof Error ? e.message : "表达式求值失败", }); } } // Otherwise, treat as a dice expression const p = resolveRollPayload({ notation: arg, label: arg }); const result = sendMessage("roll", p); return finish(unwrap(result)); } 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; // Use getCombined with stream fallback for old value so tag transitions // are detected correctly even when the variable has active mods. const oldValue = getCombined(key, ctx.variables); let newValue: string; try { if (p.tags && p.tags.length > 0) { // Rolltag: pick random tag, format as tagmap entry const idx = Math.floor(Math.random() * p.tags.length); newValue = normalizeTagMap(p.tags[idx]); } else if (p.expr) { // Evaluate expression — handles both numeric and tagmap values const result = evaluateExpression(p.expr, { // Strict: referencing an undefined variable is an error, not 0. lookup: (name) => resolveVariable(name, ctx.variables), }); newValue = exprValueToString(result.value); } else { return { ok: false, error: "缺少表达式" }; } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : "表达式求值失败", }; } // Update base value so getCombined returns the correct new value // during cascade computation setBase(key, newValue); // Build working variable store for cascade (includes the new value) 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, base: change.base, }); } } catch (e) { // Cascade errors are non-fatal — the direct set already succeeded console.warn("[dispatch] cascade error:", e); } // Send the direct set last so cascade messages arrive first. // Publish the combined value for display plus the raw base so hydrated // clients can rebuild the engine exactly (see rebuildReactivityFromStore). const combinedValue = getCombined(key, ctx.variables); const r1 = sendMessage("var", { action: "set", key, value: combinedValue, base: newValue, }); const u1 = unwrap(r1); if (!u1.ok) return u1; 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 }; }