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:
hyper
2026-09-12 21:59:45 +08:00
parent 0ce91afc49
commit a26d53cbeb
6 changed files with 290 additions and 46 deletions
+61 -27
View File
@@ -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);