/** * 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) * - numericMods: per-target list of {tag, value, source} from tag activations * - tagMapMods: per-target list of {tag, value, source} for tagmap targets * - sourceActivations: per-source list of {tag, target, value, threshold, kind} * for deactivation * * Variables have one of two types: * - numeric: base + sum(numericMods) * - tagmap (#warrior:1;#druid:2): the tagmap is used for threshold-gated * modifier activation; tagmap variables do not receive numeric mods but * can receive tagmap mods (adding/subtracting to specific tag counts). * * 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, exprValueToString } 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 threshold: number; // the threshold that triggered this activation kind: "numeric" | "tagmap"; } // --------------------------------------------------------------------------- // Tagmap helpers // --------------------------------------------------------------------------- /** Tagmap serialization format: "#warrior:1;#druid:2" */ const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/; /** Parse a tagmap string. Returns null if the value is not a valid tagmap. */ function parseTagMap(value: string | undefined): Record | null { if (!value) return null; const trimmed = value.trim(); if (!trimmed.startsWith("#")) return null; const parts = trimmed.split(";"); const map: Record = {}; for (const part of parts) { const m = TAGMAP_RE.exec(part.trim()); if (!m) return null; // invalid entry const tag = "#" + m[1]; const count = parseInt(m[2], 10); if (count <= 0) continue; // skip zero/negative counts on parse map[tag] = (map[tag] ?? 0) + count; } return Object.keys(map).length > 0 ? map : null; } /** Serialize a tagmap back to string. Returns empty string if map is empty. */ function tagMapToString(map: Record): string { const entries = Object.entries(map).filter(([, c]) => c > 0); if (entries.length === 0) return ""; return entries.map(([tag, count]) => `${tag}:${count}`).join(";"); } /** Check if a value is a tagmap string. */ function isTagMapValue(value: string | undefined): boolean { if (!value) return false; const trimmed = value.trim(); if (!trimmed.startsWith("#")) return false; return parseTagMap(trimmed) !== null; } // --------------------------------------------------------------------------- // 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, threshold}] */ let tagModMap: Map> | null = null; /** Base values set by /set or declaration evaluation */ const baseValues = new Map(); /** Numeric mods per target: $target → [{tag, value, source, ...}] */ const numericMods = new Map(); /** Tagmap mods per target: $target → [{tag, value, source, ...}] */ const tagMapMods = new Map(); /** Activations per source: $source → [{tag, target, value, threshold, kind}] */ 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(); numericMods.clear(); tagMapMods.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, threshold: tm.threshold }); } // 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. * - For tagmap variables: returns the serialized tagmap (base + tagmap mods). * - For numeric variables: returns base + sum(numericMods). * 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 && isTagMapValue(base)) { // Tagmap variable: apply tagmap mods, then serialize const baseMap = parseTagMap(base); const mods = tagMapMods.get(key) ?? []; if (baseMap && mods.length === 0) return base; const resultMap: Record = { ...(baseMap ?? {}) }; for (const m of mods) { resultMap[m.tag] = (resultMap[m.tag] ?? 0) + m.value; if (resultMap[m.tag] <= 0) delete resultMap[m.tag]; } const serialized = tagMapToString(resultMap); return serialized || "0"; } const baseNum = base !== undefined ? parseFloat(base) : NaN; const mods = numericMods.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 numeric mods if (mods.length > 0) return String(modSum); return "0"; } /** * Get the effective tagmap for a variable (base + tagmap mods). * Returns null if the variable is not a tagmap variable. */ export function getTagMap(key: string): Record | null { const base = baseValues.get(key); if (base === undefined || !isTagMapValue(base)) return null; const baseMap = parseTagMap(base); if (!baseMap) return null; const mods = tagMapMods.get(key) ?? []; const result: Record = { ...baseMap }; for (const m of mods) { result[m.tag] = (result[m.tag] ?? 0) + m.value; if (result[m.tag] <= 0) delete result[m.tag]; } return Object.keys(result).length > 0 ? result : null; } /** Get the active mods for a variable (for UI hover display). */ export function getMods(key: string): ActiveMod[] { const nums = numericMods.get(key) ?? []; const tags = tagMapMods.get(key) ?? []; return [...nums, ...tags]; } /** 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); } /** * Rebuild local reactivity state (baseValues, mods, sourceActivations) * from the hydrated stream variable store. Must be called after replayReducers * so that tag activations are correctly tracked for subsequent cascade * computations. */ export function rebuildReactivityFromStore(variables: VariableStore): void { if (!tagModMap) return; for (const [key, value] of Object.entries(variables)) { if (!baseValues.has(key)) { baseValues.set(key, value); } const tagMap = isTagMapValue(value) ? parseTagMap(value) : null; if (tagMap && !sourceActivations.has(key)) { // Activate all tags in the tagmap const added = applyTagMapActivations(key, {}, tagMap, variables); // We don't return the added entries here — they'll be applied // to the store separately } } } /** * 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 oldTagMap = isTagMapValue(oldValue) ? parseTagMap(oldValue) : {}; const newTagMap = isTagMapValue(newValue) ? parseTagMap(newValue) : {}; // Diff tagmaps and apply changes const tagResults = applyTagMapActivations(changedVar, oldTagMap ?? {}, newTagMap ?? {}, currentVars); results.push(...tagResults); // ---- 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 = exprValueToString(result.value); baseValues.set(key, rawValue); // Check if this is a tagmap value — if so, activate matching modifiers const tagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : null; if (tagMap) { const added = applyTagMapActivations(key, {}, tagMap, 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 (threshold-based) // --------------------------------------------------------------------------- /** * Apply tagmap changes for a source variable. Compares old and new tagmaps, * activating/deactivating modifiers whose threshold crossing state changed. */ function applyTagMapActivations( source: string, oldTagMap: Record, newTagMap: Record, fallback: VariableStore, ): Array<{ key: string; value: string }> { if (!tagModMap) return []; const results: Array<{ key: string; value: string }> = []; const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]); for (const tag of allTags) { const mods = tagModMap.get(tag); if (!mods || mods.length === 0) continue; const oldCount = oldTagMap[tag] ?? 0; const newCount = newTagMap[tag] ?? 0; for (let modIdx = 0; modIdx < mods.length; modIdx++) { const mod = mods[modIdx]; const wasActive = oldCount >= mod.threshold; const isActive = newCount >= mod.threshold; if (!wasActive && isActive) { // Activate this modifier try { // Snapshot target's current value as base if needed if (!baseValues.has(mod.target)) { baseValues.set(mod.target, getCombined(mod.target, fallback)); } const evalResult = evaluateExpression(mod.expression, { lookup: (name: string) => { const k = "$" + name; return getCombined(k, fallback); }, }); // Modifier expressions must evaluate to a number if (evalResult.value.kind !== "number") { throw new Error( `Modifier expression "${mod.expression}" must evaluate to a number`, ); } const value = evalResult.value.value; const targetIsTagMap = isTagMapValue(baseValues.get(mod.target)); if (targetIsTagMap) { // Apply as tagmap mod to the target's tag count const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "tagmap" }; let targetMods = tagMapMods.get(mod.target); if (!targetMods) { targetMods = []; tagMapMods.set(mod.target, targetMods); } targetMods.push(entry); } else { // Apply as numeric mod const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "numeric" }; let targetMods = numericMods.get(mod.target); if (!targetMods) { targetMods = []; numericMods.set(mod.target, targetMods); } targetMods.push(entry); } // Track in source activations for deactivation let sourceEntries = sourceActivations.get(source); if (!sourceEntries) { sourceEntries = []; sourceActivations.set(source, sourceEntries); } sourceEntries.push({ tag, target: mod.target, value, threshold: mod.threshold, kind: targetIsTagMap ? "tagmap" : "numeric", }); // Emit new combined value 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 } } else if (wasActive && !isActive) { // Deactivate this modifier const sourceEntries = sourceActivations.get(source); if (!sourceEntries) continue; const idx = sourceEntries.findIndex( (e) => e.tag === tag && e.target === mod.target && e.threshold === mod.threshold, ); if (idx < 0) continue; const entry = sourceEntries[idx]; sourceEntries.splice(idx, 1); // Remove from the appropriate mod list if (entry.kind === "tagmap") { const targetMods = tagMapMods.get(entry.target); if (targetMods) { const modIdx = targetMods.findIndex( (m) => m.tag === tag && m.source === source && m.threshold === mod.threshold, ); if (modIdx >= 0) targetMods.splice(modIdx, 1); if (targetMods.length === 0) tagMapMods.delete(entry.target); } } else { const targetMods = numericMods.get(entry.target); if (targetMods) { const modIdx = targetMods.findIndex( (m) => m.tag === tag && m.source === source && m.threshold === mod.threshold, ); if (modIdx >= 0) targetMods.splice(modIdx, 1); if (targetMods.length === 0) numericMods.delete(entry.target); } } // Emit updated combined value const combined = getCombined(entry.target, fallback); const existing = results.findIndex((r) => r.key === entry.target); if (existing >= 0) { results[existing] = { key: entry.target, value: combined }; } else { results.push({ key: entry.target, value: combined }); } } } } // Clean up empty sourceActivations if (sourceActivations.get(source)?.length === 0) { sourceActivations.delete(source); } 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 = exprValueToString(result.value); // Check for tagmap transition on this declared variable const oldCombined = getCombined(key, fallback); const oldTagMap = isTagMapValue(oldCombined) ? parseTagMap(oldCombined) : {}; const newTagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : {}; if (JSON.stringify(oldTagMap) !== JSON.stringify(newTagMap)) { const tagResults = applyTagMapActivations(key, oldTagMap ?? {}, newTagMap ?? {}, fallback); for (const r of tagResults) { 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); } }