refactor: extract command dispatch logic to shared module

Move command parsing and dispatching logic from `CommandLinkManager`
and `JournalInput` into a new `command-dispatcher.ts` module. This
unifies how commands are handled whether they are typed manually or
triggered via `:cmd[]` directive clicks.
This commit is contained in:
hypercross 2026-07-12 14:26:32 +08:00
parent d690d5922e
commit ffbfa65716
4 changed files with 253 additions and 291 deletions

View File

@ -1,41 +1,20 @@
/**
* CommandLinkManager mounts as a child of <Article> 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>(
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<string, unknown>);
} 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<string, unknown>) {
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;
};

View File

@ -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<string, unknown>) {
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<R>(
function unwrapSendResult<R>(
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 ----

View File

@ -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<string, string>;
/** 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<DispatchResult> {
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<string, unknown>, 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<string, unknown>, ctx);
}
const result = sendMessage(parsed.type, parsed.payload);
return unwrap(result);
}
// ---------------------------------------------------------------------------
// Stat dispatch
// ---------------------------------------------------------------------------
function dispatchStat(
payload: Record<string, unknown>,
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>(
r: { success: true; msg: R } | { success: false; error: string },
): DispatchResult {
return r.success ? { ok: true } : { ok: false, error: r.error };
}

View File

@ -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";