/** * 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 variable update produced by the engine: the combined `value` to publish * for display, plus the raw `base` it was derived from. The base is carried on * the stream so a hydrated client can rebuild the engine without having to * reverse-engineer base values out of combined ones. */ export interface VarChange { key: string; value: string; base: 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 threshold: number; // the threshold that triggered this activation kind: "numeric" | "tagmap"; } /** * A problem detected while evaluating declarations or tag modifiers at * runtime. These are benign to the engine (the offending entry is skipped) but * signal an authoring mistake that would otherwise silently produce a wrong * value, so they are collected for surfacing in the UI. */ export interface ReactivityDiagnostic { kind: "init" | "declaration" | "modifier"; /** Human-readable description, already including the offending context. */ message: string; /** The variable the diagnostic concerns (e.g. "$hp"). */ key?: string; /** The expression that failed to evaluate. */ expression?: string; /** For modifiers: the tag whose activation triggered the modifier ("#warrior"). */ tag?: string; /** For modifiers: the variable whose tagmap activated the tag ("$class"). */ source?: string; } // --------------------------------------------------------------------------- // 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(); /** Diagnostics collected since the last init, keyed by a stable id to dedupe. */ const diagnostics = new Map(); /** Listeners notified whenever the diagnostic set changes. */ const diagnosticListeners = new Set<(diagnostics: ReactivityDiagnostic[]) => void>(); function errorText(e: unknown): string { return e instanceof Error ? e.message : String(e); } function diagnosticId(d: ReactivityDiagnostic): string { return [d.kind, d.key ?? "", d.expression ?? "", d.source ?? "", d.tag ?? "", d.message].join("\u0000"); } function notifyDiagnostics(): void { const list = [...diagnostics.values()]; for (const listener of diagnosticListeners) listener(list); } /** Record a diagnostic, dedupe by identity, and notify listeners. */ function reportDiagnostic(d: ReactivityDiagnostic): void { const id = diagnosticId(d); if (diagnostics.has(id)) return; diagnostics.set(id, d); notifyDiagnostics(); console.warn(`[var-reactivity] ${d.message}`); } /** * Diagnostics collected by the engine (bad declarations, modifier expressions, * circular dependencies). Cleared by `initReactivity` so a re-init reports a * fresh set. Repeated failures of the same expression are reported once. */ export function getReactivityDiagnostics(): ReactivityDiagnostic[] { return [...diagnostics.values()]; } /** Remove all collected diagnostics. */ export function clearReactivityDiagnostics(): void { if (diagnostics.size === 0) return; diagnostics.clear(); notifyDiagnostics(); } /** * Subscribe to diagnostic changes. The listener receives the current list on * every change. Returns an unsubscribe function. */ export function onReactivityDiagnostics( listener: (diagnostics: ReactivityDiagnostic[]) => void, ): () => void { diagnosticListeners.add(listener); return () => diagnosticListeners.delete(listener); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Initialize (or re-initialize) the reactivity engine from declarations * and tag modifiers parsed from role=declare blocks. * * The new state is built into locals and only committed once validation * passes, so a throw (e.g. a circular dependency) leaves the previously * initialized engine intact rather than half-populated. * * Throws if a circular dependency is detected. */ export function initReactivity(state: VarReactivityState): void { const nextTagModMap = new Map< string, Array<{ target: string; expression: string; threshold: number }> >(); const nextDeclExprs = new Map(); const nextDepGraph = new Map>(); // Index tag modifiers for (const tm of state.tagModifiers) { let list = nextTagModMap.get(tm.tag); if (!list) { list = []; nextTagModMap.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) { nextDeclExprs.set(decl.key, decl.expression); const deps = extractDependencies(decl.expression); for (const dep of deps) { let dependents = nextDepGraph.get(dep); if (!dependents) { dependents = new Set(); nextDepGraph.set(dep, dependents); } dependents.add(decl.key); } } try { checkCircular(nextDeclExprs); } catch (e) { reportDiagnostic({ kind: "init", message: `Declarations contain an unresolvable dependency: ${errorText(e)}`, }); throw e; } // Validation passed — commit the new configuration and reset runtime state. depGraph = nextDepGraph; declExprs = nextDeclExprs; tagModMap = nextTagModMap; baseValues.clear(); numericMods.clear(); tagMapMods.clear(); sourceActivations.clear(); inFlight.clear(); clearReactivityDiagnostics(); } /** 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"; } /** * Resolve a `$var` to its combined value, distinguishing an unknown variable * (undefined) from a real value of "0". Strict contexts such as `/set` and * `/roll` use this so a typo'd or undeclared reference errors instead of * silently evaluating to 0. */ export function resolveVariable( key: string, fallback?: VariableStore, ): string | undefined { const known = baseValues.has(key) || numericMods.has(key) || tagMapMods.has(key) || (fallback !== undefined && fallback[key] !== undefined); return known ? getCombined(key, fallback) : undefined; } /** * 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. * * The stream store holds *combined* values (base + active modifiers) alongside * the raw `base` each was derived from, so bases are restored exactly rather * than reconstructed by subtracting modifiers back out. */ export function rebuildReactivityFromStore( variables: VariableStore, bases?: VariableStore, ): void { if (!tagModMap) return; // Reset, then restore exact bases from the store's base map. baseValues.clear(); numericMods.clear(); tagMapMods.clear(); sourceActivations.clear(); for (const [key, value] of Object.entries(variables)) { baseValues.set(key, bases?.[key] ?? value); } // Re-attach tag modifiers from each source's *base* tagmap. Using the // combined value here would re-trigger a modifier that merely added a tag to // the source (feedback), inflating counts on every rebuild. for (const [key, value] of Object.entries(variables)) { const source = bases?.[key] ?? value; const tagMap = isTagMapValue(source) ? parseTagMap(source) : null; if (tagMap) applyTagMapActivations(key, {}, tagMap, variables); } } /** * 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, ): VarChange[] { if (!depGraph || !declExprs || !tagModMap) { return []; } const results: VarChange[] = []; // ---- 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 after initReactivity() to seed the store. * * @param existing - declared keys already present in the store; these are * skipped so hydrated/overridden session values are not clobbered. */ export function computeInitialValues( currentVars: VariableStore, existing?: ReadonlySet, ): VarChange[] { if (!declExprs) return []; const allKeys = [...declExprs.keys()]; const sorted = topoSortAffected(new Set(allKeys)); const results: VarChange[] = []; for (const key of sorted) { // Variables already in the store were seeded (or overridden) previously; // recomputing them would clobber session state on every reload. if (existing?.has(key)) continue; const expr = declExprs.get(key); if (!expr) continue; try { const result = evaluateExpression(expr, { // Declarations may reference runtime/external variables; getCombined // defaults unknown keys to "0" rather than erroring. lookup: (name) => getCombined(name, 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, base: rawValue }); } } catch (e) { reportDiagnostic({ kind: "declaration", key, expression: expr, message: `Declaration ${key} failed to evaluate "${expr}": ${errorText(e)}`, }); } } 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, ): VarChange[] { if (!tagModMap) return []; const results: VarChange[] = []; 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, { // Modifier expressions tolerate undefined references (→ "0"). lookup: (name) => getCombined(name, 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 base = baseValues.get(mod.target) ?? combined; const existing = results.findIndex((r) => r.key === mod.target); if (existing >= 0) { results[existing] = { key: mod.target, value: combined, base }; } else { results.push({ key: mod.target, value: combined, base }); } } catch (e) { reportDiagnostic({ kind: "modifier", key: mod.target, expression: mod.expression, tag, source, message: `Modifier ${tag} → ${mod.target} failed to evaluate ` + `"${mod.expression}" (activated by ${source}): ${errorText(e)}`, }); } } 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 base = baseValues.get(entry.target) ?? combined; const existing = results.findIndex((r) => r.key === entry.target); if (existing >= 0) { results[existing] = { key: entry.target, value: combined, base }; } else { results.push({ key: entry.target, value: combined, base }); } } } } // Clean up empty sourceActivations if (sourceActivations.get(source)?.length === 0) { sourceActivations.delete(source); } return results; } // --------------------------------------------------------------------------- // Declaration re-evaluation // --------------------------------------------------------------------------- function reevaluateDependents( changedVar: string, fallback: VariableStore, ): VarChange[] { 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: VarChange[] = []; 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, { // Declaration re-evaluation stays lenient for undeclared references. lookup: (name) => getCombined(name, 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, base: rawValue }); } } catch (e) { // Report the bad declaration but keep evaluating unrelated dependents. reportDiagnostic({ kind: "declaration", key, expression: declExprs.get(key), message: `Declaration ${key} failed to re-evaluate "${declExprs.get(key) ?? ""}": ${errorText(e)}`, }); } 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) { // Only order within the affected set. Declared dependencies that did // not change must not be pulled in, or reevaluateDependents would // re-publish variables that were never touched. if (affected.has(dep)) { visit(dep); } } } temp.delete(key); visited.add(key); result.push(key); } for (const key of affected) { visit(key); } return result; } function checkCircular(exprs: Map): void { const allKeys = [...exprs.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 = exprs.get(key); if (expr) { const deps = extractDependencies(expr); for (const dep of deps) { if (exprs.has(dep)) { dfs(dep); } } } path.pop(); state.set(key, "visited"); } for (const key of allKeys) { dfs(key); } }