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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { roll as rollDice } from "./roller";
|
||||
|
||||
export interface DiceRollerResult {
|
||||
formula: string;
|
||||
result: {
|
||||
total: number;
|
||||
detail: string;
|
||||
plainDetail: string;
|
||||
pools: Array<{
|
||||
rolls: number[];
|
||||
keptRolls: number[];
|
||||
subtotal: number;
|
||||
}>;
|
||||
};
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行掷骰
|
||||
*
|
||||
* 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 {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 骰子引擎类型定义
|
||||
*
|
||||
* 支持语法:
|
||||
* - 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 };
|
||||
Reference in New Issue
Block a user