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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user