feat: dice roller improved
This commit is contained in:
parent
66aaaa96e1
commit
cc10cea31d
|
|
@ -1,14 +1,38 @@
|
||||||
import { type Component, For, Show } from "solid-js";
|
import { type Component, For, Show, createEffect, on } from "solid-js";
|
||||||
import type { CommanderEntry } from "./types";
|
import type { CommanderEntry } from "./types";
|
||||||
import { getResultClass } from "./hooks";
|
import { getResultClass } from "./hooks";
|
||||||
|
|
||||||
export interface CommanderEntriesProps {
|
export interface CommanderEntriesProps {
|
||||||
entries: () => CommanderEntry[];
|
entries: () => CommanderEntry[];
|
||||||
|
onCommandClick?: (command: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommanderEntries: Component<CommanderEntriesProps> = (props) => {
|
export const CommanderEntries: Component<CommanderEntriesProps> = (props) => {
|
||||||
|
let containerRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
// 当 entries 变化时自动滚动到底部
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.entries().length,
|
||||||
|
() => {
|
||||||
|
if (containerRef) {
|
||||||
|
containerRef.scrollTop = containerRef.scrollHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCommandClick = (command: string) => {
|
||||||
|
if (props.onCommandClick) {
|
||||||
|
props.onCommandClick(command);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="commander-entries flex-1 overflow-auto p-3 bg-white space-y-2">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class="commander-entries flex-1 overflow-auto p-3 bg-white space-y-2"
|
||||||
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.entries().length > 0}
|
when={props.entries().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
|
|
@ -19,15 +43,22 @@ export const CommanderEntries: Component<CommanderEntriesProps> = (props) => {
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
<div class="border-l-2 border-gray-300 pl-3 py-1">
|
<div class="border-l-2 border-gray-300 pl-3 py-1">
|
||||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||||
<span class="font-mono">{entry.command}</span>
|
<span
|
||||||
|
class="font-mono cursor-pointer hover:text-blue-600 hover:underline"
|
||||||
|
onClick={() => handleCommandClick(entry.command)}
|
||||||
|
title="点击复制到输入框"
|
||||||
|
>
|
||||||
|
{entry.command}
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{entry.timestamp.toLocaleTimeString()}
|
{entry.timestamp.toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class={`text-sm whitespace-pre-wrap ${getResultClass(entry.result.type)}`}
|
class={`text-sm whitespace-pre-wrap ${getResultClass(entry.result.type)}`}
|
||||||
|
innerHTML={entry.result.isHtml ? entry.result.message : undefined}
|
||||||
>
|
>
|
||||||
{entry.result.message}
|
{!entry.result.isHtml && entry.result.message}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,9 @@ export const CommanderInput: Component<CommanderInputProps> = (props) => {
|
||||||
执行
|
执行
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 自动补全下拉框 */}
|
{/* 自动补全下拉框 - 向上弹出 */}
|
||||||
<Show when={props.showCompletions() && props.completions().length > 0}>
|
<Show when={props.showCompletions() && props.completions().length > 0}>
|
||||||
<div class="absolute left-0 top-10 z-10 w-full bg-white border border-gray-300 shadow-lg max-h-48 overflow-auto mt-1">
|
<div class="absolute left-0 bottom-full w-full bg-white border border-gray-300 shadow-lg max-h-48 overflow-auto mb-1">
|
||||||
<For each={props.completions()}>{(comp, idx) => (
|
<For each={props.completions()}>{(comp, idx) => (
|
||||||
<div
|
<div
|
||||||
class={`px-3 py-2 cursor-pointer flex justify-between items-center ${
|
class={`px-3 py-2 cursor-pointer flex justify-between items-center ${
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
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";
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
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];
|
||||||
|
|
||||||
|
// 数字字面量
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修饰符 kh/kl/dh/dl
|
||||||
|
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 });
|
||||||
|
} 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 });
|
||||||
|
} 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 });
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未知字符,跳过
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens[i].type === "dice") {
|
||||||
|
for (let j = 0; j < tokens[i].count; j++) {
|
||||||
|
dice.push({
|
||||||
|
sides: tokens[i].sides,
|
||||||
|
value: 0,
|
||||||
|
isNegative: sign < 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
} else if (tokens[i].type === "number") {
|
||||||
|
// 数字作为 0 面骰子(固定值)
|
||||||
|
dice.push({
|
||||||
|
sides: 0,
|
||||||
|
value: tokens[i].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,136 @@
|
||||||
|
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 rolls: DieResult[] = pool.dice.map((die) => ({
|
||||||
|
sides: die.sides,
|
||||||
|
value: rollDie(die.sides),
|
||||||
|
isNegative: die.isNegative,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 应用修饰符
|
||||||
|
const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier);
|
||||||
|
|
||||||
|
// 计算小计
|
||||||
|
const subtotal = keptRolls.reduce((sum, roll) => {
|
||||||
|
return sum + (roll.isNegative ? -roll.value : roll.value);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pool,
|
||||||
|
rolls,
|
||||||
|
keptRolls,
|
||||||
|
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;
|
||||||
|
if (modifier.type === "kh") {
|
||||||
|
const kept = sorted.slice(-count);
|
||||||
|
const dropped = sorted.slice(0, -count);
|
||||||
|
return { keptRolls: kept, droppedRolls: dropped };
|
||||||
|
} else {
|
||||||
|
const kept = sorted.slice(0, -count);
|
||||||
|
const dropped = sorted.slice(-count);
|
||||||
|
return { keptRolls: kept, droppedRolls: dropped };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "kl": // Keep Lowest - 保留最小的 N 个
|
||||||
|
case "dl": { // Drop Lowest - 丢弃最小的 N 个
|
||||||
|
const count = modifier.count;
|
||||||
|
if (modifier.type === "kl") {
|
||||||
|
const kept = sorted.slice(0, count);
|
||||||
|
const dropped = sorted.slice(count);
|
||||||
|
return { keptRolls: kept, droppedRolls: dropped };
|
||||||
|
} else {
|
||||||
|
const kept = sorted.slice(count);
|
||||||
|
const dropped = sorted.slice(0, count);
|
||||||
|
return { keptRolls: kept, droppedRolls: dropped };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行完整的掷骰
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 生成详细表达式 - 使用 HTML 格式
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const result of poolResults) {
|
||||||
|
const diceCount = result.pool.dice.filter((d) => d.sides > 0).length;
|
||||||
|
const fixedValue = result.pool.dice.find((d) => d.sides === 0)?.value || 0;
|
||||||
|
const modStr = result.pool.modifier
|
||||||
|
? `${result.pool.modifier.type}${result.pool.modifier.count}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// 骰子部分
|
||||||
|
let dicePart: string;
|
||||||
|
if (diceCount > 0) {
|
||||||
|
const sides = result.pool.dice[0]?.sides || 0;
|
||||||
|
dicePart = `${diceCount}d${sides}${modStr}`;
|
||||||
|
} else {
|
||||||
|
dicePart = `${fixedValue}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建骰子结果 HTML
|
||||||
|
const rollSpans = result.rolls.map((roll) => {
|
||||||
|
const isKept = result.keptRolls.some(
|
||||||
|
(kept) => kept === roll
|
||||||
|
);
|
||||||
|
if (isKept) {
|
||||||
|
return `<strong>[${roll.value}]</strong>`;
|
||||||
|
} else {
|
||||||
|
return `<span class="text-gray-400">[${roll.value}]</span>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rollsStr = rollSpans.join(" ");
|
||||||
|
parts.push(`${dicePart} ${rollsStr} = <strong>${result.subtotal}</strong>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = parts.join(" + ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
pools: poolResults,
|
||||||
|
total,
|
||||||
|
detail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* 骰子引擎类型定义
|
||||||
|
*
|
||||||
|
* 支持语法:
|
||||||
|
* - 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; // 详细表达式
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个骰池的掷骰结果
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
export { useCommander, defaultCommands, parseInput, getCompletions, getResultClass } from './useCommander';
|
export { useCommander, defaultCommands, parseInput, getCompletions, getResultClass } from './useCommander';
|
||||||
export type { UseCommanderReturn } from './useCommander';
|
export type { UseCommanderReturn } from './useCommander';
|
||||||
|
export { useDiceRoller } from './useDiceRoller';
|
||||||
|
export * from './dice-engine';
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
CommanderEntry,
|
CommanderEntry,
|
||||||
CompletionItem,
|
CompletionItem,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { useDiceRoller } from "./useDiceRoller";
|
||||||
|
|
||||||
// ==================== 默认命令 ====================
|
// ==================== 默认命令 ====================
|
||||||
|
|
||||||
|
|
@ -43,32 +44,23 @@ export const defaultCommands: Record<string, MdCommanderCommand> = {
|
||||||
},
|
},
|
||||||
roll: {
|
roll: {
|
||||||
command: "roll",
|
command: "roll",
|
||||||
description: "掷骰子",
|
description: "掷骰子 - 支持骰池、修饰符和组合",
|
||||||
options: {
|
options: {
|
||||||
formula: {
|
formula: {
|
||||||
option: "formula",
|
option: "formula",
|
||||||
description: "骰子公式,如 2d6+3",
|
description: "骰子公式,如 3d6+{d4,d8}kh2-5",
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: (args) => {
|
handler: (args) => {
|
||||||
const formula = args.formula || "1d6";
|
const formula = args.formula || "1d6";
|
||||||
const match = formula.match(/^(\d+)?d(\d+)([+-]\d+)?$/i);
|
const { rollSimple } = useDiceRoller();
|
||||||
if (!match) {
|
const result = rollSimple(formula);
|
||||||
return { message: `无效的骰子公式:${formula}`, type: "error" };
|
|
||||||
}
|
|
||||||
const count = parseInt(match[1] || "1");
|
|
||||||
const sides = parseInt(match[2]);
|
|
||||||
const modifier = parseInt(match[3] || "0");
|
|
||||||
const rolls: number[] = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
rolls.push(Math.floor(Math.random() * sides) + 1);
|
|
||||||
}
|
|
||||||
const total = rolls.reduce((a, b) => a + b, 0) + modifier;
|
|
||||||
return {
|
return {
|
||||||
message: `${formula} = [${rolls.join(", ")}]${modifier !== 0 ? (modifier > 0 ? `+${modifier}` : modifier) : ""} = ${total}`,
|
message: result.text,
|
||||||
type: "success",
|
isHtml: result.isHtml,
|
||||||
|
type: result.text.startsWith("错误") ? "error" : "success",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { roll as rollDice } from "./dice-engine";
|
||||||
|
|
||||||
|
export interface DiceRollerResult {
|
||||||
|
formula: string;
|
||||||
|
result: {
|
||||||
|
total: number;
|
||||||
|
detail: string;
|
||||||
|
pools: Array<{
|
||||||
|
rolls: number[];
|
||||||
|
keptRolls: number[];
|
||||||
|
subtotal: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 骰子引擎 Hook
|
||||||
|
* 提供高级骰子功能:骰池、修饰符、组合等
|
||||||
|
*/
|
||||||
|
export function useDiceRoller() {
|
||||||
|
/**
|
||||||
|
* 执行掷骰
|
||||||
|
* @param formula 骰子公式,支持:
|
||||||
|
* - 3d6: 标准骰子
|
||||||
|
* - {d4, d8, 2d6}: 骰池字面量
|
||||||
|
* - kh2/dl1: 修饰符
|
||||||
|
* - +: 组合骰池
|
||||||
|
* - -: 负数组合
|
||||||
|
* - 5: 固定数字
|
||||||
|
*/
|
||||||
|
function roll(formula: string): DiceRollerResult {
|
||||||
|
try {
|
||||||
|
const result = rollDice(formula);
|
||||||
|
|
||||||
|
return {
|
||||||
|
formula,
|
||||||
|
result: {
|
||||||
|
total: result.total,
|
||||||
|
detail: result.detail,
|
||||||
|
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: "",
|
||||||
|
pools: [],
|
||||||
|
},
|
||||||
|
success: false,
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化版掷骰,返回 HTML 格式结果
|
||||||
|
*/
|
||||||
|
function rollSimple(formula: string): { text: string; isHtml?: boolean } {
|
||||||
|
try {
|
||||||
|
const result = rollDice(formula);
|
||||||
|
return {
|
||||||
|
text: `${result.formula} = ${result.total} (${result.detail})`,
|
||||||
|
isHtml: true, // detail 包含 HTML
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
text: `错误:${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
isHtml: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
roll,
|
||||||
|
rollSimple,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
import { customElement, noShadowDOM } from "solid-element";
|
import { customElement, noShadowDOM } from "solid-element";
|
||||||
import { onMount, onCleanup } from "solid-js";
|
import { onMount, onCleanup } from "solid-js";
|
||||||
import { useCommander } from "./hooks";
|
import { useCommander } from "./hooks/useCommander";
|
||||||
import { CommanderInput } from "./CommanderInput";
|
import { CommanderInput } from "./CommanderInput";
|
||||||
import { CommanderEntries } from "./CommanderEntries";
|
import { CommanderEntries } from "./CommanderEntries";
|
||||||
import type { MdCommanderProps } from "./types";
|
import type { MdCommanderProps } from "./types";
|
||||||
|
|
||||||
export { CommanderInput } from './CommanderInput';
|
|
||||||
export type { CommanderInputProps } from './CommanderInput';
|
|
||||||
export { CommanderEntries } from './CommanderEntries';
|
|
||||||
export type { CommanderEntriesProps } from './CommanderEntries';
|
|
||||||
export type {
|
|
||||||
MdCommanderProps,
|
|
||||||
MdCommanderCommand,
|
|
||||||
MdCommanderOption,
|
|
||||||
MdCommanderOptionType,
|
|
||||||
CommanderEntry,
|
|
||||||
CompletionItem,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
customElement<MdCommanderProps>(
|
customElement<MdCommanderProps>(
|
||||||
"md-commander",
|
"md-commander",
|
||||||
{ placeholder: "", class: "", height: "" },
|
{ placeholder: "", class: "", height: "" },
|
||||||
|
|
@ -73,14 +60,17 @@ customElement<MdCommanderProps>(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`md-commander flex flex-col border border-gray-300 rounded-lg overflow-visible ${props.class || ""}`}
|
class={`md-commander flex flex-col border border-gray-300 rounded-lg overflow-hidden ${props.class || ""}`}
|
||||||
style={{ height: heightStyle() }}
|
style={{ height: heightStyle() }}
|
||||||
>
|
>
|
||||||
{/* 命令执行结果 */}
|
{/* 命令执行结果 */}
|
||||||
<CommanderEntries entries={commander.entries} />
|
<CommanderEntries
|
||||||
|
entries={commander.entries}
|
||||||
|
onCommandClick={(cmd) => commander.setInputValue(cmd)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 命令输入框 */}
|
{/* 命令输入框 */}
|
||||||
<div class="relative">
|
<div class="relative border-t border-gray-300">
|
||||||
<CommanderInput
|
<CommanderInput
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
inputValue={commander.inputValue}
|
inputValue={commander.inputValue}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export interface MdCommanderCommand {
|
||||||
handler?: (args: Record<string, any>) => {
|
handler?: (args: Record<string, any>) => {
|
||||||
message: string;
|
message: string;
|
||||||
type?: "success" | "error" | "info" | "warning";
|
type?: "success" | "error" | "info" | "warning";
|
||||||
|
isHtml?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +44,7 @@ export interface CommanderEntry {
|
||||||
result: {
|
result: {
|
||||||
message: string;
|
message: string;
|
||||||
type?: "success" | "error" | "warning" | "info";
|
type?: "success" | "error" | "warning" | "info";
|
||||||
|
isHtml?: boolean;
|
||||||
};
|
};
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue