ttrpg-tools/src/components/journal/command-dispatcher.ts

204 lines
6.5 KiB
TypeScript

/**
* 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 { createSignal } from "solid-js";
import { parseInput } from "./command-parser";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
import { evaluateExpression, expressionIsTag } from "./variable-expression";
import { computeCascade } from "./var-reactivity";
import type { VarDeclaration, TagModifier } from "./declare-parser";
// ---------------------------------------------------------------------------
// Result
// ---------------------------------------------------------------------------
export type DispatchResult =
| { ok: true }
| { ok: false; error: string };
/**
* Shared signal for dispatch errors from any source (typed or cmd-link clicks).
* Components that show errors (JournalInput) read from here; callers that
* want errors surfaced (CommandLinkManager) write to it.
*/
export const [dispatchError, setDispatchError] =
createSignal<string | null>(null);
// ---------------------------------------------------------------------------
// 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 variable values */
variables: Record<string, string>;
/** Variable declarations (from role=declare blocks) */
declarations: VarDeclaration[];
/** Tag modifiers (from role=declare blocks) */
tagModifiers: TagModifier[];
}
/**
* 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 finish(unwrap(result));
}
const prefixed = raw.startsWith("/") ? raw : "/" + raw;
const parsed = parseInput(prefixed);
if (parsed.error) return finish({ ok: false, error: parsed.error });
// Players: chat + set commands only
if (ctx.role === "player") {
if (parsed.type === "chat") {
const result = sendMessage("chat", { text: raw });
return finish(unwrap(result));
}
if (parsed.type === "set" || parsed.type === "rolltag") {
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
}
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" });
}
// GM: all commands
if (parsed.type === "roll") {
const p = resolveRollPayload(
parsed.payload as { notation: string; label?: string },
);
const result = sendMessage("roll", p);
return finish(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 finish(unwrap(result));
} catch (e) {
return finish({
ok: false,
error: e instanceof Error ? e.message : "种子表掷骰失败",
});
}
}
if (parsed.type === "set" || parsed.type === "rolltag") {
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
}
const result = sendMessage(parsed.type, parsed.payload);
return finish(unwrap(result));
}
/** Update the shared error signal and return the result (pass-through). */
function finish(r: DispatchResult): DispatchResult {
setDispatchError(r.ok ? null : r.error);
return r;
}
// ---------------------------------------------------------------------------
// Set dispatch
// ---------------------------------------------------------------------------
function dispatchSet(
payload: Record<string, unknown>,
ctx: DispatchContext,
): DispatchResult {
const p = payload as { key?: string; expr?: string; tags?: string[] };
if (!p.key) {
return { ok: false, error: "缺少变量名" };
}
const key = p.key;
const oldValue = ctx.variables[key] ?? undefined;
let newValue: string;
try {
if (p.tags && p.tags.length > 0) {
// Rolltag: pick random tag
const idx = Math.floor(Math.random() * p.tags.length);
newValue = p.tags[idx];
} else if (p.expr && expressionIsTag(p.expr)) {
// Bare tag value
newValue = p.expr.trim();
} else if (p.expr) {
// Numeric expression — evaluate
const result = evaluateExpression(p.expr, {
lookup: (name: string) => {
const k = "$" + name;
return ctx.variables[k] ?? undefined;
},
});
newValue = String(result.value);
} else {
return { ok: false, error: "缺少表达式" };
}
} catch (e) {
return {
ok: false,
error: e instanceof Error ? e.message : "表达式求值失败",
};
}
// Send the direct set
const r1 = sendMessage("var", { action: "set", key, value: newValue });
const u1 = unwrap(r1);
if (!u1.ok) return u1;
// Build working variable store for cascade
const workingVars = { ...ctx.variables, [key]: newValue };
// Compute cascade (tag activation/deactivation + declaration re-eval)
try {
const cascade = computeCascade(key, oldValue, workingVars);
for (const change of cascade) {
sendMessage("var", { action: "set", key: change.key, value: change.value });
}
} catch (e) {
// Cascade errors are non-fatal — the direct set already succeeded
console.warn("[dispatch] cascade error:", e);
}
return { ok: true };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** 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 };
}