refactor(dice): extract engine from md-commander

The dice engine lived under md-commander/hooks but is shared by :md-dice, spark tables, the journal /roll command, and dice inside variable expressions. Move it to src/components/dice so the md-commander widget can be retired without losing rolling.

Also promote the browser-console harness to a real jest suite; the file was named test.ts, so jest never ran it.
This commit is contained in:
hyper
2026-09-12 23:14:28 +08:00
parent 0b029b325e
commit 3508286792
14 changed files with 183 additions and 184 deletions
+143
View File
@@ -0,0 +1,143 @@
import { tokenize, parse, roll, applyModifier, rollFormula } from "./index";
import type { DieResult } from "./index";
describe("dice engine — tokenizer", () => {
test("tokenizes NdX", () => {
expect(tokenize("3d6")).toEqual([{ type: "dice", count: 3, sides: 6 }]);
});
test("tokenizes bare dX as a single die", () => {
expect(tokenize("d20")).toEqual([{ type: "dice", count: 1, sides: 20 }]);
});
test("tokenizes a pool literal", () => {
expect(tokenize("{d4,d8}")).toEqual([
{ type: "pool_start" },
{ type: "dice", count: 1, sides: 4 },
{ type: "comma" },
{ type: "dice", count: 1, sides: 8 },
{ type: "pool_end" },
]);
});
test("tokenizes a keep-highest modifier", () => {
expect(tokenize("3d6kh2")).toEqual([
{ type: "dice", count: 3, sides: 6 },
{ type: "modifier", modType: "kh", count: 2 },
]);
});
test("ignores whitespace", () => {
expect(tokenize(" 3d6 + 5 ")).toEqual([
{ type: "dice", count: 3, sides: 6 },
{ type: "plus" },
{ type: "number", value: 5 },
]);
});
});
describe("dice engine — parser", () => {
test("builds one pool for NdX", () => {
const pools = parse(tokenize("3d6"));
expect(pools).toHaveLength(1);
expect(pools[0].dice).toHaveLength(3);
expect(pools[0].dice.every((d) => d.sides === 6)).toBe(true);
});
test("builds a combined pool list for +", () => {
const pools = parse(tokenize("3d6+5"));
expect(pools).toHaveLength(2);
expect(pools[1].dice[0]).toMatchObject({ sides: 0, value: 5 });
});
test("marks a subtracted pool negative", () => {
const pools = parse(tokenize("3d6-2"));
expect(pools).toHaveLength(2);
expect(pools[1].isNegative).toBe(true);
expect(pools[1].dice[0].isNegative).toBe(true);
});
});
describe("dice engine — applyModifier", () => {
const dice = (...values: number[]): DieResult[] =>
values.map((value) => ({ sides: 6, value, isNegative: false }));
test("keeps the highest N", () => {
const { keptRolls, droppedRolls } = applyModifier(dice(2, 6, 4), {
type: "kh",
count: 2,
});
expect(keptRolls.map((r) => r.value).sort()).toEqual([4, 6]);
expect(droppedRolls.map((r) => r.value)).toEqual([2]);
});
test("drops the lowest N", () => {
const { keptRolls, droppedRolls } = applyModifier(dice(2, 6, 4, 1), {
type: "dl",
count: 1,
});
expect(keptRolls.map((r) => r.value).sort()).toEqual([2, 4, 6]);
expect(droppedRolls.map((r) => r.value)).toEqual([1]);
});
test("keeps all when there is no modifier", () => {
const { keptRolls, droppedRolls } = applyModifier(dice(1, 2), undefined);
expect(keptRolls).toHaveLength(2);
expect(droppedRolls).toHaveLength(0);
});
});
describe("dice engine — roll", () => {
test("rolls 3d6 within range", () => {
for (let i = 0; i < 25; i++) {
const result = roll("3d6");
expect(result.total).toBeGreaterThanOrEqual(3);
expect(result.total).toBeLessThanOrEqual(18);
expect(result.pools).toHaveLength(1);
}
});
test("rolls d20 within range", () => {
const result = roll("d20");
expect(result.total).toBeGreaterThanOrEqual(1);
expect(result.total).toBeLessThanOrEqual(20);
});
test("adds fixed numbers to the total", () => {
const result = roll("3d6+5");
expect(result.total).toBeGreaterThanOrEqual(8);
expect(result.total).toBeLessThanOrEqual(23);
});
test("keep-highest keeps N dice and totals only those", () => {
const result = roll("3d6kh2");
const [pool] = result.pools;
expect(pool.keptRolls).toHaveLength(2);
expect(pool.droppedRolls).toHaveLength(1);
expect(result.total).toBe(pool.subtotal);
});
test("emits matching HTML and plain-text detail", () => {
const result = roll("2d6");
expect(result.detail).toContain(String(result.total));
expect(result.detail).toContain("<strong>");
expect(result.plainDetail).toContain(String(result.total));
});
});
describe("dice engine — rollFormula", () => {
test("normalizes a successful roll", () => {
const result = rollFormula("3d6");
expect(result.success).toBe(true);
expect(result.formula).toBe("3d6");
expect(result.result.total).toBeGreaterThanOrEqual(3);
expect(result.result.pools[0].rolls).toHaveLength(3);
expect(result.result.pools[0].keptRolls).toHaveLength(3);
});
test("supports pool literals and modifiers", () => {
const result = rollFormula("{d4,d8}kh2-5");
expect(result.success).toBe(true);
expect(result.result.pools.length).toBeGreaterThanOrEqual(2);
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Dice engine — shared infrastructure for every dice-rolling feature
* (`:md-dice`, spark tables, journal `/roll`, and dice inside variable
* expressions).
*
* `rollFormula` is the normalized entry point; the tokenizer/parser/roller are
* exported for tests and for callers that need the structured pool results.
*/
export { rollFormula } from "./rollFormula";
export type { DiceRollerResult } from "./rollFormula";
export { tokenize, parse } from "./parser";
export { roll, rollDie, rollDicePool, applyModifier } from "./roller";
export type {
DieResult,
DicePool,
RollResult,
DicePoolResult,
Token,
} from "./types";
@@ -1,4 +1,4 @@
import { roll as rollDice } from "./dice-engine"; import { roll as rollDice } from "./roller";
export interface DiceRollerResult { export interface DiceRollerResult {
formula: string; formula: string;
@@ -16,19 +16,14 @@ export interface DiceRollerResult {
error?: string; error?: string;
} }
/**
* 骰子引擎 Hook
* 提供高级骰子功能:骰池、修饰符、组合等
*/
/** /**
* 执行掷骰 * 执行掷骰
* @param formula 骰子公式,支持: *
* - 3d6: 标准骰子 * Supports pools, modifiers and combinations. For example:
* - {d4, d8, 2d6}: 骰池字面量 * - `3d6`: standard dice
* - kh2/dl1: 修饰符 * - `{d4, d8, 2d6}`: pool literal
* - +: 组合骰池 * - `kh2` / `dl1`: keep-high / drop-low modifiers
* - -: 负数组合 * - `+` / `-`: combine pools; a bare number is a fixed value
* - 5: 固定数字
*/ */
export function rollFormula(formula: string): DiceRollerResult { export function rollFormula(formula: string): DiceRollerResult {
try { try {
@@ -63,24 +58,4 @@ export function rollFormula(formula: string): DiceRollerResult {
} }
} }
/**
* 简化版掷骰,返回 HTML 格式结果
* 格式:[1] [2] [3] = <strong>6</strong>
*/
export function rollSimple(formula: string): {
text: string;
isHtml?: boolean;
} {
try {
const result = rollDice(formula);
return {
text: result.detail,
isHtml: true, // detail 包含 HTML
};
} catch (e) {
return {
text: `错误:${e instanceof Error ? e.message : String(e)}`,
isHtml: false,
};
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
import { z } from "zod"; import { z } from "zod";
import { registerMessageType } from "../registry"; import { registerMessageType } from "../registry";
import { rollFormula } from "../../md-commander/hooks"; import { rollFormula } from "../../dice";
const schema = z.object({ const schema = z.object({
notation: z.string().min(1), notation: z.string().min(1),
@@ -21,7 +21,7 @@
* - Malformed numeric literals (e.g. "1.2.3") * - Malformed numeric literals (e.g. "1.2.3")
*/ */
import { rollFormula } from "../md-commander/hooks"; import { rollFormula } from "../dice";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
+5 -5
View File
@@ -1,5 +1,5 @@
import type { MdCommanderCommand } from "../types"; import type { MdCommanderCommand } from "../types";
import { rollSimple } from "../hooks/useDiceRoller"; import { rollFormula } from "../../dice";
export const rollCommand: MdCommanderCommand = { export const rollCommand: MdCommanderCommand = {
command: "roll", command: "roll",
@@ -14,11 +14,11 @@ export const rollCommand: MdCommanderCommand = {
], ],
handler: (args) => { handler: (args) => {
const formula = args.params.formula || "1d6"; const formula = args.params.formula || "1d6";
const result = rollSimple(formula); const result = rollFormula(formula);
return { return {
message: result.text, message: result.success ? result.result.detail : `错误:${result.error ?? formula}`,
isHtml: result.isHtml, isHtml: result.success,
type: result.text.startsWith("错误") ? "error" : "success", type: result.success ? "success" : "error",
}; };
}, },
}; };
@@ -1,11 +0,0 @@
export type {
DieResult,
DicePool,
RollResult,
DicePoolResult,
Token,
} from "./types";
export { tokenize, parse } from "./parser";
export { rollDie, rollDicePool, applyModifier, roll } from "./roller";
export { testDiceEngine } from "./test";
@@ -1,129 +0,0 @@
import { tokenize, parse } from "./parser";
import { rollDicePool, roll } from "./roller";
import type { DicePool } from "./types";
/**
* 骰子引擎测试
* 在浏览器控制台运行:window.testDiceEngine()
*/
export function testDiceEngine() {
console.log("=== 骰子引擎测试 ===\n");
// 测试用例
const testCases: Array<{
name: string;
formula: string;
expected: {
hasPools: number;
hasModifier?: boolean;
};
}> = [
{
name: "标准骰子 3d6",
formula: "3d6",
expected: { hasPools: 1 },
},
{
name: "单个骰子 d20",
formula: "d20",
expected: { hasPools: 1 },
},
{
name: "骰池字面量 {d4,d8}",
formula: "{d4,d8}",
expected: { hasPools: 1 },
},
{
name: "带修饰符 3d6kh2",
formula: "3d6kh2",
expected: { hasPools: 1, hasModifier: true },
},
{
name: "带修饰符 4d6dl1",
formula: "4d6dl1",
expected: { hasPools: 1, hasModifier: true },
},
{
name: "组合骰池 3d6+5",
formula: "3d6+5",
expected: { hasPools: 2 },
},
{
name: "负数组合 3d6-2",
formula: "3d6-2",
expected: { hasPools: 2 },
},
{
name: "复杂公式 3d6+{d4,d8}kh2-5",
formula: "3d6+{d4,d8}kh2-5",
expected: { hasPools: 4, hasModifier: true },
},
{
name: "多个骰池 {d6,d8}+{d10,d12}",
formula: "{d6,d8}+{d10,d12}",
expected: { hasPools: 2 },
},
{
name: "Keep Lowest 5d6kl3",
formula: "5d6kl3",
expected: { hasPools: 1, hasModifier: true },
},
{
name: "Drop Highest 4d6dh1",
formula: "4d6dh1",
expected: { hasPools: 1, hasModifier: true },
},
];
let passed = 0;
let failed = 0;
for (const tc of testCases) {
try {
// 测试词法分析
const tokens = tokenize(tc.formula);
console.log(`\n公式:${tc.formula}`);
console.log(` 令牌:${tokens.length} 个`);
// 测试语法分析
const pools = parse(tokens);
console.log(` 骰池:${pools.length} 个`);
// 测试掷骰
const result = roll(tc.formula);
console.log(` 结果:${result.total}`);
console.log(` 详情:${result.detail}`);
// 验证
if (pools.length >= tc.expected.hasPools) {
console.log(` ✓ 通过`);
passed++;
} else {
console.log(` ✗ 失败:期望至少 ${tc.expected.hasPools} 个骰池,实际 ${pools.length} 个`);
failed++;
}
if (tc.expected.hasModifier) {
const hasMod = pools.some((p) => p.modifier !== undefined);
if (hasMod) {
console.log(` ✓ 修饰符正确`);
} else {
console.log(` ✗ 修饰符缺失`);
failed++;
}
}
} catch (e) {
console.log(` ✗ 错误:${e instanceof Error ? e.message : String(e)}`);
failed++;
}
}
console.log(`\n=== 测试完成:${passed} 通过,${failed} 失败 ===`);
return { passed, failed };
}
// 在浏览器环境下暴露到全局
if (typeof window !== "undefined") {
(window as any).testDiceEngine = testDiceEngine;
}
+2 -2
View File
@@ -1,6 +1,6 @@
export { useCommander } from "./useCommander"; export { useCommander } from "./useCommander";
export type { UseCommanderReturn } from "./useCommander"; export type { UseCommanderReturn } from "./useCommander";
export { rollSimple, rollFormula } from "./useDiceRoller"; export { rollFormula } from "../../dice";
export type { DiceRollerResult } from "./useDiceRoller"; export type { DiceRollerResult } from "../../dice";
export { parseInput, getCompletions, getResultClass } from "./completions"; export { parseInput, getCompletions, getResultClass } from "./completions";
export type { ParsedCommand } from "./completions"; export type { ParsedCommand } from "./completions";
+1 -1
View File
@@ -1,6 +1,6 @@
import { customElement, noShadowDOM } from "solid-element"; import { customElement, noShadowDOM } from "solid-element";
import { createSignal, onMount } from "solid-js"; import { createSignal, onMount } from "solid-js";
import { rollFormula } from "./md-commander/hooks"; import { rollFormula } from "./dice";
export interface DiceProps { export interface DiceProps {
key?: string; key?: string;
+1 -1
View File
@@ -15,7 +15,7 @@
import Slugger from "github-slugger"; import Slugger from "github-slugger";
import { parse } from "csv-parse/browser/esm/sync"; import { parse } from "csv-parse/browser/esm/sync";
import { parseCSVString } from "../utils/csv-loader"; import { parseCSVString } from "../utils/csv-loader";
import { rollFormula } from "../md-commander/hooks"; import { rollFormula } from "../dice";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types