561 lines
16 KiB
TypeScript
561 lines
16 KiB
TypeScript
/**
|
|
* 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<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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Dependency graph: $dep → Set<$declaredVar> */
|
|
let depGraph: Map<string, Set<string>> | null = null;
|
|
|
|
/** Reverse: $declaredVar → its expression */
|
|
let declExprs: Map<string, string> | null = null;
|
|
|
|
/** Tag modifiers from declare blocks: #tag → [{target, expression}] */
|
|
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) */
|
|
const inFlight = new Set<string>();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<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, 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<string>();
|
|
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>): string[] {
|
|
if (!depGraph || !declExprs) return [...affected];
|
|
|
|
const result: string[] = [];
|
|
const visited = new Set<string>();
|
|
const temp = new Set<string>();
|
|
|
|
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<string, "unvisited" | "visiting" | "visited">();
|
|
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);
|
|
}
|
|
}
|