diff --git a/src/components/journal/VariableView.tsx b/src/components/journal/VariableView.tsx index 9e388be..bb68b4b 100644 --- a/src/components/journal/VariableView.tsx +++ b/src/components/journal/VariableView.tsx @@ -1,96 +1,34 @@ /** - * VariableView — table view of all variable key-value pairs and active tags. + * VariableView — flat list of all variable key-value pairs. * - * Shows variables grouped by type (declared, plain, tag-typed) and an - * active tags section showing which variable activates each tag and - * what modifier effects are in play. + * Hovering a value shows a popup with: + * - Declaration expression (if any), styled distinctly + * - Active tag modifiers: #tag +N (from $source) */ -import { Component, For, createMemo, Show } from "solid-js"; +import { Component, For, createMemo, Show, createSignal } from "solid-js"; import { useJournalStream } from "../stores/journalStream"; import { useJournalCompletions } from "./completions"; -import { computeActiveTags } from "./var-reactivity"; -import { extractDependencies } from "./var-reactivity"; -import type { VarDeclaration } from "./declare-parser"; +import { getMods, getDeclExpr } from "./var-reactivity"; export const VariableView: Component = () => { const stream = useJournalStream(); const comp = useJournalCompletions(); - const declarations = createMemo(() => comp.data.declarations); - const tagModifiers = createMemo(() => comp.data.tagModifiers); const values = createMemo(() => stream.variables); - /** Build a map: $key → VarDeclaration */ - const declMap = createMemo(() => { - const map = new Map(); - for (const d of declarations()) { - map.set(d.key, d); - } - return map; - }); - - /** Active tags */ - const activeTags = createMemo(() => computeActiveTags(values())); - - /** Tag modifier index: #tag → [{target, expression}] */ - const tagModMap = createMemo(() => { - const map = new Map>(); - for (const tm of tagModifiers()) { - let list = map.get(tm.tag); - if (!list) { - list = []; - map.set(tm.tag, list); - } - list.push({ target: tm.target, expression: tm.expression }); - } - return map; - }); - - /** Which variable activates each tag */ - const tagActivators = createMemo(() => { - const map = new Map(); + /** Flat list of all variables */ + const items = createMemo(() => { const vars = values(); - for (const [key, val] of Object.entries(vars)) { - if (val.startsWith("#")) { - if (!map.has(val)) map.set(val, key); - } - } - return map; - }); - - /** Group variables: declared, plain (set directly), tag-typed */ - const groups = createMemo(() => { - const vars = values(); - const declKeys = new Set(declarations().map((d) => d.key)); - - const declared: Array<{ key: string; value: string; expr: string; deps: string[] }> = []; - const plain: Array<{ key: string; value: string }> = []; - const tagVars: Array<{ key: string; value: string }> = []; - - for (const [key, val] of Object.entries(vars)) { - if (val.startsWith("#")) { - tagVars.push({ key, value: val }); - } else if (declKeys.has(key)) { - const decl = declMap().get(key)!; - declared.push({ - key, - value: val, - expr: decl.expression, - deps: extractDependencies(decl.expression), - }); - } else { - plain.push({ key, value: val }); - } - } - - return { declared, plain, tagVars }; + return Object.entries(vars).map(([key, value]) => ({ + key, + value, + isTag: value.startsWith("#"), + })); }); const hasAnyData = () => - declarations().length > 0 || - Object.keys(values()).length > 0 || - activeTags().length > 0; + comp.data.declarations.length > 0 || Object.keys(values()).length > 0; return (
@@ -102,142 +40,85 @@ export const VariableView: Component = () => {

} > - {/* Active Tags */} - 0}> -
-
- 激活标签 -
-
- - {(tag) => { - const activator = () => tagActivators().get(tag); - const mods = () => tagModMap().get(tag) ?? []; - return ( -
-
- - {tag} - - - - ← {activator()} - - -
- 0}> -
- - {(mod) => { - const currentVal = () => values()[mod.target]; - return ( -
- {mod.target} - +{mod.expression} - - - = {currentVal()} - - -
- ); - }} -
-
-
-
- ); - }} -
-
-
-
- - {/* Declared Variables */} - 0}> -
-
- - 声明变量 - -
-
- - {(item) => ( -
-
- - {item.key} - - - {item.expr} - -
-
- - {item.value} - - 0}> - - ← {item.deps.join(", ")} - - -
-
- )} -
-
-
-
- - {/* Plain Variables */} - 0}> -
-
- - 直接设置 - -
-
- - {(item) => ( -
- - {item.key} - - - {item.value} - -
- )} -
-
-
-
- - {/* Tag Variables */} - 0}> -
-
- 标签变量 -
-
- - {(item) => ( -
- - {item.key} - - - {item.value} - -
- )} -
-
-
-
+
+ + {(item) => ( + + )} + +
); -}; \ No newline at end of file +}; + +// --------------------------------------------------------------------------- +// VariableRow — single row with hover popup +// --------------------------------------------------------------------------- + +const VariableRow: Component<{ + key: string; + value: string; + isTag: boolean; +}> = (props) => { + const [hovered, setHovered] = createSignal(false); + + const declExpr = createMemo(() => getDeclExpr(props.key)); + const mods = createMemo(() => getMods(props.key)); + + const hasPopup = () => !!declExpr() || mods().length > 0; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {props.key} + + {props.value} + + + {/* Hover popup */} + +
+ {/* Declaration expression */} + +
+ expr: + + {declExpr()} + +
+
+ + {/* Tag modifiers */} + 0}> +
+ + {(mod) => ( +
+ {mod.tag} + +{mod.value} + (from {mod.source}) +
+ )} +
+
+
+
+
+
+ ); +}; + +export default VariableView; diff --git a/src/components/journal/command-dispatcher.ts b/src/components/journal/command-dispatcher.ts index e51621f..a586c20 100644 --- a/src/components/journal/command-dispatcher.ts +++ b/src/components/journal/command-dispatcher.ts @@ -11,7 +11,7 @@ import { parseInput } from "./command-parser"; import { resolveRollPayload } from "./types/roll"; import { resolveSparkPayload } from "./types/spark"; import { evaluateExpression, expressionIsTag } from "./variable-expression"; -import { computeCascade } from "./var-reactivity"; +import { computeCascade, getCombined, setBase } from "./var-reactivity"; import type { VarDeclaration, TagModifier } from "./declare-parser"; // --------------------------------------------------------------------------- @@ -140,7 +140,9 @@ function dispatchSet( } const key = p.key; - const oldValue = ctx.variables[key] ?? undefined; + // Use getCombined for old value so tag transitions are detected correctly + // even when the variable has active mods. + const oldValue = getCombined(key); let newValue: string; try { @@ -152,11 +154,12 @@ function dispatchSet( // Bare tag value newValue = p.expr.trim(); } else if (p.expr) { - // Numeric expression — evaluate + // Numeric expression — evaluate using combined values const result = evaluateExpression(p.expr, { lookup: (name: string) => { const k = "$" + name; - return ctx.variables[k] ?? undefined; + // Use getCombined for accurate values including active mods + return k === key ? undefined : getCombined(k); }, }); newValue = String(result.value); @@ -170,12 +173,11 @@ function dispatchSet( }; } - // Send the direct set - const r1 = sendMessage("var", { action: "set", key, value: newValue }); - const u1 = unwrap(r1); - if (!u1.ok) return u1; + // Update base value so getCombined returns the correct new value + // during cascade computation + setBase(key, newValue); - // Build working variable store for cascade + // Build working variable store for cascade (includes the new value) const workingVars = { ...ctx.variables, [key]: newValue }; // Compute cascade (tag activation/deactivation + declaration re-eval) @@ -189,6 +191,14 @@ function dispatchSet( console.warn("[dispatch] cascade error:", e); } + // Send the direct set last so cascade messages arrive first. + // Use getCombined so the stream store receives the correct value + // (base + active mods) rather than just the raw base. + const combinedValue = getCombined(key); + const r1 = sendMessage("var", { action: "set", key, value: combinedValue }); + const u1 = unwrap(r1); + if (!u1.ok) return u1; + return { ok: true }; } diff --git a/src/components/journal/index.ts b/src/components/journal/index.ts index 44c94af..e501d19 100644 --- a/src/components/journal/index.ts +++ b/src/components/journal/index.ts @@ -60,7 +60,7 @@ export { parseDeclareCsv } from "./declare-parser"; export type { VarDeclaration, TagModifier } from "./declare-parser"; export { evaluateExpression, expressionIsTag } from "./variable-expression"; export type { EvalContext, EvalResult } from "./variable-expression"; -export { initReactivity, computeCascade, computeActiveTags, extractDependencies, computeInitialValues } from "./var-reactivity"; +export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity"; export type { VarReactivityState, VariableStore } from "./var-reactivity"; export { JournalContext, useJournalContext } from "./JournalContext"; export type { JournalContextValue } from "./JournalContext"; diff --git a/src/components/journal/var-reactivity.ts b/src/components/journal/var-reactivity.ts index 88013a7..6a63872 100644 --- a/src/components/journal/var-reactivity.ts +++ b/src/components/journal/var-reactivity.ts @@ -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; +/** 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> | null = null; /** Reverse: $declaredVar → its expression */ let declExprs: Map | null = null; -/** Tag modifiers: #tag → [{target, expression}] */ +/** Tag modifiers from declare blocks: #tag → [{target, expression}] */ let tagModMap: Map> | null = null; +/** Base values set by /set or declaration evaluation */ +const baseValues = new Map(); + +/** Active mods per target: $target → [{tag, value, source}] */ +const activeMods = new Map(); + +/** Activations per source: $source → [{tag, target, value}] */ +const sourceActivations = new Map>(); + /** Set of $vars currently being re-evaluated (cycle guard) */ const inFlight = new Set(); +/** 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(); + + // 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[] { if (!depGraph || !declExprs) return [...affected]; @@ -444,12 +499,10 @@ function topoSortAffected(affected: 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); @@ -472,10 +525,6 @@ function topoSortAffected(affected: Set): 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); } -} \ No newline at end of file +}