/** * Variable reactivity engine — tracks variable declarations and tag * modifiers, then cascades changes when variables are set. * * Runs client-side (in the sender's tab, via command-dispatcher). * Uses a base/mod separation: * - baseValues: what /set writes (or declaration evaluation produces) * - activeMods: per-target list of {tag, value, source} from tag activations * - sourceActivations: per-source list of {tag, target, value} for deactivation * * Combined value = base + sum(activeMods). Tag values (starting with #) * are not numeric and don't receive mods. * * All functions that resolve variable values accept an explicit `fallback` * (the stream VariableStore) rather than relying on mutable module state. * This ensures correctness regardless of call order. */ import type { VarDeclaration, TagModifier } from "./declare-parser"; import { evaluateExpression } from "./variable-expression"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface VarReactivityState { declarations: VarDeclaration[]; tagModifiers: TagModifier[]; } export type VariableStore = Record; /** 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 // --------------------------------------------------------------------------- /** Dependency graph: $dep → Set<$declaredVar> */ let depGraph: Map> | null = null; /** Reverse: $declaredVar → its expression */ let declExprs: Map | null = null; /** Tag modifiers from declare blocks: #tag → [{target, expression}] */ let tagModMap: Map> | null = null; /** Base values set by /set or declaration evaluation */ const baseValues = new Map(); /** Active mods per target: $target → [{tag, value, source}] */ const activeMods = new Map(); /** Activations per source: $source → [{tag, target, value}] */ const sourceActivations = new Map>(); /** Set of $vars currently being re-evaluated (cycle guard) */ const inFlight = new Set(); // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Initialize (or re-initialize) the reactivity engine from declarations * and tag modifiers parsed from role=declare blocks. * * Throws if a circular dependency is detected. */ export function initReactivity(state: VarReactivityState): void { depGraph = new Map(); declExprs = new Map(); tagModMap = new Map(); baseValues.clear(); activeMods.clear(); sourceActivations.clear(); // Index tag modifiers for (const tm of state.tagModifiers) { let list = tagModMap.get(tm.tag); if (!list) { list = []; tagModMap.set(tm.tag, list); } list.push({ target: tm.target, expression: tm.expression }); } // Index declarations and build dependency graph for (const decl of state.declarations) { declExprs.set(decl.key, decl.expression); const deps = extractDependencies(decl.expression); for (const dep of deps) { let dependents = depGraph.get(dep); if (!dependents) { dependents = new Set(); depGraph.set(dep, dependents); } dependents.add(decl.key); } } checkCircular(); } /** Extract $var names from an expression string. */ export function extractDependencies(expr: string): string[] { const vars: string[] = []; const re = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g; let m: RegExpExecArray | null; while ((m = re.exec(expr)) !== null) { const name = "$" + m[1]; if (!vars.includes(name)) vars.push(name); } return vars; } /** * Get the combined value of a variable (base + sum of active mods). * Falls back to the provided stream store for variables not tracked locally. */ export function getCombined(key: string, fallback?: VariableStore): 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. The stream value is authoritative for // variables not tracked locally — it already includes any mods from // the sender's engine, so we return it as-is without adding local mods. const fb = fallback?.[key]; if (fb !== undefined) return fb; // 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. * * @param changedVar - the variable that was just set (e.g. "$con") * @param oldValue - its previous combined value (for tag transition detection) * @param currentVars - the full stream variable store (as fallback) * @returns list of {key, value} pairs (combined values) to publish as var messages */ export function computeCascade( changedVar: string, oldValue: string | undefined, currentVars: VariableStore, ): Array<{ key: string; value: string }> { if (!depGraph || !declExprs || !tagModMap) { return []; } const results: Array<{ key: string; value: string }> = []; // ---- Tag activation/deactivation ---- const newValue = getCombined(changedVar, currentVars); const newTag = isTagValue(newValue); const oldTag = isTagValue(oldValue); if (newTag !== oldTag) { // Deactivate old tag if (oldTag) { const removed = deactivateTagFromSource(changedVar, oldTag, currentVars); for (const r of removed) { results.push(r); } } // Activate new tag if (newTag) { const added = activateTagFromSource(changedVar, newTag, currentVars); for (const r of added) { results.push(r); } } } // ---- Declaration re-evaluation ---- const reevaluated = reevaluateDependents(changedVar, currentVars); for (const r of reevaluated) { if (!results.some((x) => x.key === r.key)) { results.push(r); } } return results; } /** * Compute initial values for all declared variables. * Called once after initReactivity() to seed the store. */ export function computeInitialValues( currentVars: VariableStore, ): Array<{ key: string; value: string }> { if (!declExprs) return []; const allKeys = [...declExprs.keys()]; const sorted = topoSortAffected(new Set(allKeys)); const results: Array<{ key: string; value: string }> = []; for (const key of sorted) { const expr = declExprs.get(key); if (!expr) continue; try { const result = evaluateExpression(expr, { lookup: (name: string) => { const k = "$" + name; return getCombined(k, currentVars); }, }); const rawValue = String(result.value); baseValues.set(key, rawValue); // Check if this is a tag value — if so, activate it const tag = isTagValue(rawValue); if (tag) { const added = activateTagFromSource(key, tag, currentVars); 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, currentVars); if (!results.some((x) => x.key === key)) { results.push({ key, value: combined }); } } catch { // skip failed evaluations at init time } } return results; } // --------------------------------------------------------------------------- // Tag activation / deactivation (source-based) // --------------------------------------------------------------------------- function isTagValue(value: string | undefined): string | null { if (!value) return null; const trimmed = value.trim(); return trimmed.startsWith("#") ? trimmed : null; } /** Activate a tag from a source variable. Evaluates modifiers once. */ function activateTagFromSource( source: string, tag: string, fallback: 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 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, fallback)); } const result = evaluateExpression(mod.expression, { lookup: (name: string) => { const k = "$" + name; return getCombined(k, fallback); }, }); 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, fallback); 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, fallback: VariableStore, ): Array<{ key: string; value: string }> { const entries = sourceActivations.get(source); if (!entries) return []; const results: Array<{ key: string; value: string }> = []; const affectedTargets = new Set(); // 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, fallback) }); } return results; } // --------------------------------------------------------------------------- // Declaration re-evaluation // --------------------------------------------------------------------------- function reevaluateDependents( changedVar: string, fallback: VariableStore, ): Array<{ key: string; value: string }> { if (!depGraph || !declExprs) return []; // Collect all dependents reachable from changedVar (BFS) const affected = new Set(); const queue = [changedVar]; while (queue.length > 0) { const dep = queue.shift()!; const dependents = depGraph.get(dep); if (!dependents) continue; for (const d of dependents) { if (!affected.has(d)) { affected.add(d); queue.push(d); } } } const sorted = topoSortAffected(affected); const results: Array<{ key: string; value: string }> = []; for (const key of sorted) { if (inFlight.has(key)) { throw new Error( `Circular dependency detected during evaluation of ${key}`, ); } inFlight.add(key); try { const expr = declExprs.get(key); if (!expr) continue; const result = evaluateExpression(expr, { lookup: (name: string) => { const k = "$" + name; return getCombined(k, fallback); }, }); const rawValue = String(result.value); // Check for tag transition on this declared variable const oldCombined = getCombined(key, fallback); const oldTag = isTagValue(oldCombined); const newTag = isTagValue(rawValue); if (oldTag !== newTag) { // Deactivate old tag if (oldTag) { const removed = deactivateTagFromSource(key, oldTag, fallback); 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, fallback); 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, fallback); if (!results.some((x) => x.key === key)) { results.push({ key, value: combined }); } } finally { inFlight.delete(key); } } return results; } // --------------------------------------------------------------------------- // Topological sort & circular check // --------------------------------------------------------------------------- function topoSortAffected(affected: Set): string[] { if (!depGraph || !declExprs) return [...affected]; const result: string[] = []; const visited = new Set(); const temp = new Set(); function visit(key: string): void { if (visited.has(key)) return; if (temp.has(key)) { throw new Error(`Circular dependency involving ${key}`); } temp.add(key); const expr = declExprs!.get(key); if (expr) { const deps = extractDependencies(expr); for (const dep of deps) { if (affected.has(dep) || declExprs!.has(dep)) { visit(dep); } } } temp.delete(key); visited.add(key); result.push(key); } for (const key of affected) { visit(key); } return result; } function checkCircular(): void { if (!declExprs) return; const allKeys = [...declExprs.keys()]; const state = new Map(); for (const k of allKeys) state.set(k, "unvisited"); const path: string[] = []; function dfs(key: string): void { const s = state.get(key); if (s === "visited") return; if (s === "visiting") { const cycleStart = path.indexOf(key); const cycle = path.slice(cycleStart).concat(key); throw new Error( `Circular dependency detected: ${cycle.join(" → ")}`, ); } state.set(key, "visiting"); path.push(key); const expr = declExprs!.get(key); if (expr) { const deps = extractDependencies(expr); for (const dep of deps) { if (declExprs!.has(dep)) { dfs(dep); } } } path.pop(); state.set(key, "visited"); } for (const key of allKeys) { dfs(key); } }