refactor: implement base/modifier separation for variable reactivity

Introduce a distinction between base values (set via `/set` or
declarations) and active modifiers (applied via tags). This ensures
tag transitions and numeric modifiers are calculated correctly during
cascades.

- Refactor `VariableView` to use a flat list with hover popups for
  declarations and active modifiers.
- Update `command-dispatcher` to use `getCombined` for accurate
  value lookups and `setBase` for variable updates.
- Update `var-reactivity` to manage `baseValues`, `activeMods`, and
  `sourceActivations` separately.
- Export new utility functions for accessing combined values,
  modifiers, and declaration expressions.
This commit is contained in:
2026-07-13 10:10:47 +08:00
parent 411f4d79ff
commit c524dd5867
4 changed files with 375 additions and 435 deletions
+262 -213
View File
@@ -3,16 +3,18 @@
* 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
* 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
*
* Circular dependency detection happens at registration time
* (topological sort). At runtime, we guard against re-entrant
* evaluation with an in-flight set.
* Combined value = base + sum(activeMods). Tag values (starting with #)
* are not numeric and don't receive mods.
*
* Limitation: base snapshots are taken at tag activation time. If another
* user changes a variable's value remotely while a local tag is active,
* the deactivation will restore the snapshot base rather than the remote
* value. In practice this is rare — GMs set bases, players toggle tags.
*/
import type { VarDeclaration, TagModifier } from "./declare-parser";
@@ -23,15 +25,19 @@ import { evaluateExpression } from "./variable-expression";
// ---------------------------------------------------------------------------
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>;
/** 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
// ---------------------------------------------------------------------------
@@ -42,12 +48,24 @@ let depGraph: Map<string, Set<string>> | null = null;
/** Reverse: $declaredVar → its expression */
let declExprs: Map<string, string> | null = null;
/** Tag modifiers: #tag → [{target, expression}] */
/** 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>();
/** Fallback store for variables not tracked locally (set by other users) */
let streamFallback: VariableStore = {};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -62,6 +80,9 @@ 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) {
@@ -87,14 +108,12 @@ export function initReactivity(state: VarReactivityState): void {
}
}
// 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) {
@@ -105,16 +124,63 @@ export function extractDependencies(expr: string): string[] {
}
/**
* Compute which declared variables need re-evaluation after
* a set of base variables changed, and what tag modifier effects
* to apply.
* Get the combined value of a variable (base + sum of active mods).
* Falls back to the stream store for variables not tracked locally.
*/
export function getCombined(key: string): 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
const fallback = streamFallback[key];
if (fallback !== undefined) {
if (isTagValue(fallback)) return fallback;
const fbNum = parseFloat(fallback);
if (!isNaN(fbNum)) return String(fbNum + modSum);
return fallback;
}
// 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.
*
* 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.
* @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,
@@ -125,49 +191,38 @@ export function computeCascade(
return [];
}
streamFallback = currentVars;
const results: Array<{ key: string; value: string }> = [];
// ---- Tag activation/deactivation ----
const newTag = isTagValue(currentVars[changedVar]);
const newValue = getCombined(changedVar);
const newTag = isTagValue(newValue);
const oldTag = isTagValue(oldValue);
if (newTag !== oldTag) {
// Deactivate old tag
if (oldTag) {
const removed = computeTagDeactivation(
oldTag,
currentVars,
);
const removed = deactivateTagFromSource(changedVar, oldTag);
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,
);
const added = activateTagFromSource(changedVar, newTag);
for (const r of added) {
results.push(r);
currentVars = { ...currentVars, [r.key]: r.value };
}
}
}
// ---- Declaration re-evaluation ----
const reevaluated = reevaluateDependents(
changedVar,
currentVars,
);
const reevaluated = reevaluateDependents(changedVar);
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 };
}
}
@@ -177,18 +232,18 @@ export function computeCascade(
/**
* 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 [];
streamFallback = currentVars;
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);
@@ -198,16 +253,28 @@ export function computeInitialValues(
const result = evaluateExpression(expr, {
lookup: (name: string) => {
const k = "$" + name;
return localVars[k] ?? undefined;
return getCombined(k);
},
});
const newValue = String(result.value);
const finalValue = applyTagModifiersTo(key, newValue, localVars);
const rawValue = String(result.value);
baseValues.set(key, rawValue);
if (finalValue !== (localVars[key] ?? "")) {
localVars[key] = finalValue;
results.push({ key, value: finalValue });
// Check if this is a tag value — if so, activate it
const tag = isTagValue(rawValue);
if (tag) {
const added = activateTagFromSource(key, tag);
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);
if (!results.some((x) => x.key === key)) {
results.push({ key, value: combined });
}
} catch {
// skip failed evaluations at init time
@@ -217,22 +284,8 @@ export function computeInitialValues(
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
// Tag activation / deactivation (source-based)
// ---------------------------------------------------------------------------
function isTagValue(value: string | undefined): string | null {
@@ -241,13 +294,112 @@ function isTagValue(value: string | undefined): string | null {
return trimmed.startsWith("#") ? trimmed : null;
}
/**
* Walk the dependency graph from `changedVar` and re-evaluate all
* affected declarations. Uses topological order.
*/
/** Activate a tag from a source variable. Evaluates modifiers once. */
function activateTagFromSource(
source: string,
tag: string,
): 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));
}
const result = evaluateExpression(mod.expression, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k);
},
});
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);
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,
): 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) });
}
return results;
}
// ---------------------------------------------------------------------------
// Declaration re-evaluation
// ---------------------------------------------------------------------------
function reevaluateDependents(
changedVar: string,
vars: VariableStore,
): Array<{ key: string; value: string }> {
if (!depGraph || !declExprs) return [];
@@ -261,21 +413,19 @@ function reevaluateDependents(
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...
queue.push(d);
}
}
}
// 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}`);
throw new Error(
`Circular dependency detected during evaluation of ${key}`,
);
}
inFlight.add(key);
@@ -283,23 +433,49 @@ function reevaluateDependents(
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;
return getCombined(k);
},
});
const newValue = String(result.value);
const rawValue = String(result.value);
// Apply any active tag modifiers to this key
const finalValue = applyTagModifiersTo(key, newValue, localVars);
// Check for tag transition on this declared variable
const oldCombined = getCombined(key);
const oldTag = isTagValue(oldCombined);
const newTag = isTagValue(rawValue);
if (finalValue !== localVars[key]) {
localVars[key] = finalValue;
results.push({ key, value: finalValue });
if (oldTag !== newTag) {
// Deactivate old tag
if (oldTag) {
const removed = deactivateTagFromSource(key, oldTag);
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);
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);
if (!results.some((x) => x.key === key)) {
results.push({ key, value: combined });
}
} finally {
inFlight.delete(key);
@@ -309,131 +485,10 @@ function reevaluateDependents(
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];
@@ -444,12 +499,10 @@ function topoSortAffected(affected: Set<string>): 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);
@@ -472,10 +525,6 @@ function topoSortAffected(affected: Set<string>): string[] {
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;
@@ -516,4 +565,4 @@ function checkCircular(): void {
for (const key of allKeys) {
dfs(key);
}
}
}