Compare commits

..

2 Commits

Author SHA1 Message Date
hypercross 7cb28e631e 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.
2026-07-13 15:09:50 +08:00
hypercross d74ba69fdf refactor: merge /spark command into /roll
Consolidate the `/spark` command into `/roll` to simplify the CLI.
The `/roll` command now handles both dice notation and spark table
slugs.

- Update `parseInput` to treat `/roll` arguments as a generic string.
- Update `dispatchCommand` to check for spark table matches before
  evaluating dice notation or variable expressions.
- Update command completions to show both dice and spark table
  suggestions under `/roll`.
- Update documentation and UI placeholders to reflect the change.
2026-07-13 14:43:55 +08:00
10 changed files with 554 additions and 292 deletions

View File

@ -3,15 +3,16 @@
* *
* 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` is optional): * Format (columns can be in any order; `tag` and `threshold` are optional):
* tag,key,expr * tag,threshold,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,$mod_hp,20 tag modifier * #warrior,2,$mod_hp,20 tag modifier (threshold 2)
* #warrior,$mod_str,1 tag modifier * #warrior,,$mod_str,1 tag modifier (threshold defaults to 1)
* *
* 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 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"; import { parse } from "csv-parse/browser/esm/sync";
@ -57,6 +58,7 @@ 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 {
@ -84,7 +86,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; key: string; expr: string }>; }) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
const variables: VarDeclaration[] = []; const variables: VarDeclaration[] = [];
const tagModifiers: TagModifier[] = []; const tagModifiers: TagModifier[] = [];
@ -111,7 +113,14 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
`${source}: key must start with $, got "${key}"`, `${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 { } else {
// Variable declaration // Variable declaration
if (!key.startsWith("$")) { if (!key.startsWith("$")) {

View File

@ -86,14 +86,44 @@ 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
@ -176,6 +206,7 @@ $con*5,$hp,
tag: "#warrior", tag: "#warrior",
target: "$mod_hp", target: "$mod_hp",
expression: "20", expression: "20",
threshold: 1,
}); });
}); });
}); });
@ -484,10 +515,10 @@ describe("var-reactivity", () => {
expect(getCombined("$hp", { $hp: "99" })).toBe("99"); expect(getCombined("$hp", { $hp: "99" })).toBe("99");
}); });
test("returns tag value without numeric mods", () => { test("returns tagmap value without numeric mods", () => {
initReactivity({ declarations: [], tagModifiers: [] }); initReactivity({ declarations: [], tagModifiers: [] });
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
expect(getCombined("$class")).toBe("#warrior"); expect(getCombined("$class")).toBe("#warrior:1");
}); });
}); });
@ -496,14 +527,14 @@ describe("var-reactivity", () => {
initReactivity({ initReactivity({
declarations: [], declarations: [],
tagModifiers: [ tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20" }, { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{ tag: "#warrior", target: "$mod_str", expression: "5" }, { tag: "#warrior", target: "$mod_str", expression: "5", threshold: 1 },
], ],
}); });
// Set $class to #warrior — should activate both modifiers // Set $class to #warrior:1 — should activate both modifiers
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior" })); const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
// 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");
@ -512,24 +543,24 @@ describe("var-reactivity", () => {
expect(modStr?.value).toBe("5"); expect(modStr?.value).toBe("5");
}); });
test("deactivates tag modifiers when tag value changes", () => { test("deactivates tag modifiers when tag is removed from tagmap", () => {
initReactivity({ initReactivity({
declarations: [], declarations: [],
tagModifiers: [ tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20" }, { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
], ],
}); });
// First activate // First activate
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
computeCascade("$class", undefined, store({ $class: "#warrior" })); computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
// Now change to a different tag // Now change to a different tag
setBase("$class", "#mage"); setBase("$class", "#mage:1");
const cascade = computeCascade( const cascade = computeCascade(
"$class", "$class",
"#warrior", "#warrior:1",
store({ $class: "#mage" }), store({ $class: "#mage:1" }),
); );
// Should deactivate #warrior mods (mod_hp goes back to 0) // Should deactivate #warrior mods (mod_hp goes back to 0)
@ -541,21 +572,21 @@ describe("var-reactivity", () => {
initReactivity({ initReactivity({
declarations: [], declarations: [],
tagModifiers: [ tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20" }, { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{ tag: "#mage", target: "$mod_hp", expression: "10" }, { tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 },
], ],
}); });
// Activate #warrior // Activate #warrior
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
computeCascade("$class", undefined, store({ $class: "#warrior" })); computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
// Switch to #mage // Switch to #mage
setBase("$class", "#mage"); setBase("$class", "#mage:1");
const cascade = computeCascade( const cascade = computeCascade(
"$class", "$class",
"#warrior", "#warrior:1",
store({ $class: "#mage" }), store({ $class: "#mage:1" }),
); );
// Cascade returns both deactivation ("0") and activation ("10") entries. // Cascade returns both deactivation ("0") and activation ("10") entries.
@ -564,6 +595,69 @@ 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", () => {
@ -605,29 +699,27 @@ describe("var-reactivity", () => {
expect(cResult?.value).toBe("20"); // 10 + 10 expect(cResult?.value).toBe("20"); // 10 + 10
}); });
test("handles tag transition during re-evaluation", () => { test("handles tagmap transition during re-evaluation", () => {
initReactivity({ initReactivity({
declarations: [ declarations: [
{ key: "$class", expression: "$level > 5 ? '#warrior' : '#novice'" }, { key: "$class", expression: "0" },
], ],
tagModifiers: [ tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20" }, { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{ tag: "#novice", target: "$mod_hp", expression: "5" }, { 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 // Set $class directly to #novice:1
setBase("$class", "#novice"); setBase("$class", "#novice:1");
computeCascade("$class", undefined, store({ $class: "#novice" })); computeCascade("$class", undefined, store({ $class: "#novice:1" }));
// Now change to #warrior // Now change to #warrior:1
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
const cascade = computeCascade( const cascade = computeCascade(
"$class", "$class",
"#novice", "#novice:1",
store({ $class: "#warrior" }), store({ $class: "#warrior:1" }),
); );
// Cascade returns both deactivation and activation entries. // Cascade returns both deactivation and activation entries.
@ -652,12 +744,12 @@ describe("var-reactivity", () => {
initReactivity({ initReactivity({
declarations: [], declarations: [],
tagModifiers: [ tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20" }, { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
], ],
}); });
setBase("$class", "#warrior"); setBase("$class", "#warrior:1");
computeCascade("$class", undefined, store({ $class: "#warrior" })); computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
const mods = getMods("$mod_hp"); const mods = getMods("$mod_hp");
expect(mods).toHaveLength(1); expect(mods).toHaveLength(1);
@ -690,16 +782,16 @@ describe("var-reactivity", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("parseInput", () => { describe("parseInput", () => {
test("parses /roll command", () => { test("parses /roll command with spark table slug", () => {
const result = parseInput("/roll 3d6+5"); const result = parseInput("/roll my-table");
expect(result.type).toBe("roll"); expect(result.type).toBe("roll");
expect(result.payload).toEqual({ notation: "3d6+5", label: "3d6+5" }); expect(result.payload).toEqual({ arg: "my-table" });
}); });
test("parses /spark command", () => { test("parses /roll command with dice notation", () => {
const result = parseInput("/spark my-table"); const result = parseInput("/roll 3d6+5");
expect(result.type).toBe("spark"); expect(result.type).toBe("roll");
expect(result.payload).toEqual({ key: "my-table" }); expect(result.payload).toEqual({ arg: "3d6+5" });
}); });
test("parses /link command", () => { test("parses /link command", () => {
@ -743,7 +835,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", () => {

View File

@ -126,7 +126,7 @@ export const RevealManager: Component = () => {
rect: sparkTable.getBoundingClientRect(), rect: sparkTable.getBoundingClientRect(),
action: () => action: () =>
setActionPrefill({ setActionPrefill({
command: "/spark", command: "/roll",
text: combinedSlug, text: combinedSlug,
}), }),
title: "Roll spark table", title: "Roll spark table",

View File

@ -45,7 +45,6 @@ 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[] = [
@ -53,7 +52,6 @@ const rawDocuments: string[] = [
journalPlayerRaw, journalPlayerRaw,
journalSetRaw, journalSetRaw,
journalRollRaw, journalRollRaw,
journalSparkRaw,
journalLinkRaw, journalLinkRaw,
]; ];

View File

@ -265,7 +265,7 @@ export const JournalInput: Component = () => {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={
isGm() isGm()
? "输入消息,或使用 /roll、/spark、/link、/set 命令..." ? "输入消息,或使用 /roll、/link、/set 命令..."
: "输入消息,或使用 /set 命令..." : "输入消息,或使用 /set 命令..."
} }
rows={2} rows={2}

View File

@ -21,7 +21,6 @@ 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 " },
]; ];
@ -40,46 +39,52 @@ export function buildCompletions(
: [{ label: "未知命令", kind: "no-results", insertText: "" }]; : [{ label: "未知命令", kind: "no-results", insertText: "" }];
} }
// After /roll — show dice suggestions // After /roll — show dice AND spark table 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, 8); .slice(0, 4);
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,
}));
}
// After /spark — show spark table suggestions const sparkMatches = data.sparkTables
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, 8); .slice(0, 4);
if (matches.length === 0) {
return [{ label: "未找到种子表", kind: "no-results", insertText: "" }]; const items: CompletionItem[] = [];
for (const d of diceMatches) {
items.push({
label: `🎲 ${d.notation}`,
kind: "value",
insertText: "/roll " + d.notation,
});
} }
return matches.map((s) => ({
label: `${s.slug} (${s.notation})`, for (const s of sparkMatches) {
kind: "value" as const, items.push({
insertText: `/spark ${s.slug}`, label: `${s.slug} (${s.notation})`,
})); 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();

View File

@ -10,10 +10,25 @@ 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, expressionIsTag } from "./variable-expression"; import { evaluateExpression } 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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -87,30 +102,57 @@ export async function dispatchCommand(
// GM: all commands // GM: all commands
if (parsed.type === "roll") { if (parsed.type === "roll") {
const p = resolveRollPayload( const arg = (parsed.payload as { arg: string }).arg;
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));
} }
@ -147,12 +189,12 @@ function dispatchSet(
try { try {
if (p.tags && p.tags.length > 0) { if (p.tags && p.tags.length > 0) {
// Rolltag: pick random tag // Rolltag: pick random tag, format as tagmap entry
const idx = Math.floor(Math.random() * p.tags.length); const idx = Math.floor(Math.random() * p.tags.length);
newValue = p.tags[idx]; newValue = normalizeTagMap(p.tags[idx]);
} else if (p.expr && expressionIsTag(p.expr)) { } else if (p.expr && isTagMapExpr(p.expr)) {
// Bare tag value // Tagmap value (e.g. "#warrior:1;#druid:2" or bare "#warrior")
newValue = p.expr.trim(); newValue = normalizeTagMap(p.expr);
} 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, {

View File

@ -12,23 +12,17 @@ export interface CompletionItem {
} }
export interface ParsedInput { export interface ParsedInput {
type: "chat" | "roll" | "spark" | "link" | "set" | "rolltag"; type: "chat" | "roll" | "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 notation = raw.slice("/roll ".length).trim(); const arg = raw.slice("/roll ".length).trim();
if (!notation) if (!arg)
return { type: "roll", payload: {}, error: "需要骰子表达式" }; return { type: "roll", payload: {}, error: "需要骰子表达式或种子表键名" };
return { type: "roll", payload: { notation, label: notation } }; return { type: "roll", payload: { arg } };
}
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 ")) {
@ -69,7 +63,7 @@ export function parseInput(raw: string): ParsedInput {
return { type: "set", payload: { key, expr } }; return { type: "set", payload: { key, expr } };
} }
if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/set") { if (raw === "/roll" || raw === "/link" || raw === "/set") {
return { type: "chat", payload: {}, error: "请补全命令" }; return { type: "chat", payload: {}, error: "请补全命令" };
} }

View File

@ -5,11 +5,16 @@
* 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)
* - activeMods: per-target list of {tag, value, source} from tag activations * - numericMods: per-target list of {tag, value, source} from tag activations
* - sourceActivations: per-source list of {tag, target, value} for deactivation * - tagMapMods: per-target list of {tag, value, source} for tagmap targets
* - sourceActivations: per-source list of {tag, target, value, threshold, kind}
* for deactivation
* *
* Combined value = base + sum(activeMods). Tag values (starting with #) * Variables have one of two types:
* are not numeric and don't receive mods. * - numeric: base + sum(numericMods)
* - 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.
@ -32,9 +37,54 @@ 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;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -47,17 +97,26 @@ 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}] */ /** Tag modifiers from declare blocks: #tag → [{target, expression, threshold}] */
let tagModMap: Map<string, Array<{ target: string; expression: string }>> | null = null; let tagModMap: Map<string, Array<{ target: string; expression: string; threshold: number }>> | 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>();
/** Active mods per target: $target → [{tag, value, source}] */ /** Numeric mods per target: $target → [{tag, value, source, ...}] */
const activeMods = new Map<string, ActiveMod[]>(); const numericMods = new Map<string, ActiveMod[]>();
/** Activations per source: $source → [{tag, target, value}] */ /** Tagmap mods per target: $target → [{tag, value, source, ...}] */
const sourceActivations = new Map<string, Array<{ tag: string; target: string; value: number }>>(); const tagMapMods = new Map<string, ActiveMod[]>();
/** 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>();
@ -77,7 +136,8 @@ export function initReactivity(state: VarReactivityState): void {
declExprs = new Map(); declExprs = new Map();
tagModMap = new Map(); tagModMap = new Map();
baseValues.clear(); baseValues.clear();
activeMods.clear(); numericMods.clear();
tagMapMods.clear();
sourceActivations.clear(); sourceActivations.clear();
// Index tag modifiers // Index tag modifiers
@ -87,7 +147,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 }); list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
} }
// Index declarations and build dependency graph // Index declarations and build dependency graph
@ -120,17 +180,30 @@ export function extractDependencies(expr: string): string[] {
} }
/** /**
* Get the combined value of a variable (base + sum of active mods). * Get the combined value of a variable.
* - 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)) {
return base; // tag values don't receive numeric mods if (base !== undefined && isTagMapValue(base)) {
// 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 = activeMods.get(key) ?? []; const mods = numericMods.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)) {
@ -143,15 +216,35 @@ 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 mods // No base, but has numeric 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[] {
return activeMods.get(key) ?? []; const nums = numericMods.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. */
@ -168,7 +261,7 @@ export function setBase(key: string, value: string): void {
} }
/** /**
* Rebuild local reactivity state (baseValues, activeMods, sourceActivations) * Rebuild local reactivity state (baseValues, mods, 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.
@ -181,9 +274,12 @@ export function rebuildReactivityFromStore(variables: VariableStore): void {
baseValues.set(key, value); baseValues.set(key, value);
} }
const tag = isTagValue(value); const tagMap = isTagMapValue(value) ? parseTagMap(value) : null;
if (tag && !sourceActivations.has(key)) { if (tagMap && !sourceActivations.has(key)) {
activateTagFromSource(key, tag, variables); // Activate all tags in the tagmap
const added = applyTagMapActivations(key, {}, tagMap, variables);
// We don't return the added entries here — they'll be applied
// to the store separately
} }
} }
} }
@ -209,26 +305,12 @@ export function computeCascade(
// ---- Tag activation/deactivation ---- // ---- Tag activation/deactivation ----
const newValue = getCombined(changedVar, currentVars); const newValue = getCombined(changedVar, currentVars);
const newTag = isTagValue(newValue); const oldTagMap = isTagMapValue(oldValue) ? parseTagMap(oldValue) : {};
const oldTag = isTagValue(oldValue); const newTagMap = isTagMapValue(newValue) ? parseTagMap(newValue) : {};
if (newTag !== oldTag) { // Diff tagmaps and apply changes
// Deactivate old tag const tagResults = applyTagMapActivations(changedVar, oldTagMap ?? {}, newTagMap ?? {}, currentVars);
if (oldTag) { results.push(...tagResults);
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);
@ -270,10 +352,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 tag value — if so, activate it // Check if this is a tagmap value — if so, activate matching modifiers
const tag = isTagValue(rawValue); const tagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : null;
if (tag) { if (tagMap) {
const added = activateTagFromSource(key, tag, currentVars); const added = applyTagMapActivations(key, {}, tagMap, 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);
@ -295,112 +377,148 @@ export function computeInitialValues(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tag activation / deactivation (source-based) // Tag activation / deactivation (threshold-based)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function isTagValue(value: string | undefined): string | null { /**
if (!value) return null; * Apply tagmap changes for a source variable. Compares old and new tagmaps,
const trimmed = value.trim(); * activating/deactivating modifiers whose threshold crossing state changed.
return trimmed.startsWith("#") ? trimmed : null; */
} function applyTagMapActivations(
/** Activate a tag from a source variable. Evaluates modifiers once. */
function activateTagFromSource(
source: string, source: string,
tag: string, oldTagMap: Record<string, number>,
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 sourceEntries: Array<{ tag: string; target: string; value: number }> = []; const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
for (const mod of mods) { for (const tag of allTags) {
try { const mods = tagModMap.get(tag);
// Snapshot the target's current combined value as its base before if (!mods || mods.length === 0) continue;
// adding the mod, so deactivation can restore it correctly.
if (!baseValues.has(mod.target)) { const oldCount = oldTagMap[tag] ?? 0;
baseValues.set(mod.target, getCombined(mod.target, fallback)); const newCount = newTagMap[tag] ?? 0;
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
} }
} }
sourceActivations.set(source, sourceEntries); // Clean up empty sourceActivations
return results; if (sourceActivations.get(source)?.length === 0) {
} 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;
@ -455,29 +573,16 @@ function reevaluateDependents(
const rawValue = String(result.value); const rawValue = String(result.value);
// Check for tag transition on this declared variable // Check for tagmap transition on this declared variable
const oldCombined = getCombined(key, fallback); const oldCombined = getCombined(key, fallback);
const oldTag = isTagValue(oldCombined); const oldTagMap = isTagMapValue(oldCombined) ? parseTagMap(oldCombined) : {};
const newTag = isTagValue(rawValue); const newTagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : {};
if (oldTag !== newTag) { if (JSON.stringify(oldTagMap) !== JSON.stringify(newTagMap)) {
// Deactivate old tag const tagResults = applyTagMapActivations(key, oldTagMap ?? {}, newTagMap ?? {}, fallback);
if (oldTag) { for (const r of tagResults) {
const removed = deactivateTagFromSource(key, oldTag, fallback); if (!results.some((x) => x.key === r.key)) {
for (const r of removed) { results.push(r);
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);
}
} }
} }
} }

View File

@ -1,27 +1,32 @@
--- ---
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 desc: 骰子表达式(如 3d6、2d8kh1、d20+5或已定义的种子表 slug
--- ---
## 概述 ## 概述
`/roll` 命令用于在 Journal 流中掷骰并发布结果。结果会同步到所有连接的客户端。 `/roll` 命令用于掷骰或随机抽取种子表,并将结果同步到所有客户端。
- 如果参数匹配已定义的**种子表** slug则抽取并发布对应种子表的结果。
- 否则,作为**骰子表达式**求值并发布掷骰结果。
## 语法 ## 语法
``` ```
/roll 骰子表达式 /roll 骰子表达式 | 种子表键名
``` ```
## 示例 ## 示例
### 掷骰
``` ```
/roll d20 /roll d20
/roll 3d6 /roll 3d6
@ -30,6 +35,14 @@ props:
/roll 4d6k3 /roll 4d6k3
``` ```
### 种子表
```
/roll npc
/roll encounter
/roll loot
```
## 支持的骰子表达式 ## 支持的骰子表达式
| 表达式 | 说明 | | 表达式 | 说明 |
@ -40,8 +53,12 @@ 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**:可使用
- **玩家**:不可使用 - **玩家**:不可使用
- **观察者**:不可使用 - **观察者**:不可使用