refactor: implement base/modifier separation for variable reactivity
Introduce a distinction between base values (set via `/set` or declarations) and active modifiers (applied via tags). This ensures tag transitions and numeric modifiers are calculated correctly during cascades. - Refactor `VariableView` to use a flat list with hover popups for declarations and active modifiers. - Update `command-dispatcher` to use `getCombined` for accurate value lookups and `setBase` for variable updates. - Update `var-reactivity` to manage `baseValues`, `activeMods`, and `sourceActivations` separately. - Export new utility functions for accessing combined values, modifiers, and declaration expressions.
This commit is contained in:
parent
411f4d79ff
commit
c524dd5867
|
|
@ -1,96 +1,34 @@
|
||||||
/**
|
/**
|
||||||
* VariableView — table view of all variable key-value pairs and active tags.
|
* VariableView — flat list of all variable key-value pairs.
|
||||||
*
|
*
|
||||||
* Shows variables grouped by type (declared, plain, tag-typed) and an
|
* Hovering a value shows a popup with:
|
||||||
* active tags section showing which variable activates each tag and
|
* - Declaration expression (if any), styled distinctly
|
||||||
* what modifier effects are in play.
|
* - Active tag modifiers: #tag +N (from $source)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, For, createMemo, Show } from "solid-js";
|
import { Component, For, createMemo, Show, createSignal } from "solid-js";
|
||||||
import { useJournalStream } from "../stores/journalStream";
|
import { useJournalStream } from "../stores/journalStream";
|
||||||
import { useJournalCompletions } from "./completions";
|
import { useJournalCompletions } from "./completions";
|
||||||
import { computeActiveTags } from "./var-reactivity";
|
import { getMods, getDeclExpr } from "./var-reactivity";
|
||||||
import { extractDependencies } from "./var-reactivity";
|
|
||||||
import type { VarDeclaration } from "./declare-parser";
|
|
||||||
|
|
||||||
export const VariableView: Component = () => {
|
export const VariableView: Component = () => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const comp = useJournalCompletions();
|
const comp = useJournalCompletions();
|
||||||
|
|
||||||
const declarations = createMemo(() => comp.data.declarations);
|
|
||||||
const tagModifiers = createMemo(() => comp.data.tagModifiers);
|
|
||||||
const values = createMemo(() => stream.variables);
|
const values = createMemo(() => stream.variables);
|
||||||
|
|
||||||
/** Build a map: $key → VarDeclaration */
|
/** Flat list of all variables */
|
||||||
const declMap = createMemo(() => {
|
const items = 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();
|
const vars = values();
|
||||||
for (const [key, val] of Object.entries(vars)) {
|
return Object.entries(vars).map(([key, value]) => ({
|
||||||
if (val.startsWith("#")) {
|
key,
|
||||||
if (!map.has(val)) map.set(val, key);
|
value,
|
||||||
}
|
isTag: value.startsWith("#"),
|
||||||
}
|
}));
|
||||||
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 = () =>
|
const hasAnyData = () =>
|
||||||
declarations().length > 0 ||
|
comp.data.declarations.length > 0 || Object.keys(values()).length > 0;
|
||||||
Object.keys(values()).length > 0 ||
|
|
||||||
activeTags().length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="h-full overflow-y-auto bg-gray-50">
|
<div class="h-full overflow-y-auto bg-gray-50">
|
||||||
|
|
@ -102,142 +40,85 @@ export const VariableView: Component = () => {
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Active Tags */}
|
<div class="divide-y divide-gray-100">
|
||||||
<Show when={activeTags().length > 0}>
|
<For each={items()}>
|
||||||
<div class="mb-3">
|
{(item) => (
|
||||||
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
<VariableRow
|
||||||
<span class="text-xs font-semibold text-gray-600">激活标签</span>
|
key={item.key}
|
||||||
</div>
|
value={item.value}
|
||||||
<div class="divide-y divide-gray-100">
|
isTag={item.isTag}
|
||||||
<For each={activeTags()}>
|
/>
|
||||||
{(tag) => {
|
)}
|
||||||
const activator = () => tagActivators().get(tag);
|
</For>
|
||||||
const mods = () => tagModMap().get(tag) ?? [];
|
</div>
|
||||||
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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VariableRow — single row with hover popup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const VariableRow: Component<{
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
isTag: boolean;
|
||||||
|
}> = (props) => {
|
||||||
|
const [hovered, setHovered] = createSignal(false);
|
||||||
|
|
||||||
|
const declExpr = createMemo(() => getDeclExpr(props.key));
|
||||||
|
const mods = createMemo(() => getMods(props.key));
|
||||||
|
|
||||||
|
const hasPopup = () => !!declExpr() || mods().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="relative flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors"
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
<span class="flex-1 text-sm font-mono text-gray-800">{props.key}</span>
|
||||||
|
<span
|
||||||
|
class={props.isTag
|
||||||
|
? "text-sm font-mono text-purple-700 font-semibold"
|
||||||
|
: "text-sm font-mono text-gray-700 font-semibold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.value}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Hover popup */}
|
||||||
|
<Show when={hasPopup() && hovered()}>
|
||||||
|
<div class="absolute right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-md shadow-lg p-2 min-w-[180px] text-xs">
|
||||||
|
{/* Declaration expression */}
|
||||||
|
<Show when={declExpr()}>
|
||||||
|
<div class="mb-1">
|
||||||
|
<span class="text-gray-400">expr: </span>
|
||||||
|
<span class="font-mono text-gray-500 bg-gray-50 px-1 rounded">
|
||||||
|
{declExpr()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Tag modifiers */}
|
||||||
|
<Show when={mods().length > 0}>
|
||||||
|
<div class={declExpr() ? "border-t border-gray-100 pt-1 mt-1" : ""}>
|
||||||
|
<For each={mods()}>
|
||||||
|
{(mod) => (
|
||||||
|
<div class="flex items-center gap-1 text-gray-500">
|
||||||
|
<span class="font-mono text-purple-600">{mod.tag}</span>
|
||||||
|
<span>+{mod.value}</span>
|
||||||
|
<span class="text-gray-400">(from {mod.source})</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VariableView;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ 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 { evaluateExpression, expressionIsTag } from "./variable-expression";
|
import { evaluateExpression, expressionIsTag } from "./variable-expression";
|
||||||
import { computeCascade } from "./var-reactivity";
|
import { computeCascade, getCombined, setBase } from "./var-reactivity";
|
||||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -140,7 +140,9 @@ function dispatchSet(
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = p.key;
|
const key = p.key;
|
||||||
const oldValue = ctx.variables[key] ?? undefined;
|
// Use getCombined for old value so tag transitions are detected correctly
|
||||||
|
// even when the variable has active mods.
|
||||||
|
const oldValue = getCombined(key);
|
||||||
let newValue: string;
|
let newValue: string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -152,11 +154,12 @@ function dispatchSet(
|
||||||
// Bare tag value
|
// Bare tag value
|
||||||
newValue = p.expr.trim();
|
newValue = p.expr.trim();
|
||||||
} else if (p.expr) {
|
} else if (p.expr) {
|
||||||
// Numeric expression — evaluate
|
// Numeric expression — evaluate using combined values
|
||||||
const result = evaluateExpression(p.expr, {
|
const result = evaluateExpression(p.expr, {
|
||||||
lookup: (name: string) => {
|
lookup: (name: string) => {
|
||||||
const k = "$" + name;
|
const k = "$" + name;
|
||||||
return ctx.variables[k] ?? undefined;
|
// Use getCombined for accurate values including active mods
|
||||||
|
return k === key ? undefined : getCombined(k);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
newValue = String(result.value);
|
newValue = String(result.value);
|
||||||
|
|
@ -170,12 +173,11 @@ function dispatchSet(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the direct set
|
// Update base value so getCombined returns the correct new value
|
||||||
const r1 = sendMessage("var", { action: "set", key, value: newValue });
|
// during cascade computation
|
||||||
const u1 = unwrap(r1);
|
setBase(key, newValue);
|
||||||
if (!u1.ok) return u1;
|
|
||||||
|
|
||||||
// Build working variable store for cascade
|
// Build working variable store for cascade (includes the new value)
|
||||||
const workingVars = { ...ctx.variables, [key]: newValue };
|
const workingVars = { ...ctx.variables, [key]: newValue };
|
||||||
|
|
||||||
// Compute cascade (tag activation/deactivation + declaration re-eval)
|
// Compute cascade (tag activation/deactivation + declaration re-eval)
|
||||||
|
|
@ -189,6 +191,14 @@ function dispatchSet(
|
||||||
console.warn("[dispatch] cascade error:", e);
|
console.warn("[dispatch] cascade error:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send the direct set last so cascade messages arrive first.
|
||||||
|
// Use getCombined so the stream store receives the correct value
|
||||||
|
// (base + active mods) rather than just the raw base.
|
||||||
|
const combinedValue = getCombined(key);
|
||||||
|
const r1 = sendMessage("var", { action: "set", key, value: combinedValue });
|
||||||
|
const u1 = unwrap(r1);
|
||||||
|
if (!u1.ok) return u1;
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ export { parseDeclareCsv } from "./declare-parser";
|
||||||
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
export { evaluateExpression, expressionIsTag } from "./variable-expression";
|
export { evaluateExpression, expressionIsTag } from "./variable-expression";
|
||||||
export type { EvalContext, EvalResult } from "./variable-expression";
|
export type { EvalContext, EvalResult } from "./variable-expression";
|
||||||
export { initReactivity, computeCascade, computeActiveTags, extractDependencies, computeInitialValues } from "./var-reactivity";
|
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
|
||||||
export type { VarReactivityState, VariableStore } 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";
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,18 @@
|
||||||
* modifiers, then cascades changes when variables are set.
|
* modifiers, then cascades changes when variables are set.
|
||||||
*
|
*
|
||||||
* Runs client-side (in the sender's tab, via command-dispatcher).
|
* Runs client-side (in the sender's tab, via command-dispatcher).
|
||||||
* When a variable changes, it:
|
* Uses a base/mod separation:
|
||||||
* 1. Sends the base var message
|
* - baseValues: what /set writes (or declaration evaluation produces)
|
||||||
* 2. Detects tag activation/deactivation diff
|
* - activeMods: per-target list of {tag, value, source} from tag activations
|
||||||
* 3. Re-evaluates declared variables whose dependencies changed
|
* - sourceActivations: per-source list of {tag, target, value} for deactivation
|
||||||
* 4. Applies/removes tag modifiers on affected targets
|
|
||||||
* 5. Sends var messages for every derived change
|
|
||||||
*
|
*
|
||||||
* Circular dependency detection happens at registration time
|
* Combined value = base + sum(activeMods). Tag values (starting with #)
|
||||||
* (topological sort). At runtime, we guard against re-entrant
|
* are not numeric and don't receive mods.
|
||||||
* evaluation with an in-flight set.
|
*
|
||||||
|
* Limitation: base snapshots are taken at tag activation time. If another
|
||||||
|
* user changes a variable's value remotely while a local tag is active,
|
||||||
|
* the deactivation will restore the snapshot base rather than the remote
|
||||||
|
* value. In practice this is rare — GMs set bases, players toggle tags.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
|
|
@ -23,15 +25,19 @@ import { evaluateExpression } from "./variable-expression";
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface VarReactivityState {
|
export interface VarReactivityState {
|
||||||
/** All variable declarations (from role=declare blocks) */
|
|
||||||
declarations: VarDeclaration[];
|
declarations: VarDeclaration[];
|
||||||
/** All tag modifiers (from role=declare blocks) */
|
|
||||||
tagModifiers: TagModifier[];
|
tagModifiers: TagModifier[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The runtime variable store (read from stream state). */
|
|
||||||
export type VariableStore = Record<string, string>;
|
export type VariableStore = Record<string, string>;
|
||||||
|
|
||||||
|
/** A single modifier applied to a target variable. */
|
||||||
|
export interface ActiveMod {
|
||||||
|
tag: string; // "#warrior"
|
||||||
|
value: number; // evaluated modifier expression result
|
||||||
|
source: string; // "$class" — which variable activated this tag
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Internal state
|
// Internal state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -42,12 +48,24 @@ let depGraph: Map<string, Set<string>> | null = null;
|
||||||
/** Reverse: $declaredVar → its expression */
|
/** Reverse: $declaredVar → its expression */
|
||||||
let declExprs: Map<string, string> | null = null;
|
let declExprs: Map<string, string> | null = null;
|
||||||
|
|
||||||
/** Tag modifiers: #tag → [{target, expression}] */
|
/** Tag modifiers from declare blocks: #tag → [{target, expression}] */
|
||||||
let tagModMap: Map<string, Array<{ target: string; expression: string }>> | null = null;
|
let tagModMap: Map<string, Array<{ target: string; expression: string }>> | null = null;
|
||||||
|
|
||||||
|
/** Base values set by /set or declaration evaluation */
|
||||||
|
const baseValues = new Map<string, string>();
|
||||||
|
|
||||||
|
/** Active mods per target: $target → [{tag, value, source}] */
|
||||||
|
const activeMods = new Map<string, ActiveMod[]>();
|
||||||
|
|
||||||
|
/** Activations per source: $source → [{tag, target, value}] */
|
||||||
|
const sourceActivations = new Map<string, Array<{ tag: string; target: string; value: number }>>();
|
||||||
|
|
||||||
/** Set of $vars currently being re-evaluated (cycle guard) */
|
/** Set of $vars currently being re-evaluated (cycle guard) */
|
||||||
const inFlight = new Set<string>();
|
const inFlight = new Set<string>();
|
||||||
|
|
||||||
|
/** Fallback store for variables not tracked locally (set by other users) */
|
||||||
|
let streamFallback: VariableStore = {};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -62,6 +80,9 @@ export function initReactivity(state: VarReactivityState): void {
|
||||||
depGraph = new Map();
|
depGraph = new Map();
|
||||||
declExprs = new Map();
|
declExprs = new Map();
|
||||||
tagModMap = new Map();
|
tagModMap = new Map();
|
||||||
|
baseValues.clear();
|
||||||
|
activeMods.clear();
|
||||||
|
sourceActivations.clear();
|
||||||
|
|
||||||
// Index tag modifiers
|
// Index tag modifiers
|
||||||
for (const tm of state.tagModifiers) {
|
for (const tm of state.tagModifiers) {
|
||||||
|
|
@ -87,14 +108,12 @@ export function initReactivity(state: VarReactivityState): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Circular dependency check
|
|
||||||
checkCircular();
|
checkCircular();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract $var names from an expression string. */
|
/** Extract $var names from an expression string. */
|
||||||
export function extractDependencies(expr: string): string[] {
|
export function extractDependencies(expr: string): string[] {
|
||||||
const vars: string[] = [];
|
const vars: string[] = [];
|
||||||
// Match $identifier patterns (not inside function names, just bare $var)
|
|
||||||
const re = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
const re = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = re.exec(expr)) !== null) {
|
while ((m = re.exec(expr)) !== null) {
|
||||||
|
|
@ -105,16 +124,63 @@ export function extractDependencies(expr: string): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute which declared variables need re-evaluation after
|
* Get the combined value of a variable (base + sum of active mods).
|
||||||
* a set of base variables changed, and what tag modifier effects
|
* Falls back to the stream store for variables not tracked locally.
|
||||||
* to apply.
|
*/
|
||||||
|
export function getCombined(key: string): string {
|
||||||
|
const base = baseValues.get(key);
|
||||||
|
if (base !== undefined && isTagValue(base)) {
|
||||||
|
return base; // tag values don't receive numeric mods
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseNum = base !== undefined ? parseFloat(base) : NaN;
|
||||||
|
const mods = activeMods.get(key) ?? [];
|
||||||
|
const modSum = mods.reduce((sum, m) => sum + m.value, 0);
|
||||||
|
|
||||||
|
if (!isNaN(baseNum)) {
|
||||||
|
return String(baseNum + modSum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to stream store
|
||||||
|
const fallback = streamFallback[key];
|
||||||
|
if (fallback !== undefined) {
|
||||||
|
if (isTagValue(fallback)) return fallback;
|
||||||
|
const fbNum = parseFloat(fallback);
|
||||||
|
if (!isNaN(fbNum)) return String(fbNum + modSum);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No base, but has mods
|
||||||
|
if (mods.length > 0) return String(modSum);
|
||||||
|
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the active mods for a variable (for UI hover display). */
|
||||||
|
export function getMods(key: string): ActiveMod[] {
|
||||||
|
return activeMods.get(key) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the declaration expression for a variable, if any. */
|
||||||
|
export function getDeclExpr(key: string): string | undefined {
|
||||||
|
return declExprs?.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the base value for a variable. Called by dispatchSet before
|
||||||
|
* computing the cascade, so getCombined returns the correct new value.
|
||||||
|
*/
|
||||||
|
export function setBase(key: string, value: string): void {
|
||||||
|
baseValues.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute cascade effects after a variable change.
|
||||||
*
|
*
|
||||||
* Returns the list of { key, value } pairs to publish as var messages.
|
* @param changedVar - the variable that was just set (e.g. "$con")
|
||||||
* The caller is responsible for sending these via sendMessage.
|
* @param oldValue - its previous combined value (for tag transition detection)
|
||||||
*
|
* @param currentVars - the full stream variable store (as fallback)
|
||||||
* `changedVar` is the variable that was just set (e.g. "$con").
|
* @returns list of {key, value} pairs (combined values) to publish as var messages
|
||||||
* `oldValue` is its previous value (for detecting tag transitions).
|
|
||||||
* `currentVars` is the full variable store.
|
|
||||||
*/
|
*/
|
||||||
export function computeCascade(
|
export function computeCascade(
|
||||||
changedVar: string,
|
changedVar: string,
|
||||||
|
|
@ -125,49 +191,38 @@ export function computeCascade(
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
streamFallback = currentVars;
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: Array<{ key: string; value: string }> = [];
|
||||||
|
|
||||||
// ---- Tag activation/deactivation ----
|
// ---- Tag activation/deactivation ----
|
||||||
const newTag = isTagValue(currentVars[changedVar]);
|
const newValue = getCombined(changedVar);
|
||||||
|
const newTag = isTagValue(newValue);
|
||||||
const oldTag = isTagValue(oldValue);
|
const oldTag = isTagValue(oldValue);
|
||||||
|
|
||||||
if (newTag !== oldTag) {
|
if (newTag !== oldTag) {
|
||||||
// Deactivate old tag
|
// Deactivate old tag
|
||||||
if (oldTag) {
|
if (oldTag) {
|
||||||
const removed = computeTagDeactivation(
|
const removed = deactivateTagFromSource(changedVar, oldTag);
|
||||||
oldTag,
|
|
||||||
currentVars,
|
|
||||||
);
|
|
||||||
for (const r of removed) {
|
for (const r of removed) {
|
||||||
results.push(r);
|
results.push(r);
|
||||||
// Update currentVars in-place so subsequent steps see new values
|
|
||||||
currentVars = { ...currentVars, [r.key]: r.value };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate new tag
|
// Activate new tag
|
||||||
if (newTag) {
|
if (newTag) {
|
||||||
const added = computeTagActivation(
|
const added = activateTagFromSource(changedVar, newTag);
|
||||||
newTag,
|
|
||||||
currentVars,
|
|
||||||
);
|
|
||||||
for (const r of added) {
|
for (const r of added) {
|
||||||
results.push(r);
|
results.push(r);
|
||||||
currentVars = { ...currentVars, [r.key]: r.value };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Declaration re-evaluation ----
|
// ---- Declaration re-evaluation ----
|
||||||
const reevaluated = reevaluateDependents(
|
const reevaluated = reevaluateDependents(changedVar);
|
||||||
changedVar,
|
|
||||||
currentVars,
|
|
||||||
);
|
|
||||||
for (const r of reevaluated) {
|
for (const r of reevaluated) {
|
||||||
// Avoid re-sending the same key if it was already in tag results
|
|
||||||
if (!results.some((x) => x.key === r.key)) {
|
if (!results.some((x) => x.key === r.key)) {
|
||||||
results.push(r);
|
results.push(r);
|
||||||
currentVars = { ...currentVars, [r.key]: r.value };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,18 +232,18 @@ export function computeCascade(
|
||||||
/**
|
/**
|
||||||
* Compute initial values for all declared variables.
|
* Compute initial values for all declared variables.
|
||||||
* Called once after initReactivity() to seed the store.
|
* Called once after initReactivity() to seed the store.
|
||||||
* Unset dependencies default to 0.
|
|
||||||
*/
|
*/
|
||||||
export function computeInitialValues(
|
export function computeInitialValues(
|
||||||
currentVars: VariableStore,
|
currentVars: VariableStore,
|
||||||
): Array<{ key: string; value: string }> {
|
): Array<{ key: string; value: string }> {
|
||||||
if (!declExprs) return [];
|
if (!declExprs) return [];
|
||||||
|
|
||||||
|
streamFallback = currentVars;
|
||||||
|
|
||||||
const allKeys = [...declExprs.keys()];
|
const allKeys = [...declExprs.keys()];
|
||||||
const sorted = topoSortAffected(new Set(allKeys));
|
const sorted = topoSortAffected(new Set(allKeys));
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: Array<{ key: string; value: string }> = [];
|
||||||
const localVars = { ...currentVars };
|
|
||||||
|
|
||||||
for (const key of sorted) {
|
for (const key of sorted) {
|
||||||
const expr = declExprs.get(key);
|
const expr = declExprs.get(key);
|
||||||
|
|
@ -198,16 +253,28 @@ export function computeInitialValues(
|
||||||
const result = evaluateExpression(expr, {
|
const result = evaluateExpression(expr, {
|
||||||
lookup: (name: string) => {
|
lookup: (name: string) => {
|
||||||
const k = "$" + name;
|
const k = "$" + name;
|
||||||
return localVars[k] ?? undefined;
|
return getCombined(k);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const newValue = String(result.value);
|
const rawValue = String(result.value);
|
||||||
const finalValue = applyTagModifiersTo(key, newValue, localVars);
|
baseValues.set(key, rawValue);
|
||||||
|
|
||||||
if (finalValue !== (localVars[key] ?? "")) {
|
// Check if this is a tag value — if so, activate it
|
||||||
localVars[key] = finalValue;
|
const tag = isTagValue(rawValue);
|
||||||
results.push({ key, value: finalValue });
|
if (tag) {
|
||||||
|
const added = activateTagFromSource(key, tag);
|
||||||
|
for (const r of added) {
|
||||||
|
if (!results.some((x) => x.key === r.key)) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always emit the combined value for this key
|
||||||
|
const combined = getCombined(key);
|
||||||
|
if (!results.some((x) => x.key === key)) {
|
||||||
|
results.push({ key, value: combined });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// skip failed evaluations at init time
|
// skip failed evaluations at init time
|
||||||
|
|
@ -217,22 +284,8 @@ export function computeInitialValues(
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute which tags are active given the current variable store.
|
|
||||||
* A tag is active if any variable has that tag as its value.
|
|
||||||
*/
|
|
||||||
export function computeActiveTags(vars: VariableStore): string[] {
|
|
||||||
const active: string[] = [];
|
|
||||||
for (const value of Object.values(vars)) {
|
|
||||||
if (isTagValue(value) && !active.includes(value)) {
|
|
||||||
active.push(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return active;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Internal helpers
|
// Tag activation / deactivation (source-based)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function isTagValue(value: string | undefined): string | null {
|
function isTagValue(value: string | undefined): string | null {
|
||||||
|
|
@ -241,13 +294,112 @@ function isTagValue(value: string | undefined): string | null {
|
||||||
return trimmed.startsWith("#") ? trimmed : null;
|
return trimmed.startsWith("#") ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Activate a tag from a source variable. Evaluates modifiers once. */
|
||||||
* Walk the dependency graph from `changedVar` and re-evaluate all
|
function activateTagFromSource(
|
||||||
* affected declarations. Uses topological order.
|
source: string,
|
||||||
*/
|
tag: string,
|
||||||
|
): Array<{ key: string; value: string }> {
|
||||||
|
if (!tagModMap) return [];
|
||||||
|
|
||||||
|
const mods = tagModMap.get(tag);
|
||||||
|
if (!mods) return [];
|
||||||
|
|
||||||
|
const results: Array<{ key: string; value: string }> = [];
|
||||||
|
const sourceEntries: Array<{ tag: string; target: string; value: number }> = [];
|
||||||
|
|
||||||
|
for (const mod of mods) {
|
||||||
|
try {
|
||||||
|
// Snapshot the target's current combined value as its base before
|
||||||
|
// adding the mod, so deactivation can restore it correctly.
|
||||||
|
if (!baseValues.has(mod.target)) {
|
||||||
|
baseValues.set(mod.target, getCombined(mod.target));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = evaluateExpression(mod.expression, {
|
||||||
|
lookup: (name: string) => {
|
||||||
|
const k = "$" + name;
|
||||||
|
return getCombined(k);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry: ActiveMod = { tag, value: result.value, source };
|
||||||
|
|
||||||
|
let targetMods = activeMods.get(mod.target);
|
||||||
|
if (!targetMods) {
|
||||||
|
targetMods = [];
|
||||||
|
activeMods.set(mod.target, targetMods);
|
||||||
|
}
|
||||||
|
targetMods.push(entry);
|
||||||
|
|
||||||
|
sourceEntries.push({ tag, target: mod.target, value: result.value });
|
||||||
|
|
||||||
|
// Emit new combined value for the target
|
||||||
|
const combined = getCombined(mod.target);
|
||||||
|
const existing = results.findIndex((r) => r.key === mod.target);
|
||||||
|
if (existing >= 0) {
|
||||||
|
results[existing] = { key: mod.target, value: combined };
|
||||||
|
} else {
|
||||||
|
results.push({ key: mod.target, value: combined });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip failed modifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceActivations.set(source, sourceEntries);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deactivate a tag from a source variable. Removes exactly the mods it created. */
|
||||||
|
function deactivateTagFromSource(
|
||||||
|
source: string,
|
||||||
|
_tag: string,
|
||||||
|
): Array<{ key: string; value: string }> {
|
||||||
|
const entries = sourceActivations.get(source);
|
||||||
|
if (!entries) return [];
|
||||||
|
|
||||||
|
const results: Array<{ key: string; value: string }> = [];
|
||||||
|
const affectedTargets = new Set<string>();
|
||||||
|
|
||||||
|
// Remove mods from activeMods
|
||||||
|
for (const entry of entries) {
|
||||||
|
const targetMods = activeMods.get(entry.target);
|
||||||
|
if (!targetMods) continue;
|
||||||
|
|
||||||
|
const idx = targetMods.findIndex(
|
||||||
|
(m) => m.tag === entry.tag && m.source === source,
|
||||||
|
);
|
||||||
|
if (idx >= 0) {
|
||||||
|
targetMods.splice(idx, 1);
|
||||||
|
affectedTargets.add(entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty arrays
|
||||||
|
for (const target of affectedTargets) {
|
||||||
|
const mods = activeMods.get(target);
|
||||||
|
if (mods && mods.length === 0) {
|
||||||
|
activeMods.delete(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove source activations
|
||||||
|
sourceActivations.delete(source);
|
||||||
|
|
||||||
|
// Emit new combined values for affected targets
|
||||||
|
for (const target of affectedTargets) {
|
||||||
|
results.push({ key: target, value: getCombined(target) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Declaration re-evaluation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function reevaluateDependents(
|
function reevaluateDependents(
|
||||||
changedVar: string,
|
changedVar: string,
|
||||||
vars: VariableStore,
|
|
||||||
): Array<{ key: string; value: string }> {
|
): Array<{ key: string; value: string }> {
|
||||||
if (!depGraph || !declExprs) return [];
|
if (!depGraph || !declExprs) return [];
|
||||||
|
|
||||||
|
|
@ -261,21 +413,19 @@ function reevaluateDependents(
|
||||||
for (const d of dependents) {
|
for (const d of dependents) {
|
||||||
if (!affected.has(d)) {
|
if (!affected.has(d)) {
|
||||||
affected.add(d);
|
affected.add(d);
|
||||||
queue.push(d); // transitive: if C depends on B which depends on A...
|
queue.push(d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topological sort: declarations that depend only on base vars go first
|
|
||||||
const sorted = topoSortAffected(affected);
|
const sorted = topoSortAffected(affected);
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: Array<{ key: string; value: string }> = [];
|
||||||
const localVars = { ...vars }; // working copy
|
|
||||||
|
|
||||||
for (const key of sorted) {
|
for (const key of sorted) {
|
||||||
if (inFlight.has(key)) {
|
if (inFlight.has(key)) {
|
||||||
// Shouldn't happen (caught at init), but guard anyway
|
throw new Error(
|
||||||
throw new Error(`Circular dependency detected during evaluation of ${key}`);
|
`Circular dependency detected during evaluation of ${key}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
inFlight.add(key);
|
inFlight.add(key);
|
||||||
|
|
||||||
|
|
@ -283,23 +433,49 @@ function reevaluateDependents(
|
||||||
const expr = declExprs.get(key);
|
const expr = declExprs.get(key);
|
||||||
if (!expr) continue;
|
if (!expr) continue;
|
||||||
|
|
||||||
// Evaluate with current localVars (which includes previously
|
|
||||||
// re-evaluated dependents)
|
|
||||||
const result = evaluateExpression(expr, {
|
const result = evaluateExpression(expr, {
|
||||||
lookup: (name: string) => {
|
lookup: (name: string) => {
|
||||||
const k = "$" + name;
|
const k = "$" + name;
|
||||||
return localVars[k] ?? undefined;
|
return getCombined(k);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const newValue = String(result.value);
|
const rawValue = String(result.value);
|
||||||
|
|
||||||
// Apply any active tag modifiers to this key
|
// Check for tag transition on this declared variable
|
||||||
const finalValue = applyTagModifiersTo(key, newValue, localVars);
|
const oldCombined = getCombined(key);
|
||||||
|
const oldTag = isTagValue(oldCombined);
|
||||||
|
const newTag = isTagValue(rawValue);
|
||||||
|
|
||||||
if (finalValue !== localVars[key]) {
|
if (oldTag !== newTag) {
|
||||||
localVars[key] = finalValue;
|
// Deactivate old tag
|
||||||
results.push({ key, value: finalValue });
|
if (oldTag) {
|
||||||
|
const removed = deactivateTagFromSource(key, oldTag);
|
||||||
|
for (const r of removed) {
|
||||||
|
if (!results.some((x) => x.key === r.key)) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Activate new tag
|
||||||
|
if (newTag) {
|
||||||
|
baseValues.set(key, rawValue);
|
||||||
|
const added = activateTagFromSource(key, newTag);
|
||||||
|
for (const r of added) {
|
||||||
|
if (!results.some((x) => x.key === r.key)) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update base value
|
||||||
|
baseValues.set(key, rawValue);
|
||||||
|
|
||||||
|
// Emit combined value
|
||||||
|
const combined = getCombined(key);
|
||||||
|
if (!results.some((x) => x.key === key)) {
|
||||||
|
results.push({ key, value: combined });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
inFlight.delete(key);
|
inFlight.delete(key);
|
||||||
|
|
@ -309,131 +485,10 @@ function reevaluateDependents(
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply all active tag modifiers that target `key`.
|
|
||||||
* Returns the modified value (base + sum of active modifiers).
|
|
||||||
*/
|
|
||||||
function applyTagModifiersTo(
|
|
||||||
key: string,
|
|
||||||
baseValue: string,
|
|
||||||
vars: VariableStore,
|
|
||||||
): string {
|
|
||||||
if (!tagModMap) return baseValue;
|
|
||||||
|
|
||||||
const activeTags = computeActiveTags(vars);
|
|
||||||
const baseNum = parseFloat(baseValue);
|
|
||||||
if (isNaN(baseNum)) return baseValue; // non-numeric, can't apply modifiers
|
|
||||||
|
|
||||||
let modifierSum = 0;
|
|
||||||
for (const tag of activeTags) {
|
|
||||||
const mods = tagModMap.get(tag);
|
|
||||||
if (!mods) continue;
|
|
||||||
for (const mod of mods) {
|
|
||||||
if (mod.target === key) {
|
|
||||||
try {
|
|
||||||
const result = evaluateExpression(mod.expression, {
|
|
||||||
lookup: (name: string) => {
|
|
||||||
const k = "$" + name;
|
|
||||||
return vars[k] ?? undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
modifierSum += result.value;
|
|
||||||
} catch {
|
|
||||||
// If modifier expression fails, skip it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(baseNum + modifierSum);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the effects of activating a tag: for each modifier,
|
|
||||||
* evaluate and add to the target variable's current value.
|
|
||||||
*/
|
|
||||||
function computeTagActivation(
|
|
||||||
tag: string,
|
|
||||||
vars: VariableStore,
|
|
||||||
): Array<{ key: string; value: string }> {
|
|
||||||
if (!tagModMap) return [];
|
|
||||||
|
|
||||||
const mods = tagModMap.get(tag);
|
|
||||||
if (!mods) return [];
|
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
|
||||||
const localVars = { ...vars };
|
|
||||||
|
|
||||||
for (const mod of mods) {
|
|
||||||
try {
|
|
||||||
const result = evaluateExpression(mod.expression, {
|
|
||||||
lookup: (name: string) => {
|
|
||||||
const k = "$" + name;
|
|
||||||
return localVars[k] ?? undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentBase = parseFloat(localVars[mod.target] ?? "0");
|
|
||||||
if (!isNaN(currentBase)) {
|
|
||||||
const newValue = String(currentBase + result.value);
|
|
||||||
localVars[mod.target] = newValue;
|
|
||||||
results.push({ key: mod.target, value: newValue });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// skip failed modifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the effects of deactivating a tag: for each modifier,
|
|
||||||
* subtract from the target variable's current value.
|
|
||||||
*/
|
|
||||||
function computeTagDeactivation(
|
|
||||||
tag: string,
|
|
||||||
vars: VariableStore,
|
|
||||||
): Array<{ key: string; value: string }> {
|
|
||||||
if (!tagModMap) return [];
|
|
||||||
|
|
||||||
const mods = tagModMap.get(tag);
|
|
||||||
if (!mods) return [];
|
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
|
||||||
const localVars = { ...vars };
|
|
||||||
|
|
||||||
for (const mod of mods) {
|
|
||||||
try {
|
|
||||||
const result = evaluateExpression(mod.expression, {
|
|
||||||
lookup: (name: string) => {
|
|
||||||
const k = "$" + name;
|
|
||||||
return localVars[k] ?? undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentValue = parseFloat(localVars[mod.target] ?? "0");
|
|
||||||
if (!isNaN(currentValue)) {
|
|
||||||
const newValue = String(currentValue - result.value);
|
|
||||||
localVars[mod.target] = newValue;
|
|
||||||
results.push({ key: mod.target, value: newValue });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// skip failed modifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Topological sort & circular check
|
// Topological sort & circular check
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* Topologically sort a set of affected declaration keys so that
|
|
||||||
* dependents are evaluated after their dependencies.
|
|
||||||
*/
|
|
||||||
function topoSortAffected(affected: Set<string>): string[] {
|
function topoSortAffected(affected: Set<string>): string[] {
|
||||||
if (!depGraph || !declExprs) return [...affected];
|
if (!depGraph || !declExprs) return [...affected];
|
||||||
|
|
||||||
|
|
@ -444,12 +499,10 @@ function topoSortAffected(affected: Set<string>): string[] {
|
||||||
function visit(key: string): void {
|
function visit(key: string): void {
|
||||||
if (visited.has(key)) return;
|
if (visited.has(key)) return;
|
||||||
if (temp.has(key)) {
|
if (temp.has(key)) {
|
||||||
// This shouldn't happen if checkCircular passed, but guard
|
|
||||||
throw new Error(`Circular dependency involving ${key}`);
|
throw new Error(`Circular dependency involving ${key}`);
|
||||||
}
|
}
|
||||||
temp.add(key);
|
temp.add(key);
|
||||||
|
|
||||||
// Visit dependencies first
|
|
||||||
const expr = declExprs!.get(key);
|
const expr = declExprs!.get(key);
|
||||||
if (expr) {
|
if (expr) {
|
||||||
const deps = extractDependencies(expr);
|
const deps = extractDependencies(expr);
|
||||||
|
|
@ -472,10 +525,6 @@ function topoSortAffected(affected: Set<string>): string[] {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for circular dependencies in the full declaration graph.
|
|
||||||
* Throws with a descriptive message if a cycle is found.
|
|
||||||
*/
|
|
||||||
function checkCircular(): void {
|
function checkCircular(): void {
|
||||||
if (!declExprs) return;
|
if (!declExprs) return;
|
||||||
|
|
||||||
|
|
@ -516,4 +565,4 @@ function checkCircular(): void {
|
||||||
for (const key of allKeys) {
|
for (const key of allKeys) {
|
||||||
dfs(key);
|
dfs(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue