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
+132
View File
@@ -39,6 +39,7 @@ import {
getMods,
getDeclExpr,
extractDependencies,
rebuildReactivityFromStore,
} from "../../components/journal/var-reactivity";
import { parseInput } from "../../components/journal/command-parser";
import {
@@ -539,6 +540,33 @@ describe("var-reactivity", () => {
expect(hpResult?.value).toBe("60"); // 12 * 5
});
test("reports the base alongside the combined value", () => {
initReactivity({
declarations: [{ key: "$hp", expression: "$con * 5" }],
tagModifiers: [],
});
const results = computeInitialValues({ $con: "12" });
const hp = results.find((r) => r.key === "$hp");
expect(hp?.value).toBe("60");
expect(hp?.base).toBe("60");
});
test("skips variables already present in the store", () => {
initReactivity({
declarations: [
{ key: "$hp", expression: "10 + 5" },
{ key: "$ac", expression: "15" },
],
tagModifiers: [],
});
const results = computeInitialValues({ $hp: "99" }, new Set(["$hp"]));
// Existing value is left untouched; only missing keys are computed.
expect(results.find((r) => r.key === "$hp")).toBeUndefined();
expect(results.find((r) => r.key === "$ac")?.value).toBe("15");
});
test("returns empty array when no declarations", () => {
initReactivity({ declarations: [], tagModifiers: [] });
const results = computeInitialValues(store());
@@ -570,6 +598,89 @@ describe("var-reactivity", () => {
});
});
describe("rebuildReactivityFromStore", () => {
test("restores bases exactly without double-counting modifiers", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
],
});
// `variables` holds combined values; `bases` holds what derived them.
const hydrated = { $class: "#warrior:1", $mod_hp: "20" };
const bases = { $class: "#warrior:1", $mod_hp: "0" };
rebuildReactivityFromStore(hydrated, bases);
expect(getCombined("$mod_hp", hydrated)).toBe("20");
});
test("recovers a non-zero base and keeps modifiers applied", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$hp", expression: "20", threshold: 1 },
],
});
const hydrated = { $class: "#warrior:1", $hp: "70" }; // base 50 + 20
const bases = { $class: "#warrior:1", $hp: "50" };
rebuildReactivityFromStore(hydrated, bases);
expect(getCombined("$hp", hydrated)).toBe("70");
// A later /set replaces the base; the modifier still applies.
setBase("$hp", "60");
expect(getCombined("$hp", hydrated)).toBe("80");
});
test("deactivates a hydrated modifier on a later tag change", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
],
});
const hydrated = { $class: "#warrior:1", $mod_hp: "20" };
const bases = { $class: "#warrior:1", $mod_hp: "0" };
rebuildReactivityFromStore(hydrated, bases);
setBase("$class", "#mage:1");
const cascade = computeCascade("$class", "#warrior:1", {
...hydrated,
$class: "#mage:1",
});
expect(cascade.find((r) => r.key === "$mod_hp")?.value).toBe("0");
expect(getCombined("$mod_hp", { $class: "#mage:1" })).toBe("0");
});
test("rebuilds a tagmap variable's base from its published base", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$pool", expression: "2", threshold: 1 },
],
});
// $pool is a tagmap variable that receives a +2 #warrior tagmap mod.
const hydrated = { $class: "#warrior:1", $pool: "#druid:3;#warrior:2" };
const bases = { $class: "#warrior:1", $pool: "#druid:3" };
rebuildReactivityFromStore(hydrated, bases);
expect(getCombined("$pool", hydrated)).toBe("#druid:3;#warrior:2");
});
test("is idempotent when called twice", () => {
initReactivity({ declarations: [], tagModifiers: [] });
const hydrated = { $hp: "42" };
rebuildReactivityFromStore(hydrated, { $hp: "42" });
rebuildReactivityFromStore(hydrated, { $hp: "42" });
expect(getCombined("$hp", hydrated)).toBe("42");
});
});
describe("computeCascade — tag activation/deactivation", () => {
test("activates tag modifiers when a tag value is set", () => {
initReactivity({
@@ -605,6 +716,27 @@ describe("var-reactivity", () => {
expect(modStr?.value).toBe("5");
});
test("reports the raw base alongside the combined value", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$hp", expression: "20", threshold: 1 },
],
});
setBase("$hp", "50");
setBase("$class", "#warrior:1");
const cascade = computeCascade(
"$class",
undefined,
store({ $class: "#warrior:1" }),
);
const hp = cascade.find((r) => r.key === "$hp");
expect(hp?.value).toBe("70");
expect(hp?.base).toBe("50");
});
test("deactivates tag modifiers when tag is removed from tagmap", () => {
initReactivity({
declarations: [],