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.
This commit is contained in:
hypercross 2026-07-06 18:18:16 +08:00
parent 0fc61e2f94
commit 1d0f2f7c3e
5 changed files with 62 additions and 41 deletions

View File

@ -16,6 +16,7 @@ const schema = z.object({
result: z.object({ result: z.object({
total: z.number(), total: z.number(),
detail: z.string(), detail: z.string(),
plainDetail: z.string(),
pools: z.array( pools: z.array(
z.object({ z.object({
rolls: z.array(z.number()), rolls: z.array(z.number()),
@ -47,7 +48,7 @@ registerMessageType<RollPayload>({
schema, schema,
defaultPayload: () => ({ defaultPayload: () => ({
notation: "1d20", notation: "1d20",
result: { total: 0, detail: "", pools: [] }, result: { total: 0, detail: "", plainDetail: "", pools: [] },
}), }),
render: (p) => { render: (p) => {
const { result } = p; const { result } = p;
@ -60,7 +61,9 @@ registerMessageType<RollPayload>({
<span class="font-bold text-base">{result.total}</span> <span class="font-bold text-base">{result.total}</span>
</span> </span>
</div> </div>
{result.detail && <p class="text-xs text-gray-400">{result.detail}</p>} {result.detail && (
<p class="text-xs text-gray-400" innerHTML={result.detail} />
)}
</div> </div>
); );
}, },

View File

@ -38,7 +38,10 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier); const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier);
// 固定数字总是保留 // 固定数字总是保留
const finalKeptRolls = [...keptRolls, ...allRolls.filter((r) => r.sides === 0)]; const finalKeptRolls = [
...keptRolls,
...allRolls.filter((r) => r.sides === 0),
];
// 计算小计 // 计算小计
const subtotal = finalKeptRolls.reduce((sum, roll) => { const subtotal = finalKeptRolls.reduce((sum, roll) => {
@ -59,7 +62,7 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
*/ */
export function applyModifier( export function applyModifier(
rolls: DieResult[], rolls: DieResult[],
modifier: DicePool["modifier"] modifier: DicePool["modifier"],
): { keptRolls: DieResult[]; droppedRolls: DieResult[] } { ): { keptRolls: DieResult[]; droppedRolls: DieResult[] } {
if (!modifier) { if (!modifier) {
return { keptRolls: rolls, droppedRolls: [] }; return { keptRolls: rolls, droppedRolls: [] };
@ -70,7 +73,8 @@ export function applyModifier(
switch (modifier.type) { switch (modifier.type) {
case "kh": // Keep Highest - 保留最大的 N 个 case "kh": // Keep Highest - 保留最大的 N 个
case "dh": { // Drop Highest - 丢弃最大的 N 个 case "dh": {
// Drop Highest - 丢弃最大的 N 个
const count = modifier.count; const count = modifier.count;
const higher = sorted.slice(-count); const higher = sorted.slice(-count);
const rest = sorted.slice(0, -count); const rest = sorted.slice(0, -count);
@ -81,7 +85,8 @@ export function applyModifier(
} }
} }
case "kl": // Keep Lowest - 保留最小的 N 个 case "kl": // Keep Lowest - 保留最小的 N 个
case "dl": { // Drop Lowest - 丢弃最小的 N 个 case "dl": {
// Drop Lowest - 丢弃最小的 N 个
const count = modifier.count; const count = modifier.count;
const lower = sorted.slice(0, count); const lower = sorted.slice(0, count);
const rest = sorted.slice(count); const rest = sorted.slice(count);
@ -105,29 +110,35 @@ export function roll(formula: string): RollResult {
const total = poolResults.reduce((sum, result) => sum + result.subtotal, 0); const total = poolResults.reduce((sum, result) => sum + result.subtotal, 0);
// 生成详细表达式 - 使用 HTML 格式 // Generate detail expressions (both HTML and plain text)
const rollSpans: string[] = []; const htmlSpans: string[] = [];
const textSpans: string[] = [];
for (const result of poolResults) { for (const result of poolResults) {
// 构建骰子结果 HTML
for (const roll of result.rolls) { for (const roll of result.rolls) {
const isKept = result.keptRolls.some((kept) => kept === roll); const isKept = result.keptRolls.some((kept) => kept === roll);
if (roll.sides === 0) { if (roll.sides === 0) {
// 固定数字,不显示括号 const sign = roll.isNegative ? "-" : "";
rollSpans.push(`<strong>${roll.isNegative ? '-' : ''}${roll.value}</strong>`); htmlSpans.push(`<strong>${sign}${roll.value}</strong>`);
textSpans.push(`${sign}${roll.value}`);
} else if (isKept) { } else if (isKept) {
rollSpans.push(`<strong>[${roll.value}]</strong>`); htmlSpans.push(`<strong>[${roll.value}]</strong>`);
textSpans.push(`[${roll.value}]`);
} else { } else {
rollSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`); htmlSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
textSpans.push(`[${roll.value}]`);
} }
} }
} }
const rollsStr = rollSpans.join(" + "); const rollHtml = htmlSpans.join(" + ");
const detail = `<strong>${total}</strong> = ${rollsStr}`; const rollText = textSpans.join(" + ");
const detail = `<strong>${total}</strong> = ${rollHtml}`;
const plainDetail = `${total} = ${rollText}`;
return { return {
pools: poolResults, pools: poolResults,
total, total,
detail, detail,
plainDetail,
}; };
} }

View File

@ -14,8 +14,8 @@
* *
*/ */
export interface DieResult { export interface DieResult {
sides: number; // 骰子面数0 表示固定数字 sides: number; // 骰子面数0 表示固定数字
value: number; // 掷骰结果 value: number; // 掷骰结果
isNegative: boolean; // 是否为负数 isNegative: boolean; // 是否为负数
} }
@ -23,9 +23,9 @@ export interface DieResult {
* *
*/ */
export interface DicePool { export interface DicePool {
dice: DieResult[]; // 骰子列表(未掷骰前为模板) dice: DieResult[]; // 骰子列表(未掷骰前为模板)
modifier?: { modifier?: {
type: 'kh' | 'kl' | 'dh' | 'dl'; type: "kh" | "kl" | "dh" | "dl";
count: number; count: number;
}; };
isNegative: boolean; // 整个骰池是否为负 isNegative: boolean; // 整个骰池是否为负
@ -35,31 +35,32 @@ export interface DicePool {
* *
*/ */
export interface RollResult { export interface RollResult {
pools: DicePoolResult[]; // 各骰池结果 pools: DicePoolResult[]; // 各骰池结果
total: number; // 总和 total: number; // 总和
detail: string; // 详细表达式 detail: string; // 详细表达式 (HTML)
plainDetail: string; // 详细表达式 (plain text)
} }
/** /**
* *
*/ */
export interface DicePoolResult { export interface DicePoolResult {
pool: DicePool; // 原始骰池定义 pool: DicePool; // 原始骰池定义
rolls: DieResult[]; // 掷骰结果 rolls: DieResult[]; // 掷骰结果
keptRolls: DieResult[]; // 保留的骰子(应用修饰符后) keptRolls: DieResult[]; // 保留的骰子(应用修饰符后)
droppedRolls: DieResult[]; // 丢弃的骰子 droppedRolls: DieResult[]; // 丢弃的骰子
subtotal: number; // 该骰池小计 subtotal: number; // 该骰池小计
} }
/** /**
* *
*/ */
export type Token = export type Token =
| { type: 'number'; value: number } | { type: "number"; value: number }
| { type: 'dice'; count: number; sides: number } | { type: "dice"; count: number; sides: number }
| { type: 'pool_start' } | { type: "pool_start" }
| { type: 'pool_end' } | { type: "pool_end" }
| { type: 'comma' } | { type: "comma" }
| { type: 'plus' } | { type: "plus" }
| { type: 'minus' } | { type: "minus" }
| { type: 'modifier'; modType: 'kh' | 'kl' | 'dh' | 'dl'; count: number }; | { type: "modifier"; modType: "kh" | "kl" | "dh" | "dl"; count: number };

View File

@ -5,6 +5,7 @@ export interface DiceRollerResult {
result: { result: {
total: number; total: number;
detail: string; detail: string;
plainDetail: string;
pools: Array<{ pools: Array<{
rolls: number[]; rolls: number[];
keptRolls: number[]; keptRolls: number[];
@ -38,6 +39,7 @@ export function rollFormula(formula: string): DiceRollerResult {
result: { result: {
total: result.total, total: result.total,
detail: result.detail, detail: result.detail,
plainDetail: result.plainDetail,
pools: result.pools.map((pool) => ({ pools: result.pools.map((pool) => ({
rolls: pool.rolls.map((r) => r.value), rolls: pool.rolls.map((r) => r.value),
keptRolls: pool.keptRolls.map((r) => r.value), keptRolls: pool.keptRolls.map((r) => r.value),
@ -52,6 +54,7 @@ export function rollFormula(formula: string): DiceRollerResult {
result: { result: {
total: 0, total: 0,
detail: "", detail: "",
plainDetail: "",
pools: [], pools: [],
}, },
success: false, success: false,
@ -64,7 +67,10 @@ export function rollFormula(formula: string): DiceRollerResult {
* HTML * HTML
* [1] [2] [3] = <strong>6</strong> * [1] [2] [3] = <strong>6</strong>
*/ */
export function rollSimple(formula: string): { text: string; isHtml?: boolean } { export function rollSimple(formula: string): {
text: string;
isHtml?: boolean;
} {
try { try {
const result = rollDice(formula); const result = rollDice(formula);
return { return {

View File

@ -1,6 +1,6 @@
import { customElement, noShadowDOM } from "solid-element"; import { customElement, noShadowDOM } from "solid-element";
import { createSignal, onMount } from "solid-js"; import { createSignal, onMount } from "solid-js";
import {rollFormula} from "./md-commander/hooks"; import { rollFormula } from "./md-commander/hooks";
export interface DiceProps { export interface DiceProps {
key?: string; key?: string;
@ -57,9 +57,9 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
const handleRoll = (e: MouseEvent) => { const handleRoll = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
// 点击骰子图标:总是重 roll // 点击骰子图标:总是重 roll
const rollResult = rollFormula(formula).result; const rollResult = rollFormula(formula);
setResult(rollResult.total); setResult(rollResult.result.total);
setRollDetail(rollResult.detail); setRollDetail(rollResult.result.plainDetail);
setIsRolled(true); setIsRolled(true);
if (effectiveKey()) { if (effectiveKey()) {
setDiceResultToUrl(effectiveKey(), rollResult.total); setDiceResultToUrl(effectiveKey(), rollResult.total);