/** * 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 { parseInput } from "./command-parser"; import { resolveRollPayload } from "./types/roll"; import { resolveSparkPayload } from "./types/spark"; import { resolveStatRoll, resolveTemplateSet, canModifyStat, fullKey, findStatDef, } from "./stat-helpers"; import type { StatDef, StatTemplate } from "./completions"; // --------------------------------------------------------------------------- // Result // --------------------------------------------------------------------------- export type DispatchResult = | { ok: true } | { ok: false; error: string }; // --------------------------------------------------------------------------- // 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 stat values */ statValues: Record; /** Stat definitions */ statDefs: StatDef[]; /** Stat templates */ statTemplates: StatTemplate[]; } /** * 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 unwrap(result); } const prefixed = raw.startsWith("/") ? raw : "/" + raw; const parsed = parseInput(prefixed); if (parsed.error) return { ok: false, error: parsed.error }; // Players: chat + stat commands only if (ctx.role === "player") { if (parsed.type === "chat") { const result = sendMessage("chat", { text: raw }); return unwrap(result); } if (parsed.type === "stat") { return dispatchStat(parsed.payload as Record, ctx); } return { ok: false, error: "玩家只能发送聊天消息或使用 /stat 命令" }; } // GM: all commands if (parsed.type === "roll") { const p = resolveRollPayload( parsed.payload as { notation: string; label?: string }, ); const result = sendMessage("roll", p); return 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 unwrap(result); } catch (e) { return { ok: false, error: e instanceof Error ? e.message : "种子表掷骰失败", }; } } if (parsed.type === "stat") { return dispatchStat(parsed.payload as Record, ctx); } const result = sendMessage(parsed.type, parsed.payload); return unwrap(result); } // --------------------------------------------------------------------------- // Stat dispatch // --------------------------------------------------------------------------- function dispatchStat( payload: Record, ctx: DispatchContext, ): DispatchResult { const p = payload as { action?: string; key?: string; value?: string }; if (p.action === "roll" && p.key) { const resolved = resolveStatRoll( p.key, ctx.statDefs, ctx.statValues, ctx.myName, ctx.statTemplates, ); if (resolved.error) return { ok: false, error: resolved.error }; const result = sendMessage("stat", { action: "set", key: resolved.fullKey, value: resolved.value, }); const r = unwrap(result); if (!r.ok) return r; if (resolved.modifiers) { for (const [mk, mv] of Object.entries(resolved.modifiers)) { sendMessage("stat", { action: "set", key: mk, value: mv }); } } return { ok: true }; } if (!p.action || !p.key) { return { ok: false, error: "无效的 stat 命令" }; } const fk = resolveKey(p.key, ctx.statDefs, ctx.myName); if (!canModifyStat(ctx.role, ctx.myName, fk, ctx.statDefs)) { return { ok: false, error: `无权修改属性: ${p.key}` }; } const result = sendMessage("stat", { action: p.action, key: fk, value: p.value, }); const r = unwrap(result); if (!r.ok) return r; if (p.action === "set" && p.value) { const def = findStatDef(fk, ctx.statDefs, ctx.myName); if (def) { const modifiers = resolveTemplateSet( def, p.value, ctx.statDefs, ctx.statValues, ctx.myName, ctx.statTemplates, ); if (modifiers) { for (const [mk, mv] of Object.entries(modifiers)) { sendMessage("stat", { action: "set", key: mk, value: mv }); } } } } return { ok: true }; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Resolve a bare or full key to the actual runtime key. */ function resolveKey( inputKey: string, statDefs: StatDef[], playerName: string, ): string { if (statDefs.some((d) => fullKey(d, playerName) === inputKey)) return inputKey; const def = statDefs.find((d) => d.key === inputKey); if (def) return fullKey(def, playerName); return inputKey; } /** 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 }; }