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
@@ -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,256 +0,0 @@
import type { Token, DicePool } from "./types";
/**
* 词法分析器 - 将输入字符串转换为令牌流
*/
export function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
const str = input.replace(/\s+/g, ""); // 移除所有空格
while (i < str.length) {
const char = str[i];
// 修饰符 kh/kl/dh/dl - 必须在检查 'd' 骰子之前检查
if (char === "k" || char === "d") {
const rest = str.slice(i).toLowerCase();
if (rest.startsWith("kh")) {
i += 2;
let countStr = "";
while (i < str.length && /\d/.test(str[i])) {
countStr += str[i];
i++;
}
tokens.push({ type: "modifier", modType: "kh", count: parseInt(countStr) || 1 });
continue;
} else if (rest.startsWith("kl")) {
i += 2;
let countStr = "";
while (i < str.length && /\d/.test(str[i])) {
countStr += str[i];
i++;
}
tokens.push({ type: "modifier", modType: "kl", count: parseInt(countStr) || 1 });
continue;
} else if (rest.startsWith("dh")) {
i += 2;
let countStr = "";
while (i < str.length && /\d/.test(str[i])) {
countStr += str[i];
i++;
}
tokens.push({ type: "modifier", modType: "dh", count: parseInt(countStr) || 1 });
continue;
} else if (rest.startsWith("dl")) {
i += 2;
let countStr = "";
while (i < str.length && /\d/.test(str[i])) {
countStr += str[i];
i++;
}
tokens.push({ type: "modifier", modType: "dl", count: parseInt(countStr) || 1 });
continue;
}
}
// 数字字面量
if (/\d/.test(char)) {
let numStr = "";
while (i < str.length && /\d/.test(str[i])) {
numStr += str[i];
i++;
}
const num = parseInt(numStr);
// 检查是否是 NdX 格式
if (str[i] === "d" || str[i] === "D") {
i++; // 跳过 'd'
let sidesStr = "";
while (i < str.length && /\d/.test(str[i])) {
sidesStr += str[i];
i++;
}
const sides = parseInt(sidesStr);
tokens.push({ type: "dice", count: num, sides });
} else {
tokens.push({ type: "number", value: num });
}
continue;
}
// 单独的 dX 格式(等同于 1dX)
if (char === "d" || char === "D") {
i++;
let sidesStr = "";
while (i < str.length && /\d/.test(str[i])) {
sidesStr += str[i];
i++;
}
if (sidesStr.length > 0) {
const sides = parseInt(sidesStr);
tokens.push({ type: "dice", count: 1, sides });
}
continue;
}
// 骰池开始
if (char === "{") {
tokens.push({ type: "pool_start" });
i++;
continue;
}
// 骰池结束
if (char === "}") {
tokens.push({ type: "pool_end" });
i++;
continue;
}
// 逗号
if (char === ",") {
tokens.push({ type: "comma" });
i++;
continue;
}
// 加号
if (char === "+") {
tokens.push({ type: "plus" });
i++;
continue;
}
// 减号
if (char === "-") {
tokens.push({ type: "minus" });
i++;
continue;
}
// 未知字符,跳过
i++;
}
return tokens;
}
/**
* 语法分析器 - 将令牌流转换为骰池列表
*/
export function parse(tokens: Token[]): DicePool[] {
const pools: DicePool[] = [];
let i = 0;
let currentSign = 1; // 当前符号,1 为正,-1 为负
while (i < tokens.length) {
const token = tokens[i];
// 处理运算符
if (token.type === "plus") {
currentSign = 1;
i++;
continue;
}
if (token.type === "minus") {
currentSign = -1;
i++;
continue;
}
// 解析骰池
const pool = parsePool(tokens, i, currentSign);
if (pool) {
pools.push(pool.pool);
i = pool.nextIndex;
currentSign = 1; // 重置符号
} else {
i++;
}
}
return pools;
}
/**
* 解析单个骰池
*/
function parsePool(
tokens: Token[],
startIndex: number,
sign: number
): { pool: DicePool; nextIndex: number } | null {
const dice: DicePool["dice"] = [];
let i = startIndex;
let modifier: DicePool["modifier"] = undefined;
const token = tokens[i];
if (!token) return null;
// 骰池字面量 {d4, d6, ...}
if (token.type === "pool_start") {
i++; // 跳过 {
while (i < tokens.length && tokens[i].type !== "pool_end") {
if (tokens[i].type === "comma") {
i++;
continue;
}
const token = tokens[i];
if (token.type === "dice") {
for (let j = 0; j < token.count; j++) {
dice.push({
sides: token.sides,
value: 0,
isNegative: sign < 0,
});
}
i++;
} else if (token.type === "number") {
// 数字作为 0 面骰子(固定值)
dice.push({
sides: 0,
value: token.value,
isNegative: sign < 0,
});
i++;
} else {
i++;
}
}
i++; // 跳过 }
}
// 单个骰子或数字
else if (token.type === "dice") {
for (let j = 0; j < token.count; j++) {
dice.push({
sides: token.sides,
value: 0,
isNegative: sign < 0,
});
}
i++;
} else if (token.type === "number") {
dice.push({
sides: 0,
value: token.value,
isNegative: sign < 0,
});
i++;
} else {
return null;
}
// 检查修饰符
if (i < tokens.length && tokens[i].type === "modifier") {
const mod = tokens[i] as Extract<Token, { type: "modifier" }>;
modifier = { type: mod.modType, count: mod.count };
i++;
}
return {
pool: { dice, modifier, isNegative: sign < 0 },
nextIndex: i,
};
}
@@ -1,144 +0,0 @@
import type { DicePool, DicePoolResult, DieResult, RollResult } from "./types";
import { tokenize, parse } from "./parser";
/**
* 掷骰子
*/
export function rollDie(sides: number): number {
if (sides === 0) return 0; // 固定数字
return Math.floor(Math.random() * sides) + 1;
}
/**
* 掷骰池
*/
export function rollDicePool(pool: DicePool): DicePoolResult {
// 分离骰子和固定数字
const diceToRoll = pool.dice.filter((die) => die.sides > 0);
const fixedNumbers = pool.dice.filter((die) => die.sides === 0);
// 只掷真正的骰子
const rolls: DieResult[] = diceToRoll.map((die) => ({
sides: die.sides,
value: rollDie(die.sides),
isNegative: die.isNegative,
}));
// 添加固定数字
const allRolls: DieResult[] = [
...rolls,
...fixedNumbers.map((die) => ({
sides: 0,
value: die.value,
isNegative: die.isNegative,
})),
];
// 应用修饰符(只对有面数的骰子生效)
const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier);
// 固定数字总是保留
const finalKeptRolls = [
...keptRolls,
...allRolls.filter((r) => r.sides === 0),
];
// 计算小计
const subtotal = finalKeptRolls.reduce((sum, roll) => {
return sum + (roll.isNegative ? -roll.value : roll.value);
}, 0);
return {
pool,
rolls: allRolls,
keptRolls: finalKeptRolls,
droppedRolls,
subtotal,
};
}
/**
* 应用修饰符
*/
export function applyModifier(
rolls: DieResult[],
modifier: DicePool["modifier"],
): { keptRolls: DieResult[]; droppedRolls: DieResult[] } {
if (!modifier) {
return { keptRolls: rolls, droppedRolls: [] };
}
// 按值排序
const sorted = [...rolls].sort((a, b) => a.value - b.value);
switch (modifier.type) {
case "kh": // Keep Highest - 保留最大的 N 个
case "dh": {
// Drop Highest - 丢弃最大的 N 个
const count = modifier.count;
const higher = sorted.slice(-count);
const rest = sorted.slice(0, -count);
if (modifier.type === "kh") {
return { keptRolls: higher, droppedRolls: rest };
} else {
return { keptRolls: rest, droppedRolls: higher };
}
}
case "kl": // Keep Lowest - 保留最小的 N 个
case "dl": {
// Drop Lowest - 丢弃最小的 N 个
const count = modifier.count;
const lower = sorted.slice(0, count);
const rest = sorted.slice(count);
if (modifier.type === "kl") {
return { keptRolls: lower, droppedRolls: rest };
} else {
return { keptRolls: rest, droppedRolls: lower };
}
}
}
}
/**
* 执行完整的掷骰
*/
export function roll(formula: string): RollResult {
const tokens = tokenize(formula);
const pools = parse(tokens);
const poolResults = pools.map((pool) => rollDicePool(pool));
const total = poolResults.reduce((sum, result) => sum + result.subtotal, 0);
// Generate detail expressions (both HTML and plain text)
const htmlSpans: string[] = [];
const textSpans: string[] = [];
for (const result of poolResults) {
for (const roll of result.rolls) {
const isKept = result.keptRolls.some((kept) => kept === roll);
if (roll.sides === 0) {
const sign = roll.isNegative ? "-" : "";
htmlSpans.push(`<strong>${sign}${roll.value}</strong>`);
textSpans.push(`${sign}${roll.value}`);
} else if (isKept) {
htmlSpans.push(`<strong>[${roll.value}]</strong>`);
textSpans.push(`[${roll.value}]`);
} else {
htmlSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
textSpans.push(`[${roll.value}]`);
}
}
}
const rollHtml = htmlSpans.join(" + ");
const rollText = textSpans.join(" + ");
const detail = `<strong>${total}</strong> = ${rollHtml}`;
const plainDetail = `${total} = ${rollText}`;
return {
pools: poolResults,
total,
detail,
plainDetail,
};
}
@@ -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,66 +0,0 @@
/**
* 骰子引擎类型定义
*
* 支持语法:
* - 3d6: 标准骰子表示
* - {d4, d8}: 骰池字面量
* - kh2/dl1: 修饰符 (keep high / drop low)
* - +: 组合骰池
* - -: 转换负数后组合
* - 数字字面量:单一数字的骰池
*/
/**
* 单个骰子结果
*/
export interface DieResult {
sides: number; // 骰子面数,0 表示固定数字
value: number; // 掷骰结果
isNegative: boolean; // 是否为负数
}
/**
* 骰池
*/
export interface DicePool {
dice: DieResult[]; // 骰子列表(未掷骰前为模板)
modifier?: {
type: "kh" | "kl" | "dh" | "dl";
count: number;
};
isNegative: boolean; // 整个骰池是否为负
}
/**
* 掷骰结果
*/
export interface RollResult {
pools: DicePoolResult[]; // 各骰池结果
total: number; // 总和
detail: string; // 详细表达式 (HTML)
plainDetail: string; // 详细表达式 (plain text)
}
/**
* 单个骰池的掷骰结果
*/
export interface DicePoolResult {
pool: DicePool; // 原始骰池定义
rolls: DieResult[]; // 掷骰结果
keptRolls: DieResult[]; // 保留的骰子(应用修饰符后)
droppedRolls: DieResult[]; // 丢弃的骰子
subtotal: number; // 该骰池小计
}
/**
* 解析后的令牌
*/
export type Token =
| { type: "number"; value: number }
| { type: "dice"; count: number; sides: number }
| { type: "pool_start" }
| { type: "pool_end" }
| { type: "comma" }
| { type: "plus" }
| { type: "minus" }
| { type: "modifier"; modType: "kh" | "kl" | "dh" | "dl"; count: number };
+2 -2
View File
@@ -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";
@@ -1,86 +0,0 @@
import { roll as rollDice } from "./dice-engine";
export interface DiceRollerResult {
formula: string;
result: {
total: number;
detail: string;
plainDetail: string;
pools: Array<{
rolls: number[];
keptRolls: number[];
subtotal: number;
}>;
};
success: boolean;
error?: string;
}
/**
* 骰子引擎 Hook
* 提供高级骰子功能:骰池、修饰符、组合等
*/
/**
* 执行掷骰
* @param formula 骰子公式,支持:
* - 3d6: 标准骰子
* - {d4, d8, 2d6}: 骰池字面量
* - kh2/dl1: 修饰符
* - +: 组合骰池
* - -: 负数组合
* - 5: 固定数字
*/
export function rollFormula(formula: string): DiceRollerResult {
try {
const result = rollDice(formula);
return {
formula,
result: {
total: result.total,
detail: result.detail,
plainDetail: result.plainDetail,
pools: result.pools.map((pool) => ({
rolls: pool.rolls.map((r) => r.value),
keptRolls: pool.keptRolls.map((r) => r.value),
subtotal: pool.subtotal,
})),
},
success: true,
};
} catch (e) {
return {
formula,
result: {
total: 0,
detail: "",
plainDetail: "",
pools: [],
},
success: false,
error: e instanceof Error ? e.message : String(e),
};
}
}
/**
* 简化版掷骰,返回 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,
};
}
}