diff --git a/src/cli/completions/declare-parser.ts b/src/cli/completions/declare-parser.ts index 45e92b0..ebff5f1 100644 --- a/src/cli/completions/declare-parser.ts +++ b/src/cli/completions/declare-parser.ts @@ -3,15 +3,16 @@ * * Shared between CLI completions scanner and browser-side completions. * - * Format (columns can be in any order; `tag` is optional): - * tag,key,expr - * ,$hp,$con*5+$mod_hp ← variable declaration - * ,$ac,10+$dex ← variable declaration - * #warrior,$mod_hp,20 ← tag modifier - * #warrior,$mod_str,1 ← tag modifier + * Format (columns can be in any order; `tag` and `threshold` are optional): + * tag,threshold,key,expr + * ,,$hp,$con*5+$mod_hp ← variable declaration + * ,,$ac,10+$dex ← variable declaration + * #warrior,2,$mod_hp,20 ← tag modifier (threshold 2) + * #warrior,,$mod_str,1 ← tag modifier (threshold defaults to 1) * * When `tag` is empty: $key is a reactively computed variable. - * When `tag` is present: when #tag is active, $key gets expr added to its base. + * When `tag` is present: when #tag met (source's tagmap value >= threshold), + * $key gets expr added to its base. */ import { parse } from "csv-parse/browser/esm/sync"; @@ -57,6 +58,7 @@ export interface TagModifier { tag: string; // "#warrior" target: string; // "$mod_hp" expression: string; // "20" + threshold: number; // minimum tagmap count to activate (default 1) } export interface DeclareResult { @@ -84,7 +86,7 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult { columns: true, trim: true, skipEmptyLines: true, - }) as Array<{ tag?: string; key: string; expr: string }>; + }) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>; const variables: VarDeclaration[] = []; const tagModifiers: TagModifier[] = []; @@ -111,7 +113,14 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult { `${source}: key must start with $, got "${key}"`, ); } - tagModifiers.push({ tag, target: key, expression: expr }); + const thresholdRaw = row.threshold?.trim() ?? ""; + const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1; + if (isNaN(threshold) || threshold < 1) { + throw new Error( + `${source}: threshold must be a positive integer, got "${row.threshold}"`, + ); + } + tagModifiers.push({ tag, target: key, expression: expr, threshold }); } else { // Variable declaration if (!key.startsWith("$")) { diff --git a/src/cli/completions/variable-system.test.ts b/src/cli/completions/variable-system.test.ts index 60e7666..b5bd1da 100644 --- a/src/cli/completions/variable-system.test.ts +++ b/src/cli/completions/variable-system.test.ts @@ -86,14 +86,44 @@ describe("parseDeclareCsv", () => { tag: "#warrior", target: "$mod_hp", expression: "20", + threshold: 1, }); expect(result.tagModifiers[1]).toEqual({ tag: "#mage", target: "$mod_mp", expression: "15", + threshold: 1, }); }); + test("parses tag modifiers with threshold", () => { + const csv = `tag,threshold,key,expr +#warrior,2,$mod_hp,20 +#mage,,$mod_mp,15`; + const result = parseDeclareCsv(csv, "test.md"); + expect(result.tagModifiers).toHaveLength(2); + expect(result.tagModifiers[0]).toEqual({ + tag: "#warrior", + target: "$mod_hp", + expression: "20", + threshold: 2, + }); + expect(result.tagModifiers[1]).toEqual({ + tag: "#mage", + target: "$mod_mp", + expression: "15", + threshold: 1, + }); + }); + + test("rejects invalid threshold", () => { + const csv = `tag,threshold,key,expr +#warrior,0,$mod_hp,20`; + expect(() => parseDeclareCsv(csv, "test.md")).toThrow( + "threshold must be a positive integer", + ); + }); + test("parses mixed declarations and tag modifiers", () => { const csv = `tag,key,expr ,$hp,$con*5 @@ -176,6 +206,7 @@ $con*5,$hp, tag: "#warrior", target: "$mod_hp", expression: "20", + threshold: 1, }); }); }); @@ -484,10 +515,10 @@ describe("var-reactivity", () => { expect(getCombined("$hp", { $hp: "99" })).toBe("99"); }); - test("returns tag value without numeric mods", () => { + test("returns tagmap value without numeric mods", () => { initReactivity({ declarations: [], tagModifiers: [] }); - setBase("$class", "#warrior"); - expect(getCombined("$class")).toBe("#warrior"); + setBase("$class", "#warrior:1"); + expect(getCombined("$class")).toBe("#warrior:1"); }); }); @@ -496,14 +527,14 @@ describe("var-reactivity", () => { initReactivity({ declarations: [], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20" }, - { tag: "#warrior", target: "$mod_str", expression: "5" }, + { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, + { tag: "#warrior", target: "$mod_str", expression: "5", threshold: 1 }, ], }); - // Set $class to #warrior — should activate both modifiers - setBase("$class", "#warrior"); - const cascade = computeCascade("$class", undefined, store({ $class: "#warrior" })); + // Set $class to #warrior:1 — should activate both modifiers + setBase("$class", "#warrior:1"); + const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:1" })); // Should produce combined values for both targets const modHp = cascade.find((r) => r.key === "$mod_hp"); @@ -512,24 +543,24 @@ describe("var-reactivity", () => { expect(modStr?.value).toBe("5"); }); - test("deactivates tag modifiers when tag value changes", () => { + test("deactivates tag modifiers when tag is removed from tagmap", () => { initReactivity({ declarations: [], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20" }, + { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, ], }); // First activate - setBase("$class", "#warrior"); - computeCascade("$class", undefined, store({ $class: "#warrior" })); + setBase("$class", "#warrior:1"); + computeCascade("$class", undefined, store({ $class: "#warrior:1" })); // Now change to a different tag - setBase("$class", "#mage"); + setBase("$class", "#mage:1"); const cascade = computeCascade( "$class", - "#warrior", - store({ $class: "#mage" }), + "#warrior:1", + store({ $class: "#mage:1" }), ); // Should deactivate #warrior mods (mod_hp goes back to 0) @@ -541,21 +572,21 @@ describe("var-reactivity", () => { initReactivity({ declarations: [], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20" }, - { tag: "#mage", target: "$mod_hp", expression: "10" }, + { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, + { tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 }, ], }); // Activate #warrior - setBase("$class", "#warrior"); - computeCascade("$class", undefined, store({ $class: "#warrior" })); + setBase("$class", "#warrior:1"); + computeCascade("$class", undefined, store({ $class: "#warrior:1" })); // Switch to #mage - setBase("$class", "#mage"); + setBase("$class", "#mage:1"); const cascade = computeCascade( "$class", - "#warrior", - store({ $class: "#mage" }), + "#warrior:1", + store({ $class: "#mage:1" }), ); // Cascade returns both deactivation ("0") and activation ("10") entries. @@ -564,6 +595,69 @@ describe("var-reactivity", () => { const lastModHp = modHpEntries[modHpEntries.length - 1]; expect(lastModHp?.value).toBe("10"); }); + + test("respects threshold — activates only when count >= threshold", () => { + initReactivity({ + declarations: [], + tagModifiers: [ + { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 }, + ], + }); + + // Count 1 < threshold 2 — should NOT activate + setBase("$class", "#warrior:1"); + const cascade1 = computeCascade("$class", undefined, store({ $class: "#warrior:1" })); + const modHp1 = cascade1.find((r) => r.key === "$mod_hp"); + expect(modHp1).toBeUndefined(); + + // Increase to count 2 >= threshold 2 — should activate + setBase("$class", "#warrior:2"); + const cascade2 = computeCascade("$class", "#warrior:1", store({ $class: "#warrior:2" })); + const modHp2 = cascade2.find((r) => r.key === "$mod_hp"); + expect(modHp2?.value).toBe("20"); + }); + + test("deactivates when count drops below threshold", () => { + initReactivity({ + declarations: [], + tagModifiers: [ + { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 }, + ], + }); + + // Activate with count 2 + setBase("$class", "#warrior:2"); + computeCascade("$class", undefined, store({ $class: "#warrior:2" })); + expect(getCombined("$mod_hp")).toBe("20"); + + // Drop to count 1 < threshold 2 — should deactivate + setBase("$class", "#warrior:1"); + const cascade = computeCascade( + "$class", + "#warrior:2", + store({ $class: "#warrior:1" }), + ); + const modHp = cascade.find((r) => r.key === "$mod_hp"); + expect(modHp?.value).toBe("0"); + }); + + test("handles multi-tag tagmaps", () => { + initReactivity({ + declarations: [], + tagModifiers: [ + { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, + { tag: "#druid", target: "$mod_mp", expression: "15", threshold: 1 }, + ], + }); + + setBase("$class", "#warrior:2;#druid:1"); + const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:2;#druid:1" })); + + const modHp = cascade.find((r) => r.key === "$mod_hp"); + const modMp = cascade.find((r) => r.key === "$mod_mp"); + expect(modHp?.value).toBe("20"); + expect(modMp?.value).toBe("15"); + }); }); describe("computeCascade — declaration re-evaluation", () => { @@ -605,29 +699,27 @@ describe("var-reactivity", () => { expect(cResult?.value).toBe("20"); // 10 + 10 }); - test("handles tag transition during re-evaluation", () => { + test("handles tagmap transition during re-evaluation", () => { initReactivity({ declarations: [ - { key: "$class", expression: "$level > 5 ? '#warrior' : '#novice'" }, + { key: "$class", expression: "0" }, ], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20" }, - { tag: "#novice", target: "$mod_hp", expression: "5" }, + { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, + { tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 }, ], - // Note: the expression above uses a ternary which isn't supported - // by the expression evaluator. We'll test with direct tag values. }); - // Set $class directly to a tag - setBase("$class", "#novice"); - computeCascade("$class", undefined, store({ $class: "#novice" })); + // Set $class directly to #novice:1 + setBase("$class", "#novice:1"); + computeCascade("$class", undefined, store({ $class: "#novice:1" })); - // Now change to #warrior - setBase("$class", "#warrior"); + // Now change to #warrior:1 + setBase("$class", "#warrior:1"); const cascade = computeCascade( "$class", - "#novice", - store({ $class: "#warrior" }), + "#novice:1", + store({ $class: "#warrior:1" }), ); // Cascade returns both deactivation and activation entries. @@ -652,12 +744,12 @@ describe("var-reactivity", () => { initReactivity({ declarations: [], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20" }, + { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, ], }); - setBase("$class", "#warrior"); - computeCascade("$class", undefined, store({ $class: "#warrior" })); + setBase("$class", "#warrior:1"); + computeCascade("$class", undefined, store({ $class: "#warrior:1" })); const mods = getMods("$mod_hp"); expect(mods).toHaveLength(1); diff --git a/src/components/journal/command-dispatcher.ts b/src/components/journal/command-dispatcher.ts index 2740841..4ecf2ac 100644 --- a/src/components/journal/command-dispatcher.ts +++ b/src/components/journal/command-dispatcher.ts @@ -10,10 +10,25 @@ import { createSignal } from "solid-js"; import { parseInput } from "./command-parser"; import { resolveRollPayload } from "./types/roll"; import { resolveSparkPayload } from "./types/spark"; -import { evaluateExpression, expressionIsTag } from "./variable-expression"; +import { evaluateExpression } from "./variable-expression"; import { computeCascade, getCombined, setBase } from "./var-reactivity"; import type { VarDeclaration, TagModifier } from "./declare-parser"; +// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand. +const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/; +const TAGMAP_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/; + +function isTagMapExpr(expr: string): boolean { + const t = expr.trim(); + return BARE_TAG_PATTERN.test(t) || TAGMAP_PATTERN.test(t); +} + +function normalizeTagMap(expr: string): string { + const t = expr.trim(); + if (BARE_TAG_PATTERN.test(t)) return t + ":1"; + return t; +} + // --------------------------------------------------------------------------- // Result // --------------------------------------------------------------------------- @@ -174,12 +189,12 @@ function dispatchSet( try { if (p.tags && p.tags.length > 0) { - // Rolltag: pick random tag + // Rolltag: pick random tag, format as tagmap entry const idx = Math.floor(Math.random() * p.tags.length); - newValue = p.tags[idx]; - } else if (p.expr && expressionIsTag(p.expr)) { - // Bare tag value - newValue = p.expr.trim(); + newValue = normalizeTagMap(p.tags[idx]); + } else if (p.expr && isTagMapExpr(p.expr)) { + // Tagmap value (e.g. "#warrior:1;#druid:2" or bare "#warrior") + newValue = normalizeTagMap(p.expr); } else if (p.expr) { // Numeric expression — evaluate using combined values const result = evaluateExpression(p.expr, { diff --git a/src/components/journal/var-reactivity.ts b/src/components/journal/var-reactivity.ts index 7f7424c..3b25fca 100644 --- a/src/components/journal/var-reactivity.ts +++ b/src/components/journal/var-reactivity.ts @@ -5,11 +5,16 @@ * 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 + * - 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 * - * Combined value = base + sum(activeMods). Tag values (starting with #) - * are not numeric and don't receive mods. + * 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. @@ -32,9 +37,54 @@ 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 + 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"; +} + +// --------------------------------------------------------------------------- +// 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; } // --------------------------------------------------------------------------- @@ -47,17 +97,26 @@ let depGraph: Map> | null = null; /** Reverse: $declaredVar → its expression */ let declExprs: Map | null = null; -/** Tag modifiers from declare blocks: #tag → [{target, expression}] */ -let tagModMap: 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(); -/** Active mods per target: $target → [{tag, value, source}] */ -const activeMods = new Map(); +/** Numeric mods per target: $target → [{tag, value, source, ...}] */ +const numericMods = new Map(); -/** Activations per source: $source → [{tag, target, value}] */ -const sourceActivations = 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(); @@ -77,7 +136,8 @@ export function initReactivity(state: VarReactivityState): void { declExprs = new Map(); tagModMap = new Map(); baseValues.clear(); - activeMods.clear(); + numericMods.clear(); + tagMapMods.clear(); sourceActivations.clear(); // Index tag modifiers @@ -87,7 +147,7 @@ export function initReactivity(state: VarReactivityState): void { list = []; tagModMap.set(tm.tag, list); } - list.push({ target: tm.target, expression: tm.expression }); + list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold }); } // Index declarations and build dependency graph @@ -120,17 +180,30 @@ export function extractDependencies(expr: string): string[] { } /** - * Get the combined value of a variable (base + sum of active mods). + * 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 && isTagValue(base)) { - return base; // tag values don't receive numeric mods + + 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 = activeMods.get(key) ?? []; + const mods = numericMods.get(key) ?? []; const modSum = mods.reduce((sum, m) => sum + m.value, 0); if (!isNaN(baseNum)) { @@ -143,15 +216,35 @@ export function getCombined(key: string, fallback?: VariableStore): string { const fb = fallback?.[key]; if (fb !== undefined) return fb; - // No base, but has mods + // No base, but has numeric mods if (mods.length > 0) return String(modSum); return "0"; } +/** + * 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[] { - return activeMods.get(key) ?? []; + const nums = numericMods.get(key) ?? []; + const tags = tagMapMods.get(key) ?? []; + return [...nums, ...tags]; } /** Get the declaration expression for a variable, if any. */ @@ -168,7 +261,7 @@ export function setBase(key: string, value: string): void { } /** - * Rebuild local reactivity state (baseValues, activeMods, sourceActivations) + * 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. @@ -181,9 +274,12 @@ export function rebuildReactivityFromStore(variables: VariableStore): void { baseValues.set(key, value); } - const tag = isTagValue(value); - if (tag && !sourceActivations.has(key)) { - activateTagFromSource(key, tag, variables); + const tagMap = isTagMapValue(value) ? parseTagMap(value) : null; + if (tagMap && !sourceActivations.has(key)) { + // Activate all tags in the tagmap + const added = applyTagMapActivations(key, {}, tagMap, variables); + // We don't return the added entries here — they'll be applied + // to the store separately } } } @@ -209,26 +305,12 @@ export function computeCascade( // ---- Tag activation/deactivation ---- const newValue = getCombined(changedVar, currentVars); - const newTag = isTagValue(newValue); - const oldTag = isTagValue(oldValue); + const oldTagMap = isTagMapValue(oldValue) ? parseTagMap(oldValue) : {}; + const newTagMap = isTagMapValue(newValue) ? parseTagMap(newValue) : {}; - 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); - } - } - } + // Diff tagmaps and apply changes + const tagResults = applyTagMapActivations(changedVar, oldTagMap ?? {}, newTagMap ?? {}, currentVars); + results.push(...tagResults); // ---- Declaration re-evaluation ---- const reevaluated = reevaluateDependents(changedVar, currentVars); @@ -270,10 +352,10 @@ export function computeInitialValues( 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); + // 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); @@ -295,112 +377,148 @@ export function computeInitialValues( } // --------------------------------------------------------------------------- -// Tag activation / deactivation (source-based) +// Tag activation / deactivation (threshold-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( +/** + * Apply tagmap changes for a source variable. Compares old and new tagmaps, + * activating/deactivating modifiers whose threshold crossing state changed. + */ +function applyTagMapActivations( source: string, - tag: string, + oldTagMap: Record, + newTagMap: Record, 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 }> = []; + const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]); - 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)); + 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, { + lookup: (name: string) => { + const k = "$" + name; + return getCombined(k, fallback); + }, + }); + + const value = evalResult.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 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 + } + } 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 existing = results.findIndex((r) => r.key === entry.target); + if (existing >= 0) { + results[existing] = { key: entry.target, value: combined }; + } else { + results.push({ key: entry.target, value: combined }); + } } - - 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(); - - // 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) }); + // Clean up empty sourceActivations + if (sourceActivations.get(source)?.length === 0) { + sourceActivations.delete(source); } return results; @@ -455,29 +573,16 @@ function reevaluateDependents( const rawValue = String(result.value); - // Check for tag transition on this declared variable + // Check for tagmap transition on this declared variable const oldCombined = getCombined(key, fallback); - const oldTag = isTagValue(oldCombined); - const newTag = isTagValue(rawValue); + const oldTagMap = isTagMapValue(oldCombined) ? parseTagMap(oldCombined) : {}; + const newTagMap = isTagMapValue(rawValue) ? parseTagMap(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); - } + 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); } } }