2026-07-12 14:26:32 +08:00
|
|
|
/**
|
|
|
|
|
* 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";
|
2026-07-12 14:38:06 +08:00
|
|
|
import { createSignal } from "solid-js";
|
2026-07-12 14:26:32 +08:00
|
|
|
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 };
|
|
|
|
|
|
2026-07-12 14:38:06 +08:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
|
2026-07-12 14:26:32 +08:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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 });
|
2026-07-12 14:38:06 +08:00
|
|
|
return finish(unwrap(result));
|
2026-07-12 14:26:32 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const prefixed = raw.startsWith("/") ? raw : "/" + raw;
|
|
|
|
|
const parsed = parseInput(prefixed);
|
2026-07-12 14:38:06 +08:00
|
|
|
if (parsed.error) return finish({ ok: false, error: parsed.error });
|
2026-07-12 14:26:32 +08:00
|
|
|
|
|
|
|
|
// Players: chat + stat commands only
|
|
|
|
|
if (ctx.role === "player") {
|
|
|
|
|
if (parsed.type === "chat") {
|
|
|
|
|
const result = sendMessage("chat", { text: raw });
|
2026-07-12 14:38:06 +08:00
|
|
|
return finish(unwrap(result));
|
2026-07-12 14:26:32 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (parsed.type === "stat") {
|
2026-07-12 14:38:06 +08:00
|
|
|
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
|
2026-07-12 14:26:32 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 14:38:06 +08:00
|
|
|
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /stat 命令" });
|
2026-07-12 14:26:32 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GM: all commands
|
|
|
|
|
if (parsed.type === "roll") {
|
|
|
|
|
const p = resolveRollPayload(
|
|
|
|
|
parsed.payload as { notation: string; label?: string },
|
|
|
|
|
);
|
|
|
|
|
const result = sendMessage("roll", p);
|
2026-07-12 14:38:06 +08:00
|
|
|
return finish(unwrap(result));
|
2026-07-12 14:26:32 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-07-12 14:38:06 +08:00
|
|
|
return finish(unwrap(result));
|
2026-07-12 14:26:32 +08:00
|
|
|
} catch (e) {
|
2026-07-12 14:38:06 +08:00
|
|
|
return finish({
|
2026-07-12 14:26:32 +08:00
|
|
|
ok: false,
|
|
|
|
|
error: e instanceof Error ? e.message : "种子表掷骰失败",
|
2026-07-12 14:38:06 +08:00
|
|
|
});
|
2026-07-12 14:26:32 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (parsed.type === "stat") {
|
2026-07-12 14:38:06 +08:00
|
|
|
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
|
2026-07-12 14:26:32 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = sendMessage(parsed.type, parsed.payload);
|
2026-07-12 14:38:06 +08:00
|
|
|
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;
|
2026-07-12 14:26:32 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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 };
|
|
|
|
|
}
|