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.
This commit is contained in:
hypercross 2026-07-13 14:43:55 +08:00
parent c4038a5213
commit d74ba69fdf
8 changed files with 118 additions and 77 deletions

View File

@ -690,16 +690,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 +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", () => {

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

@ -87,30 +87,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));
} }

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

@ -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,6 +53,10 @@ 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**:可使用