Compare commits
No commits in common. "7cb28e631ec815eff1a3f7e72d27c9ccf2d5f56b" and "c4038a5213ce90425a6ae5a77badac3a60547ea9" have entirely different histories.
7cb28e631e
...
c4038a5213
|
|
@ -3,16 +3,15 @@
|
||||||
*
|
*
|
||||||
* Shared between CLI completions scanner and browser-side completions.
|
* Shared between CLI completions scanner and browser-side completions.
|
||||||
*
|
*
|
||||||
* Format (columns can be in any order; `tag` and `threshold` are optional):
|
* Format (columns can be in any order; `tag` is optional):
|
||||||
* tag,threshold,key,expr
|
* tag,key,expr
|
||||||
* ,,$hp,$con*5+$mod_hp ← variable declaration
|
* ,$hp,$con*5+$mod_hp ← variable declaration
|
||||||
* ,,$ac,10+$dex ← variable declaration
|
* ,$ac,10+$dex ← variable declaration
|
||||||
* #warrior,2,$mod_hp,20 ← tag modifier (threshold 2)
|
* #warrior,$mod_hp,20 ← tag modifier
|
||||||
* #warrior,,$mod_str,1 ← tag modifier (threshold defaults to 1)
|
* #warrior,$mod_str,1 ← tag modifier
|
||||||
*
|
*
|
||||||
* When `tag` is empty: $key is a reactively computed variable.
|
* When `tag` is empty: $key is a reactively computed variable.
|
||||||
* When `tag` is present: when #tag met (source's tagmap value >= threshold),
|
* When `tag` is present: when #tag is active, $key gets expr added to its base.
|
||||||
* $key gets expr added to its base.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parse } from "csv-parse/browser/esm/sync";
|
import { parse } from "csv-parse/browser/esm/sync";
|
||||||
|
|
@ -58,7 +57,6 @@ export interface TagModifier {
|
||||||
tag: string; // "#warrior"
|
tag: string; // "#warrior"
|
||||||
target: string; // "$mod_hp"
|
target: string; // "$mod_hp"
|
||||||
expression: string; // "20"
|
expression: string; // "20"
|
||||||
threshold: number; // minimum tagmap count to activate (default 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeclareResult {
|
export interface DeclareResult {
|
||||||
|
|
@ -86,7 +84,7 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
||||||
columns: true,
|
columns: true,
|
||||||
trim: true,
|
trim: true,
|
||||||
skipEmptyLines: true,
|
skipEmptyLines: true,
|
||||||
}) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
|
}) as Array<{ tag?: string; key: string; expr: string }>;
|
||||||
|
|
||||||
const variables: VarDeclaration[] = [];
|
const variables: VarDeclaration[] = [];
|
||||||
const tagModifiers: TagModifier[] = [];
|
const tagModifiers: TagModifier[] = [];
|
||||||
|
|
@ -113,14 +111,7 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
||||||
`${source}: key must start with $, got "${key}"`,
|
`${source}: key must start with $, got "${key}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const thresholdRaw = row.threshold?.trim() ?? "";
|
tagModifiers.push({ tag, target: key, expression: expr });
|
||||||
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 {
|
} else {
|
||||||
// Variable declaration
|
// Variable declaration
|
||||||
if (!key.startsWith("$")) {
|
if (!key.startsWith("$")) {
|
||||||
|
|
|
||||||
|
|
@ -86,44 +86,14 @@ describe("parseDeclareCsv", () => {
|
||||||
tag: "#warrior",
|
tag: "#warrior",
|
||||||
target: "$mod_hp",
|
target: "$mod_hp",
|
||||||
expression: "20",
|
expression: "20",
|
||||||
threshold: 1,
|
|
||||||
});
|
});
|
||||||
expect(result.tagModifiers[1]).toEqual({
|
expect(result.tagModifiers[1]).toEqual({
|
||||||
tag: "#mage",
|
tag: "#mage",
|
||||||
target: "$mod_mp",
|
target: "$mod_mp",
|
||||||
expression: "15",
|
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", () => {
|
test("parses mixed declarations and tag modifiers", () => {
|
||||||
const csv = `tag,key,expr
|
const csv = `tag,key,expr
|
||||||
,$hp,$con*5
|
,$hp,$con*5
|
||||||
|
|
@ -206,7 +176,6 @@ $con*5,$hp,
|
||||||
tag: "#warrior",
|
tag: "#warrior",
|
||||||
target: "$mod_hp",
|
target: "$mod_hp",
|
||||||
expression: "20",
|
expression: "20",
|
||||||
threshold: 1,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -515,10 +484,10 @@ describe("var-reactivity", () => {
|
||||||
expect(getCombined("$hp", { $hp: "99" })).toBe("99");
|
expect(getCombined("$hp", { $hp: "99" })).toBe("99");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns tagmap value without numeric mods", () => {
|
test("returns tag value without numeric mods", () => {
|
||||||
initReactivity({ declarations: [], tagModifiers: [] });
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
setBase("$class", "#warrior:1");
|
setBase("$class", "#warrior");
|
||||||
expect(getCombined("$class")).toBe("#warrior:1");
|
expect(getCombined("$class")).toBe("#warrior");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -527,14 +496,14 @@ describe("var-reactivity", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||||
{ tag: "#warrior", target: "$mod_str", expression: "5", threshold: 1 },
|
{ tag: "#warrior", target: "$mod_str", expression: "5" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set $class to #warrior:1 — should activate both modifiers
|
// Set $class to #warrior — should activate both modifiers
|
||||||
setBase("$class", "#warrior:1");
|
setBase("$class", "#warrior");
|
||||||
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
|
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||||
|
|
||||||
// Should produce combined values for both targets
|
// Should produce combined values for both targets
|
||||||
const modHp = cascade.find((r) => r.key === "$mod_hp");
|
const modHp = cascade.find((r) => r.key === "$mod_hp");
|
||||||
|
|
@ -543,24 +512,24 @@ describe("var-reactivity", () => {
|
||||||
expect(modStr?.value).toBe("5");
|
expect(modStr?.value).toBe("5");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("deactivates tag modifiers when tag is removed from tagmap", () => {
|
test("deactivates tag modifiers when tag value changes", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// First activate
|
// First activate
|
||||||
setBase("$class", "#warrior:1");
|
setBase("$class", "#warrior");
|
||||||
computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
|
computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||||
|
|
||||||
// Now change to a different tag
|
// Now change to a different tag
|
||||||
setBase("$class", "#mage:1");
|
setBase("$class", "#mage");
|
||||||
const cascade = computeCascade(
|
const cascade = computeCascade(
|
||||||
"$class",
|
"$class",
|
||||||
"#warrior:1",
|
"#warrior",
|
||||||
store({ $class: "#mage:1" }),
|
store({ $class: "#mage" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should deactivate #warrior mods (mod_hp goes back to 0)
|
// Should deactivate #warrior mods (mod_hp goes back to 0)
|
||||||
|
|
@ -572,21 +541,21 @@ describe("var-reactivity", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||||
{ tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 },
|
{ tag: "#mage", target: "$mod_hp", expression: "10" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate #warrior
|
// Activate #warrior
|
||||||
setBase("$class", "#warrior:1");
|
setBase("$class", "#warrior");
|
||||||
computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
|
computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||||
|
|
||||||
// Switch to #mage
|
// Switch to #mage
|
||||||
setBase("$class", "#mage:1");
|
setBase("$class", "#mage");
|
||||||
const cascade = computeCascade(
|
const cascade = computeCascade(
|
||||||
"$class",
|
"$class",
|
||||||
"#warrior:1",
|
"#warrior",
|
||||||
store({ $class: "#mage:1" }),
|
store({ $class: "#mage" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cascade returns both deactivation ("0") and activation ("10") entries.
|
// Cascade returns both deactivation ("0") and activation ("10") entries.
|
||||||
|
|
@ -595,69 +564,6 @@ describe("var-reactivity", () => {
|
||||||
const lastModHp = modHpEntries[modHpEntries.length - 1];
|
const lastModHp = modHpEntries[modHpEntries.length - 1];
|
||||||
expect(lastModHp?.value).toBe("10");
|
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", () => {
|
describe("computeCascade — declaration re-evaluation", () => {
|
||||||
|
|
@ -699,27 +605,29 @@ describe("var-reactivity", () => {
|
||||||
expect(cResult?.value).toBe("20"); // 10 + 10
|
expect(cResult?.value).toBe("20"); // 10 + 10
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles tagmap transition during re-evaluation", () => {
|
test("handles tag transition during re-evaluation", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [
|
declarations: [
|
||||||
{ key: "$class", expression: "0" },
|
{ key: "$class", expression: "$level > 5 ? '#warrior' : '#novice'" },
|
||||||
],
|
],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||||
{ tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 },
|
{ tag: "#novice", target: "$mod_hp", expression: "5" },
|
||||||
],
|
],
|
||||||
|
// 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 #novice:1
|
// Set $class directly to a tag
|
||||||
setBase("$class", "#novice:1");
|
setBase("$class", "#novice");
|
||||||
computeCascade("$class", undefined, store({ $class: "#novice:1" }));
|
computeCascade("$class", undefined, store({ $class: "#novice" }));
|
||||||
|
|
||||||
// Now change to #warrior:1
|
// Now change to #warrior
|
||||||
setBase("$class", "#warrior:1");
|
setBase("$class", "#warrior");
|
||||||
const cascade = computeCascade(
|
const cascade = computeCascade(
|
||||||
"$class",
|
"$class",
|
||||||
"#novice:1",
|
"#novice",
|
||||||
store({ $class: "#warrior:1" }),
|
store({ $class: "#warrior" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cascade returns both deactivation and activation entries.
|
// Cascade returns both deactivation and activation entries.
|
||||||
|
|
@ -744,12 +652,12 @@ describe("var-reactivity", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
setBase("$class", "#warrior:1");
|
setBase("$class", "#warrior");
|
||||||
computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
|
computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||||
|
|
||||||
const mods = getMods("$mod_hp");
|
const mods = getMods("$mod_hp");
|
||||||
expect(mods).toHaveLength(1);
|
expect(mods).toHaveLength(1);
|
||||||
|
|
@ -782,16 +690,16 @@ describe("var-reactivity", () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("parseInput", () => {
|
describe("parseInput", () => {
|
||||||
test("parses /roll command with spark table slug", () => {
|
test("parses /roll command", () => {
|
||||||
const result = parseInput("/roll my-table");
|
|
||||||
expect(result.type).toBe("roll");
|
|
||||||
expect(result.payload).toEqual({ arg: "my-table" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses /roll command with dice notation", () => {
|
|
||||||
const result = parseInput("/roll 3d6+5");
|
const result = parseInput("/roll 3d6+5");
|
||||||
expect(result.type).toBe("roll");
|
expect(result.type).toBe("roll");
|
||||||
expect(result.payload).toEqual({ arg: "3d6+5" });
|
expect(result.payload).toEqual({ notation: "3d6+5", label: "3d6+5" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses /spark command", () => {
|
||||||
|
const result = parseInput("/spark my-table");
|
||||||
|
expect(result.type).toBe("spark");
|
||||||
|
expect(result.payload).toEqual({ key: "my-table" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses /link command", () => {
|
test("parses /link command", () => {
|
||||||
|
|
@ -835,7 +743,7 @@ describe("parseInput", () => {
|
||||||
|
|
||||||
test("returns error for empty /roll", () => {
|
test("returns error for empty /roll", () => {
|
||||||
const result = parseInput("/roll ");
|
const result = parseInput("/roll ");
|
||||||
expect(result.error).toBe("需要骰子表达式或种子表键名");
|
expect(result.error).toBe("需要骰子表达式");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns error for empty /set", () => {
|
test("returns error for empty /set", () => {
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ export const RevealManager: Component = () => {
|
||||||
rect: sparkTable.getBoundingClientRect(),
|
rect: sparkTable.getBoundingClientRect(),
|
||||||
action: () =>
|
action: () =>
|
||||||
setActionPrefill({
|
setActionPrefill({
|
||||||
command: "/roll",
|
command: "/spark",
|
||||||
text: combinedSlug,
|
text: combinedSlug,
|
||||||
}),
|
}),
|
||||||
title: "Roll spark table",
|
title: "Roll spark table",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import journalGmRaw from "../doc-entries/journal-gm.md";
|
||||||
import journalPlayerRaw from "../doc-entries/journal-player.md";
|
import journalPlayerRaw from "../doc-entries/journal-player.md";
|
||||||
import journalSetRaw from "../doc-entries/journal-set.md";
|
import journalSetRaw from "../doc-entries/journal-set.md";
|
||||||
import journalRollRaw from "../doc-entries/journal-roll.md";
|
import journalRollRaw from "../doc-entries/journal-roll.md";
|
||||||
|
import journalSparkRaw from "../doc-entries/journal-spark.md";
|
||||||
import journalLinkRaw from "../doc-entries/journal-link.md";
|
import journalLinkRaw from "../doc-entries/journal-link.md";
|
||||||
|
|
||||||
const rawDocuments: string[] = [
|
const rawDocuments: string[] = [
|
||||||
|
|
@ -52,6 +53,7 @@ const rawDocuments: string[] = [
|
||||||
journalPlayerRaw,
|
journalPlayerRaw,
|
||||||
journalSetRaw,
|
journalSetRaw,
|
||||||
journalRollRaw,
|
journalRollRaw,
|
||||||
|
journalSparkRaw,
|
||||||
journalLinkRaw,
|
journalLinkRaw,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -265,7 +265,7 @@ export const JournalInput: Component = () => {
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
isGm()
|
isGm()
|
||||||
? "输入消息,或使用 /roll、/link、/set 命令..."
|
? "输入消息,或使用 /roll、/spark、/link、/set 命令..."
|
||||||
: "输入消息,或使用 /set 命令..."
|
: "输入消息,或使用 /set 命令..."
|
||||||
}
|
}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export function buildCompletions(
|
||||||
|
|
||||||
const commands = [
|
const commands = [
|
||||||
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
||||||
|
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
|
||||||
{ label: "/link", kind: "command" as const, insertText: "/link " },
|
{ label: "/link", kind: "command" as const, insertText: "/link " },
|
||||||
{ label: "/set", kind: "command" as const, insertText: "/set " },
|
{ label: "/set", kind: "command" as const, insertText: "/set " },
|
||||||
];
|
];
|
||||||
|
|
@ -39,52 +40,46 @@ export function buildCompletions(
|
||||||
: [{ label: "未知命令", kind: "no-results", insertText: "" }];
|
: [{ label: "未知命令", kind: "no-results", insertText: "" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
// After /roll — show dice AND spark table suggestions
|
// After /roll — show dice suggestions
|
||||||
if (raw.startsWith("/roll ")) {
|
if (raw.startsWith("/roll ")) {
|
||||||
const prefix = raw.slice("/roll ".length).toLowerCase();
|
const prefix = raw.slice("/roll ".length).toLowerCase();
|
||||||
|
const matches = data.dice
|
||||||
const diceMatches = data.dice
|
|
||||||
.filter(
|
.filter(
|
||||||
(d) =>
|
(d) =>
|
||||||
d.notation.toLowerCase().includes(prefix) ||
|
d.notation.toLowerCase().includes(prefix) ||
|
||||||
d.label.toLowerCase().includes(prefix),
|
d.label.toLowerCase().includes(prefix),
|
||||||
)
|
)
|
||||||
.slice(0, 4);
|
.slice(0, 8);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return [{ label: "未找到骰子", kind: "no-results", insertText: "" }];
|
||||||
|
}
|
||||||
|
return matches.map((d) => ({
|
||||||
|
label: d.notation,
|
||||||
|
kind: "value" as const,
|
||||||
|
insertText: "/roll " + d.notation,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const sparkMatches = data.sparkTables
|
// After /spark — show spark table suggestions
|
||||||
|
if (raw.startsWith("/spark ")) {
|
||||||
|
const prefix = raw.slice("/spark ".length).toLowerCase();
|
||||||
|
const matches = data.sparkTables
|
||||||
.filter(
|
.filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
s.slug.toLowerCase().includes(prefix) ||
|
s.slug.toLowerCase().includes(prefix) ||
|
||||||
s.label.toLowerCase().includes(prefix),
|
s.label.toLowerCase().includes(prefix),
|
||||||
)
|
)
|
||||||
.slice(0, 4);
|
.slice(0, 8);
|
||||||
|
if (matches.length === 0) {
|
||||||
const items: CompletionItem[] = [];
|
return [{ label: "未找到种子表", kind: "no-results", insertText: "" }];
|
||||||
|
|
||||||
for (const d of diceMatches) {
|
|
||||||
items.push({
|
|
||||||
label: `🎲 ${d.notation}`,
|
|
||||||
kind: "value",
|
|
||||||
insertText: "/roll " + d.notation,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return matches.map((s) => ({
|
||||||
for (const s of sparkMatches) {
|
label: `${s.slug} (${s.notation})`,
|
||||||
items.push({
|
kind: "value" as const,
|
||||||
label: `✨ ${s.slug} (${s.notation})`,
|
insertText: `/spark ${s.slug}`,
|
||||||
kind: "value",
|
}));
|
||||||
insertText: `/roll ${s.slug}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return [{ label: "输入骰子表达式", kind: "no-results", insertText: "" }];
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// After /link — show article and heading suggestions
|
// After /link — show article and heading suggestions
|
||||||
if (raw.startsWith("/link ")) {
|
if (raw.startsWith("/link ")) {
|
||||||
const prefix = raw.slice("/link ".length).toLowerCase();
|
const prefix = raw.slice("/link ".length).toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -10,25 +10,10 @@ import { createSignal } from "solid-js";
|
||||||
import { parseInput } from "./command-parser";
|
import { parseInput } from "./command-parser";
|
||||||
import { resolveRollPayload } from "./types/roll";
|
import { resolveRollPayload } from "./types/roll";
|
||||||
import { resolveSparkPayload } from "./types/spark";
|
import { resolveSparkPayload } from "./types/spark";
|
||||||
import { evaluateExpression } from "./variable-expression";
|
import { evaluateExpression, expressionIsTag } from "./variable-expression";
|
||||||
import { computeCascade, getCombined, setBase } from "./var-reactivity";
|
import { computeCascade, getCombined, setBase } from "./var-reactivity";
|
||||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
|
|
||||||
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
|
||||||
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
||||||
const TAGMAP_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/;
|
|
||||||
|
|
||||||
function isTagMapExpr(expr: string): boolean {
|
|
||||||
const t = expr.trim();
|
|
||||||
return BARE_TAG_PATTERN.test(t) || TAGMAP_PATTERN.test(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTagMap(expr: string): string {
|
|
||||||
const t = expr.trim();
|
|
||||||
if (BARE_TAG_PATTERN.test(t)) return t + ":1";
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Result
|
// Result
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -102,57 +87,30 @@ export async function dispatchCommand(
|
||||||
|
|
||||||
// GM: all commands
|
// GM: all commands
|
||||||
if (parsed.type === "roll") {
|
if (parsed.type === "roll") {
|
||||||
const arg = (parsed.payload as { arg: string }).arg;
|
const p = resolveRollPayload(
|
||||||
|
parsed.payload as { notation: string; label?: string },
|
||||||
// Try spark table first: if arg matches a known spark table slug, roll it
|
);
|
||||||
const match = ctx.sparkTables.find((s) => s.slug === arg);
|
|
||||||
if (match) {
|
|
||||||
try {
|
|
||||||
const csvPath = match.csvPath ?? "";
|
|
||||||
const remix = match.remix ?? false;
|
|
||||||
const p = await resolveSparkPayload({ key: arg, csvPath, remix });
|
|
||||||
const result = sendMessage("spark", p);
|
|
||||||
return finish(unwrap(result));
|
|
||||||
} catch (e) {
|
|
||||||
return finish({
|
|
||||||
ok: false,
|
|
||||||
error: e instanceof Error ? e.message : "种子表掷骰失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variable expression (e.g. "2d6 + $mod" or "$hp / 2")
|
|
||||||
if (arg.includes("$")) {
|
|
||||||
try {
|
|
||||||
const ev = evaluateExpression(arg, {
|
|
||||||
lookup: (name: string) => getCombined("$" + name, ctx.variables),
|
|
||||||
});
|
|
||||||
const payload = {
|
|
||||||
notation: arg,
|
|
||||||
label: arg,
|
|
||||||
result: {
|
|
||||||
total: ev.value,
|
|
||||||
detail: "",
|
|
||||||
plainDetail: "",
|
|
||||||
pools: [] as { rolls: number[]; subtotal: number }[],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const result = sendMessage("roll", payload);
|
|
||||||
return finish(unwrap(result));
|
|
||||||
} catch (e) {
|
|
||||||
return finish({
|
|
||||||
ok: false,
|
|
||||||
error: e instanceof Error ? e.message : "表达式求值失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, treat as a dice expression
|
|
||||||
const p = resolveRollPayload({ notation: arg, label: arg });
|
|
||||||
const result = sendMessage("roll", p);
|
const result = sendMessage("roll", p);
|
||||||
return finish(unwrap(result));
|
return finish(unwrap(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "spark") {
|
||||||
|
try {
|
||||||
|
const key = (parsed.payload as { key: string }).key;
|
||||||
|
const match = ctx.sparkTables.find((s) => s.slug === key);
|
||||||
|
const csvPath = match?.csvPath ?? "";
|
||||||
|
const remix = match?.remix ?? false;
|
||||||
|
const p = await resolveSparkPayload({ key, csvPath, remix });
|
||||||
|
const result = sendMessage("spark", p);
|
||||||
|
return finish(unwrap(result));
|
||||||
|
} catch (e) {
|
||||||
|
return finish({
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : "种子表掷骰失败",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.type === "set" || parsed.type === "rolltag") {
|
if (parsed.type === "set" || parsed.type === "rolltag") {
|
||||||
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
|
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
|
||||||
}
|
}
|
||||||
|
|
@ -189,12 +147,12 @@ function dispatchSet(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (p.tags && p.tags.length > 0) {
|
if (p.tags && p.tags.length > 0) {
|
||||||
// Rolltag: pick random tag, format as tagmap entry
|
// Rolltag: pick random tag
|
||||||
const idx = Math.floor(Math.random() * p.tags.length);
|
const idx = Math.floor(Math.random() * p.tags.length);
|
||||||
newValue = normalizeTagMap(p.tags[idx]);
|
newValue = p.tags[idx];
|
||||||
} else if (p.expr && isTagMapExpr(p.expr)) {
|
} else if (p.expr && expressionIsTag(p.expr)) {
|
||||||
// Tagmap value (e.g. "#warrior:1;#druid:2" or bare "#warrior")
|
// Bare tag value
|
||||||
newValue = normalizeTagMap(p.expr);
|
newValue = p.expr.trim();
|
||||||
} else if (p.expr) {
|
} else if (p.expr) {
|
||||||
// Numeric expression — evaluate using combined values
|
// Numeric expression — evaluate using combined values
|
||||||
const result = evaluateExpression(p.expr, {
|
const result = evaluateExpression(p.expr, {
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,23 @@ export interface CompletionItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedInput {
|
export interface ParsedInput {
|
||||||
type: "chat" | "roll" | "link" | "set" | "rolltag";
|
type: "chat" | "roll" | "spark" | "link" | "set" | "rolltag";
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseInput(raw: string): ParsedInput {
|
export function parseInput(raw: string): ParsedInput {
|
||||||
if (raw.startsWith("/roll ")) {
|
if (raw.startsWith("/roll ")) {
|
||||||
const arg = raw.slice("/roll ".length).trim();
|
const notation = raw.slice("/roll ".length).trim();
|
||||||
if (!arg)
|
if (!notation)
|
||||||
return { type: "roll", payload: {}, error: "需要骰子表达式或种子表键名" };
|
return { type: "roll", payload: {}, error: "需要骰子表达式" };
|
||||||
return { type: "roll", payload: { arg } };
|
return { type: "roll", payload: { notation, label: notation } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.startsWith("/spark ")) {
|
||||||
|
const key = raw.slice("/spark ".length).trim();
|
||||||
|
if (!key) return { type: "spark", payload: {}, error: "需要种子表键名" };
|
||||||
|
return { type: "spark", payload: { key } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.startsWith("/link ")) {
|
if (raw.startsWith("/link ")) {
|
||||||
|
|
@ -63,7 +69,7 @@ export function parseInput(raw: string): ParsedInput {
|
||||||
return { type: "set", payload: { key, expr } };
|
return { type: "set", payload: { key, expr } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw === "/roll" || raw === "/link" || raw === "/set") {
|
if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/set") {
|
||||||
return { type: "chat", payload: {}, error: "请补全命令" };
|
return { type: "chat", payload: {}, error: "请补全命令" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,11 @@
|
||||||
* Runs client-side (in the sender's tab, via command-dispatcher).
|
* Runs client-side (in the sender's tab, via command-dispatcher).
|
||||||
* Uses a base/mod separation:
|
* Uses a base/mod separation:
|
||||||
* - baseValues: what /set writes (or declaration evaluation produces)
|
* - baseValues: what /set writes (or declaration evaluation produces)
|
||||||
* - numericMods: per-target list of {tag, value, source} from tag activations
|
* - activeMods: per-target list of {tag, value, source} from tag activations
|
||||||
* - tagMapMods: per-target list of {tag, value, source} for tagmap targets
|
* - sourceActivations: per-source list of {tag, target, value} for deactivation
|
||||||
* - sourceActivations: per-source list of {tag, target, value, threshold, kind}
|
|
||||||
* for deactivation
|
|
||||||
*
|
*
|
||||||
* Variables have one of two types:
|
* Combined value = base + sum(activeMods). Tag values (starting with #)
|
||||||
* - numeric: base + sum(numericMods)
|
* are not numeric and don't receive mods.
|
||||||
* - tagmap (#warrior:1;#druid:2): the tagmap is used for threshold-gated
|
|
||||||
* modifier activation; tagmap variables do not receive numeric mods but
|
|
||||||
* can receive tagmap mods (adding/subtracting to specific tag counts).
|
|
||||||
*
|
*
|
||||||
* All functions that resolve variable values accept an explicit `fallback`
|
* All functions that resolve variable values accept an explicit `fallback`
|
||||||
* (the stream VariableStore) rather than relying on mutable module state.
|
* (the stream VariableStore) rather than relying on mutable module state.
|
||||||
|
|
@ -37,54 +32,9 @@ export type VariableStore = Record<string, string>;
|
||||||
|
|
||||||
/** A single modifier applied to a target variable. */
|
/** A single modifier applied to a target variable. */
|
||||||
export interface ActiveMod {
|
export interface ActiveMod {
|
||||||
tag: string; // "#warrior"
|
tag: string; // "#warrior"
|
||||||
value: number; // evaluated modifier expression result
|
value: number; // evaluated modifier expression result
|
||||||
source: string; // "$class" — which variable activated this tag
|
source: string; // "$class" — which variable activated this tag
|
||||||
threshold: number; // the threshold that triggered this activation
|
|
||||||
kind: "numeric" | "tagmap";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tagmap helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Tagmap serialization format: "#warrior:1;#druid:2" */
|
|
||||||
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
|
|
||||||
|
|
||||||
/** Parse a tagmap string. Returns null if the value is not a valid tagmap. */
|
|
||||||
function parseTagMap(value: string | undefined): Record<string, number> | null {
|
|
||||||
if (!value) return null;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed.startsWith("#")) return null;
|
|
||||||
|
|
||||||
const parts = trimmed.split(";");
|
|
||||||
const map: Record<string, number> = {};
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
const m = TAGMAP_RE.exec(part.trim());
|
|
||||||
if (!m) return null; // invalid entry
|
|
||||||
const tag = "#" + m[1];
|
|
||||||
const count = parseInt(m[2], 10);
|
|
||||||
if (count <= 0) continue; // skip zero/negative counts on parse
|
|
||||||
map[tag] = (map[tag] ?? 0) + count;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(map).length > 0 ? map : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Serialize a tagmap back to string. Returns empty string if map is empty. */
|
|
||||||
function tagMapToString(map: Record<string, number>): string {
|
|
||||||
const entries = Object.entries(map).filter(([, c]) => c > 0);
|
|
||||||
if (entries.length === 0) return "";
|
|
||||||
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if a value is a tagmap string. */
|
|
||||||
function isTagMapValue(value: string | undefined): boolean {
|
|
||||||
if (!value) return false;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed.startsWith("#")) return false;
|
|
||||||
return parseTagMap(trimmed) !== null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -97,26 +47,17 @@ let depGraph: Map<string, Set<string>> | null = null;
|
||||||
/** Reverse: $declaredVar → its expression */
|
/** Reverse: $declaredVar → its expression */
|
||||||
let declExprs: Map<string, string> | null = null;
|
let declExprs: Map<string, string> | null = null;
|
||||||
|
|
||||||
/** Tag modifiers from declare blocks: #tag → [{target, expression, threshold}] */
|
/** Tag modifiers from declare blocks: #tag → [{target, expression}] */
|
||||||
let tagModMap: Map<string, Array<{ target: string; expression: string; threshold: number }>> | null = null;
|
let tagModMap: Map<string, Array<{ target: string; expression: string }>> | null = null;
|
||||||
|
|
||||||
/** Base values set by /set or declaration evaluation */
|
/** Base values set by /set or declaration evaluation */
|
||||||
const baseValues = new Map<string, string>();
|
const baseValues = new Map<string, string>();
|
||||||
|
|
||||||
/** Numeric mods per target: $target → [{tag, value, source, ...}] */
|
/** Active mods per target: $target → [{tag, value, source}] */
|
||||||
const numericMods = new Map<string, ActiveMod[]>();
|
const activeMods = new Map<string, ActiveMod[]>();
|
||||||
|
|
||||||
/** Tagmap mods per target: $target → [{tag, value, source, ...}] */
|
/** Activations per source: $source → [{tag, target, value}] */
|
||||||
const tagMapMods = new Map<string, ActiveMod[]>();
|
const sourceActivations = new Map<string, Array<{ tag: string; target: string; value: number }>>();
|
||||||
|
|
||||||
/** Activations per source: $source → [{tag, target, value, threshold, kind}] */
|
|
||||||
const sourceActivations = new Map<string, Array<{
|
|
||||||
tag: string;
|
|
||||||
target: string;
|
|
||||||
value: number;
|
|
||||||
threshold: number;
|
|
||||||
kind: "numeric" | "tagmap";
|
|
||||||
}>>();
|
|
||||||
|
|
||||||
/** Set of $vars currently being re-evaluated (cycle guard) */
|
/** Set of $vars currently being re-evaluated (cycle guard) */
|
||||||
const inFlight = new Set<string>();
|
const inFlight = new Set<string>();
|
||||||
|
|
@ -136,8 +77,7 @@ export function initReactivity(state: VarReactivityState): void {
|
||||||
declExprs = new Map();
|
declExprs = new Map();
|
||||||
tagModMap = new Map();
|
tagModMap = new Map();
|
||||||
baseValues.clear();
|
baseValues.clear();
|
||||||
numericMods.clear();
|
activeMods.clear();
|
||||||
tagMapMods.clear();
|
|
||||||
sourceActivations.clear();
|
sourceActivations.clear();
|
||||||
|
|
||||||
// Index tag modifiers
|
// Index tag modifiers
|
||||||
|
|
@ -147,7 +87,7 @@ export function initReactivity(state: VarReactivityState): void {
|
||||||
list = [];
|
list = [];
|
||||||
tagModMap.set(tm.tag, list);
|
tagModMap.set(tm.tag, list);
|
||||||
}
|
}
|
||||||
list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
|
list.push({ target: tm.target, expression: tm.expression });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index declarations and build dependency graph
|
// Index declarations and build dependency graph
|
||||||
|
|
@ -180,30 +120,17 @@ export function extractDependencies(expr: string): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the combined value of a variable.
|
* Get the combined value of a variable (base + sum of active mods).
|
||||||
* - For tagmap variables: returns the serialized tagmap (base + tagmap mods).
|
|
||||||
* - For numeric variables: returns base + sum(numericMods).
|
|
||||||
* Falls back to the provided stream store for variables not tracked locally.
|
* Falls back to the provided stream store for variables not tracked locally.
|
||||||
*/
|
*/
|
||||||
export function getCombined(key: string, fallback?: VariableStore): string {
|
export function getCombined(key: string, fallback?: VariableStore): string {
|
||||||
const base = baseValues.get(key);
|
const base = baseValues.get(key);
|
||||||
|
if (base !== undefined && isTagValue(base)) {
|
||||||
if (base !== undefined && isTagMapValue(base)) {
|
return base; // tag values don't receive numeric mods
|
||||||
// Tagmap variable: apply tagmap mods, then serialize
|
|
||||||
const baseMap = parseTagMap(base);
|
|
||||||
const mods = tagMapMods.get(key) ?? [];
|
|
||||||
if (baseMap && mods.length === 0) return base;
|
|
||||||
const resultMap: Record<string, number> = { ...(baseMap ?? {}) };
|
|
||||||
for (const m of mods) {
|
|
||||||
resultMap[m.tag] = (resultMap[m.tag] ?? 0) + m.value;
|
|
||||||
if (resultMap[m.tag] <= 0) delete resultMap[m.tag];
|
|
||||||
}
|
|
||||||
const serialized = tagMapToString(resultMap);
|
|
||||||
return serialized || "0";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseNum = base !== undefined ? parseFloat(base) : NaN;
|
const baseNum = base !== undefined ? parseFloat(base) : NaN;
|
||||||
const mods = numericMods.get(key) ?? [];
|
const mods = activeMods.get(key) ?? [];
|
||||||
const modSum = mods.reduce((sum, m) => sum + m.value, 0);
|
const modSum = mods.reduce((sum, m) => sum + m.value, 0);
|
||||||
|
|
||||||
if (!isNaN(baseNum)) {
|
if (!isNaN(baseNum)) {
|
||||||
|
|
@ -216,35 +143,15 @@ export function getCombined(key: string, fallback?: VariableStore): string {
|
||||||
const fb = fallback?.[key];
|
const fb = fallback?.[key];
|
||||||
if (fb !== undefined) return fb;
|
if (fb !== undefined) return fb;
|
||||||
|
|
||||||
// No base, but has numeric mods
|
// No base, but has mods
|
||||||
if (mods.length > 0) return String(modSum);
|
if (mods.length > 0) return String(modSum);
|
||||||
|
|
||||||
return "0";
|
return "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the effective tagmap for a variable (base + tagmap mods).
|
|
||||||
* Returns null if the variable is not a tagmap variable.
|
|
||||||
*/
|
|
||||||
export function getTagMap(key: string): Record<string, number> | null {
|
|
||||||
const base = baseValues.get(key);
|
|
||||||
if (base === undefined || !isTagMapValue(base)) return null;
|
|
||||||
const baseMap = parseTagMap(base);
|
|
||||||
if (!baseMap) return null;
|
|
||||||
const mods = tagMapMods.get(key) ?? [];
|
|
||||||
const result: Record<string, number> = { ...baseMap };
|
|
||||||
for (const m of mods) {
|
|
||||||
result[m.tag] = (result[m.tag] ?? 0) + m.value;
|
|
||||||
if (result[m.tag] <= 0) delete result[m.tag];
|
|
||||||
}
|
|
||||||
return Object.keys(result).length > 0 ? result : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the active mods for a variable (for UI hover display). */
|
/** Get the active mods for a variable (for UI hover display). */
|
||||||
export function getMods(key: string): ActiveMod[] {
|
export function getMods(key: string): ActiveMod[] {
|
||||||
const nums = numericMods.get(key) ?? [];
|
return activeMods.get(key) ?? [];
|
||||||
const tags = tagMapMods.get(key) ?? [];
|
|
||||||
return [...nums, ...tags];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the declaration expression for a variable, if any. */
|
/** Get the declaration expression for a variable, if any. */
|
||||||
|
|
@ -261,7 +168,7 @@ export function setBase(key: string, value: string): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild local reactivity state (baseValues, mods, sourceActivations)
|
* Rebuild local reactivity state (baseValues, activeMods, sourceActivations)
|
||||||
* from the hydrated stream variable store. Must be called after replayReducers
|
* from the hydrated stream variable store. Must be called after replayReducers
|
||||||
* so that tag activations are correctly tracked for subsequent cascade
|
* so that tag activations are correctly tracked for subsequent cascade
|
||||||
* computations.
|
* computations.
|
||||||
|
|
@ -274,12 +181,9 @@ export function rebuildReactivityFromStore(variables: VariableStore): void {
|
||||||
baseValues.set(key, value);
|
baseValues.set(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagMap = isTagMapValue(value) ? parseTagMap(value) : null;
|
const tag = isTagValue(value);
|
||||||
if (tagMap && !sourceActivations.has(key)) {
|
if (tag && !sourceActivations.has(key)) {
|
||||||
// Activate all tags in the tagmap
|
activateTagFromSource(key, tag, variables);
|
||||||
const added = applyTagMapActivations(key, {}, tagMap, variables);
|
|
||||||
// We don't return the added entries here — they'll be applied
|
|
||||||
// to the store separately
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -305,12 +209,26 @@ export function computeCascade(
|
||||||
|
|
||||||
// ---- Tag activation/deactivation ----
|
// ---- Tag activation/deactivation ----
|
||||||
const newValue = getCombined(changedVar, currentVars);
|
const newValue = getCombined(changedVar, currentVars);
|
||||||
const oldTagMap = isTagMapValue(oldValue) ? parseTagMap(oldValue) : {};
|
const newTag = isTagValue(newValue);
|
||||||
const newTagMap = isTagMapValue(newValue) ? parseTagMap(newValue) : {};
|
const oldTag = isTagValue(oldValue);
|
||||||
|
|
||||||
// Diff tagmaps and apply changes
|
if (newTag !== oldTag) {
|
||||||
const tagResults = applyTagMapActivations(changedVar, oldTagMap ?? {}, newTagMap ?? {}, currentVars);
|
// Deactivate old tag
|
||||||
results.push(...tagResults);
|
if (oldTag) {
|
||||||
|
const removed = deactivateTagFromSource(changedVar, oldTag, currentVars);
|
||||||
|
for (const r of removed) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate new tag
|
||||||
|
if (newTag) {
|
||||||
|
const added = activateTagFromSource(changedVar, newTag, currentVars);
|
||||||
|
for (const r of added) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Declaration re-evaluation ----
|
// ---- Declaration re-evaluation ----
|
||||||
const reevaluated = reevaluateDependents(changedVar, currentVars);
|
const reevaluated = reevaluateDependents(changedVar, currentVars);
|
||||||
|
|
@ -352,10 +270,10 @@ export function computeInitialValues(
|
||||||
const rawValue = String(result.value);
|
const rawValue = String(result.value);
|
||||||
baseValues.set(key, rawValue);
|
baseValues.set(key, rawValue);
|
||||||
|
|
||||||
// Check if this is a tagmap value — if so, activate matching modifiers
|
// Check if this is a tag value — if so, activate it
|
||||||
const tagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : null;
|
const tag = isTagValue(rawValue);
|
||||||
if (tagMap) {
|
if (tag) {
|
||||||
const added = applyTagMapActivations(key, {}, tagMap, currentVars);
|
const added = activateTagFromSource(key, tag, currentVars);
|
||||||
for (const r of added) {
|
for (const r of added) {
|
||||||
if (!results.some((x) => x.key === r.key)) {
|
if (!results.some((x) => x.key === r.key)) {
|
||||||
results.push(r);
|
results.push(r);
|
||||||
|
|
@ -377,148 +295,112 @@ export function computeInitialValues(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tag activation / deactivation (threshold-based)
|
// Tag activation / deactivation (source-based)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
function isTagValue(value: string | undefined): string | null {
|
||||||
* Apply tagmap changes for a source variable. Compares old and new tagmaps,
|
if (!value) return null;
|
||||||
* activating/deactivating modifiers whose threshold crossing state changed.
|
const trimmed = value.trim();
|
||||||
*/
|
return trimmed.startsWith("#") ? trimmed : null;
|
||||||
function applyTagMapActivations(
|
}
|
||||||
|
|
||||||
|
/** Activate a tag from a source variable. Evaluates modifiers once. */
|
||||||
|
function activateTagFromSource(
|
||||||
source: string,
|
source: string,
|
||||||
oldTagMap: Record<string, number>,
|
tag: string,
|
||||||
newTagMap: Record<string, number>,
|
|
||||||
fallback: VariableStore,
|
fallback: VariableStore,
|
||||||
): Array<{ key: string; value: string }> {
|
): Array<{ key: string; value: string }> {
|
||||||
if (!tagModMap) return [];
|
if (!tagModMap) return [];
|
||||||
|
|
||||||
|
const mods = tagModMap.get(tag);
|
||||||
|
if (!mods) return [];
|
||||||
|
|
||||||
const results: Array<{ key: string; value: string }> = [];
|
const results: Array<{ key: string; value: string }> = [];
|
||||||
const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
|
const sourceEntries: Array<{ tag: string; target: string; value: number }> = [];
|
||||||
|
|
||||||
for (const tag of allTags) {
|
for (const mod of mods) {
|
||||||
const mods = tagModMap.get(tag);
|
try {
|
||||||
if (!mods || mods.length === 0) continue;
|
// Snapshot the target's current combined value as its base before
|
||||||
|
// adding the mod, so deactivation can restore it correctly.
|
||||||
const oldCount = oldTagMap[tag] ?? 0;
|
if (!baseValues.has(mod.target)) {
|
||||||
const newCount = newTagMap[tag] ?? 0;
|
baseValues.set(mod.target, getCombined(mod.target, fallback));
|
||||||
|
|
||||||
for (let modIdx = 0; modIdx < mods.length; modIdx++) {
|
|
||||||
const mod = mods[modIdx];
|
|
||||||
const wasActive = oldCount >= mod.threshold;
|
|
||||||
const isActive = newCount >= mod.threshold;
|
|
||||||
|
|
||||||
if (!wasActive && isActive) {
|
|
||||||
// Activate this modifier
|
|
||||||
try {
|
|
||||||
// Snapshot target's current value as base if needed
|
|
||||||
if (!baseValues.has(mod.target)) {
|
|
||||||
baseValues.set(mod.target, getCombined(mod.target, fallback));
|
|
||||||
}
|
|
||||||
|
|
||||||
const evalResult = evaluateExpression(mod.expression, {
|
|
||||||
lookup: (name: string) => {
|
|
||||||
const k = "$" + name;
|
|
||||||
return getCombined(k, fallback);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const value = evalResult.value;
|
|
||||||
const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
|
|
||||||
|
|
||||||
if (targetIsTagMap) {
|
|
||||||
// Apply as tagmap mod to the target's tag count
|
|
||||||
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "tagmap" };
|
|
||||||
let targetMods = tagMapMods.get(mod.target);
|
|
||||||
if (!targetMods) {
|
|
||||||
targetMods = [];
|
|
||||||
tagMapMods.set(mod.target, targetMods);
|
|
||||||
}
|
|
||||||
targetMods.push(entry);
|
|
||||||
} else {
|
|
||||||
// Apply as numeric mod
|
|
||||||
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "numeric" };
|
|
||||||
let targetMods = numericMods.get(mod.target);
|
|
||||||
if (!targetMods) {
|
|
||||||
targetMods = [];
|
|
||||||
numericMods.set(mod.target, targetMods);
|
|
||||||
}
|
|
||||||
targetMods.push(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track in source activations for deactivation
|
|
||||||
let sourceEntries = sourceActivations.get(source);
|
|
||||||
if (!sourceEntries) {
|
|
||||||
sourceEntries = [];
|
|
||||||
sourceActivations.set(source, sourceEntries);
|
|
||||||
}
|
|
||||||
sourceEntries.push({
|
|
||||||
tag,
|
|
||||||
target: mod.target,
|
|
||||||
value,
|
|
||||||
threshold: mod.threshold,
|
|
||||||
kind: targetIsTagMap ? "tagmap" : "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emit new combined value
|
|
||||||
const combined = getCombined(mod.target, fallback);
|
|
||||||
const existing = results.findIndex((r) => r.key === mod.target);
|
|
||||||
if (existing >= 0) {
|
|
||||||
results[existing] = { key: mod.target, value: combined };
|
|
||||||
} else {
|
|
||||||
results.push({ key: mod.target, value: combined });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// skip failed modifier
|
|
||||||
}
|
|
||||||
} else if (wasActive && !isActive) {
|
|
||||||
// Deactivate this modifier
|
|
||||||
const sourceEntries = sourceActivations.get(source);
|
|
||||||
if (!sourceEntries) continue;
|
|
||||||
|
|
||||||
const idx = sourceEntries.findIndex(
|
|
||||||
(e) => e.tag === tag && e.target === mod.target && e.threshold === mod.threshold,
|
|
||||||
);
|
|
||||||
if (idx < 0) continue;
|
|
||||||
|
|
||||||
const entry = sourceEntries[idx];
|
|
||||||
sourceEntries.splice(idx, 1);
|
|
||||||
|
|
||||||
// Remove from the appropriate mod list
|
|
||||||
if (entry.kind === "tagmap") {
|
|
||||||
const targetMods = tagMapMods.get(entry.target);
|
|
||||||
if (targetMods) {
|
|
||||||
const modIdx = targetMods.findIndex(
|
|
||||||
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
|
|
||||||
);
|
|
||||||
if (modIdx >= 0) targetMods.splice(modIdx, 1);
|
|
||||||
if (targetMods.length === 0) tagMapMods.delete(entry.target);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const targetMods = numericMods.get(entry.target);
|
|
||||||
if (targetMods) {
|
|
||||||
const modIdx = targetMods.findIndex(
|
|
||||||
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
|
|
||||||
);
|
|
||||||
if (modIdx >= 0) targetMods.splice(modIdx, 1);
|
|
||||||
if (targetMods.length === 0) numericMods.delete(entry.target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit updated combined value
|
|
||||||
const combined = getCombined(entry.target, fallback);
|
|
||||||
const existing = results.findIndex((r) => r.key === entry.target);
|
|
||||||
if (existing >= 0) {
|
|
||||||
results[existing] = { key: entry.target, value: combined };
|
|
||||||
} else {
|
|
||||||
results.push({ key: entry.target, value: combined });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = evaluateExpression(mod.expression, {
|
||||||
|
lookup: (name: string) => {
|
||||||
|
const k = "$" + name;
|
||||||
|
return getCombined(k, fallback);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry: ActiveMod = { tag, value: result.value, source };
|
||||||
|
|
||||||
|
let targetMods = activeMods.get(mod.target);
|
||||||
|
if (!targetMods) {
|
||||||
|
targetMods = [];
|
||||||
|
activeMods.set(mod.target, targetMods);
|
||||||
|
}
|
||||||
|
targetMods.push(entry);
|
||||||
|
|
||||||
|
sourceEntries.push({ tag, target: mod.target, value: result.value });
|
||||||
|
|
||||||
|
// Emit new combined value for the target
|
||||||
|
const combined = getCombined(mod.target, fallback);
|
||||||
|
const existing = results.findIndex((r) => r.key === mod.target);
|
||||||
|
if (existing >= 0) {
|
||||||
|
results[existing] = { key: mod.target, value: combined };
|
||||||
|
} else {
|
||||||
|
results.push({ key: mod.target, value: combined });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip failed modifier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up empty sourceActivations
|
sourceActivations.set(source, sourceEntries);
|
||||||
if (sourceActivations.get(source)?.length === 0) {
|
return results;
|
||||||
sourceActivations.delete(source);
|
}
|
||||||
|
|
||||||
|
/** Deactivate a tag from a source variable. Removes exactly the mods it created. */
|
||||||
|
function deactivateTagFromSource(
|
||||||
|
source: string,
|
||||||
|
_tag: string,
|
||||||
|
fallback: VariableStore,
|
||||||
|
): Array<{ key: string; value: string }> {
|
||||||
|
const entries = sourceActivations.get(source);
|
||||||
|
if (!entries) return [];
|
||||||
|
|
||||||
|
const results: Array<{ key: string; value: string }> = [];
|
||||||
|
const affectedTargets = new Set<string>();
|
||||||
|
|
||||||
|
// Remove mods from activeMods
|
||||||
|
for (const entry of entries) {
|
||||||
|
const targetMods = activeMods.get(entry.target);
|
||||||
|
if (!targetMods) continue;
|
||||||
|
|
||||||
|
const idx = targetMods.findIndex(
|
||||||
|
(m) => m.tag === entry.tag && m.source === source,
|
||||||
|
);
|
||||||
|
if (idx >= 0) {
|
||||||
|
targetMods.splice(idx, 1);
|
||||||
|
affectedTargets.add(entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty arrays
|
||||||
|
for (const target of affectedTargets) {
|
||||||
|
const mods = activeMods.get(target);
|
||||||
|
if (mods && mods.length === 0) {
|
||||||
|
activeMods.delete(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove source activations
|
||||||
|
sourceActivations.delete(source);
|
||||||
|
|
||||||
|
// Emit new combined values for affected targets
|
||||||
|
for (const target of affectedTargets) {
|
||||||
|
results.push({ key: target, value: getCombined(target, fallback) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|
@ -573,16 +455,29 @@ function reevaluateDependents(
|
||||||
|
|
||||||
const rawValue = String(result.value);
|
const rawValue = String(result.value);
|
||||||
|
|
||||||
// Check for tagmap transition on this declared variable
|
// Check for tag transition on this declared variable
|
||||||
const oldCombined = getCombined(key, fallback);
|
const oldCombined = getCombined(key, fallback);
|
||||||
const oldTagMap = isTagMapValue(oldCombined) ? parseTagMap(oldCombined) : {};
|
const oldTag = isTagValue(oldCombined);
|
||||||
const newTagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : {};
|
const newTag = isTagValue(rawValue);
|
||||||
|
|
||||||
if (JSON.stringify(oldTagMap) !== JSON.stringify(newTagMap)) {
|
if (oldTag !== newTag) {
|
||||||
const tagResults = applyTagMapActivations(key, oldTagMap ?? {}, newTagMap ?? {}, fallback);
|
// Deactivate old tag
|
||||||
for (const r of tagResults) {
|
if (oldTag) {
|
||||||
if (!results.some((x) => x.key === r.key)) {
|
const removed = deactivateTagFromSource(key, oldTag, fallback);
|
||||||
results.push(r);
|
for (const r of removed) {
|
||||||
|
if (!results.some((x) => x.key === r.key)) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Activate new tag
|
||||||
|
if (newTag) {
|
||||||
|
baseValues.set(key, rawValue);
|
||||||
|
const added = activateTagFromSource(key, newTag, fallback);
|
||||||
|
for (const r of added) {
|
||||||
|
if (!results.some((x) => x.key === r.key)) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,27 @@
|
||||||
---
|
---
|
||||||
tag: journal-roll
|
tag: journal-roll
|
||||||
icon: 🎲
|
icon: 🎲
|
||||||
title: 掷骰与种子表命令
|
title: 掷骰命令
|
||||||
description: 在 Journal 中掷骰或随机生成种子表内容。优先匹配种子表,否则作为骰子表达式处理。
|
description: 在 Journal 中发送掷骰结果,支持标准骰子表达式。
|
||||||
syntax: '/roll 骰子表达式 | 种子表键名'
|
syntax: '/roll 骰子表达式'
|
||||||
props:
|
props:
|
||||||
- name: 参数
|
- name: 骰子表达式
|
||||||
type: string
|
type: string
|
||||||
desc: 骰子表达式(如 3d6、2d8kh1、d20+5)或已定义的种子表 slug
|
desc: 标准骰子表达式,如 3d6、2d8kh1、d20+5
|
||||||
---
|
---
|
||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
`/roll` 命令用于掷骰或随机抽取种子表,并将结果同步到所有客户端。
|
`/roll` 命令用于在 Journal 流中掷骰并发布结果。结果会同步到所有连接的客户端。
|
||||||
|
|
||||||
- 如果参数匹配已定义的**种子表** slug,则抽取并发布对应种子表的结果。
|
|
||||||
- 否则,作为**骰子表达式**求值并发布掷骰结果。
|
|
||||||
|
|
||||||
## 语法
|
## 语法
|
||||||
|
|
||||||
```
|
```
|
||||||
/roll 骰子表达式 | 种子表键名
|
/roll 骰子表达式
|
||||||
```
|
```
|
||||||
|
|
||||||
## 示例
|
## 示例
|
||||||
|
|
||||||
### 掷骰
|
|
||||||
|
|
||||||
```
|
```
|
||||||
/roll d20
|
/roll d20
|
||||||
/roll 3d6
|
/roll 3d6
|
||||||
|
|
@ -35,14 +30,6 @@ props:
|
||||||
/roll 4d6k3
|
/roll 4d6k3
|
||||||
```
|
```
|
||||||
|
|
||||||
### 种子表
|
|
||||||
|
|
||||||
```
|
|
||||||
/roll npc
|
|
||||||
/roll encounter
|
|
||||||
/roll loot
|
|
||||||
```
|
|
||||||
|
|
||||||
## 支持的骰子表达式
|
## 支持的骰子表达式
|
||||||
|
|
||||||
| 表达式 | 说明 |
|
| 表达式 | 说明 |
|
||||||
|
|
@ -53,12 +40,8 @@ props:
|
||||||
| `1d20+5` | 1 个 20 面骰加 5 |
|
| `1d20+5` | 1 个 20 面骰加 5 |
|
||||||
| `4d6k3` | 4 个 6 面骰保留最高 3 个 |
|
| `4d6k3` | 4 个 6 面骰保留最高 3 个 |
|
||||||
|
|
||||||
## 种子表定义
|
|
||||||
|
|
||||||
种子表在文档中通过 `:spark[CSV路径]` 指令或 spark 形状的 markdown 表格定义,CSV 文件的第一列表头为骰子公式(如 d6、d20),后续列为数据列。
|
|
||||||
|
|
||||||
## 权限
|
## 权限
|
||||||
|
|
||||||
- **GM**:可使用
|
- **GM**:可使用
|
||||||
- **玩家**:不可使用
|
- **玩家**:不可使用
|
||||||
- **观察者**:不可使用
|
- **观察者**:不可使用
|
||||||
Loading…
Reference in New Issue