diff --git a/src/App.tsx b/src/App.tsx index 0b6028a..f75e92b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,7 @@ import { DocDialog, DataSourceDialog, RevealManager, - ReactiveStatManager, + ReactiveVariableManager, CommandLinkManager, } from "./components"; import { generateToc, type FileNode, type TocNode } from "./data-loader"; @@ -158,7 +158,7 @@ const App: Component = () => { src={currentPath()} > - + diff --git a/src/components/CommandLinkManager.tsx b/src/components/CommandLinkManager.tsx index 77781a0..de6ac56 100644 --- a/src/components/CommandLinkManager.tsx +++ b/src/components/CommandLinkManager.tsx @@ -32,9 +32,9 @@ export const CommandLinkManager: Component = () => { myName: stream.myName, command, sparkTables: comp.data.sparkTables, - statValues: stream.stats, - statDefs: comp.data.stats, - statTemplates: comp.data.statTemplates, + variables: stream.variables, + declarations: comp.data.declarations, + tagModifiers: comp.data.tagModifiers, }).then((result) => { if (!result.ok) { setDispatchError(result.error); diff --git a/src/components/index.ts b/src/components/index.ts index ce2f4e3..41d8395 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -16,7 +16,7 @@ export { Article } from "./Article"; export type { ArticleProps } from "./Article"; export { RevealManager } from "./RevealManager"; export { CommandLinkManager } from "./CommandLinkManager"; -export { ReactiveStatManager } from "./journal/ReactiveStatManager"; +export { ReactiveVariableManager } from "./journal/ReactiveVariableManager"; export { MobileSidebar, DesktopSidebar } from "./Sidebar"; export type { SidebarProps } from "./Sidebar"; export { FileTreeNode, HeadingNode } from "./FileTree"; diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index 3a8611b..6e82a9a 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -85,7 +85,7 @@ export const JournalInput: Component = () => { setDispatchError(null); - // Players: chat + stat commands only + // Players: chat + set commands only if (isPlayer()) { if (parsed.type === "chat") { const result = sendMessage("chat", { text: raw }); @@ -94,13 +94,13 @@ export const JournalInput: Component = () => { return; } - if (parsed.type !== "stat") { - setDispatchError("玩家只能发送聊天消息或使用 /stat 命令"); + if (parsed.type !== "set" && parsed.type !== "rolltag") { + setDispatchError("玩家只能发送聊天消息或使用 /set 命令"); return; } } - // GM: all commands, Player: stat commands + // GM: all commands, Player: set commands setSending(true); await dispatchCommand({ @@ -108,9 +108,9 @@ export const JournalInput: Component = () => { myName: stream.myName, command: raw, sparkTables: comp.data.sparkTables, - statValues: stream.stats, - statDefs: comp.data.stats, - statTemplates: comp.data.statTemplates, + variables: stream.variables, + declarations: comp.data.declarations, + tagModifiers: comp.data.tagModifiers, }); // dispatchCommand already set the shared error if it failed. @@ -181,7 +181,7 @@ export const JournalInput: Component = () => { return; } const raw = text(); - if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) { + if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) { e.preventDefault(); openCompletions(); return; @@ -265,8 +265,8 @@ export const JournalInput: Component = () => { onKeyDown={handleKeyDown} placeholder={ isGm() - ? "输入消息,或使用 /roll、/spark、/link、/stat 命令..." - : "输入消息,或使用 /stat 命令..." + ? "输入消息,或使用 /roll、/spark、/link、/set 命令..." + : "输入消息,或使用 /set 命令..." } rows={2} class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm diff --git a/src/components/journal/JournalPanel.tsx b/src/components/journal/JournalPanel.tsx index 70b3d1d..f67d677 100644 --- a/src/components/journal/JournalPanel.tsx +++ b/src/components/journal/JournalPanel.tsx @@ -15,7 +15,7 @@ import { JournalHeader } from "./JournalHeader"; import { ConnectDialog } from "./ConnectDialog"; import { InviteDialog } from "./InviteDialog"; import { CreateSessionDialog } from "./CreateSessionDialog"; -import { StatsView } from "./StatsView"; +import { VariableView } from "./VariableView"; export interface JournalPanelProps { open: boolean; @@ -26,7 +26,7 @@ export const JournalPanel: Component = (props) => { const stream = useJournalStream(); const [showInvite, setShowInvite] = createSignal(false); const [showCreateSession, setShowCreateSession] = createSignal(false); - const [viewMode, setViewMode] = createSignal<"stream" | "stats">("stream"); + const [viewMode, setViewMode] = createSignal<"stream" | "variables">("stream"); // Player list (exclude gm and observer) const playerEntries = () => @@ -99,18 +99,18 @@ export const JournalPanel: Component = (props) => { 消息流
- }> + }>
diff --git a/src/components/journal/ReactiveStatManager.tsx b/src/components/journal/ReactiveVariableManager.tsx similarity index 63% rename from src/components/journal/ReactiveStatManager.tsx rename to src/components/journal/ReactiveVariableManager.tsx index 02f1777..5f61e7a 100644 --- a/src/components/journal/ReactiveStatManager.tsx +++ b/src/components/journal/ReactiveVariableManager.tsx @@ -1,61 +1,30 @@ /** - * ReactiveStatManager — mounts as a child of
and reactively - * replaces ${key} template patterns in the rendered markdown DOM with - * live stat values from the journal stream. + * ReactiveVariableManager — mounts as a child of
and reactively + * replaces {{$key}} template patterns in the rendered markdown DOM with + * live variable values from the journal stream. * * On mount, walks all text nodes in the article content looking for - * ${key} placeholders. Each match is wrapped in a with a - * data-reactive-stat attribute so that subsequent updates (driven by + * {{$key}} placeholders. Each match is wrapped in a with a + * data-reactive-var attribute so that subsequent updates (driven by * a Solid effect) only touch those spans. */ -import { Component, createEffect, onCleanup, createMemo } from "solid-js"; +import { Component, createEffect, onCleanup } from "solid-js"; import { useArticleDom } from "../Article"; import { useJournalStream } from "../stores/journalStream"; -import { useJournalCompletions } from "../journal/completions"; -import { fullKey } from "../journal/stat-helpers"; -import type { StatDef } from "../journal/completions"; -const TEMPLATE_RE = /\$\{(\w+)\}/g; +const TEMPLATE_RE = /\{\{(\$[a-zA-Z_][a-zA-Z0-9_]*)\}\}/g; /** Tag name used for marker spans — must stay in sync with the scan pass. */ -const MARKER = "data-reactive-stat"; +const MARKER = "data-reactive-var"; -export const ReactiveStatManager: Component = () => { +export const ReactiveVariableManager: Component = () => { const contentDom = useArticleDom(); const stream = useJournalStream(); - const comp = useJournalCompletions(); - const statDefs = createMemo(() => comp.data.stats); - const values = createMemo(() => stream.stats); + const values = () => stream.variables; - /** Build a lookup: bare key -> StatDef (for scope-aware resolution) */ - const bareDefMap = createMemo(() => { - const map = new Map(); - for (const def of statDefs()) { - if (!map.has(def.key)) { - map.set(def.key, def); - } - } - return map; - }); - - /** - * Resolve a bare key from markdown template to its runtime value. - * Uses StatDef scoping when available, falls back to player-first, global-second. - */ - function resolveBareKey(bareKey: string): string { - const bareDef = bareDefMap().get(bareKey); - if (bareDef) { - const fk = fullKey(bareDef, stream.myName); - return values()[fk] ?? bareDef.default ?? ""; - } - const pk = `${stream.myName}:${bareKey}`; - if (values()[pk] !== undefined) return values()[pk]; - return values()[bareKey] ?? ""; - } - - // ---- Initial scan: wrap ${key} text in marker spans ---- + // ---- Initial scan: wrap {{$key}} text in marker spans ---- createEffect(() => { const dom = contentDom(); @@ -73,7 +42,7 @@ export const ReactiveStatManager: Component = () => { if (dom) unwrapAll(dom); }); - // ---- Reactive updates: whenever stat values change, update all spans ---- + // ---- Reactive updates: whenever variable values change, update all spans ---- createEffect(() => { const dom = contentDom(); @@ -88,7 +57,9 @@ export const ReactiveStatManager: Component = () => { TEMPLATE_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = TEMPLATE_RE.exec(template)) !== null) { - result = result.replace(`\${${m[1]}}`, resolveBareKey(m[1])); + const key = m[1]; // includes $ prefix + const resolved = v[key] ?? ""; + result = result.replace(`{{${key}}}`, resolved); } if (span.textContent !== result) { span.textContent = result; @@ -104,8 +75,8 @@ export const ReactiveStatManager: Component = () => { // --------------------------------------------------------------------------- /** - * Walk all text nodes in a subtree and wrap `${key}` patterns in - * `` elements. + * Walk all text nodes in a subtree and wrap `{{$key}}` patterns in + * `` elements. */ function scanAndWrap(root: Element): void { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); @@ -119,7 +90,7 @@ function scanAndWrap(root: Element): void { TEMPLATE_RE.lastIndex = 0; if (!TEMPLATE_RE.test(text)) continue; - // Build replacement HTML: split on ${key} and wrap each key span + // Build replacement HTML: split on {{$key}} and wrap each key span TEMPLATE_RE.lastIndex = 0; const parts: string[] = []; let lastIndex = 0; @@ -180,4 +151,4 @@ function escapeAttr(s: string): string { .replace(/>/g, ">"); } -export default ReactiveStatManager; +export default ReactiveVariableManager; \ No newline at end of file diff --git a/src/components/journal/StatsView.tsx b/src/components/journal/StatsView.tsx deleted file mode 100644 index 01b3cce..0000000 --- a/src/components/journal/StatsView.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/** - * StatsView — table view of all stat key-value pairs. - * - * Groups stats by scope (global, then per-player). Shows computed values - * (base + modifiers) and inline roll buttons for stats with roll/table/formula. - */ - -import { Component, For, createMemo, Show } from "solid-js"; -import { useJournalStream } from "../stores/journalStream"; -import { useJournalCompletions } from "./completions"; -import { fullKey, modifierLabel } from "./stat-helpers"; -import type { StatDef } from "./completions"; - -export const StatsView: Component = () => { - const stream = useJournalStream(); - const comp = useJournalCompletions(); - - const statDefs = createMemo(() => comp.data.stats); - const values = createMemo(() => stream.stats); - - /** Build a lookup: fullKey -> StatDef */ - const defMap = createMemo(() => { - const map = new Map(); - for (const def of statDefs()) { - const fk = fullKey(def, stream.myName); - map.set(fk, def); - } - return map; - }); - - /** Compute the effective value of a stat (base + modifiers) */ - function computedValue(key: string): string { - const def = defMap().get(key); - if (!def) return values()[key] ?? ""; - - const base = values()[key] ?? def.default ?? ""; - const baseNum = parseFloat(base); - - if (def.type === "number" || def.type === "derived") { - // Sum modifiers that target this key (fullKey matching) - const modKeys: string[] = []; - for (const [fk, d] of defMap()) { - if ( - d.type === "modifier" && - d.target && - fullKeyTarget(d.target, d, def) - ) { - modKeys.push(fk); - } - } - let total = isNaN(baseNum) ? 0 : baseNum; - for (const mk of modKeys) { - const mv = parseFloat(values()[mk] ?? defMap().get(mk)?.default ?? "0"); - if (!isNaN(mv)) total += mv; - } - return String(total); - } - - return base || "—"; - } - - /** Check if a modifier's target matches a given def (scoped correctly). */ - function fullKeyTarget( - target: string, - modDef: StatDef, - targetDef: StatDef, - ): boolean { - if (modDef.scope === targetDef.scope) { - return ( - fullKey({ ...modDef, key: target }, stream.myName) === - fullKey(targetDef, stream.myName) - ); - } - return false; - } - - /** Group stats by scope */ - const groups = createMemo(() => { - const result: { - scope: string; - label: string; - entries: { fullKey: string; def: StatDef }[]; - }[] = []; - const globalEntries: { fullKey: string; def: StatDef }[] = []; - const playerEntries: { fullKey: string; def: StatDef }[] = []; - - for (const def of statDefs()) { - const fk = fullKey(def, stream.myName); - if (def.scope === "global") { - globalEntries.push({ fullKey: fk, def }); - } else { - playerEntries.push({ fullKey: fk, def }); - } - } - - if (globalEntries.length > 0) { - result.push({ scope: "global", label: "全局", entries: globalEntries }); - } - - if (playerEntries.length > 0) { - result.push({ - scope: "player", - label: `玩家 (${stream.myName})`, - entries: playerEntries, - }); - } - - return result; - }); - - return ( -
- 0} - fallback={ -

- 暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。 -

- } - > - - {(group) => ( -
-
- - {group.label} - -
-
- - {({ fullKey: fk, def }) => { - const val = () => values()[fk]; - const comp = () => computedValue(fk); - const hasModifiers = () => { - for (const [mfk, md] of defMap()) { - if ( - md.type === "modifier" && - md.target && - fullKeyTarget(md.target, md, def) - ) { - return true; - } - } - return false; - }; - const canRoll = () => - def.roll || - def.formula || - def.type === "enum" || - def.type === "template"; - - return ( -
-
- - {modifierLabel(def, statDefs())} - - - {def.key} - - - - →{def.target} - - -
- -
- - {def.default ?? "—"} - - } - > - - {val()} - - - - - - = {comp()} - - - - - - -
-
- ); - }} -
-
-
- )} -
-
-
- ); -}; diff --git a/src/components/journal/VariableView.tsx b/src/components/journal/VariableView.tsx new file mode 100644 index 0000000..9e388be --- /dev/null +++ b/src/components/journal/VariableView.tsx @@ -0,0 +1,243 @@ +/** + * VariableView — table view of all variable key-value pairs and active tags. + * + * Shows variables grouped by type (declared, plain, tag-typed) and an + * active tags section showing which variable activates each tag and + * what modifier effects are in play. + */ + +import { Component, For, createMemo, Show } from "solid-js"; +import { useJournalStream } from "../stores/journalStream"; +import { useJournalCompletions } from "./completions"; +import { computeActiveTags } from "./var-reactivity"; +import { extractDependencies } from "./var-reactivity"; +import type { VarDeclaration } from "./declare-parser"; + +export const VariableView: Component = () => { + const stream = useJournalStream(); + const comp = useJournalCompletions(); + + const declarations = createMemo(() => comp.data.declarations); + const tagModifiers = createMemo(() => comp.data.tagModifiers); + const values = createMemo(() => stream.variables); + + /** Build a map: $key → VarDeclaration */ + const declMap = createMemo(() => { + const map = new Map(); + for (const d of declarations()) { + map.set(d.key, d); + } + return map; + }); + + /** Active tags */ + const activeTags = createMemo(() => computeActiveTags(values())); + + /** Tag modifier index: #tag → [{target, expression}] */ + const tagModMap = createMemo(() => { + const map = new Map>(); + for (const tm of tagModifiers()) { + let list = map.get(tm.tag); + if (!list) { + list = []; + map.set(tm.tag, list); + } + list.push({ target: tm.target, expression: tm.expression }); + } + return map; + }); + + /** Which variable activates each tag */ + const tagActivators = createMemo(() => { + const map = new Map(); + const vars = values(); + for (const [key, val] of Object.entries(vars)) { + if (val.startsWith("#")) { + if (!map.has(val)) map.set(val, key); + } + } + return map; + }); + + /** Group variables: declared, plain (set directly), tag-typed */ + const groups = createMemo(() => { + const vars = values(); + const declKeys = new Set(declarations().map((d) => d.key)); + + const declared: Array<{ key: string; value: string; expr: string; deps: string[] }> = []; + const plain: Array<{ key: string; value: string }> = []; + const tagVars: Array<{ key: string; value: string }> = []; + + for (const [key, val] of Object.entries(vars)) { + if (val.startsWith("#")) { + tagVars.push({ key, value: val }); + } else if (declKeys.has(key)) { + const decl = declMap().get(key)!; + declared.push({ + key, + value: val, + expr: decl.expression, + deps: extractDependencies(decl.expression), + }); + } else { + plain.push({ key, value: val }); + } + } + + return { declared, plain, tagVars }; + }); + + const hasAnyData = () => + declarations().length > 0 || + Object.keys(values()).length > 0 || + activeTags().length > 0; + + return ( +
+ + 暂无变量定义。在文档中使用 ```csv role=declare 代码块定义变量。 +

+ } + > + {/* Active Tags */} + 0}> +
+
+ 激活标签 +
+
+ + {(tag) => { + const activator = () => tagActivators().get(tag); + const mods = () => tagModMap().get(tag) ?? []; + return ( +
+
+ + {tag} + + + + ← {activator()} + + +
+ 0}> +
+ + {(mod) => { + const currentVal = () => values()[mod.target]; + return ( +
+ {mod.target} + +{mod.expression} + + + = {currentVal()} + + +
+ ); + }} +
+
+
+
+ ); + }} +
+
+
+
+ + {/* Declared Variables */} + 0}> +
+
+ + 声明变量 + +
+
+ + {(item) => ( +
+
+ + {item.key} + + + {item.expr} + +
+
+ + {item.value} + + 0}> + + ← {item.deps.join(", ")} + + +
+
+ )} +
+
+
+
+ + {/* Plain Variables */} + 0}> +
+
+ + 直接设置 + +
+
+ + {(item) => ( +
+ + {item.key} + + + {item.value} + +
+ )} +
+
+
+
+ + {/* Tag Variables */} + 0}> +
+
+ 标签变量 +
+
+ + {(item) => ( +
+ + {item.key} + + + {item.value} + +
+ )} +
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/journal/command-completions.ts b/src/components/journal/command-completions.ts index 4b79cc9..3804ae1 100644 --- a/src/components/journal/command-completions.ts +++ b/src/components/journal/command-completions.ts @@ -23,7 +23,7 @@ export function buildCompletions( { label: "/roll", kind: "command" as const, insertText: "/roll " }, { label: "/spark", kind: "command" as const, insertText: "/spark " }, { label: "/link", kind: "command" as const, insertText: "/link " }, - { label: "/stat", kind: "command" as const, insertText: "/stat " }, + { label: "/set", kind: "command" as const, insertText: "/set " }, ]; // Show commands when user types / or starts typing a command name @@ -33,7 +33,7 @@ export function buildCompletions( c.label.toLowerCase().startsWith(prefix), ); if (!isGm) { - matches = matches.filter((c) => c.label === "/stat"); + matches = matches.filter((c) => c.label === "/set"); } return matches.length > 0 ? matches @@ -101,50 +101,43 @@ export function buildCompletions( }); } - // After /stat — show subcommands or stat keys - if (raw.startsWith("/stat ")) { - const rest = raw.slice("/stat ".length).trim(); + // After /set — show known variable and tag names + if (raw.startsWith("/set ")) { + const rest = raw.slice("/set ".length).trim(); - if (!rest || rest.length < 3) { - const subs = ["set", "del", "roll"]; - const matches = subs.filter((s) => s.startsWith(rest.toLowerCase())); - if (matches.length === 0) - return [{ label: "set/del/roll", kind: "no-results", insertText: "" }]; - return matches.map((s) => ({ - label: `/stat ${s}`, - kind: "value" as const, - insertText: `/stat ${s} `, - })); - } - - if (rest.startsWith("set ")) { - const prefix = rest.slice("set ".length).toLowerCase(); - const matches = data.stats - .filter((s) => s.key.toLowerCase().includes(prefix)) + // Already has a key? Show tag suggestions + if (rest.includes(" ")) { + const afterSpace = rest.slice(rest.indexOf(" ") + 1).toLowerCase(); + const tags = data.tagModifiers.map((tm) => tm.tag); + // Also gather unique tags from declarations + const uniqueTags = [...new Set(tags)]; + const matches = uniqueTags + .filter((t) => t.toLowerCase().startsWith(afterSpace)) .slice(0, 8); - if (matches.length === 0) - return [{ label: "未找到属性", kind: "no-results", insertText: "" }]; - return matches.map((s) => ({ - label: `${s.key} (${s.label})`, + if (matches.length === 0) { + return [{ label: "输入表达式", kind: "no-results", insertText: "" }]; + } + return matches.map((t) => ({ + label: t, kind: "value" as const, - insertText: `/stat set ${s.key}=`, + insertText: raw + t, })); } - if (rest.startsWith("del ") || rest.startsWith("roll ")) { - const [cmd, ...restParts] = rest.split(" "); - const prefix = restParts.join(" ").toLowerCase(); - const matches = data.stats - .filter((s) => s.key.toLowerCase().startsWith(prefix)) - .slice(0, 8); - if (matches.length === 0) - return [{ label: "未找到属性", kind: "no-results", insertText: "" }]; - return matches.map((s) => ({ - label: `${s.key} (${s.label})`, - kind: "value" as const, - insertText: `/stat ${cmd} ${s.key}`, - })); + // Show variable name suggestions + const prefix = rest.toLowerCase(); + const varNames = data.declarations.map((d) => d.key); + const matches = varNames + .filter((v) => v.toLowerCase().startsWith(prefix)) + .slice(0, 8); + if (matches.length === 0) { + return [{ label: "$variable", kind: "no-results", insertText: "" }]; } + return matches.map((v) => ({ + label: v, + kind: "value" as const, + insertText: `/set ${v} `, + })); } return []; diff --git a/src/components/journal/command-dispatcher.ts b/src/components/journal/command-dispatcher.ts index 637848f..e51621f 100644 --- a/src/components/journal/command-dispatcher.ts +++ b/src/components/journal/command-dispatcher.ts @@ -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; - /** Stat definitions */ - statDefs: StatDef[]; - /** Stat templates */ - statTemplates: StatTemplate[]; + /** Current runtime variable values */ + variables: Record; + /** 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, ctx)); + if (parsed.type === "set" || parsed.type === "rolltag") { + return finish(dispatchSet(parsed.payload as Record, 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, ctx)); + if (parsed.type === "set" || parsed.type === "rolltag") { + return finish(dispatchSet(parsed.payload as Record, 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, 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: { success: true; msg: R } | { success: false; error: string }, ): DispatchResult { return r.success ? { ok: true } : { ok: false, error: r.error }; -} +} \ No newline at end of file diff --git a/src/components/journal/command-parser.ts b/src/components/journal/command-parser.ts index 7748c06..e6e9de5 100644 --- a/src/components/journal/command-parser.ts +++ b/src/components/journal/command-parser.ts @@ -12,7 +12,7 @@ export interface CompletionItem { } export interface ParsedInput { - type: "chat" | "roll" | "spark" | "link" | "stat"; + type: "chat" | "roll" | "spark" | "link" | "set" | "rolltag"; payload: Record; error?: string; } @@ -41,41 +41,35 @@ export function parseInput(raw: string): ParsedInput { return { type: "link", payload: { path, section } }; } - if (raw.startsWith("/stat ")) { - const arg = raw.slice("/stat ".length).trim(); - if (!arg) - return { type: "stat", payload: {}, error: "需要 stat 命令 (set/del/roll)" }; + if (raw.startsWith("/set ")) { + const rest = raw.slice("/set ".length).trim(); + if (!rest) + return { type: "set", payload: {}, error: "格式: /set $key expression" }; - if (arg.startsWith("set ")) { - const rest = arg.slice("set ".length).trim(); - const eqIdx = rest.indexOf("="); - if (eqIdx === -1) - return { type: "stat", payload: {}, error: "格式: /stat set key=value" }; - const key = rest.slice(0, eqIdx).trim(); - const value = rest.slice(eqIdx + 1).trim(); - if (!key || !value) - return { type: "stat", payload: {}, error: "key 和 value 不能为空" }; - return { type: "stat", payload: { action: "set", key, value } }; + // Split on first space to get key and expression + const spaceIdx = rest.indexOf(" "); + if (spaceIdx === -1) + return { type: "set", payload: {}, error: "格式: /set $key expression" }; + + const key = rest.slice(0, spaceIdx).trim(); + const expr = rest.slice(spaceIdx + 1).trim(); + + if (!key || !expr) + return { type: "set", payload: {}, error: "key 和 expression 不能为空" }; + + if (!key.startsWith("$")) + return { type: "set", payload: {}, error: "key 必须以 $ 开头" }; + + // Check if expr is a rolltag (e.g. #w|#d|#s) + if (expr.includes("|") && expr.split("|").every((t) => t.trim().startsWith("#"))) { + const tags = expr.split("|").map((t) => t.trim()); + return { type: "rolltag", payload: { key, tags } }; } - if (arg.startsWith("del ")) { - const key = arg.slice("del ".length).trim(); - if (!key) - return { type: "stat", payload: {}, error: "格式: /stat del key" }; - return { type: "stat", payload: { action: "del", key } }; - } - - if (arg.startsWith("roll ")) { - const key = arg.slice("roll ".length).trim(); - if (!key) - return { type: "stat", payload: {}, error: "格式: /stat roll key" }; - return { type: "stat", payload: { action: "roll", key } }; - } - - return { type: "stat", payload: {}, error: "未知 stat 子命令: set/del/roll" }; + return { type: "set", payload: { key, expr } }; } - if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/stat") { + if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/set") { return { type: "chat", payload: {}, error: "请补全命令" }; } diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index fc47fb6..db7c28a 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -2,7 +2,8 @@ * Journal completions — client-side loader for /__COMPLETIONS.json * * In CLI mode, fetches the pre-computed index. In dev/browser mode, falls - * back to scanning the in-memory file index for dice expressions and headings. + * back to scanning the in-memory file index for dice expressions, headings, + * and declare blocks. * * The fetch runs eagerly on module import. Call `useJournalCompletions()` * from any Solid component to reactively read the state. @@ -15,13 +16,6 @@ import { getPathsByExtension, getIndexedData, } from "../../data-loader/file-index"; -import { - parseStatYaml, - parseStatCsv, - parseTemplateCsv, - parseStatModifiers, -} from "../../cli/completions/stat-parser"; -import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser"; import { FENCED_BLOCK_RE, parseBlockAttrs, @@ -29,8 +23,11 @@ import { import { scanDirectives, } from "../../cli/completions/directive-scanner"; +import { parseDeclareCsv } from "./declare-parser"; +import type { VarDeclaration, TagModifier } from "./declare-parser"; +import { initReactivity } from "./var-reactivity"; -export type { StatDef, StatTemplate }; +export type { VarDeclaration, TagModifier }; // ------------------- Types (mirrors CLI) ------------------- @@ -60,8 +57,8 @@ export interface JournalCompletions { dice: DiceCompletion[]; links: LinkCompletion[]; sparkTables: SparkTableCompletion[]; - stats: StatDef[]; - statTemplates: StatTemplate[]; + declarations: VarDeclaration[]; + tagModifiers: TagModifier[]; } export type CompletionsState = @@ -87,9 +84,11 @@ async function tryServer(): Promise { dice: Array.isArray(data.dice) ? data.dice : [], links: Array.isArray(data.links) ? data.links : [], sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [], - stats: Array.isArray(data.stats) ? data.stats : [], - statTemplates: Array.isArray(data.statTemplates) - ? data.statTemplates + declarations: Array.isArray(data.declarations) + ? data.declarations + : [], + tagModifiers: Array.isArray(data.tagModifiers) + ? data.tagModifiers : [], }; } catch { @@ -104,8 +103,8 @@ async function scanClientSide(): Promise { const dice: DiceCompletion[] = []; const links: LinkCompletion[] = []; const sparkTables: SparkTableCompletion[] = []; - const stats: StatDef[] = []; - const statTemplates: StatTemplate[] = []; + const declarations: VarDeclaration[] = []; + const tagModifiers: TagModifier[] = []; // Build a temporary index for resolving CSV paths const tempIndex: Record = {}; @@ -135,7 +134,7 @@ async function scanClientSide(): Promise { }); } - // ---- Unified block scanning (stats) ---- + // ---- Unified block scanning (declare) ---- FENCED_BLOCK_RE.lastIndex = 0; let blockMatch: RegExpExecArray | null; while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) { @@ -143,26 +142,15 @@ async function scanClientSide(): Promise { const attrs = parseBlockAttrs(infoString); attrs.lang = attrs.lang || lang; - if (attrs.role === "stat") { - if (attrs.lang === "yaml" || attrs.lang === "yml") { - stats.push(...parseStatYaml(body, filePath)); - } else if (attrs.lang === "csv") { - stats.push(...parseStatCsv(body, filePath)); + if (attrs.role === "declare") { + try { + const result = parseDeclareCsv(body, filePath); + declarations.push(...result.variables); + tagModifiers.push(...result.tagModifiers); + } catch (e) { + console.warn(`[completions] ${filePath}: ${e}`); } } - - if (attrs.role === "stat-template") { - const name = attrs.id || `_tpl_${filePath}_${stats.length}`; - statTemplates.push(parseTemplateCsv(body, filePath, name)); - } - - if (attrs.role === "stat-modifiers") { - const id = attrs.id || `_mod_${filePath}_${stats.length}`; - const result = parseStatModifiers(body, filePath, id, "player", attrs.extra["label"]); - stats.push(result.statDef); - stats.push(...result.modifierDefs); - statTemplates.push(result.template); - } } // ---- Directive scanning (dice + spark tables) ---- @@ -172,7 +160,7 @@ async function scanClientSide(): Promise { sparkTables.push(...directiveResult.sparkTables); } - return { dice, links, sparkTables, stats, statTemplates }; + return { dice, links, sparkTables, declarations, tagModifiers }; } // ------------------- Init (runs eagerly at import time) ------------------- @@ -183,6 +171,7 @@ const _initPromise: Promise = (async () => { const serverData = await tryServer(); if (serverData) { setCompletionsState({ status: "loaded", data: serverData }); + try { initReactivity({ declarations: serverData.declarations, tagModifiers: serverData.tagModifiers }); } catch (e) { console.warn("[completions] reactivity init error:", e); } return; } @@ -191,6 +180,7 @@ const _initPromise: Promise = (async () => { const data = await scanClientSide(); if (data.dice.length > 0 || data.links.length > 0) { setCompletionsState({ status: "loaded", data }); + try { initReactivity({ declarations: data.declarations, tagModifiers: data.tagModifiers }); } catch (e) { console.warn("[completions] reactivity init error:", e); } } else { setCompletionsState({ status: "empty" }); } @@ -216,8 +206,6 @@ export function ensureCompletions(): Promise { let _invalidated = false; export function invalidateCompletions(): void { _invalidated = true; - // On next import (page reload), the module will re-init. - // For a runtime invalidation, you could call init again. } /** @@ -238,8 +226,8 @@ export function useJournalCompletions(): { dice: [], links: [], sparkTables: [], - stats: [], - statTemplates: [], + declarations: [], + tagModifiers: [], }, }; -} +} \ No newline at end of file diff --git a/src/components/journal/index.ts b/src/components/journal/index.ts index 9ab1161..a375eed 100644 --- a/src/components/journal/index.ts +++ b/src/components/journal/index.ts @@ -51,5 +51,16 @@ export { DynamicForm } from "./DynamicForm"; export { useJournalCompletions, invalidateCompletions } from "./completions"; export { dispatchCommand } from "./command-dispatcher"; export type { DispatchContext, DispatchResult } from "./command-dispatcher"; +export { parseInput } from "./command-parser"; +export type { ParsedInput, CompletionItem } from "./command-parser"; +export { buildCompletions } from "./command-completions"; +export type { CompletionsContext } from "./command-completions"; +export { VariableView } from "./VariableView"; +export { parseDeclareCsv } from "./declare-parser"; +export type { VarDeclaration, TagModifier } from "./declare-parser"; +export { evaluateExpression, expressionIsTag } from "./variable-expression"; +export type { EvalContext, EvalResult } from "./variable-expression"; +export { initReactivity, computeCascade, computeActiveTags, extractDependencies } from "./var-reactivity"; +export type { VarReactivityState, VariableStore } from "./var-reactivity"; export { JournalContext, useJournalContext } from "./JournalContext"; export type { JournalContextValue } from "./JournalContext"; diff --git a/src/components/journal/stat-formula.ts b/src/components/journal/stat-formula.ts deleted file mode 100644 index 8ece321..0000000 --- a/src/components/journal/stat-formula.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Minimal expression evaluator for derived stat formulas. - * - * Supports: - * - Stat references: any identifier resolves to a number from the lookup - * - Arithmetic: + - * / - * - Functions: floor(x), ceil(x), round(x) - * - Parentheses for grouping - */ - -export type StatLookup = (key: string) => number; - -/** Evaluate a formula string against a stat lookup function. */ -export function evaluateFormula(formula: string, lookup: StatLookup): number { - const tokens = tokenize(formula); - const result = parseExpression(tokens, 0, lookup); - if (result.next < tokens.length) { - throw new Error(`Unexpected token at position ${result.next}: "${tokens[result.next]}"`); - } - return result.value; -} - -// --------------------------------------------------------------------------- -// Tokenizer -// --------------------------------------------------------------------------- - -type Token = - | { kind: "number"; value: number } - | { kind: "ident"; value: string } - | { kind: "op"; value: string } - | { kind: "lparen" } - | { kind: "rparen" } - | { kind: "comma" }; - -function tokenize(input: string): Token[] { - const tokens: Token[] = []; - let i = 0; - - while (i < input.length) { - const ch = input[i]; - - // Whitespace - if (/\s/.test(ch)) { - i++; - continue; - } - - // Number (integer or decimal) - if (/[0-9]/.test(ch)) { - let num = ""; - while (i < input.length && /[0-9.]/.test(input[i])) { - num += input[i]; - i++; - } - tokens.push({ kind: "number", value: parseFloat(num) }); - continue; - } - - // Identifier or function name - if (/[a-zA-Z_]/.test(ch)) { - let ident = ""; - while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) { - ident += input[i]; - i++; - } - tokens.push({ kind: "ident", value: ident }); - continue; - } - - // Operators and punctuation - switch (ch) { - case "+": - case "-": - case "*": - case "/": - tokens.push({ kind: "op", value: ch }); - i++; - break; - case "(": - tokens.push({ kind: "lparen" }); - i++; - break; - case ")": - tokens.push({ kind: "rparen" }); - i++; - break; - case ",": - tokens.push({ kind: "comma" }); - i++; - break; - default: - throw new Error(`Unexpected character: "${ch}"`); - } - } - - return tokens; -} - -// --------------------------------------------------------------------------- -// Recursive descent parser -// --------------------------------------------------------------------------- - -interface ParseResult { - value: number; - next: number; // index of next unconsumed token -} - -/** expression := term (("+" | "-") term)* */ -function parseExpression(tokens: Token[], pos: number, lookup: StatLookup): ParseResult { - let result = parseTerm(tokens, pos, lookup); - pos = result.next; - - while (pos < tokens.length) { - const tok = tokens[pos]; - if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) { - const right = parseTerm(tokens, pos + 1, lookup); - if (tok.value === "+") { - result = { value: result.value + right.value, next: right.next }; - } else { - result = { value: result.value - right.value, next: right.next }; - } - pos = result.next; - } else { - break; - } - } - - return result; -} - -/** term := factor (("*" | "/") factor)* */ -function parseTerm(tokens: Token[], pos: number, lookup: StatLookup): ParseResult { - let result = parseFactor(tokens, pos, lookup); - pos = result.next; - - while (pos < tokens.length) { - const tok = tokens[pos]; - if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) { - const right = parseFactor(tokens, pos + 1, lookup); - if (tok.value === "*") { - result = { value: result.value * right.value, next: right.next }; - } else { - if (right.value === 0) throw new Error("Division by zero"); - result = { value: result.value / right.value, next: right.next }; - } - pos = result.next; - } else { - break; - } - } - - return result; -} - -/** factor := number | ident ["(" expression ")"] | "(" expression ")" | "-" factor */ -function parseFactor(tokens: Token[], pos: number, lookup: StatLookup): ParseResult { - if (pos >= tokens.length) { - throw new Error("Unexpected end of formula"); - } - - const tok = tokens[pos]; - - // Unary minus - if (tok.kind === "op" && tok.value === "-") { - const inner = parseFactor(tokens, pos + 1, lookup); - return { value: -inner.value, next: inner.next }; - } - - // Number literal - if (tok.kind === "number") { - return { value: tok.value, next: pos + 1 }; - } - - // Parenthesized expression - if (tok.kind === "lparen") { - const inner = parseExpression(tokens, pos + 1, lookup); - if (inner.next >= tokens.length || tokens[inner.next].kind !== "rparen") { - throw new Error("Missing closing parenthesis"); - } - return { value: inner.value, next: inner.next + 1 }; - } - - // Identifier (stat reference or function call) - if (tok.kind === "ident") { - const name = tok.value; - - // Check for function call: ident "(" ... - if (pos + 1 < tokens.length && tokens[pos + 1].kind === "lparen") { - const arg = parseExpression(tokens, pos + 2, lookup); - if (arg.next >= tokens.length || tokens[arg.next].kind !== "rparen") { - throw new Error(`Missing closing parenthesis after ${name}(...)`); - } - const value = applyFunction(name, arg.value); - return { value, next: arg.next + 1 }; - } - - // Plain stat reference - const value = lookup(name); - return { value, next: pos + 1 }; - } - - throw new Error(`Unexpected token: "${JSON.stringify(tok)}"`); -} - -function applyFunction(name: string, arg: number): number { - switch (name.toLowerCase()) { - case "floor": - return Math.floor(arg); - case "ceil": - return Math.ceil(arg); - case "round": - return Math.round(arg); - default: - throw new Error(`Unknown function: ${name}`); - } -} diff --git a/src/components/journal/stat-helpers.ts b/src/components/journal/stat-helpers.ts deleted file mode 100644 index 2de2794..0000000 --- a/src/components/journal/stat-helpers.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Stat command helpers — stat resolution, permission checking, and - * dice-formula stat-reference expansion. - * - * Extracted from JournalInput to keep that component focused on UI. - */ - -import { rollFormula } from "../md-commander/hooks"; -import { evaluateFormula } from "./stat-formula"; -import type { StatDef, StatTemplate } from "./completions"; - -// --------------------------------------------------------------------------- -// Key resolution -// --------------------------------------------------------------------------- - -/** Get the full runtime key for a stat def, given a player name. */ -export function fullKey(def: StatDef, playerName: string): string { - return def.scope === "player" ? `${playerName}:${def.key}` : def.key; -} - -/** - * Derive a display label for a modifier stat. - * - * If the key follows the `{parent}_{target}` convention (from stat-modifiers), - * returns `{parent_label}/{target_label}` by looking up both defs. - * Otherwise falls back to the def's own label. - */ -export function modifierLabel( - def: StatDef, - statDefs: StatDef[], -): string { - if (def.type !== "modifier") return def.label; - - // Try to split key as parent_target - const lastUnderscore = def.key.lastIndexOf("_"); - if (lastUnderscore === -1) return def.label; - - const parentKey = def.key.slice(0, lastUnderscore); - const targetKey = def.key.slice(lastUnderscore + 1); - - const parentDef = statDefs.find((d) => d.key === parentKey); - const targetDef = statDefs.find((d) => d.key === targetKey); - - if (parentDef && targetDef) { - return `${parentDef.label}/${targetDef.label}`; - } - - return def.label; -} - -/** Find a stat def by its full runtime key. */ -export function findStatDef( - fk: string, - statDefs: StatDef[], - playerName: string, -): StatDef | undefined { - return statDefs.find((d) => fullKey(d, playerName) === fk); -} - -// --------------------------------------------------------------------------- -// Formula stat reference resolution -// --------------------------------------------------------------------------- - -/** - * Replace stat key references in a dice formula (e.g. "1d20 + attack") - * with their numeric values, so the result can be passed to rollFormula. - * - * Bare keys in formulas resolve relative to the caller's scope: if the - * caller def is `scope: player`, then `attack` resolves to `alice:attack`. - */ -export function resolveStatRefs( - formula: string, - lookup: (key: string) => number, -): string { - return formula.replace(/[a-zA-Z_]\w*/g, (match) => { - if (/^d\d/i.test(match)) return match; - if (/^[kdh]\d/i.test(match)) return match; - const val = lookup(match); - return String(val); - }); -} - -/** - * Build a stat lookup function that resolves bare keys to numbers. - * Bare keys are scoped by `playerName` if the originating def is player-scoped. - */ -export function makeStatLookup( - runtimeStats: Record, - statDefs: StatDef[], - playerName: string, - /** The def that references are being resolved for (for scoping bare keys). */ - callerDef?: StatDef, -): (bareKey: string) => number { - return (bareKey: string): number => { - // Try as a full key first, then try scoped - const candidates = [bareKey]; - if (callerDef && callerDef.scope === "player") { - candidates.push(`${playerName}:${bareKey}`); - } - - for (const k of candidates) { - const val = runtimeStats[k]; - if (val !== undefined) { - const n = parseFloat(val); - if (!isNaN(n)) return n; - } - const sdef = statDefs.find((d) => fullKey(d, playerName) === k); - if (sdef?.default !== undefined) { - const n = parseFloat(sdef.default); - if (!isNaN(n)) return n; - } - } - return 0; - }; -} - -// --------------------------------------------------------------------------- -// Permission -// --------------------------------------------------------------------------- - -/** Check whether a role can modify a given stat key. */ -export function canModifyStat( - role: string, - myName: string, - fullKey: string, - statDefs: StatDef[], -): boolean { - if (role === "gm") return true; - if (role === "observer") return false; - - const def = findStatDef(fullKey, statDefs, myName); - if (!def) return false; - if (def.scope === "player") { - return fullKey.startsWith(myName + ":"); - } - // Global stats: player can't modify - return false; -} - -// --------------------------------------------------------------------------- -// Roll resolution -// --------------------------------------------------------------------------- - -export interface StatRollResult { - fullKey: string; - value: string; - error?: string; - /** For template rolls: additional modifier keys → values to apply */ - modifiers?: Record; -} - -/** - * When a template-type stat is set explicitly (not rolled), look up the - * template entry by label and return the modifier overrides to apply. - * Returns null if the stat is not a template type or the label doesn't match. - */ -export function resolveTemplateSet( - def: StatDef, - value: string, - statDefs: StatDef[], - runtimeStats: Record, - playerName: string, - templates?: StatTemplate[], -): Record | null { - if (def.type !== "template" || !def.template) return null; - - const tpl = templates?.find((t) => t.name === def.template); - if (!tpl) return null; - - const entry = tpl.entries.find((e) => e.label === value); - if (!entry || Object.keys(entry.modifiers).length === 0) return null; - - const resolved: Record = {}; - - for (const [mk, mv] of Object.entries(entry.modifiers)) { - const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk; - resolved[modFullKey] = mv; - } - - return resolved; -} - -/** - * Resolve a /stat roll command: look up the stat definition (by bare or full - * key), evaluate the appropriate resolution strategy, and return the string - * value to publish. - */ -export function resolveStatRoll( - inputKey: string, - statDefs: StatDef[], - runtimeStats: Record, - playerName: string, - templates?: StatTemplate[], -): StatRollResult { - // Try exact match first, then resolve bare key → full key - let def = statDefs.find((d) => fullKey(d, playerName) === inputKey); - if (!def) { - // Try bare key: find a player-scoped def whose fullKey would match - def = statDefs.find( - (d) => d.scope === "player" && fullKey(d, playerName) === inputKey, - ); - } - if (!def) { - // Try finding a bare key match (for global or player) - def = statDefs.find((d) => d.key === inputKey); - } - - if (!def) { - return { fullKey: inputKey, value: "", error: `未知属性: ${inputKey}` }; - } - - const fk = fullKey(def, playerName); - const lookup = makeStatLookup(runtimeStats, statDefs, playerName, def); - - if (def.type === "template" && def.template) { - const tpl = templates?.find((t) => t.name === def.template); - if (!tpl || tpl.entries.length === 0) { - return { - fullKey: fk, - value: "", - error: `未找到模板: ${def.template}`, - }; - } - - // Roll the dice to pick an entry (use template's notation) - const formula = tpl.notation || "1d" + String(tpl.entries.length); - const resolvedFormula = resolveStatRefs(formula, lookup); - const roll = rollFormula(resolvedFormula); - const rolled = roll.result.total; - - // Find matching entry by range - const entry = matchTemplateRange(rolled, tpl.entries); - if (!entry) { - return { - fullKey: fk, - value: "", - error: `模板 "${def.template}" 中未找到匹配 ${rolled} 的条目`, - }; - } - - // Resolve modifier keys to full keys (same scope as def) - // Template values are absolute — set the modifier stat directly. - // The modifier stat (type: modifier) adds to its target in StatsView. - const resolvedModifiers: Record = {}; - for (const [mk, mv] of Object.entries(entry.modifiers)) { - const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk; - resolvedModifiers[modFullKey] = mv; - } - - return { - fullKey: fk, - value: entry.label, - modifiers: resolvedModifiers, - }; - } - - if (def.type === "enum" && def.options && def.options.length > 0) { - const idx = Math.floor(Math.random() * def.options.length); - return { fullKey: fk, value: def.options[idx] }; - } - - if (def.type === "derived" && def.formula) { - try { - const result = evaluateFormula(def.formula, lookup); - return { fullKey: fk, value: String(result) }; - } catch (e) { - return { - fullKey: fk, - value: "", - error: e instanceof Error ? e.message : "公式计算失败", - }; - } - } - - if (def.roll) { - const resolvedFormula = resolveStatRefs(def.roll, lookup); - const roll = rollFormula(resolvedFormula); - return { fullKey: fk, value: String(roll.result.total) }; - } - - return { fullKey: fk, value: "", error: `属性 "${inputKey}" 不支持掷骰` }; -} - -// --------------------------------------------------------------------------- -// Template range matching -// --------------------------------------------------------------------------- - -/** - * Match a rolled number against template entries with range strings. - * - * Range formats: - * "1-3" → inclusive range - * "4" → exact match - * "1-3,5" → multiple ranges - * - * Returns the first matching entry, or undefined. - */ -function matchTemplateRange( - rolled: number, - entries: { - range: string; - label: string; - modifiers: Record; - }[], -): (typeof entries)[number] | undefined { - for (const entry of entries) { - const parts = entry.range.split(",").map((s) => s.trim()); - for (const part of parts) { - if (part.includes("-")) { - const [lo, hi] = part.split("-").map(Number); - if (!isNaN(lo) && !isNaN(hi) && rolled >= lo && rolled <= hi) { - return entry; - } - } else { - const n = Number(part); - if (!isNaN(n) && rolled === n) { - return entry; - } - } - } - } - return undefined; -} diff --git a/src/components/journal/types/index.ts b/src/components/journal/types/index.ts index 4c8c335..d786998 100644 --- a/src/components/journal/types/index.ts +++ b/src/components/journal/types/index.ts @@ -10,4 +10,4 @@ import "./roll"; import "./spark"; import "./link"; import "./intent"; -import "./stat"; +import "./var"; diff --git a/src/components/journal/types/stat.tsx b/src/components/journal/types/var.tsx similarity index 62% rename from src/components/journal/types/stat.tsx rename to src/components/journal/types/var.tsx index d0562ae..a99fc64 100644 --- a/src/components/journal/types/stat.tsx +++ b/src/components/journal/types/var.tsx @@ -1,12 +1,12 @@ /** - * Built-in message type: stat + * Built-in message type: var * * Emitters: gm, player - * Command: /stat set key=value | /stat del key | /stat roll key + * Command: /set $key expression * - * Stat definitions are discovered from ```stat YAML blocks in markdown - * documents. The stream carries set/del mutations that build up the - * runtime stat store additively. + * Variable declarations and tag modifiers are authored in ```csv role=declare + * code blocks in markdown documents. The stream carries set/del mutations + * that build up the runtime variable store additively. */ import { z } from "zod"; @@ -20,11 +20,11 @@ const schema = z.object({ value: z.string().optional(), }); -export type StatPayload = z.infer; +export type VarPayload = z.infer; -registerMessageType({ - type: "stat", - label: "属性", +registerMessageType({ + type: "var", + label: "变量", emitters: ["gm", "player"], schema, defaultPayload: () => ({ action: "set", key: "", value: "" }), @@ -32,7 +32,7 @@ registerMessageType({ if (p.action === "del") { return (
- 📊 + 🔢 {p.key} @@ -41,9 +41,9 @@ registerMessageType({ } return (
- 📊 + 🔢 - {p.key} → {p.value} + {p.key} = {p.value}
); @@ -52,11 +52,11 @@ registerMessageType({ journalSetState( produce((s) => { if (p.action === "del") { - delete s.stats[p.key]; + delete s.variables[p.key]; } else if (p.value !== undefined) { - s.stats[p.key] = p.value; + s.variables[p.key] = p.value; } }), ); }, -}); +}); \ No newline at end of file diff --git a/src/components/stores/journalStream.ts b/src/components/stores/journalStream.ts index e13a9be..e02ca0e 100644 --- a/src/components/stores/journalStream.ts +++ b/src/components/stores/journalStream.ts @@ -44,7 +44,7 @@ export interface JournalStreamState { myRole: "gm" | "player" | "observer"; brokerUrl: string | null; players: Record; - stats: Record; + variables: Record; } export interface SessionMeta { @@ -96,7 +96,7 @@ const [state, setState] = createStore({ myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm", brokerUrl: persisted.brokerUrl, players: {}, - stats: {}, + variables: {}, }); if (initialName && !urlParams.playerName) syncUrlParam("player", initialName); @@ -126,7 +126,7 @@ function runReducer(msg: StreamMessage): void { function replayReducers(): void { // Reset derived state setState("revealedPaths", {}); - setState("stats", {}); + setState("variables", {}); for (const msg of state.messages) { runReducer(msg); } @@ -160,7 +160,7 @@ function handlePatch(patch: WorkerPatch): void { syncUrlParam("player", patch.state.myName); } saveRole(patch.state.myRole); - // Rebuild derived state (revealedPaths, stats) by replaying reducers + // Rebuild derived state (revealedPaths, variables) by replaying reducers replayReducers(); break; }