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:
parent
2983aa4440
commit
894f1735e5
|
|
@ -18,7 +18,7 @@ import {
|
||||||
DocDialog,
|
DocDialog,
|
||||||
DataSourceDialog,
|
DataSourceDialog,
|
||||||
RevealManager,
|
RevealManager,
|
||||||
ReactiveStatManager,
|
ReactiveVariableManager,
|
||||||
CommandLinkManager,
|
CommandLinkManager,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||||
|
|
@ -158,7 +158,7 @@ const App: Component = () => {
|
||||||
src={currentPath()}
|
src={currentPath()}
|
||||||
>
|
>
|
||||||
<RevealManager />
|
<RevealManager />
|
||||||
<ReactiveStatManager />
|
<ReactiveVariableManager />
|
||||||
<CommandLinkManager />
|
<CommandLinkManager />
|
||||||
</Article>
|
</Article>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,9 @@ export const CommandLinkManager: Component = () => {
|
||||||
myName: stream.myName,
|
myName: stream.myName,
|
||||||
command,
|
command,
|
||||||
sparkTables: comp.data.sparkTables,
|
sparkTables: comp.data.sparkTables,
|
||||||
statValues: stream.stats,
|
variables: stream.variables,
|
||||||
statDefs: comp.data.stats,
|
declarations: comp.data.declarations,
|
||||||
statTemplates: comp.data.statTemplates,
|
tagModifiers: comp.data.tagModifiers,
|
||||||
}).then((result) => {
|
}).then((result) => {
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setDispatchError(result.error);
|
setDispatchError(result.error);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export { Article } from "./Article";
|
||||||
export type { ArticleProps } from "./Article";
|
export type { ArticleProps } from "./Article";
|
||||||
export { RevealManager } from "./RevealManager";
|
export { RevealManager } from "./RevealManager";
|
||||||
export { CommandLinkManager } from "./CommandLinkManager";
|
export { CommandLinkManager } from "./CommandLinkManager";
|
||||||
export { ReactiveStatManager } from "./journal/ReactiveStatManager";
|
export { ReactiveVariableManager } from "./journal/ReactiveVariableManager";
|
||||||
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
||||||
export type { SidebarProps } from "./Sidebar";
|
export type { SidebarProps } from "./Sidebar";
|
||||||
export { FileTreeNode, HeadingNode } from "./FileTree";
|
export { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export const JournalInput: Component = () => {
|
||||||
|
|
||||||
setDispatchError(null);
|
setDispatchError(null);
|
||||||
|
|
||||||
// Players: chat + stat commands only
|
// Players: chat + set commands only
|
||||||
if (isPlayer()) {
|
if (isPlayer()) {
|
||||||
if (parsed.type === "chat") {
|
if (parsed.type === "chat") {
|
||||||
const result = sendMessage("chat", { text: raw });
|
const result = sendMessage("chat", { text: raw });
|
||||||
|
|
@ -94,13 +94,13 @@ export const JournalInput: Component = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type !== "stat") {
|
if (parsed.type !== "set" && parsed.type !== "rolltag") {
|
||||||
setDispatchError("玩家只能发送聊天消息或使用 /stat 命令");
|
setDispatchError("玩家只能发送聊天消息或使用 /set 命令");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GM: all commands, Player: stat commands
|
// GM: all commands, Player: set commands
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
await dispatchCommand({
|
await dispatchCommand({
|
||||||
|
|
@ -108,9 +108,9 @@ export const JournalInput: Component = () => {
|
||||||
myName: stream.myName,
|
myName: stream.myName,
|
||||||
command: raw,
|
command: raw,
|
||||||
sparkTables: comp.data.sparkTables,
|
sparkTables: comp.data.sparkTables,
|
||||||
statValues: stream.stats,
|
variables: stream.variables,
|
||||||
statDefs: comp.data.stats,
|
declarations: comp.data.declarations,
|
||||||
statTemplates: comp.data.statTemplates,
|
tagModifiers: comp.data.tagModifiers,
|
||||||
});
|
});
|
||||||
|
|
||||||
// dispatchCommand already set the shared error if it failed.
|
// dispatchCommand already set the shared error if it failed.
|
||||||
|
|
@ -181,7 +181,7 @@ export const JournalInput: Component = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raw = text();
|
const raw = text();
|
||||||
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
|
if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openCompletions();
|
openCompletions();
|
||||||
return;
|
return;
|
||||||
|
|
@ -265,8 +265,8 @@ export const JournalInput: Component = () => {
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
isGm()
|
isGm()
|
||||||
? "输入消息,或使用 /roll、/spark、/link、/stat 命令..."
|
? "输入消息,或使用 /roll、/spark、/link、/set 命令..."
|
||||||
: "输入消息,或使用 /stat 命令..."
|
: "输入消息,或使用 /set 命令..."
|
||||||
}
|
}
|
||||||
rows={2}
|
rows={2}
|
||||||
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { JournalHeader } from "./JournalHeader";
|
||||||
import { ConnectDialog } from "./ConnectDialog";
|
import { ConnectDialog } from "./ConnectDialog";
|
||||||
import { InviteDialog } from "./InviteDialog";
|
import { InviteDialog } from "./InviteDialog";
|
||||||
import { CreateSessionDialog } from "./CreateSessionDialog";
|
import { CreateSessionDialog } from "./CreateSessionDialog";
|
||||||
import { StatsView } from "./StatsView";
|
import { VariableView } from "./VariableView";
|
||||||
|
|
||||||
export interface JournalPanelProps {
|
export interface JournalPanelProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -26,7 +26,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const [showInvite, setShowInvite] = createSignal(false);
|
const [showInvite, setShowInvite] = createSignal(false);
|
||||||
const [showCreateSession, setShowCreateSession] = 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)
|
// Player list (exclude gm and observer)
|
||||||
const playerEntries = () =>
|
const playerEntries = () =>
|
||||||
|
|
@ -99,18 +99,18 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
消息流
|
消息流
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode("stats")}
|
onClick={() => setViewMode("variables")}
|
||||||
class={`flex-1 text-xs py-1.5 transition-colors ${
|
class={`flex-1 text-xs py-1.5 transition-colors ${
|
||||||
viewMode() === "stats"
|
viewMode() === "variables"
|
||||||
? "bg-white text-blue-600 font-medium border-b-2 border-blue-600"
|
? "bg-white text-blue-600 font-medium border-b-2 border-blue-600"
|
||||||
: "text-gray-500 hover:text-gray-700"
|
: "text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
属性
|
变量
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-h-0">
|
<div class="flex-1 min-h-0">
|
||||||
<Show when={viewMode() === "stream"} fallback={<StatsView />}>
|
<Show when={viewMode() === "stream"} fallback={<VariableView />}>
|
||||||
<StreamView />
|
<StreamView />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,30 @@
|
||||||
/**
|
/**
|
||||||
* ReactiveStatManager — mounts as a child of <Article> and reactively
|
* ReactiveVariableManager — mounts as a child of <Article> and reactively
|
||||||
* replaces ${key} template patterns in the rendered markdown DOM with
|
* replaces {{$key}} template patterns in the rendered markdown DOM with
|
||||||
* live stat values from the journal stream.
|
* live variable values from the journal stream.
|
||||||
*
|
*
|
||||||
* On mount, walks all text nodes in the article content looking for
|
* On mount, walks all text nodes in the article content looking for
|
||||||
* ${key} placeholders. Each match is wrapped in a <span> with a
|
* {{$key}} placeholders. Each match is wrapped in a <span> with a
|
||||||
* data-reactive-stat attribute so that subsequent updates (driven by
|
* data-reactive-var attribute so that subsequent updates (driven by
|
||||||
* a Solid effect) only touch those spans.
|
* 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 { useArticleDom } from "../Article";
|
||||||
import { useJournalStream } from "../stores/journalStream";
|
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. */
|
/** 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 contentDom = useArticleDom();
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const comp = useJournalCompletions();
|
|
||||||
|
|
||||||
const statDefs = createMemo(() => comp.data.stats);
|
const values = () => stream.variables;
|
||||||
const values = createMemo(() => stream.stats);
|
|
||||||
|
|
||||||
/** Build a lookup: bare key -> StatDef (for scope-aware resolution) */
|
// ---- Initial scan: wrap {{$key}} text in marker spans ----
|
||||||
const bareDefMap = createMemo(() => {
|
|
||||||
const map = new Map<string, StatDef>();
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const dom = contentDom();
|
const dom = contentDom();
|
||||||
|
|
@ -73,7 +42,7 @@ export const ReactiveStatManager: Component = () => {
|
||||||
if (dom) unwrapAll(dom);
|
if (dom) unwrapAll(dom);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Reactive updates: whenever stat values change, update all spans ----
|
// ---- Reactive updates: whenever variable values change, update all spans ----
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const dom = contentDom();
|
const dom = contentDom();
|
||||||
|
|
@ -88,7 +57,9 @@ export const ReactiveStatManager: Component = () => {
|
||||||
TEMPLATE_RE.lastIndex = 0;
|
TEMPLATE_RE.lastIndex = 0;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = TEMPLATE_RE.exec(template)) !== 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) {
|
if (span.textContent !== result) {
|
||||||
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
|
* Walk all text nodes in a subtree and wrap `{{$key}}` patterns in
|
||||||
* `<span data-reactive-stat="originalText">` elements.
|
* `<span data-reactive-var="originalText">` elements.
|
||||||
*/
|
*/
|
||||||
function scanAndWrap(root: Element): void {
|
function scanAndWrap(root: Element): void {
|
||||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||||
|
|
@ -119,7 +90,7 @@ function scanAndWrap(root: Element): void {
|
||||||
TEMPLATE_RE.lastIndex = 0;
|
TEMPLATE_RE.lastIndex = 0;
|
||||||
if (!TEMPLATE_RE.test(text)) continue;
|
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;
|
TEMPLATE_RE.lastIndex = 0;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
|
|
@ -180,4 +151,4 @@ function escapeAttr(s: string): string {
|
||||||
.replace(/>/g, ">");
|
.replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ReactiveStatManager;
|
export default ReactiveVariableManager;
|
||||||
|
|
@ -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<string, StatDef>();
|
|
||||||
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 (
|
|
||||||
<div class="h-full overflow-y-auto bg-gray-50">
|
|
||||||
<Show
|
|
||||||
when={statDefs().length > 0}
|
|
||||||
fallback={
|
|
||||||
<p class="text-center text-gray-400 text-xs py-8">
|
|
||||||
暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={groups()}>
|
|
||||||
{(group) => (
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
|
||||||
<span class="text-xs font-semibold text-gray-600">
|
|
||||||
{group.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="divide-y divide-gray-100">
|
|
||||||
<For each={group.entries}>
|
|
||||||
{({ 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 (
|
|
||||||
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
|
||||||
<div class="flex-1 min-w-0 flex items-center gap-1.5">
|
|
||||||
<span class="text-sm text-gray-800 truncate">
|
|
||||||
{modifierLabel(def, statDefs())}
|
|
||||||
</span>
|
|
||||||
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
|
|
||||||
{def.key}
|
|
||||||
</span>
|
|
||||||
<Show when={def.type === "modifier"}>
|
|
||||||
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
|
|
||||||
→{def.target}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
|
||||||
<Show
|
|
||||||
when={val() !== undefined}
|
|
||||||
fallback={
|
|
||||||
<span class="text-sm text-gray-300">
|
|
||||||
{def.default ?? "—"}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span class="text-sm font-mono text-gray-700">
|
|
||||||
{val()}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={
|
|
||||||
hasModifiers() &&
|
|
||||||
comp() !== (val() ?? def.default ?? "")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span class="text-xs text-gray-400">
|
|
||||||
= {comp()}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={canRoll()}>
|
|
||||||
<button
|
|
||||||
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
|
|
||||||
title="掷骰"
|
|
||||||
onClick={() => {
|
|
||||||
const textarea =
|
|
||||||
document.querySelector<HTMLTextAreaElement>(
|
|
||||||
"#journal-input-textarea",
|
|
||||||
);
|
|
||||||
if (textarea) {
|
|
||||||
const nativeInputValueSetter =
|
|
||||||
Object.getOwnPropertyDescriptor(
|
|
||||||
window.HTMLTextAreaElement.prototype,
|
|
||||||
"value",
|
|
||||||
)?.set;
|
|
||||||
nativeInputValueSetter?.call(
|
|
||||||
textarea,
|
|
||||||
`/stat roll ${def.key}`,
|
|
||||||
);
|
|
||||||
textarea.dispatchEvent(
|
|
||||||
new Event("input", { bubbles: true }),
|
|
||||||
);
|
|
||||||
textarea.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🎲
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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<string, VarDeclaration>();
|
||||||
|
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<string, Array<{ target: string; expression: string }>>();
|
||||||
|
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<string, string>();
|
||||||
|
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 (
|
||||||
|
<div class="h-full overflow-y-auto bg-gray-50">
|
||||||
|
<Show
|
||||||
|
when={hasAnyData()}
|
||||||
|
fallback={
|
||||||
|
<p class="text-center text-gray-400 text-xs py-8">
|
||||||
|
暂无变量定义。在文档中使用 ```csv role=declare 代码块定义变量。
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Active Tags */}
|
||||||
|
<Show when={activeTags().length > 0}>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
||||||
|
<span class="text-xs font-semibold text-gray-600">激活标签</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
<For each={activeTags()}>
|
||||||
|
{(tag) => {
|
||||||
|
const activator = () => tagActivators().get(tag);
|
||||||
|
const mods = () => tagModMap().get(tag) ?? [];
|
||||||
|
return (
|
||||||
|
<div class="px-3 py-1.5">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-sm font-mono text-purple-700 font-semibold">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
<Show when={activator()}>
|
||||||
|
<span class="text-[10px] text-gray-400">
|
||||||
|
← {activator()}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={mods().length > 0}>
|
||||||
|
<div class="mt-1 space-y-0.5">
|
||||||
|
<For each={mods()}>
|
||||||
|
{(mod) => {
|
||||||
|
const currentVal = () => values()[mod.target];
|
||||||
|
return (
|
||||||
|
<div class="text-[10px] text-gray-500 ml-2 flex items-center gap-1">
|
||||||
|
<span class="font-mono">{mod.target}</span>
|
||||||
|
<span>+{mod.expression}</span>
|
||||||
|
<Show when={currentVal() !== undefined}>
|
||||||
|
<span class="text-gray-400">
|
||||||
|
= {currentVal()}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Declared Variables */}
|
||||||
|
<Show when={groups().declared.length > 0}>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
||||||
|
<span class="text-xs font-semibold text-gray-600">
|
||||||
|
声明变量
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
<For each={groups().declared}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
||||||
|
<div class="flex-1 min-w-0 flex items-center gap-1.5">
|
||||||
|
<span class="text-sm font-mono text-gray-800">
|
||||||
|
{item.key}
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
|
||||||
|
{item.expr}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<span class="text-sm font-mono text-gray-700 font-semibold">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
<Show when={item.deps.length > 0}>
|
||||||
|
<span class="text-[10px] text-gray-400">
|
||||||
|
← {item.deps.join(", ")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Plain Variables */}
|
||||||
|
<Show when={groups().plain.length > 0}>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
||||||
|
<span class="text-xs font-semibold text-gray-600">
|
||||||
|
直接设置
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
<For each={groups().plain}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
||||||
|
<span class="flex-1 text-sm font-mono text-gray-800">
|
||||||
|
{item.key}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-mono text-gray-700 font-semibold">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Tag Variables */}
|
||||||
|
<Show when={groups().tagVars.length > 0}>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
||||||
|
<span class="text-xs font-semibold text-gray-600">标签变量</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
<For each={groups().tagVars}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
||||||
|
<span class="flex-1 text-sm font-mono text-gray-800">
|
||||||
|
{item.key}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-mono text-purple-700 font-semibold">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -23,7 +23,7 @@ export function buildCompletions(
|
||||||
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
||||||
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
|
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
|
||||||
{ label: "/link", kind: "command" as const, insertText: "/link " },
|
{ 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
|
// Show commands when user types / or starts typing a command name
|
||||||
|
|
@ -33,7 +33,7 @@ export function buildCompletions(
|
||||||
c.label.toLowerCase().startsWith(prefix),
|
c.label.toLowerCase().startsWith(prefix),
|
||||||
);
|
);
|
||||||
if (!isGm) {
|
if (!isGm) {
|
||||||
matches = matches.filter((c) => c.label === "/stat");
|
matches = matches.filter((c) => c.label === "/set");
|
||||||
}
|
}
|
||||||
return matches.length > 0
|
return matches.length > 0
|
||||||
? matches
|
? matches
|
||||||
|
|
@ -101,50 +101,43 @@ export function buildCompletions(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// After /stat — show subcommands or stat keys
|
// After /set — show known variable and tag names
|
||||||
if (raw.startsWith("/stat ")) {
|
if (raw.startsWith("/set ")) {
|
||||||
const rest = raw.slice("/stat ".length).trim();
|
const rest = raw.slice("/set ".length).trim();
|
||||||
|
|
||||||
if (!rest || rest.length < 3) {
|
// Already has a key? Show tag suggestions
|
||||||
const subs = ["set", "del", "roll"];
|
if (rest.includes(" ")) {
|
||||||
const matches = subs.filter((s) => s.startsWith(rest.toLowerCase()));
|
const afterSpace = rest.slice(rest.indexOf(" ") + 1).toLowerCase();
|
||||||
if (matches.length === 0)
|
const tags = data.tagModifiers.map((tm) => tm.tag);
|
||||||
return [{ label: "set/del/roll", kind: "no-results", insertText: "" }];
|
// Also gather unique tags from declarations
|
||||||
return matches.map((s) => ({
|
const uniqueTags = [...new Set(tags)];
|
||||||
label: `/stat ${s}`,
|
const matches = uniqueTags
|
||||||
kind: "value" as const,
|
.filter((t) => t.toLowerCase().startsWith(afterSpace))
|
||||||
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))
|
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
if (matches.length === 0)
|
if (matches.length === 0) {
|
||||||
return [{ label: "未找到属性", kind: "no-results", insertText: "" }];
|
return [{ label: "输入表达式", kind: "no-results", insertText: "" }];
|
||||||
return matches.map((s) => ({
|
}
|
||||||
label: `${s.key} (${s.label})`,
|
return matches.map((t) => ({
|
||||||
|
label: t,
|
||||||
kind: "value" as const,
|
kind: "value" as const,
|
||||||
insertText: `/stat set ${s.key}=`,
|
insertText: raw + t,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rest.startsWith("del ") || rest.startsWith("roll ")) {
|
// Show variable name suggestions
|
||||||
const [cmd, ...restParts] = rest.split(" ");
|
const prefix = rest.toLowerCase();
|
||||||
const prefix = restParts.join(" ").toLowerCase();
|
const varNames = data.declarations.map((d) => d.key);
|
||||||
const matches = data.stats
|
const matches = varNames
|
||||||
.filter((s) => s.key.toLowerCase().startsWith(prefix))
|
.filter((v) => v.toLowerCase().startsWith(prefix))
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
if (matches.length === 0)
|
if (matches.length === 0) {
|
||||||
return [{ label: "未找到属性", kind: "no-results", insertText: "" }];
|
return [{ label: "$variable", kind: "no-results", insertText: "" }];
|
||||||
return matches.map((s) => ({
|
|
||||||
label: `${s.key} (${s.label})`,
|
|
||||||
kind: "value" as const,
|
|
||||||
insertText: `/stat ${cmd} ${s.key}`,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
return matches.map((v) => ({
|
||||||
|
label: v,
|
||||||
|
kind: "value" as const,
|
||||||
|
insertText: `/set ${v} `,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,9 @@ import { createSignal } from "solid-js";
|
||||||
import { parseInput } from "./command-parser";
|
import { parseInput } from "./command-parser";
|
||||||
import { resolveRollPayload } from "./types/roll";
|
import { resolveRollPayload } from "./types/roll";
|
||||||
import { resolveSparkPayload } from "./types/spark";
|
import { resolveSparkPayload } from "./types/spark";
|
||||||
import {
|
import { evaluateExpression, expressionIsTag } from "./variable-expression";
|
||||||
resolveStatRoll,
|
import { computeCascade } from "./var-reactivity";
|
||||||
resolveTemplateSet,
|
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
canModifyStat,
|
|
||||||
fullKey,
|
|
||||||
findStatDef,
|
|
||||||
} from "./stat-helpers";
|
|
||||||
import type { StatDef, StatTemplate } from "./completions";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Result
|
// Result
|
||||||
|
|
@ -48,12 +43,12 @@ export interface DispatchContext {
|
||||||
command: string;
|
command: string;
|
||||||
/** Spark table lookup data (from completions) */
|
/** Spark table lookup data (from completions) */
|
||||||
sparkTables: { slug: string; csvPath?: string; remix?: boolean }[];
|
sparkTables: { slug: string; csvPath?: string; remix?: boolean }[];
|
||||||
/** Current runtime stat values */
|
/** Current runtime variable values */
|
||||||
statValues: Record<string, string>;
|
variables: Record<string, string>;
|
||||||
/** Stat definitions */
|
/** Variable declarations (from role=declare blocks) */
|
||||||
statDefs: StatDef[];
|
declarations: VarDeclaration[];
|
||||||
/** Stat templates */
|
/** Tag modifiers (from role=declare blocks) */
|
||||||
statTemplates: StatTemplate[];
|
tagModifiers: TagModifier[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -76,18 +71,18 @@ export async function dispatchCommand(
|
||||||
const parsed = parseInput(prefixed);
|
const parsed = parseInput(prefixed);
|
||||||
if (parsed.error) return finish({ ok: false, error: parsed.error });
|
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 (ctx.role === "player") {
|
||||||
if (parsed.type === "chat") {
|
if (parsed.type === "chat") {
|
||||||
const result = sendMessage("chat", { text: raw });
|
const result = sendMessage("chat", { text: raw });
|
||||||
return finish(unwrap(result));
|
return finish(unwrap(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type === "stat") {
|
if (parsed.type === "set" || parsed.type === "rolltag") {
|
||||||
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
|
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
|
// GM: all commands
|
||||||
|
|
@ -116,8 +111,8 @@ export async function dispatchCommand(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type === "stat") {
|
if (parsed.type === "set" || parsed.type === "rolltag") {
|
||||||
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
|
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = sendMessage(parsed.type, parsed.payload);
|
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>,
|
payload: Record<string, unknown>,
|
||||||
ctx: DispatchContext,
|
ctx: DispatchContext,
|
||||||
): DispatchResult {
|
): 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) {
|
if (!p.key) {
|
||||||
const resolved = resolveStatRoll(
|
return { ok: false, error: "缺少变量名" };
|
||||||
p.key,
|
}
|
||||||
ctx.statDefs,
|
|
||||||
ctx.statValues,
|
|
||||||
ctx.myName,
|
|
||||||
ctx.statTemplates,
|
|
||||||
);
|
|
||||||
if (resolved.error) return { ok: false, error: resolved.error };
|
|
||||||
|
|
||||||
const result = sendMessage("stat", {
|
const key = p.key;
|
||||||
action: "set",
|
const oldValue = ctx.variables[key] ?? undefined;
|
||||||
key: resolved.fullKey,
|
let newValue: string;
|
||||||
value: resolved.value,
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const r = unwrap(result);
|
newValue = String(result.value);
|
||||||
if (!r.ok) return r;
|
} else {
|
||||||
|
return { ok: false, error: "缺少表达式" };
|
||||||
if (resolved.modifiers) {
|
|
||||||
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
|
|
||||||
sendMessage("stat", { action: "set", key: mk, value: mv });
|
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
return { ok: true };
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : "表达式求值失败",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!p.action || !p.key) {
|
// Send the direct set
|
||||||
return { ok: false, error: "无效的 stat 命令" };
|
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)) {
|
// Compute cascade (tag activation/deactivation + declaration re-eval)
|
||||||
return { ok: false, error: `无权修改属性: ${p.key}` };
|
try {
|
||||||
}
|
const cascade = computeCascade(key, oldValue, workingVars);
|
||||||
|
for (const change of cascade) {
|
||||||
const result = sendMessage("stat", {
|
sendMessage("var", { action: "set", key: change.key, value: change.value });
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Cascade errors are non-fatal — the direct set already succeeded
|
||||||
|
console.warn("[dispatch] cascade error:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
@ -210,18 +196,6 @@ function dispatchStat(
|
||||||
// Helpers
|
// 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. */
|
/** Unwrap a sendMessage result into DispatchResult. */
|
||||||
function unwrap<R>(
|
function unwrap<R>(
|
||||||
r: { success: true; msg: R } | { success: false; error: string },
|
r: { success: true; msg: R } | { success: false; error: string },
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export interface CompletionItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedInput {
|
export interface ParsedInput {
|
||||||
type: "chat" | "roll" | "spark" | "link" | "stat";
|
type: "chat" | "roll" | "spark" | "link" | "set" | "rolltag";
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -41,41 +41,35 @@ export function parseInput(raw: string): ParsedInput {
|
||||||
return { type: "link", payload: { path, section } };
|
return { type: "link", payload: { path, section } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.startsWith("/stat ")) {
|
if (raw.startsWith("/set ")) {
|
||||||
const arg = raw.slice("/stat ".length).trim();
|
const rest = raw.slice("/set ".length).trim();
|
||||||
if (!arg)
|
if (!rest)
|
||||||
return { type: "stat", payload: {}, error: "需要 stat 命令 (set/del/roll)" };
|
return { type: "set", payload: {}, error: "格式: /set $key expression" };
|
||||||
|
|
||||||
if (arg.startsWith("set ")) {
|
// Split on first space to get key and expression
|
||||||
const rest = arg.slice("set ".length).trim();
|
const spaceIdx = rest.indexOf(" ");
|
||||||
const eqIdx = rest.indexOf("=");
|
if (spaceIdx === -1)
|
||||||
if (eqIdx === -1)
|
return { type: "set", payload: {}, error: "格式: /set $key expression" };
|
||||||
return { type: "stat", payload: {}, error: "格式: /stat set key=value" };
|
|
||||||
const key = rest.slice(0, eqIdx).trim();
|
const key = rest.slice(0, spaceIdx).trim();
|
||||||
const value = rest.slice(eqIdx + 1).trim();
|
const expr = rest.slice(spaceIdx + 1).trim();
|
||||||
if (!key || !value)
|
|
||||||
return { type: "stat", payload: {}, error: "key 和 value 不能为空" };
|
if (!key || !expr)
|
||||||
return { type: "stat", payload: { action: "set", key, value } };
|
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 ")) {
|
return { type: "set", payload: { key, expr } };
|
||||||
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 ")) {
|
if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/set") {
|
||||||
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" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/stat") {
|
|
||||||
return { type: "chat", payload: {}, error: "请补全命令" };
|
return { type: "chat", payload: {}, error: "请补全命令" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
* Journal completions — client-side loader for /__COMPLETIONS.json
|
* Journal completions — client-side loader for /__COMPLETIONS.json
|
||||||
*
|
*
|
||||||
* In CLI mode, fetches the pre-computed index. In dev/browser mode, falls
|
* 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()`
|
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
|
||||||
* from any Solid component to reactively read the state.
|
* from any Solid component to reactively read the state.
|
||||||
|
|
@ -15,13 +16,6 @@ import {
|
||||||
getPathsByExtension,
|
getPathsByExtension,
|
||||||
getIndexedData,
|
getIndexedData,
|
||||||
} from "../../data-loader/file-index";
|
} 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 {
|
import {
|
||||||
FENCED_BLOCK_RE,
|
FENCED_BLOCK_RE,
|
||||||
parseBlockAttrs,
|
parseBlockAttrs,
|
||||||
|
|
@ -29,8 +23,11 @@ import {
|
||||||
import {
|
import {
|
||||||
scanDirectives,
|
scanDirectives,
|
||||||
} from "../../cli/completions/directive-scanner";
|
} 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) -------------------
|
// ------------------- Types (mirrors CLI) -------------------
|
||||||
|
|
||||||
|
|
@ -60,8 +57,8 @@ export interface JournalCompletions {
|
||||||
dice: DiceCompletion[];
|
dice: DiceCompletion[];
|
||||||
links: LinkCompletion[];
|
links: LinkCompletion[];
|
||||||
sparkTables: SparkTableCompletion[];
|
sparkTables: SparkTableCompletion[];
|
||||||
stats: StatDef[];
|
declarations: VarDeclaration[];
|
||||||
statTemplates: StatTemplate[];
|
tagModifiers: TagModifier[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompletionsState =
|
export type CompletionsState =
|
||||||
|
|
@ -87,9 +84,11 @@ async function tryServer(): Promise<JournalCompletions | null> {
|
||||||
dice: Array.isArray(data.dice) ? data.dice : [],
|
dice: Array.isArray(data.dice) ? data.dice : [],
|
||||||
links: Array.isArray(data.links) ? data.links : [],
|
links: Array.isArray(data.links) ? data.links : [],
|
||||||
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
||||||
stats: Array.isArray(data.stats) ? data.stats : [],
|
declarations: Array.isArray(data.declarations)
|
||||||
statTemplates: Array.isArray(data.statTemplates)
|
? data.declarations
|
||||||
? data.statTemplates
|
: [],
|
||||||
|
tagModifiers: Array.isArray(data.tagModifiers)
|
||||||
|
? data.tagModifiers
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -104,8 +103,8 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
const dice: DiceCompletion[] = [];
|
const dice: DiceCompletion[] = [];
|
||||||
const links: LinkCompletion[] = [];
|
const links: LinkCompletion[] = [];
|
||||||
const sparkTables: SparkTableCompletion[] = [];
|
const sparkTables: SparkTableCompletion[] = [];
|
||||||
const stats: StatDef[] = [];
|
const declarations: VarDeclaration[] = [];
|
||||||
const statTemplates: StatTemplate[] = [];
|
const tagModifiers: TagModifier[] = [];
|
||||||
|
|
||||||
// Build a temporary index for resolving CSV paths
|
// Build a temporary index for resolving CSV paths
|
||||||
const tempIndex: Record<string, string> = {};
|
const tempIndex: Record<string, string> = {};
|
||||||
|
|
@ -135,7 +134,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Unified block scanning (stats) ----
|
// ---- Unified block scanning (declare) ----
|
||||||
FENCED_BLOCK_RE.lastIndex = 0;
|
FENCED_BLOCK_RE.lastIndex = 0;
|
||||||
let blockMatch: RegExpExecArray | null;
|
let blockMatch: RegExpExecArray | null;
|
||||||
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
|
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
|
||||||
|
|
@ -143,26 +142,15 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
const attrs = parseBlockAttrs(infoString);
|
const attrs = parseBlockAttrs(infoString);
|
||||||
attrs.lang = attrs.lang || lang;
|
attrs.lang = attrs.lang || lang;
|
||||||
|
|
||||||
if (attrs.role === "stat") {
|
if (attrs.role === "declare") {
|
||||||
if (attrs.lang === "yaml" || attrs.lang === "yml") {
|
try {
|
||||||
stats.push(...parseStatYaml(body, filePath));
|
const result = parseDeclareCsv(body, filePath);
|
||||||
} else if (attrs.lang === "csv") {
|
declarations.push(...result.variables);
|
||||||
stats.push(...parseStatCsv(body, filePath));
|
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) ----
|
// ---- Directive scanning (dice + spark tables) ----
|
||||||
|
|
@ -172,7 +160,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
sparkTables.push(...directiveResult.sparkTables);
|
sparkTables.push(...directiveResult.sparkTables);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { dice, links, sparkTables, stats, statTemplates };
|
return { dice, links, sparkTables, declarations, tagModifiers };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------- Init (runs eagerly at import time) -------------------
|
// ------------------- Init (runs eagerly at import time) -------------------
|
||||||
|
|
@ -183,6 +171,7 @@ const _initPromise: Promise<void> = (async () => {
|
||||||
const serverData = await tryServer();
|
const serverData = await tryServer();
|
||||||
if (serverData) {
|
if (serverData) {
|
||||||
setCompletionsState({ status: "loaded", data: serverData });
|
setCompletionsState({ status: "loaded", data: serverData });
|
||||||
|
try { initReactivity({ declarations: serverData.declarations, tagModifiers: serverData.tagModifiers }); } catch (e) { console.warn("[completions] reactivity init error:", e); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,6 +180,7 @@ const _initPromise: Promise<void> = (async () => {
|
||||||
const data = await scanClientSide();
|
const data = await scanClientSide();
|
||||||
if (data.dice.length > 0 || data.links.length > 0) {
|
if (data.dice.length > 0 || data.links.length > 0) {
|
||||||
setCompletionsState({ status: "loaded", data });
|
setCompletionsState({ status: "loaded", data });
|
||||||
|
try { initReactivity({ declarations: data.declarations, tagModifiers: data.tagModifiers }); } catch (e) { console.warn("[completions] reactivity init error:", e); }
|
||||||
} else {
|
} else {
|
||||||
setCompletionsState({ status: "empty" });
|
setCompletionsState({ status: "empty" });
|
||||||
}
|
}
|
||||||
|
|
@ -216,8 +206,6 @@ export function ensureCompletions(): Promise<void> {
|
||||||
let _invalidated = false;
|
let _invalidated = false;
|
||||||
export function invalidateCompletions(): void {
|
export function invalidateCompletions(): void {
|
||||||
_invalidated = true;
|
_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: [],
|
dice: [],
|
||||||
links: [],
|
links: [],
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
stats: [],
|
declarations: [],
|
||||||
statTemplates: [],
|
tagModifiers: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -51,5 +51,16 @@ export { DynamicForm } from "./DynamicForm";
|
||||||
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
||||||
export { dispatchCommand } from "./command-dispatcher";
|
export { dispatchCommand } from "./command-dispatcher";
|
||||||
export type { DispatchContext, DispatchResult } 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 { JournalContext, useJournalContext } from "./JournalContext";
|
||||||
export type { JournalContextValue } from "./JournalContext";
|
export type { JournalContextValue } from "./JournalContext";
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<string, string>,
|
|
||||||
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<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<string, string>,
|
|
||||||
playerName: string,
|
|
||||||
templates?: StatTemplate[],
|
|
||||||
): Record<string, string> | 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<string, string> = {};
|
|
||||||
|
|
||||||
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<string, string>,
|
|
||||||
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<string, string> = {};
|
|
||||||
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<string, string>;
|
|
||||||
}[],
|
|
||||||
): (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;
|
|
||||||
}
|
|
||||||
|
|
@ -10,4 +10,4 @@ import "./roll";
|
||||||
import "./spark";
|
import "./spark";
|
||||||
import "./link";
|
import "./link";
|
||||||
import "./intent";
|
import "./intent";
|
||||||
import "./stat";
|
import "./var";
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
/**
|
/**
|
||||||
* Built-in message type: stat
|
* Built-in message type: var
|
||||||
*
|
*
|
||||||
* Emitters: gm, player
|
* 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
|
* Variable declarations and tag modifiers are authored in ```csv role=declare
|
||||||
* documents. The stream carries set/del mutations that build up the
|
* code blocks in markdown documents. The stream carries set/del mutations
|
||||||
* runtime stat store additively.
|
* that build up the runtime variable store additively.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -20,11 +20,11 @@ const schema = z.object({
|
||||||
value: z.string().optional(),
|
value: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type StatPayload = z.infer<typeof schema>;
|
export type VarPayload = z.infer<typeof schema>;
|
||||||
|
|
||||||
registerMessageType<StatPayload>({
|
registerMessageType<VarPayload>({
|
||||||
type: "stat",
|
type: "var",
|
||||||
label: "属性",
|
label: "变量",
|
||||||
emitters: ["gm", "player"],
|
emitters: ["gm", "player"],
|
||||||
schema,
|
schema,
|
||||||
defaultPayload: () => ({ action: "set", key: "", value: "" }),
|
defaultPayload: () => ({ action: "set", key: "", value: "" }),
|
||||||
|
|
@ -32,7 +32,7 @@ registerMessageType<StatPayload>({
|
||||||
if (p.action === "del") {
|
if (p.action === "del") {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="text-lg">📊</span>
|
<span class="text-lg">🔢</span>
|
||||||
<span class="font-mono text-sm text-gray-500 line-through">
|
<span class="font-mono text-sm text-gray-500 line-through">
|
||||||
{p.key}
|
{p.key}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -41,9 +41,9 @@ registerMessageType<StatPayload>({
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="text-lg">📊</span>
|
<span class="text-lg">🔢</span>
|
||||||
<span class="font-mono text-sm">
|
<span class="font-mono text-sm">
|
||||||
{p.key} → <span class="font-bold">{p.value}</span>
|
{p.key} = <span class="font-bold">{p.value}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -52,9 +52,9 @@ registerMessageType<StatPayload>({
|
||||||
journalSetState(
|
journalSetState(
|
||||||
produce((s) => {
|
produce((s) => {
|
||||||
if (p.action === "del") {
|
if (p.action === "del") {
|
||||||
delete s.stats[p.key];
|
delete s.variables[p.key];
|
||||||
} else if (p.value !== undefined) {
|
} else if (p.value !== undefined) {
|
||||||
s.stats[p.key] = p.value;
|
s.variables[p.key] = p.value;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -44,7 +44,7 @@ export interface JournalStreamState {
|
||||||
myRole: "gm" | "player" | "observer";
|
myRole: "gm" | "player" | "observer";
|
||||||
brokerUrl: string | null;
|
brokerUrl: string | null;
|
||||||
players: Record<string, { role: string }>;
|
players: Record<string, { role: string }>;
|
||||||
stats: Record<string, string>;
|
variables: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionMeta {
|
export interface SessionMeta {
|
||||||
|
|
@ -96,7 +96,7 @@ const [state, setState] = createStore<JournalStreamState>({
|
||||||
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
|
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
|
||||||
brokerUrl: persisted.brokerUrl,
|
brokerUrl: persisted.brokerUrl,
|
||||||
players: {},
|
players: {},
|
||||||
stats: {},
|
variables: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
|
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
|
||||||
|
|
@ -126,7 +126,7 @@ function runReducer(msg: StreamMessage): void {
|
||||||
function replayReducers(): void {
|
function replayReducers(): void {
|
||||||
// Reset derived state
|
// Reset derived state
|
||||||
setState("revealedPaths", {});
|
setState("revealedPaths", {});
|
||||||
setState("stats", {});
|
setState("variables", {});
|
||||||
for (const msg of state.messages) {
|
for (const msg of state.messages) {
|
||||||
runReducer(msg);
|
runReducer(msg);
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +160,7 @@ function handlePatch(patch: WorkerPatch): void {
|
||||||
syncUrlParam("player", patch.state.myName);
|
syncUrlParam("player", patch.state.myName);
|
||||||
}
|
}
|
||||||
saveRole(patch.state.myRole);
|
saveRole(patch.state.myRole);
|
||||||
// Rebuild derived state (revealedPaths, stats) by replaying reducers
|
// Rebuild derived state (revealedPaths, variables) by replaying reducers
|
||||||
replayReducers();
|
replayReducers();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue