feat: add threshold support and tagmap support to variable system

Introduce a `threshold` column to tag modifiers in CSV declarations,
allowing modifiers to activate only when a tag's count meets a minimum
requirement.

Implement support for "tagmap" variables (e.g., `#warrior:1;#druid:2`)
which allow for complex, multi-tag state tracking. These variables
can receive specialized tagmap modifications rather than simple
numeric additions.
This commit is contained in:
2026-07-13 15:09:50 +08:00
parent d74ba69fdf
commit 7cb28e631e
4 changed files with 436 additions and 215 deletions
+130 -38
View File
@@ -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);