Files
ttrpg-tools/src/components/md-commander/hooks/dice-engine/roller.ts
T
hypercross 1d0f2f7c3e feat: add plain text dice roll detail
Introduces `plainDetail` to the dice engine to provide a non-HTML
version of the roll results. This allows components to display
the roll breakdown without needing to parse or render HTML.
2026-07-06 18:18:16 +08:00

145 lines
3.9 KiB
TypeScript

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,
};
}