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:
parent
d690d5922e
commit
ffbfa65716
|
|
@ -1,41 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* CommandLinkManager — mounts as a child of <Article> and intercepts
|
* CommandLinkManager — mounts as a child of <Article> and intercepts
|
||||||
* clicks on :cmd[] directive spans to send them as journal stream
|
* clicks on :cmd[] directive spans to dispatch them via the shared
|
||||||
* messages.
|
* command dispatcher.
|
||||||
*
|
|
||||||
* Uses capture-phase event delegation on [data-cmd] spans.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, onCleanup } from "solid-js";
|
import { Component, onCleanup } from "solid-js";
|
||||||
import { useArticleDom } from "./Article";
|
import { useArticleDom } from "./Article";
|
||||||
import { useJournalStream, sendMessage } from "./stores/journalStream";
|
import { useJournalStream } from "./stores/journalStream";
|
||||||
import { useJournalCompletions } from "./journal/completions";
|
import { useJournalCompletions } from "./journal/completions";
|
||||||
import { parseInput } from "./journal/command-parser";
|
import { dispatchCommand } from "./journal/command-dispatcher";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CommandLinkManager: Component = () => {
|
export const CommandLinkManager: Component = () => {
|
||||||
const contentDom = useArticleDom();
|
const contentDom = useArticleDom();
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const comp = useJournalCompletions();
|
const comp = useJournalCompletions();
|
||||||
|
|
||||||
// ---- Click interceptor (capture phase) ----
|
|
||||||
|
|
||||||
const onClick = (e: MouseEvent) => {
|
const onClick = (e: MouseEvent) => {
|
||||||
const cmdSpan = (e.target as HTMLElement).closest("[data-cmd]");
|
const cmdSpan = (e.target as HTMLElement).closest("[data-cmd]");
|
||||||
if (!cmdSpan) return;
|
if (!cmdSpan) return;
|
||||||
|
|
@ -46,7 +25,16 @@ export const CommandLinkManager: Component = () => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
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();
|
const dom = contentDom();
|
||||||
|
|
@ -59,110 +47,6 @@ export const CommandLinkManager: Component = () => {
|
||||||
if (d) d.removeEventListener("click", onClick, true);
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,26 +7,20 @@
|
||||||
* - `/link path#section` → "link" type
|
* - `/link path#section` → "link" type
|
||||||
* - `/stat set key=value` → "stat" type
|
* - `/stat set key=value` → "stat" type
|
||||||
* - `/` alone opens completions dropdown
|
* - `/` alone opens completions dropdown
|
||||||
|
*
|
||||||
|
* Delegates command dispatch to the shared command-dispatcher module.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, createSignal, createEffect, onMount, Show } from "solid-js";
|
import { Component, createSignal, createEffect, onMount, Show } from "solid-js";
|
||||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||||
import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
||||||
import { useJournalCompletions, ensureCompletions } from "./completions";
|
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 { parseInput } from "./command-parser";
|
||||||
import type { CompletionItem } from "./command-parser";
|
import type { CompletionItem } from "./command-parser";
|
||||||
import { buildCompletions } from "./command-completions";
|
import { buildCompletions } from "./command-completions";
|
||||||
import { CompletionsDropdown } from "./CompletionsDropdown";
|
import { CompletionsDropdown } from "./CompletionsDropdown";
|
||||||
import { ErrorPopup } from "./ErrorPopup";
|
import { ErrorPopup } from "./ErrorPopup";
|
||||||
|
import { dispatchCommand } from "./command-dispatcher";
|
||||||
|
|
||||||
// ---- Component ----
|
// ---- Component ----
|
||||||
|
|
||||||
|
|
@ -79,8 +73,8 @@ export const JournalInput: Component = () => {
|
||||||
// Observers: everything is plain chat
|
// Observers: everything is plain chat
|
||||||
if (isObserver()) {
|
if (isObserver()) {
|
||||||
const result = sendMessage("chat", { text: raw });
|
const result = sendMessage("chat", { text: raw });
|
||||||
const r = unwrap(result);
|
const r = unwrapSendResult(result);
|
||||||
finish(r.ok, r.err);
|
finish(r.ok, r.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,164 +90,31 @@ export const JournalInput: Component = () => {
|
||||||
if (isPlayer()) {
|
if (isPlayer()) {
|
||||||
if (parsed.type === "chat") {
|
if (parsed.type === "chat") {
|
||||||
const result = sendMessage("chat", { text: raw });
|
const result = sendMessage("chat", { text: raw });
|
||||||
const r = unwrap(result);
|
const r = unwrapSendResult(result);
|
||||||
finish(r.ok, r.err);
|
finish(r.ok, r.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type === "stat") {
|
if (parsed.type !== "stat") {
|
||||||
handleStat(parsed.payload);
|
setError("玩家只能发送聊天消息或使用 /stat 命令");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError("玩家只能发送聊天消息或使用 /stat 命令");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GM: all commands
|
// GM: all commands, Player: stat commands
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
if (parsed.type === "roll") {
|
const result = await dispatchCommand({
|
||||||
const p = resolveRollPayload(
|
role: stream.myRole as "gm" | "player" | "observer",
|
||||||
parsed.payload as { notation: string; label?: string },
|
myName: stream.myName,
|
||||||
);
|
command: raw,
|
||||||
const result = sendMessage("roll", p);
|
sparkTables: comp.data.sparkTables,
|
||||||
const r = unwrap(result);
|
statValues: stream.stats,
|
||||||
finish(r.ok, r.err);
|
statDefs: comp.data.stats,
|
||||||
return;
|
statTemplates: comp.data.statTemplates,
|
||||||
}
|
|
||||||
|
|
||||||
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 r = unwrap(result);
|
|
||||||
if (!r.ok) {
|
|
||||||
finish(false, r.err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If setting a template-type stat, apply its modifier overrides
|
finish(result.ok, result.ok ? undefined : result.error);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear text + error on success, or set error on failure. */
|
/** 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(). */
|
/** Unwrap a sendMessage result into (success, error?) for finish(). */
|
||||||
function unwrap<R>(
|
function unwrapSendResult<R>(
|
||||||
r: { success: true; msg: R } | { success: false; error: string },
|
r: { success: true; msg: R } | { success: false; error: string },
|
||||||
) {
|
) {
|
||||||
return r.success
|
return r.success
|
||||||
? ({ ok: true, err: undefined } as const)
|
? ({ ok: true, error: undefined } as const)
|
||||||
: ({ ok: false, err: r.error } as const);
|
: ({ ok: false, error: r.error } as const);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Completions ----
|
// ---- Completions ----
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -49,5 +49,7 @@ export { ComposePanel } from "./ComposePanel";
|
||||||
export { JournalInput } from "./JournalInput";
|
export { JournalInput } from "./JournalInput";
|
||||||
export { DynamicForm } from "./DynamicForm";
|
export { DynamicForm } from "./DynamicForm";
|
||||||
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
||||||
|
export { dispatchCommand } from "./command-dispatcher";
|
||||||
|
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
|
||||||
export { JournalContext, useJournalContext } from "./JournalContext";
|
export { JournalContext, useJournalContext } from "./JournalContext";
|
||||||
export type { JournalContextValue } from "./JournalContext";
|
export type { JournalContextValue } from "./JournalContext";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue