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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
+8
-33
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user