fix(journal): keep variable bases in the stream
The stream only carried combined values, so hydrating a session lost the raw base and rebuildReactivityFromStore re-applied tag modifiers on top of already-combined values (e.g. $hp 70 became 90). Publish the raw base alongside the combined value on var messages, persist it in variableBases, and rebuild engine state from exact bases. Derive tag activations from base tagmaps so tagmap-valued modifier targets are not re-triggered on every rebuild. Seed declared variables only after hydration and only for keys absent from the store, so persisted values are no longer clobbered on load and reloads stop appending duplicate var messages.
This commit is contained in:
@@ -35,6 +35,18 @@ export interface VarReactivityState {
|
||||
|
||||
export type VariableStore = Record<string, string>;
|
||||
|
||||
/**
|
||||
* A variable update produced by the engine: the combined `value` to publish
|
||||
* for display, plus the raw `base` it was derived from. The base is carried on
|
||||
* the stream so a hydrated client can rebuild the engine without having to
|
||||
* reverse-engineer base values out of combined ones.
|
||||
*/
|
||||
export interface VarChange {
|
||||
key: string;
|
||||
value: string;
|
||||
base: string;
|
||||
}
|
||||
|
||||
/** A single modifier applied to a target variable. */
|
||||
export interface ActiveMod {
|
||||
tag: string; // "#warrior"
|
||||
@@ -265,22 +277,34 @@ export function setBase(key: string, value: string): void {
|
||||
* from the hydrated stream variable store. Must be called after replayReducers
|
||||
* so that tag activations are correctly tracked for subsequent cascade
|
||||
* computations.
|
||||
*
|
||||
* The stream store holds *combined* values (base + active modifiers) alongside
|
||||
* the raw `base` each was derived from, so bases are restored exactly rather
|
||||
* than reconstructed by subtracting modifiers back out.
|
||||
*/
|
||||
export function rebuildReactivityFromStore(variables: VariableStore): void {
|
||||
export function rebuildReactivityFromStore(
|
||||
variables: VariableStore,
|
||||
bases?: VariableStore,
|
||||
): void {
|
||||
if (!tagModMap) return;
|
||||
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
if (!baseValues.has(key)) {
|
||||
baseValues.set(key, value);
|
||||
}
|
||||
// Reset, then restore exact bases from the store's base map.
|
||||
baseValues.clear();
|
||||
numericMods.clear();
|
||||
tagMapMods.clear();
|
||||
sourceActivations.clear();
|
||||
|
||||
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
|
||||
}
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
baseValues.set(key, bases?.[key] ?? value);
|
||||
}
|
||||
|
||||
// Re-attach tag modifiers from each source's *base* tagmap. Using the
|
||||
// combined value here would re-trigger a modifier that merely added a tag to
|
||||
// the source (feedback), inflating counts on every rebuild.
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
const source = bases?.[key] ?? value;
|
||||
const tagMap = isTagMapValue(source) ? parseTagMap(source) : null;
|
||||
if (tagMap) applyTagMapActivations(key, {}, tagMap, variables);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,12 +320,12 @@ export function computeCascade(
|
||||
changedVar: string,
|
||||
oldValue: string | undefined,
|
||||
currentVars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
): VarChange[] {
|
||||
if (!depGraph || !declExprs || !tagModMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const results: VarChange[] = [];
|
||||
|
||||
// ---- Tag activation/deactivation ----
|
||||
const newValue = getCombined(changedVar, currentVars);
|
||||
@@ -325,19 +349,27 @@ export function computeCascade(
|
||||
|
||||
/**
|
||||
* Compute initial values for all declared variables.
|
||||
* Called once after initReactivity() to seed the store.
|
||||
* Called after initReactivity() to seed the store.
|
||||
*
|
||||
* @param existing - declared keys already present in the store; these are
|
||||
* skipped so hydrated/overridden session values are not clobbered.
|
||||
*/
|
||||
export function computeInitialValues(
|
||||
currentVars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
existing?: ReadonlySet<string>,
|
||||
): VarChange[] {
|
||||
if (!declExprs) return [];
|
||||
|
||||
const allKeys = [...declExprs.keys()];
|
||||
const sorted = topoSortAffected(new Set(allKeys));
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const results: VarChange[] = [];
|
||||
|
||||
for (const key of sorted) {
|
||||
// Variables already in the store were seeded (or overridden) previously;
|
||||
// recomputing them would clobber session state on every reload.
|
||||
if (existing?.has(key)) continue;
|
||||
|
||||
const expr = declExprs.get(key);
|
||||
if (!expr) continue;
|
||||
|
||||
@@ -366,7 +398,7 @@ export function computeInitialValues(
|
||||
// Always emit the combined value for this key
|
||||
const combined = getCombined(key, currentVars);
|
||||
if (!results.some((x) => x.key === key)) {
|
||||
results.push({ key, value: combined });
|
||||
results.push({ key, value: combined, base: rawValue });
|
||||
}
|
||||
} catch {
|
||||
// skip failed evaluations at init time
|
||||
@@ -389,10 +421,10 @@ function applyTagMapActivations(
|
||||
oldTagMap: Record<string, number>,
|
||||
newTagMap: Record<string, number>,
|
||||
fallback: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
): VarChange[] {
|
||||
if (!tagModMap) return [];
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const results: VarChange[] = [];
|
||||
const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
|
||||
|
||||
for (const tag of allTags) {
|
||||
@@ -467,11 +499,12 @@ function applyTagMapActivations(
|
||||
|
||||
// Emit new combined value
|
||||
const combined = getCombined(mod.target, fallback);
|
||||
const base = baseValues.get(mod.target) ?? combined;
|
||||
const existing = results.findIndex((r) => r.key === mod.target);
|
||||
if (existing >= 0) {
|
||||
results[existing] = { key: mod.target, value: combined };
|
||||
results[existing] = { key: mod.target, value: combined, base };
|
||||
} else {
|
||||
results.push({ key: mod.target, value: combined });
|
||||
results.push({ key: mod.target, value: combined, base });
|
||||
}
|
||||
} catch {
|
||||
// skip failed modifier
|
||||
@@ -512,11 +545,12 @@ function applyTagMapActivations(
|
||||
|
||||
// Emit updated combined value
|
||||
const combined = getCombined(entry.target, fallback);
|
||||
const base = baseValues.get(entry.target) ?? combined;
|
||||
const existing = results.findIndex((r) => r.key === entry.target);
|
||||
if (existing >= 0) {
|
||||
results[existing] = { key: entry.target, value: combined };
|
||||
results[existing] = { key: entry.target, value: combined, base };
|
||||
} else {
|
||||
results.push({ key: entry.target, value: combined });
|
||||
results.push({ key: entry.target, value: combined, base });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -537,7 +571,7 @@ function applyTagMapActivations(
|
||||
function reevaluateDependents(
|
||||
changedVar: string,
|
||||
fallback: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
): VarChange[] {
|
||||
if (!depGraph || !declExprs) return [];
|
||||
|
||||
// Collect all dependents reachable from changedVar (BFS)
|
||||
@@ -556,7 +590,7 @@ function reevaluateDependents(
|
||||
}
|
||||
|
||||
const sorted = topoSortAffected(affected);
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const results: VarChange[] = [];
|
||||
|
||||
for (const key of sorted) {
|
||||
if (inFlight.has(key)) {
|
||||
@@ -599,7 +633,7 @@ function reevaluateDependents(
|
||||
// Emit combined value
|
||||
const combined = getCombined(key, fallback);
|
||||
if (!results.some((x) => x.key === key)) {
|
||||
results.push({ key, value: combined });
|
||||
results.push({ key, value: combined, base: rawValue });
|
||||
}
|
||||
} finally {
|
||||
inFlight.delete(key);
|
||||
|
||||
Reference in New Issue
Block a user