refactor: replace stat system with variable system

Replaces the specialized `stat` system with a more general-purpose
variable system. This includes:

- Renaming `ReactiveStatManager` to `ReactiveVariableManager`
- Changing template syntax from `${key}` to `{{$key}}`
- Replacing `StatsView` with `VariableView` to show declared, plain,
  and tag-typed variables
- Updating command `/stat` to `/set`
- Refactoring the reactivity engine to support variable declarations
  and tag modifiers via `csv role=declare` blocks
This commit is contained in:
2026-07-12 17:29:03 +08:00
parent 2983aa4440
commit 894f1735e5
18 changed files with 464 additions and 1063 deletions
+63 -89
View File
@@ -10,14 +10,9 @@ import { createSignal } from "solid-js";
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";
import { evaluateExpression, expressionIsTag } from "./variable-expression";
import { computeCascade } from "./var-reactivity";
import type { VarDeclaration, TagModifier } from "./declare-parser";
// ---------------------------------------------------------------------------
// Result
@@ -48,12 +43,12 @@ export interface DispatchContext {
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[];
/** Current runtime variable values */
variables: Record<string, string>;
/** Variable declarations (from role=declare blocks) */
declarations: VarDeclaration[];
/** Tag modifiers (from role=declare blocks) */
tagModifiers: TagModifier[];
}
/**
@@ -76,18 +71,18 @@ export async function dispatchCommand(
const parsed = parseInput(prefixed);
if (parsed.error) return finish({ ok: false, error: parsed.error });
// Players: chat + stat commands only
// 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 === "stat") {
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
if (parsed.type === "set" || parsed.type === "rolltag") {
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
}
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /stat 命令" });
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" });
}
// GM: all commands
@@ -116,8 +111,8 @@ export async function dispatchCommand(
}
}
if (parsed.type === "stat") {
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
if (parsed.type === "set" || parsed.type === "rolltag") {
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
}
const result = sendMessage(parsed.type, parsed.payload);
@@ -131,76 +126,67 @@ function finish(r: DispatchResult): DispatchResult {
}
// ---------------------------------------------------------------------------
// Stat dispatch
// Set dispatch
// ---------------------------------------------------------------------------
function dispatchStat(
function dispatchSet(
payload: Record<string, unknown>,
ctx: DispatchContext,
): DispatchResult {
const p = payload as { action?: string; key?: string; value?: string };
const p = payload as { key?: string; expr?: string; tags?: 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 };
if (!p.key) {
return { ok: false, error: "缺少变量名" };
}
const result = sendMessage("stat", {
action: "set",
key: resolved.fullKey,
value: resolved.value,
});
const r = unwrap(result);
if (!r.ok) return r;
const key = p.key;
const oldValue = ctx.variables[key] ?? undefined;
let newValue: string;
if (resolved.modifiers) {
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
sendMessage("stat", { action: "set", key: mk, value: mv });
}
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: "缺少表达式" };
}
return { ok: true };
} catch (e) {
return {
ok: false,
error: e instanceof Error ? e.message : "表达式求值失败",
};
}
if (!p.action || !p.key) {
return { ok: false, error: "无效的 stat 命令" };
}
// Send the direct set
const r1 = sendMessage("var", { action: "set", key, value: newValue });
const u1 = unwrap(r1);
if (!u1.ok) return u1;
const fk = resolveKey(p.key, ctx.statDefs, ctx.myName);
// Build working variable store for cascade
const workingVars = { ...ctx.variables, [key]: newValue };
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 });
}
}
// 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 };
@@ -210,21 +196,9 @@ function dispatchStat(
// 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 };
}
}