/**
* Breakdown rendering with consumer-supplied markers.
*
* `RollResult.rendered` bakes one markdown dialect into a string. This module
* rebuilds the same breakdown from `RollResult.parts`, letting the caller
* decide how each die is marked — HTML spans, ANSI codes, Telegram
* MarkdownV2, or nothing at all. Import it from `roll-parser/render`.
*
* @module render
*/
import { joinModifierCode } from './notation.js';
import type {
DieResult,
KeepDropSpec,
ResolvedComparePoint,
ResolvedCritThreshold,
RollPart,
RollResult,
} from './types.js';
import { DegreeOfSuccess } from './types.js';
/**
* Per-die markers. Every slot is optional; an omitted slot leaves the die's
* text untouched, so `{}` renders a breakdown with no markup whatsoever.
*
* `text` arrives as the die's value already wrapped by any inner mark, and
* composition order is fixed: `critical` then `fumble` innermost, then
* exactly one of `dropped`, `success`, or `failure` outermost — matching the
* priority `RollResult.rendered` uses, where a dropped die is never also
* shown as a success.
*
* @example A die that is both critical and dropped
* ```typescript
* { critical: (_die, text) => `${text}`, dropped: (_die, text) => `${text}` }
* // renders 20
* ```
*
* @category Rendering
*/
export type DieMarks = {
/** Excluded from the total by `kh`/`kl`/`dh`/`dl`, a reroll, or group selection. */
dropped?: (die: DieResult, text: string) => string;
/** Met a success-count threshold. */
success?: (die: DieResult, text: string) => string;
/** Met a failure threshold. */
failure?: (die: DieResult, text: string) => string;
/** `DieResult.critical` — the default rule or an explicit `cs` threshold. */
critical?: (die: DieResult, text: string) => string;
/** `DieResult.fumble` — the default rule or an explicit `cf` threshold. */
fumble?: (die: DieResult, text: string) => string;
/**
* Wraps a whole sub-roll dropped by group selection (`{1d8, 1d10}kh1`).
*
* Inside it, `dropped`, `success`, and `failure` are suppressed — the
* wrapper already carries the verdict, and marking a dropped die inside a
* dropped sub-roll says nothing extra. `critical` and `fumble` still apply:
* they describe the face, not the selection. A nested `droppedGroup` also
* survives, so `{{1d6, 1d8}kh1, 1d10}kh1` can wrap twice.
*/
droppedGroup?: (inner: string) => string;
};
/**
* The marks `RollResult.rendered` itself uses. Applied when
* {@link renderBreakdown} is called without a `marks` argument; spread it to
* override one slot while keeping the rest markdown.
*
* @category Rendering
*/
export const MARKDOWN_MARKS: DieMarks = {
dropped: (_die, text) => `~~${text}~~`,
success: (_die, text) => `**${text}**`,
failure: (_die, text) => `__${text}__`,
droppedGroup: (inner) => `~~${inner}~~`,
};
const KEEP_DROP_CODES = {
keep: { highest: 'kh', lowest: 'kl' },
drop: { highest: 'dh', lowest: 'dl' },
} as const;
const EXPLODE_MARKERS = {
standard: '!',
compound: '!!',
penetrating: '!p',
} as const;
const DEGREE_LABELS: Record = {
[DegreeOfSuccess.CriticalFailure]: 'Critical Failure',
[DegreeOfSuccess.Failure]: 'Failure',
[DegreeOfSuccess.Success]: 'Success',
[DegreeOfSuccess.CriticalSuccess]: 'Critical Success',
};
/** Bare identifier grammar; anything else was written `@{like this}`. */
const BARE_VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function comparePointCode(point: ResolvedComparePoint): string {
return `${point.operator}${point.value}`;
}
function critCode(prefix: 'cs' | 'cf', threshold: ResolvedCritThreshold): string {
return threshold === 'default' ? prefix : `${prefix}${comparePointCode(threshold)}`;
}
function keepDropCode(spec: KeepDropSpec): string {
return `${KEEP_DROP_CODES[spec.kind][spec.selector]}${spec.count}`;
}
/** `'='` is elided because bare `fN` parses back to it — `f3`, `f<3`, `f>=3`. */
function failCode(point: ResolvedComparePoint): string {
return point.operator === '=' ? `f${point.value}` : `f${comparePointCode(point)}`;
}
/**
* Rebuilds the normalized expression a part contributes to
* `RollResult.expression` — the prefix every dice bracket hangs off.
*
* Meta-expressions are already resolved to numbers here, so `4d6kh(1d2)`
* comes back as `4d6kh1`, exactly as the evaluator spells it.
*/
function expr(part: RollPart): string {
switch (part.type) {
case 'literal':
case 'variable':
return String(part.value);
case 'dice':
return `${part.count}d${part.sides}`;
case 'fateDice':
return `${part.count}dF`;
case 'grouped':
return `(${expr(part.inner)})`;
case 'binaryOp':
return `${expr(part.left)} ${part.operator} ${expr(part.right)}`;
case 'unaryOp':
return `-${expr(part.operand)}`;
case 'group':
return `{${part.parts.map(expr).join(', ')}}`;
case 'functionCall':
return `${part.name}(${part.args.map(expr).join(', ')})`;
case 'keepDrop':
return joinModifierCode(expr(part.target), part.specs.map(keepDropCode).join(''));
case 'explode':
return `${expr(part.target)}${explodeCode(part)}`;
case 'reroll':
return joinModifierCode(expr(part.target), rerollCode(part));
case 'dieBound':
return joinModifierCode(expr(part.target), dieBoundCode(part));
case 'sort':
return joinModifierCode(expr(part.target), part.order === 'ascending' ? 's' : 'sd');
case 'critThreshold':
return joinModifierCode(expr(part.target), critThresholdCode(part));
case 'successCount':
return `${expr(part.target)}${successCountCode(part)}`;
case 'versus':
return `${expr(part.roll)} vs ${expr(part.dc)}`;
}
}
function explodeCode(part: Extract): string {
const marker = EXPLODE_MARKERS[part.variant];
return part.threshold == null ? marker : `${marker}${comparePointCode(part.threshold)}`;
}
function rerollCode(part: Extract): string {
return `${part.once ? 'ro' : 'r'}${comparePointCode(part.condition)}`;
}
/** Negative bounds are parenthesized so the expression re-parses. */
function dieBoundCode(part: Extract): string {
return part.value < 0 ? `${part.bound}(${part.value})` : `${part.bound}${part.value}`;
}
function critThresholdCode(part: Extract): string {
return [
...part.successThresholds.map((threshold) => critCode('cs', threshold)),
...part.failThresholds.map((threshold) => critCode('cf', threshold)),
].reduce(joinModifierCode, '');
}
function successCountCode(part: Extract): string {
const fail = part.failThreshold == null ? '' : failCode(part.failThreshold);
return `${comparePointCode(part.threshold)}${fail}`;
}
/**
* Collects the dice a part's subtree produced, in evaluation order.
*
* `sort`, `explode`, `reroll`, and `successCount` carry their own pool and
* stop the descent: a sorted pool is reordered, and an exploded or rerolled
* one holds dice that exist nowhere under `target`.
*/
function collectDice(part: RollPart, out: DieResult[]): void {
switch (part.type) {
case 'dice':
case 'fateDice':
case 'sort':
case 'explode':
case 'reroll':
case 'successCount':
for (const die of part.rolls) out.push(die);
return;
case 'grouped':
collectDice(part.inner, out);
return;
case 'unaryOp':
collectDice(part.operand, out);
return;
case 'binaryOp':
collectDice(part.left, out);
collectDice(part.right, out);
return;
case 'keepDrop':
case 'dieBound':
case 'critThreshold':
collectDice(part.target, out);
return;
case 'group':
for (const sub of part.parts) collectDice(sub, out);
return;
case 'functionCall':
for (const arg of part.args) collectDice(arg, out);
return;
case 'versus':
collectDice(part.roll, out);
collectDice(part.dc, out);
return;
case 'literal':
case 'variable':
return;
}
}
function poolOf(part: RollPart): DieResult[] {
const dice: DieResult[] = [];
collectDice(part, dice);
return dice;
}
/**
* Marks one die. `plain` suppresses the three state marks inside a dropped
* sub-roll — the rule {@link DieMarks.droppedGroup} documents.
*/
function markDie(die: DieResult, marks: DieMarks, plain: boolean): string {
let text = String(die.result);
if (die.critical) text = marks.critical?.(die, text) ?? text;
if (die.fumble) text = marks.fumble?.(die, text) ?? text;
if (plain) return text;
const { modifiers } = die;
if (modifiers.includes('dropped')) return marks.dropped?.(die, text) ?? text;
if (modifiers.includes('success')) return marks.success?.(die, text) ?? text;
if (modifiers.includes('failure')) return marks.failure?.(die, text) ?? text;
return text;
}
/** `'meta'` dice were rolled to resolve a parameter — they are never shown. */
function renderPool(dice: readonly DieResult[], marks: DieMarks, plain: boolean): string {
const shown: string[] = [];
for (const die of dice) {
if (die.modifiers.includes('meta')) continue;
shown.push(markDie(die, marks, plain));
}
return `[${shown.join(', ')}]`;
}
/**
* A modifier renders as ``, replacing whatever
* bracket the target would have shown on its own. An empty pool means the
* target rolled nothing at all, and the bracket is dropped with it.
*/
function renderModifier(
target: RollPart,
code: string,
dice: readonly DieResult[],
marks: DieMarks,
plain: boolean,
): string {
const pool = dice.length === 0 ? '' : renderPool(dice, marks, plain);
return `${joinModifierCode(expr(target), code)}${pool}`;
}
function renderPart(part: RollPart, marks: DieMarks, plain: boolean): string {
switch (part.type) {
case 'literal':
return String(part.value);
case 'variable': {
const display = BARE_VARIABLE.test(part.name) ? `@${part.name}` : `@{${part.name}}`;
return `${display}[${part.value}]`;
}
case 'dice':
case 'fateDice':
return `${expr(part)}${renderPool(part.rolls, marks, plain)}`;
case 'grouped':
return `(${renderPart(part.inner, marks, plain)})`;
case 'binaryOp':
return `${renderPart(part.left, marks, plain)} ${part.operator} ${renderPart(part.right, marks, plain)}`;
case 'unaryOp':
return `-${renderPart(part.operand, marks, plain)}`;
case 'functionCall': {
const args = part.args.map((arg) => renderPart(arg, marks, plain)).join(', ');
return `${part.name}(${args})`;
}
case 'versus':
return `${renderPart(part.roll, marks, plain)} vs ${renderPart(part.dc, marks, plain)}`;
case 'group':
return renderGroup(part, marks, plain);
case 'keepDrop':
// Sub-roll selection renders through the group, which strikes whole
// sub-rolls; only the flat-pool form collapses into one bracket.
return part.target.type === 'group' && part.target.keptIndices != null
? renderPart(part.target, marks, plain)
: `${expr(part.target)}${renderPool(poolOf(part.target), marks, plain)}`;
case 'explode':
return renderModifier(part.target, explodeCode(part), part.rolls, marks, plain);
case 'reroll':
return renderModifier(part.target, rerollCode(part), part.rolls, marks, plain);
case 'successCount':
// Subtotal counting renders through the group, which shows each sub-roll's
// own dice; only the flat form collapses into one bracket.
return part.target.type === 'group' && part.target.parts.length >= 2
? `${renderPart(part.target, marks, plain)}${successCountCode(part)}`
: renderModifier(part.target, successCountCode(part), part.rolls, marks, plain);
// These three render as their own normalized expression plus a pool.
case 'dieBound':
return `${expr(part)}${renderPool(poolOf(part.target), marks, plain)}`;
case 'sort':
return `${expr(part)}${renderPool(part.rolls, marks, plain)}`;
case 'critThreshold':
return `${expr(part)}${renderPool(poolOf(part.target), marks, plain)}`;
}
}
function renderGroup(
part: Extract,
marks: DieMarks,
plain: boolean,
): string {
const { keptIndices } = part;
const kept = keptIndices == null ? null : new Set(keptIndices);
const subRolls = part.parts.map((sub, index) => {
if (kept == null || kept.has(index)) {
return renderPart(sub, marks, plain);
}
const inner = renderPart(sub, marks, true);
return marks.droppedGroup?.(inner) ?? inner;
});
return `{${subRolls.join(', ')}}`;
}
/**
* Rebuilds a roll's breakdown from `RollResult.parts`, applying `marks` to
* every die.
*
* With no `marks` the output is byte-identical to `RollResult.rendered` — a
* property test pins that over generated notation. Pass any object to take
* over: omitted slots render plain, so `{}` strips markup entirely and
* `{ ...MARKDOWN_MARKS, critical: … }` keeps the rest of the markdown.
*
* The trailing `= ` is included, and becomes the degree label for a
* `vs` roll, exactly as `rendered` does.
*
* @param result - A finished result from `roll` or `evaluate`
* @param marks - Per-die markers; defaults to {@link MARKDOWN_MARKS}
* @returns The rendered breakdown
*
* @example
* ```typescript
* import { roll } from 'roll-parser';
* import { renderBreakdown } from 'roll-parser/render';
* import { createMockRng } from 'roll-parser/testing';
*
* const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });
*
* renderBreakdown(result); // '4d6[3, 6, ~~2~~, 5] = 14'
* renderBreakdown(result, {}); // '4d6[3, 6, 2, 5] = 14'
* renderBreakdown(result, {
* dropped: (_die, text) => `${text}`,
* critical: (_die, text) => `${text}`,
* }); // '4d6[3, 6, 2, 5] = 14'
* ```
*
* @category Rendering
*/
export function renderBreakdown(result: RollResult, marks: DieMarks = MARKDOWN_MARKS): string {
const trailing = result.degree == null ? String(result.total) : DEGREE_LABELS[result.degree];
return `${renderPart(result.parts, marks, false)} = ${trailing}`;
}