EvalContext.lookup now receives the full $var name, matching getCombined, setBase, and getMods, and parseFactor throws "Unknown variable" when the lookup returns undefined. /set and /roll use the new resolveVariable() so a typo errors instead of silently evaluating to 0. Declarations and tag modifiers stay lenient (unknown -> 0): the documented $hp = $con*5+$mod_hp pattern depends on external/undeclared variables defaulting to 0.
271 lines
8.6 KiB
TypeScript
271 lines
8.6 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, exprValueToString } from "./variable-expression";
|
|
import { computeCascade, getCombined, resolveVariable, setBase } from "./var-reactivity";
|
|
import { ensureReactivity } from "./completions";
|
|
|
|
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
|
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
|
|
function normalizeTagMap(expr: string): string {
|
|
const t = expr.trim();
|
|
if (BARE_TAG_PATTERN.test(t)) return t + ":1";
|
|
return t;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
docPath?: string;
|
|
remix?: boolean;
|
|
}[];
|
|
/** Current runtime variable values */
|
|
variables: Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* 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" };
|
|
|
|
// Make sure the engine reflects the currently loaded declarations; this
|
|
// re-initializes only when they changed since the last dispatch.
|
|
ensureReactivity();
|
|
|
|
// 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 arg = (parsed.payload as { arg: string }).arg;
|
|
|
|
// Try spark table first: if arg matches a known spark table slug, roll it
|
|
const match = ctx.sparkTables.find((s) => s.slug === arg);
|
|
if (match) {
|
|
try {
|
|
const csvPath = match.csvPath ?? "";
|
|
const docPath = match.docPath;
|
|
const remix = match.remix ?? false;
|
|
const p = await resolveSparkPayload({
|
|
key: arg,
|
|
csvPath,
|
|
docPath,
|
|
remix,
|
|
});
|
|
const result = sendMessage("spark", p);
|
|
return finish(unwrap(result));
|
|
} catch (e) {
|
|
return finish({
|
|
ok: false,
|
|
error: e instanceof Error ? e.message : "种子表掷骰失败",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Variable expression (e.g. "2d6 + $mod" or "$hp / 2")
|
|
if (arg.includes("$")) {
|
|
try {
|
|
const ev = evaluateExpression(arg, {
|
|
// Strict: referencing an undefined variable is an error, not 0.
|
|
lookup: (name) => resolveVariable(name, ctx.variables),
|
|
});
|
|
if (ev.value.kind !== "number") {
|
|
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
|
|
}
|
|
const payload = {
|
|
notation: arg,
|
|
label: arg,
|
|
result: {
|
|
total: ev.value.value,
|
|
detail: "",
|
|
plainDetail: "",
|
|
pools: [] as { rolls: number[]; subtotal: number }[],
|
|
},
|
|
};
|
|
const result = sendMessage("roll", payload);
|
|
return finish(unwrap(result));
|
|
} catch (e) {
|
|
return finish({
|
|
ok: false,
|
|
error: e instanceof Error ? e.message : "表达式求值失败",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Otherwise, treat as a dice expression
|
|
const p = resolveRollPayload({ notation: arg, label: arg });
|
|
const result = sendMessage("roll", p);
|
|
return finish(unwrap(result));
|
|
}
|
|
|
|
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;
|
|
// Use getCombined with stream fallback for old value so tag transitions
|
|
// are detected correctly even when the variable has active mods.
|
|
const oldValue = getCombined(key, ctx.variables);
|
|
let newValue: string;
|
|
|
|
try {
|
|
if (p.tags && p.tags.length > 0) {
|
|
// Rolltag: pick random tag, format as tagmap entry
|
|
const idx = Math.floor(Math.random() * p.tags.length);
|
|
newValue = normalizeTagMap(p.tags[idx]);
|
|
} else if (p.expr) {
|
|
// Evaluate expression — handles both numeric and tagmap values
|
|
const result = evaluateExpression(p.expr, {
|
|
// Strict: referencing an undefined variable is an error, not 0.
|
|
lookup: (name) => resolveVariable(name, ctx.variables),
|
|
});
|
|
newValue = exprValueToString(result.value);
|
|
} else {
|
|
return { ok: false, error: "缺少表达式" };
|
|
}
|
|
} catch (e) {
|
|
return {
|
|
ok: false,
|
|
error: e instanceof Error ? e.message : "表达式求值失败",
|
|
};
|
|
}
|
|
|
|
// Update base value so getCombined returns the correct new value
|
|
// during cascade computation
|
|
setBase(key, newValue);
|
|
|
|
// Build working variable store for cascade (includes the new value)
|
|
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,
|
|
base: change.base,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// Cascade errors are non-fatal — the direct set already succeeded
|
|
console.warn("[dispatch] cascade error:", e);
|
|
}
|
|
|
|
// Send the direct set last so cascade messages arrive first.
|
|
// Publish the combined value for display plus the raw base so hydrated
|
|
// clients can rebuild the engine exactly (see rebuildReactivityFromStore).
|
|
const combinedValue = getCombined(key, ctx.variables);
|
|
const r1 = sendMessage("var", {
|
|
action: "set",
|
|
key,
|
|
value: combinedValue,
|
|
base: newValue,
|
|
});
|
|
const u1 = unwrap(r1);
|
|
if (!u1.ok) return u1;
|
|
|
|
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 };
|
|
}
|