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", () => {
test("parses /roll command", () => {
const result = parseInput("/roll 3d6+5");
test("parses /roll command with spark table slug", () => {
const result = parseInput("/roll my-table");
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", () => {
const result = parseInput("/spark my-table");
expect(result.type).toBe("spark");
expect(result.payload).toEqual({ key: "my-table" });
test("parses /roll command with dice notation", () => {
const result = parseInput("/roll 3d6+5");
expect(result.type).toBe("roll");
expect(result.payload).toEqual({ arg: "3d6+5" });
});
test("parses /link command", () => {
@ -743,7 +743,7 @@ describe("parseInput", () => {
test("returns error for empty /roll", () => {
const result = parseInput("/roll ");
expect(result.error).toBe("需要骰子表达式");
expect(result.error).toBe("需要骰子表达式或种子表键名");
});
test("returns error for empty /set", () => {

View File

@ -126,7 +126,7 @@ export const RevealManager: Component = () => {
rect: sparkTable.getBoundingClientRect(),
action: () =>
setActionPrefill({
command: "/spark",
command: "/roll",
text: combinedSlug,
}),
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 journalSetRaw from "../doc-entries/journal-set.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";
const rawDocuments: string[] = [
@ -53,7 +52,6 @@ const rawDocuments: string[] = [
journalPlayerRaw,
journalSetRaw,
journalRollRaw,
journalSparkRaw,
journalLinkRaw,
];

View File

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

View File

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

View File

@ -87,30 +87,57 @@ export async function dispatchCommand(
// GM: all commands
if (parsed.type === "roll") {
const p = resolveRollPayload(
parsed.payload as { notation: string; label?: string },
);
const arg = (parsed.payload as { arg: string }).arg;
// 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);
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") {
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
}

View File

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

View File

@ -1,27 +1,32 @@
---
tag: journal-roll
icon: 🎲
title: 掷骰命令
description: 在 Journal 中发送掷骰结果,支持标准骰子表达式
syntax: '/roll 骰子表达式'
title: 掷骰与种子表命令
description: 在 Journal 中掷骰或随机生成种子表内容。优先匹配种子表,否则作为骰子表达式处理
syntax: '/roll 骰子表达式 | 种子表键名'
props:
- name: 骰子表达式
- name: 参数
type: string
desc: 标准骰子表达式,如 3d6、2d8kh1、d20+5
desc: 骰子表达式(如 3d6、2d8kh1、d20+5或已定义的种子表 slug
---
## 概述
`/roll` 命令用于在 Journal 流中掷骰并发布结果。结果会同步到所有连接的客户端。
`/roll` 命令用于掷骰或随机抽取种子表,并将结果同步到所有客户端。
- 如果参数匹配已定义的**种子表** slug则抽取并发布对应种子表的结果。
- 否则,作为**骰子表达式**求值并发布掷骰结果。
## 语法
```
/roll 骰子表达式
/roll 骰子表达式 | 种子表键名
```
## 示例
### 掷骰
```
/roll d20
/roll 3d6
@ -30,6 +35,14 @@ props:
/roll 4d6k3
```
### 种子表
```
/roll npc
/roll encounter
/roll loot
```
## 支持的骰子表达式
| 表达式 | 说明 |
@ -40,6 +53,10 @@ props:
| `1d20+5` | 1 个 20 面骰加 5 |
| `4d6k3` | 4 个 6 面骰保留最高 3 个 |
## 种子表定义
种子表在文档中通过 `:spark[CSV路径]` 指令或 spark 形状的 markdown 表格定义CSV 文件的第一列表头为骰子公式(如 d6、d20后续列为数据列。
## 权限
- **GM**:可使用