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:
hypercross 2026-07-13 10:10:47 +08:00
parent 411f4d79ff
commit c524dd5867
4 changed files with 375 additions and 435 deletions

View File

@ -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("#")) {
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, key,
value: val, value,
expr: decl.expression, isTag: value.startsWith("#"),
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 */}
<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"> <div class="divide-y divide-gray-100">
<For each={activeTags()}> <For each={items()}>
{(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) => ( {(item) => (
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors"> <VariableRow
<div class="flex-1 min-w-0 flex items-center gap-1.5"> key={item.key}
<span class="text-sm font-mono text-gray-800"> value={item.value}
{item.key} isTag={item.isTag}
</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> </For>
</div> </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;

View File

@ -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 };
} }

View File

@ -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";

View File

@ -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;