ttrpg-tools/src/components/journal/var-reactivity.ts

519 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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).
* When a variable changes, it:
* 1. Sends the base var message
* 2. Detects tag activation/deactivation diff
* 3. Re-evaluates declared variables whose dependencies changed
* 4. Applies/removes tag modifiers on affected targets
* 5. Sends var messages for every derived change
*
* Circular dependency detection happens at registration time
* (topological sort). At runtime, we guard against re-entrant
* evaluation with an in-flight set.
*/
import type { VarDeclaration, TagModifier } from "./declare-parser";
import { evaluateExpression } from "./variable-expression";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface VarReactivityState {
/** All variable declarations (from role=declare blocks) */
declarations: VarDeclaration[];
/** All tag modifiers (from role=declare blocks) */
tagModifiers: TagModifier[];
}
/** The runtime variable store (read from stream state). */
export type VariableStore = Record<string, string>;
// ---------------------------------------------------------------------------
// 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: #tag → [{target, expression}] */
let tagModMap: Map<string, Array<{ target: string; expression: string }>> | null = null;
/** 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();
// 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);
}
}
// Circular dependency check
checkCircular();
}
/** Extract $var names from an expression string. */
export function extractDependencies(expr: string): string[] {
const vars: string[] = [];
// Match $identifier patterns (not inside function names, just bare $var)
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;
}
/**
* Compute which declared variables need re-evaluation after
* a set of base variables changed, and what tag modifier effects
* to apply.
*
* Returns the list of { key, value } pairs to publish as var messages.
* The caller is responsible for sending these via sendMessage.
*
* `changedVar` is the variable that was just set (e.g. "$con").
* `oldValue` is its previous value (for detecting tag transitions).
* `currentVars` is the full variable store.
*/
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 newTag = isTagValue(currentVars[changedVar]);
const oldTag = isTagValue(oldValue);
if (newTag !== oldTag) {
// Deactivate old tag
if (oldTag) {
const removed = computeTagDeactivation(
oldTag,
currentVars,
);
for (const r of removed) {
results.push(r);
// Update currentVars in-place so subsequent steps see new values
currentVars = { ...currentVars, [r.key]: r.value };
}
}
// Activate new tag
if (newTag) {
const added = computeTagActivation(
newTag,
currentVars,
);
for (const r of added) {
results.push(r);
currentVars = { ...currentVars, [r.key]: r.value };
}
}
}
// ---- Declaration re-evaluation ----
const reevaluated = reevaluateDependents(
changedVar,
currentVars,
);
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)) {
results.push(r);
currentVars = { ...currentVars, [r.key]: r.value };
}
}
return results;
}
/**
* Compute initial values for all declared variables.
* Called once after initReactivity() to seed the store.
* Unset dependencies default to 0.
*/
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 }> = [];
const localVars = { ...currentVars };
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 localVars[k] ?? undefined;
},
});
const newValue = String(result.value);
const finalValue = applyTagModifiersTo(key, newValue, localVars);
if (finalValue !== (localVars[key] ?? "")) {
localVars[key] = finalValue;
results.push({ key, value: finalValue });
}
} catch {
// skip failed evaluations at init time
}
}
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
// ---------------------------------------------------------------------------
function isTagValue(value: string | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
return trimmed.startsWith("#") ? trimmed : null;
}
/**
* Walk the dependency graph from `changedVar` and re-evaluate all
* affected declarations. Uses topological order.
*/
function reevaluateDependents(
changedVar: string,
vars: 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); // transitive: if C depends on B which depends on A...
}
}
}
// Topological sort: declarations that depend only on base vars go first
const sorted = topoSortAffected(affected);
const results: Array<{ key: string; value: string }> = [];
const localVars = { ...vars }; // working copy
for (const key of sorted) {
if (inFlight.has(key)) {
// Shouldn't happen (caught at init), but guard anyway
throw new Error(`Circular dependency detected during evaluation of ${key}`);
}
inFlight.add(key);
try {
const expr = declExprs.get(key);
if (!expr) continue;
// Evaluate with current localVars (which includes previously
// re-evaluated dependents)
const result = evaluateExpression(expr, {
lookup: (name: string) => {
const k = "$" + name;
return localVars[k] ?? undefined;
},
});
const newValue = String(result.value);
// Apply any active tag modifiers to this key
const finalValue = applyTagModifiersTo(key, newValue, localVars);
if (finalValue !== localVars[key]) {
localVars[key] = finalValue;
results.push({ key, value: finalValue });
}
} finally {
inFlight.delete(key);
}
}
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
// ---------------------------------------------------------------------------
/**
* Topologically sort a set of affected declaration keys so that
* dependents are evaluated after their dependencies.
*/
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)) {
// This shouldn't happen if checkCircular passed, but guard
throw new Error(`Circular dependency involving ${key}`);
}
temp.add(key);
// Visit dependencies first
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;
}
/**
* Check for circular dependencies in the full declaration graph.
* Throws with a descriptive message if a cycle is found.
*/
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);
}
}