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:
@@ -3,15 +3,16 @@
|
||||
*
|
||||
* Shared between CLI completions scanner and browser-side completions.
|
||||
*
|
||||
* Format (columns can be in any order; `tag` is optional):
|
||||
* tag,key,expr
|
||||
* ,$hp,$con*5+$mod_hp ← variable declaration
|
||||
* ,$ac,10+$dex ← variable declaration
|
||||
* #warrior,$mod_hp,20 ← tag modifier
|
||||
* #warrior,$mod_str,1 ← tag modifier
|
||||
* Format (columns can be in any order; `tag` and `threshold` are optional):
|
||||
* tag,threshold,key,expr
|
||||
* ,,$hp,$con*5+$mod_hp ← variable declaration
|
||||
* ,,$ac,10+$dex ← variable declaration
|
||||
* #warrior,2,$mod_hp,20 ← tag modifier (threshold 2)
|
||||
* #warrior,,$mod_str,1 ← tag modifier (threshold defaults to 1)
|
||||
*
|
||||
* When `tag` is empty: $key is a reactively computed variable.
|
||||
* When `tag` is present: when #tag is active, $key gets expr added to its base.
|
||||
* When `tag` is present: when #tag met (source's tagmap value >= threshold),
|
||||
* $key gets expr added to its base.
|
||||
*/
|
||||
|
||||
import { parse } from "csv-parse/browser/esm/sync";
|
||||
@@ -57,6 +58,7 @@ export interface TagModifier {
|
||||
tag: string; // "#warrior"
|
||||
target: string; // "$mod_hp"
|
||||
expression: string; // "20"
|
||||
threshold: number; // minimum tagmap count to activate (default 1)
|
||||
}
|
||||
|
||||
export interface DeclareResult {
|
||||
@@ -84,7 +86,7 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
||||
columns: true,
|
||||
trim: true,
|
||||
skipEmptyLines: true,
|
||||
}) as Array<{ tag?: string; key: string; expr: string }>;
|
||||
}) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
|
||||
|
||||
const variables: VarDeclaration[] = [];
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
@@ -111,7 +113,14 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
||||
`${source}: key must start with $, got "${key}"`,
|
||||
);
|
||||
}
|
||||
tagModifiers.push({ tag, target: key, expression: expr });
|
||||
const thresholdRaw = row.threshold?.trim() ?? "";
|
||||
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
|
||||
if (isNaN(threshold) || threshold < 1) {
|
||||
throw new Error(
|
||||
`${source}: threshold must be a positive integer, got "${row.threshold}"`,
|
||||
);
|
||||
}
|
||||
tagModifiers.push({ tag, target: key, expression: expr, threshold });
|
||||
} else {
|
||||
// Variable declaration
|
||||
if (!key.startsWith("$")) {
|
||||
|
||||
@@ -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