diff --git a/src/components/CommandLinkManager.tsx b/src/components/CommandLinkManager.tsx index 67dcd7f..e8d2a92 100644 --- a/src/components/CommandLinkManager.tsx +++ b/src/components/CommandLinkManager.tsx @@ -1,41 +1,20 @@ /** * CommandLinkManager — mounts as a child of
and intercepts - * clicks on :cmd[] directive spans to send them as journal stream - * messages. - * - * Uses capture-phase event delegation on [data-cmd] spans. + * clicks on :cmd[] directive spans to dispatch them via the shared + * command dispatcher. */ import { Component, onCleanup } from "solid-js"; import { useArticleDom } from "./Article"; -import { useJournalStream, sendMessage } from "./stores/journalStream"; +import { useJournalStream } from "./stores/journalStream"; import { useJournalCompletions } from "./journal/completions"; -import { parseInput } from "./journal/command-parser"; -import { resolveRollPayload } from "./journal/types/roll"; -import { resolveSparkPayload } from "./journal/types/spark"; -import { - resolveStatRoll, - resolveTemplateSet, - canModifyStat, - fullKey, - findStatDef, -} from "./journal/stat-helpers"; - -function unwrap( - r: { success: true; msg: R } | { success: false; error: string }, -) { - return r.success - ? ({ ok: true, err: undefined } as const) - : ({ ok: false, err: r.error } as const); -} +import { dispatchCommand } from "./journal/command-dispatcher"; export const CommandLinkManager: Component = () => { const contentDom = useArticleDom(); const stream = useJournalStream(); const comp = useJournalCompletions(); - // ---- Click interceptor (capture phase) ---- - const onClick = (e: MouseEvent) => { const cmdSpan = (e.target as HTMLElement).closest("[data-cmd]"); if (!cmdSpan) return; @@ -46,7 +25,16 @@ export const CommandLinkManager: Component = () => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); - dispatchCommand(command); + + void dispatchCommand({ + role: stream.myRole as "gm" | "player" | "observer", + myName: stream.myName, + command, + sparkTables: comp.data.sparkTables, + statValues: stream.stats, + statDefs: comp.data.stats, + statTemplates: comp.data.statTemplates, + }); }; const dom = contentDom(); @@ -59,110 +47,6 @@ export const CommandLinkManager: Component = () => { if (d) d.removeEventListener("click", onClick, true); }); - // ---- Command dispatch ---- - - function dispatchCommand(command: string) { - // parseInput expects /roll, /spark, etc. — prepend / if missing - const prefixed = command.startsWith("/") ? command : "/" + command; - const parsed = parseInput(prefixed); - if (parsed.error) return; - - if (parsed.type === "roll") { - const p = resolveRollPayload( - parsed.payload as { notation: string; label?: string }, - ); - sendMessage("roll", p); - } else if (parsed.type === "spark") { - void (async () => { - try { - const key = (parsed.payload as { key: string }).key; - const match = comp.data.sparkTables.find((s) => s.slug === key); - const csvPath = match?.csvPath ?? ""; - const remix = match?.remix ?? false; - const p = await resolveSparkPayload({ key, csvPath, remix }); - sendMessage("spark", p); - } catch { - // silently ignore spark table errors from directive clicks - } - })(); - } else if (parsed.type === "stat") { - dispatchStat(parsed.payload as Record); - } else { - sendMessage(parsed.type, parsed.payload); - } - } - - /** Resolve bare key -> full key. */ - function resolveKey( - inputKey: string, - statDefs: typeof comp.data.stats, - 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; - } - - function dispatchStat(payload: Record) { - const p = payload as { action?: string; key?: string; value?: string }; - - if (p.action === "roll" && p.key) { - const resolved = resolveStatRoll( - p.key, - comp.data.stats, - stream.stats, - stream.myName, - comp.data.statTemplates, - ); - if (resolved.error) return; - - const result = sendMessage("stat", { - action: "set", - key: resolved.fullKey, - value: resolved.value, - }); - if (!unwrap(result).ok) return; - - if (resolved.modifiers) { - for (const [mk, mv] of Object.entries(resolved.modifiers)) { - sendMessage("stat", { action: "set", key: mk, value: mv }); - } - } - return; - } - - if (!p.action || !p.key) return; - - const fk = resolveKey(p.key, comp.data.stats, stream.myName); - - if (!canModifyStat(stream.myRole, stream.myName, fk, comp.data.stats)) { - return; - } - - sendMessage("stat", { action: p.action, key: fk, value: p.value }); - - if (p.action === "set" && p.value) { - const def = findStatDef(fk, comp.data.stats, stream.myName); - if (def) { - const modifiers = resolveTemplateSet( - def, - p.value, - comp.data.stats, - stream.stats, - stream.myName, - comp.data.statTemplates, - ); - if (modifiers) { - for (const [mk, mv] of Object.entries(modifiers)) { - sendMessage("stat", { action: "set", key: mk, value: mv }); - } - } - } - } - } - return null; }; diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index ccbfc85..e5f7867 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -7,26 +7,20 @@ * - `/link path#section` → "link" type * - `/stat set key=value` → "stat" type * - `/` alone opens completions dropdown + * + * Delegates command dispatch to the shared command-dispatcher module. */ import { Component, createSignal, createEffect, onMount, Show } from "solid-js"; import { sendMessage, useJournalStream } from "../stores/journalStream"; import { actionPrefill, setActionPrefill } from "../stores/reveal"; import { useJournalCompletions, ensureCompletions } from "./completions"; -import { resolveRollPayload } from "./types/roll"; -import { resolveSparkPayload } from "./types/spark"; -import { - resolveStatRoll, - resolveTemplateSet, - canModifyStat, - fullKey, - findStatDef, -} from "./stat-helpers"; import { parseInput } from "./command-parser"; import type { CompletionItem } from "./command-parser"; import { buildCompletions } from "./command-completions"; import { CompletionsDropdown } from "./CompletionsDropdown"; import { ErrorPopup } from "./ErrorPopup"; +import { dispatchCommand } from "./command-dispatcher"; // ---- Component ---- @@ -79,8 +73,8 @@ export const JournalInput: Component = () => { // Observers: everything is plain chat if (isObserver()) { const result = sendMessage("chat", { text: raw }); - const r = unwrap(result); - finish(r.ok, r.err); + const r = unwrapSendResult(result); + finish(r.ok, r.error); return; } @@ -96,164 +90,31 @@ export const JournalInput: Component = () => { if (isPlayer()) { if (parsed.type === "chat") { const result = sendMessage("chat", { text: raw }); - const r = unwrap(result); - finish(r.ok, r.err); + const r = unwrapSendResult(result); + finish(r.ok, r.error); return; } - if (parsed.type === "stat") { - handleStat(parsed.payload); + if (parsed.type !== "stat") { + setError("玩家只能发送聊天消息或使用 /stat 命令"); return; } - - setError("玩家只能发送聊天消息或使用 /stat 命令"); - return; } - // GM: all commands + // GM: all commands, Player: stat commands setSending(true); - if (parsed.type === "roll") { - const p = resolveRollPayload( - parsed.payload as { notation: string; label?: string }, - ); - const result = sendMessage("roll", p); - const r = unwrap(result); - finish(r.ok, r.err); - return; - } - - if (parsed.type === "spark") { - try { - const key = (parsed.payload as { key: string }).key; - const match = comp.data.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); - const r = unwrap(result); - finish(r.ok, r.err); - } catch (e) { - setError(e instanceof Error ? e.message : "种子表掷骰失败"); - setSending(false); - } - return; - } - - if (parsed.type === "stat") { - handleStat(parsed.payload); - setSending(false); - return; - } - - const result = sendMessage(parsed.type, parsed.payload); - const r = unwrap(result); - finish(r.ok, r.err); - } - - /** Handle a /stat command payload (set/del/roll) */ - function handleStat(payload: Record) { - const p = payload as { action?: string; key?: string; value?: string }; - - if (p.action === "roll" && p.key) { - const resolved = resolveStatRoll( - p.key, - comp.data.stats, - stream.stats, - stream.myName, - comp.data.statTemplates, - ); - if (resolved.error) { - setError(resolved.error); - } else { - // Send the primary stat value - const result = sendMessage("stat", { - action: "set", - key: resolved.fullKey, - value: resolved.value, - }); - const r = unwrap(result); - if (!r.ok) { - finish(false, r.err); - return; - } - - // Send any modifier overrides from template - if (resolved.modifiers) { - for (const [mk, mv] of Object.entries(resolved.modifiers)) { - sendMessage("stat", { - action: "set", - key: mk, - value: mv, - }); - } - } - - finish(true); - } - return; - } - - if (!p.action || !p.key) { - setError("无效的 stat 命令"); - return; - } - - // Resolve bare key to full key for set/del - const fk = resolveKey(p.key, comp.data.stats, stream.myName); - - if (!canModifyStat(stream.myRole, stream.myName, fk, comp.data.stats)) { - setError(`无权修改属性: ${p.key}`); - return; - } - - const result = sendMessage("stat", { - action: p.action, - key: fk, - value: p.value, + const result = await dispatchCommand({ + role: stream.myRole as "gm" | "player" | "observer", + myName: stream.myName, + command: raw, + sparkTables: comp.data.sparkTables, + statValues: stream.stats, + statDefs: comp.data.stats, + statTemplates: comp.data.statTemplates, }); - const r = unwrap(result); - if (!r.ok) { - finish(false, r.err); - return; - } - // If setting a template-type stat, apply its modifier overrides - if (p.action === "set" && p.value) { - const def = findStatDef(fk, comp.data.stats, stream.myName); - if (def) { - const modifiers = resolveTemplateSet( - def, - p.value, - comp.data.stats, - stream.stats, - stream.myName, - comp.data.statTemplates, - ); - if (modifiers) { - for (const [mk, mv] of Object.entries(modifiers)) { - sendMessage("stat", { action: "set", key: mk, value: mv }); - } - } - } - } - - finish(true); - } - - /** Resolve a bare or full key to the actual runtime key. */ - function resolveKey( - inputKey: string, - statDefs: typeof comp.data.stats, - playerName: string, - ): string { - // Exact match - if (statDefs.some((d) => fullKey(d, playerName) === inputKey)) - return inputKey; - // Bare key → full key - const def = statDefs.find((d) => d.key === inputKey); - if (def) return fullKey(def, playerName); - return inputKey; + finish(result.ok, result.ok ? undefined : result.error); } /** Clear text + error on success, or set error on failure. */ @@ -268,12 +129,12 @@ export const JournalInput: Component = () => { } /** Unwrap a sendMessage result into (success, error?) for finish(). */ - function unwrap( + function unwrapSendResult( r: { success: true; msg: R } | { success: false; error: string }, ) { return r.success - ? ({ ok: true, err: undefined } as const) - : ({ ok: false, err: r.error } as const); + ? ({ ok: true, error: undefined } as const) + : ({ ok: false, error: r.error } as const); } // ---- Completions ---- diff --git a/src/components/journal/command-dispatcher.ts b/src/components/journal/command-dispatcher.ts new file mode 100644 index 0000000..25a67d3 --- /dev/null +++ b/src/components/journal/command-dispatcher.ts @@ -0,0 +1,215 @@ +/** + * 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 }; +} diff --git a/src/components/journal/index.ts b/src/components/journal/index.ts index b065ad0..9ab1161 100644 --- a/src/components/journal/index.ts +++ b/src/components/journal/index.ts @@ -49,5 +49,7 @@ export { ComposePanel } from "./ComposePanel"; export { JournalInput } from "./JournalInput"; export { DynamicForm } from "./DynamicForm"; export { useJournalCompletions, invalidateCompletions } from "./completions"; +export { dispatchCommand } from "./command-dispatcher"; +export type { DispatchContext, DispatchResult } from "./command-dispatcher"; export { JournalContext, useJournalContext } from "./JournalContext"; export type { JournalContextValue } from "./JournalContext";