From 35082867925d7756baab20f1c119f1561c6df578 Mon Sep 17 00:00:00 2001 From: hyper Date: Sat, 12 Sep 2026 23:14:28 +0800 Subject: [PATCH] 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. --- src/components/dice/dice-engine.test.ts | 143 ++++++++++++++++++ src/components/dice/index.ts | 21 +++ .../hooks/dice-engine => dice}/parser.ts | 0 .../useDiceRoller.ts => dice/rollFormula.ts} | 41 +---- .../hooks/dice-engine => dice}/roller.ts | 0 .../hooks/dice-engine => dice}/types.ts | 0 src/components/journal/types/roll.tsx | 2 +- src/components/journal/variable-expression.ts | 2 +- src/components/md-commander/commands/roll.ts | 10 +- .../md-commander/hooks/dice-engine/index.ts | 11 -- .../md-commander/hooks/dice-engine/test.ts | 129 ---------------- src/components/md-commander/hooks/index.ts | 4 +- src/components/md-dice.tsx | 2 +- src/components/utils/spark-table.ts | 2 +- 14 files changed, 183 insertions(+), 184 deletions(-) create mode 100644 src/components/dice/dice-engine.test.ts create mode 100644 src/components/dice/index.ts rename src/components/{md-commander/hooks/dice-engine => dice}/parser.ts (100%) rename src/components/{md-commander/hooks/useDiceRoller.ts => dice/rollFormula.ts} (56%) rename src/components/{md-commander/hooks/dice-engine => dice}/roller.ts (100%) rename src/components/{md-commander/hooks/dice-engine => dice}/types.ts (100%) delete mode 100644 src/components/md-commander/hooks/dice-engine/index.ts delete mode 100644 src/components/md-commander/hooks/dice-engine/test.ts diff --git a/src/components/dice/dice-engine.test.ts b/src/components/dice/dice-engine.test.ts new file mode 100644 index 0000000..5dc95f3 --- /dev/null +++ b/src/components/dice/dice-engine.test.ts @@ -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(""); + 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); + }); +}); diff --git a/src/components/dice/index.ts b/src/components/dice/index.ts new file mode 100644 index 0000000..f8d1905 --- /dev/null +++ b/src/components/dice/index.ts @@ -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"; diff --git a/src/components/md-commander/hooks/dice-engine/parser.ts b/src/components/dice/parser.ts similarity index 100% rename from src/components/md-commander/hooks/dice-engine/parser.ts rename to src/components/dice/parser.ts diff --git a/src/components/md-commander/hooks/useDiceRoller.ts b/src/components/dice/rollFormula.ts similarity index 56% rename from src/components/md-commander/hooks/useDiceRoller.ts rename to src/components/dice/rollFormula.ts index 9a258bc..bca2972 100644 --- a/src/components/md-commander/hooks/useDiceRoller.ts +++ b/src/components/dice/rollFormula.ts @@ -1,4 +1,4 @@ -import { roll as rollDice } from "./dice-engine"; +import { roll as rollDice } from "./roller"; export interface DiceRollerResult { formula: string; @@ -16,19 +16,14 @@ export interface DiceRollerResult { error?: string; } -/** - * 骰子引擎 Hook - * 提供高级骰子功能:骰池、修饰符、组合等 - */ /** * 执行掷骰 - * @param formula 骰子公式,支持: - * - 3d6: 标准骰子 - * - {d4, d8, 2d6}: 骰池字面量 - * - kh2/dl1: 修饰符 - * - +: 组合骰池 - * - -: 负数组合 - * - 5: 固定数字 + * + * Supports pools, modifiers and combinations. For example: + * - `3d6`: standard dice + * - `{d4, d8, 2d6}`: pool literal + * - `kh2` / `dl1`: keep-high / drop-low modifiers + * - `+` / `-`: combine pools; a bare number is a fixed value */ export function rollFormula(formula: string): DiceRollerResult { try { @@ -63,24 +58,4 @@ export function rollFormula(formula: string): DiceRollerResult { } } -/** - * 简化版掷骰,返回 HTML 格式结果 - * 格式:[1] [2] [3] = 6 - */ -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, - }; - } -} + diff --git a/src/components/md-commander/hooks/dice-engine/roller.ts b/src/components/dice/roller.ts similarity index 100% rename from src/components/md-commander/hooks/dice-engine/roller.ts rename to src/components/dice/roller.ts diff --git a/src/components/md-commander/hooks/dice-engine/types.ts b/src/components/dice/types.ts similarity index 100% rename from src/components/md-commander/hooks/dice-engine/types.ts rename to src/components/dice/types.ts diff --git a/src/components/journal/types/roll.tsx b/src/components/journal/types/roll.tsx index ba0ec77..d69a10a 100644 --- a/src/components/journal/types/roll.tsx +++ b/src/components/journal/types/roll.tsx @@ -8,7 +8,7 @@ import { z } from "zod"; import { registerMessageType } from "../registry"; -import { rollFormula } from "../../md-commander/hooks"; +import { rollFormula } from "../../dice"; const schema = z.object({ notation: z.string().min(1), diff --git a/src/components/journal/variable-expression.ts b/src/components/journal/variable-expression.ts index 9616b73..0cb21d4 100644 --- a/src/components/journal/variable-expression.ts +++ b/src/components/journal/variable-expression.ts @@ -21,7 +21,7 @@ * - Malformed numeric literals (e.g. "1.2.3") */ -import { rollFormula } from "../md-commander/hooks"; +import { rollFormula } from "../dice"; // --------------------------------------------------------------------------- // Types diff --git a/src/components/md-commander/commands/roll.ts b/src/components/md-commander/commands/roll.ts index 4650c99..5f7afac 100644 --- a/src/components/md-commander/commands/roll.ts +++ b/src/components/md-commander/commands/roll.ts @@ -1,5 +1,5 @@ import type { MdCommanderCommand } from "../types"; -import { rollSimple } from "../hooks/useDiceRoller"; +import { rollFormula } from "../../dice"; export const rollCommand: MdCommanderCommand = { command: "roll", @@ -14,11 +14,11 @@ export const rollCommand: MdCommanderCommand = { ], handler: (args) => { const formula = args.params.formula || "1d6"; - const result = rollSimple(formula); + const result = rollFormula(formula); return { - message: result.text, - isHtml: result.isHtml, - type: result.text.startsWith("错误") ? "error" : "success", + message: result.success ? result.result.detail : `错误:${result.error ?? formula}`, + isHtml: result.success, + type: result.success ? "success" : "error", }; }, }; diff --git a/src/components/md-commander/hooks/dice-engine/index.ts b/src/components/md-commander/hooks/dice-engine/index.ts deleted file mode 100644 index 1a9fcb4..0000000 --- a/src/components/md-commander/hooks/dice-engine/index.ts +++ /dev/null @@ -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"; diff --git a/src/components/md-commander/hooks/dice-engine/test.ts b/src/components/md-commander/hooks/dice-engine/test.ts deleted file mode 100644 index 8ff11ca..0000000 --- a/src/components/md-commander/hooks/dice-engine/test.ts +++ /dev/null @@ -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; -} diff --git a/src/components/md-commander/hooks/index.ts b/src/components/md-commander/hooks/index.ts index 60f6cfe..8ec8352 100644 --- a/src/components/md-commander/hooks/index.ts +++ b/src/components/md-commander/hooks/index.ts @@ -1,6 +1,6 @@ export { useCommander } from "./useCommander"; export type { UseCommanderReturn } from "./useCommander"; -export { rollSimple, rollFormula } from "./useDiceRoller"; -export type { DiceRollerResult } from "./useDiceRoller"; +export { rollFormula } from "../../dice"; +export type { DiceRollerResult } from "../../dice"; export { parseInput, getCompletions, getResultClass } from "./completions"; export type { ParsedCommand } from "./completions"; diff --git a/src/components/md-dice.tsx b/src/components/md-dice.tsx index 6423c7f..70c35d4 100644 --- a/src/components/md-dice.tsx +++ b/src/components/md-dice.tsx @@ -1,6 +1,6 @@ import { customElement, noShadowDOM } from "solid-element"; import { createSignal, onMount } from "solid-js"; -import { rollFormula } from "./md-commander/hooks"; +import { rollFormula } from "./dice"; export interface DiceProps { key?: string; diff --git a/src/components/utils/spark-table.ts b/src/components/utils/spark-table.ts index 03ed186..3552bc0 100644 --- a/src/components/utils/spark-table.ts +++ b/src/components/utils/spark-table.ts @@ -15,7 +15,7 @@ import Slugger from "github-slugger"; import { parse } from "csv-parse/browser/esm/sync"; import { parseCSVString } from "../utils/csv-loader"; -import { rollFormula } from "../md-commander/hooks"; +import { rollFormula } from "../dice"; // --------------------------------------------------------------------------- // Types