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
+256
View File
@@ -0,0 +1,256 @@
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,
};
}