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:
2026-07-12 14:26:32 +08:00
parent d690d5922e
commit ffbfa65716
4 changed files with 253 additions and 291 deletions
+14 -130
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;
};