roll-parser 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/MIGRATION.md +293 -4
  3. package/README.md +246 -55
  4. package/dist/cli/format.d.ts.map +1 -1
  5. package/dist/cli/format.js +8 -7
  6. package/dist/cli/format.js.map +1 -1
  7. package/dist/evaluator/die.d.ts +2 -1
  8. package/dist/evaluator/die.d.ts.map +1 -1
  9. package/dist/evaluator/die.js.map +1 -1
  10. package/dist/evaluator/env.d.ts +57 -0
  11. package/dist/evaluator/env.d.ts.map +1 -1
  12. package/dist/evaluator/env.js.map +1 -1
  13. package/dist/evaluator/evaluator.d.ts.map +1 -1
  14. package/dist/evaluator/evaluator.js +104 -56
  15. package/dist/evaluator/evaluator.js.map +1 -1
  16. package/dist/evaluator/modifiers/crit-threshold.d.ts +43 -5
  17. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  18. package/dist/evaluator/modifiers/crit-threshold.js +24 -11
  19. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -1
  20. package/dist/evaluator/modifiers/die-bound.d.ts +5 -2
  21. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -1
  22. package/dist/evaluator/modifiers/die-bound.js +6 -3
  23. package/dist/evaluator/modifiers/die-bound.js.map +1 -1
  24. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  25. package/dist/evaluator/modifiers/explode.js +13 -5
  26. package/dist/evaluator/modifiers/explode.js.map +1 -1
  27. package/dist/evaluator/modifiers/flags.d.ts +17 -0
  28. package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
  29. package/dist/evaluator/modifiers/flags.js +4 -4
  30. package/dist/evaluator/modifiers/flags.js.map +1 -1
  31. package/dist/evaluator/modifiers/keep-drop.d.ts +5 -5
  32. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  33. package/dist/evaluator/modifiers/keep-drop.js +7 -7
  34. package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
  35. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  36. package/dist/evaluator/modifiers/reroll.js +16 -10
  37. package/dist/evaluator/modifiers/reroll.js.map +1 -1
  38. package/dist/evaluator/modifiers/sort.d.ts +1 -1
  39. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/sort.js +3 -3
  41. package/dist/evaluator/modifiers/sort.js.map +1 -1
  42. package/dist/evaluator/modifiers/success-count.d.ts +6 -2
  43. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  44. package/dist/evaluator/modifiers/success-count.js +9 -5
  45. package/dist/evaluator/modifiers/success-count.js.map +1 -1
  46. package/dist/notation.d.ts +13 -0
  47. package/dist/notation.d.ts.map +1 -0
  48. package/dist/notation.js +8 -0
  49. package/dist/notation.js.map +1 -0
  50. package/dist/parser/ast.d.ts +12 -7
  51. package/dist/parser/ast.d.ts.map +1 -1
  52. package/dist/parser/ast.js.map +1 -1
  53. package/dist/parser/guards.d.ts +38 -0
  54. package/dist/parser/guards.d.ts.map +1 -1
  55. package/dist/parser/guards.js +51 -0
  56. package/dist/parser/guards.js.map +1 -1
  57. package/dist/parser/parser.d.ts.map +1 -1
  58. package/dist/parser/parser.js +26 -2
  59. package/dist/parser/parser.js.map +1 -1
  60. package/dist/render.d.ts +95 -0
  61. package/dist/render.d.ts.map +1 -0
  62. package/dist/render.js +232 -0
  63. package/dist/render.js.map +1 -0
  64. package/dist/types.d.ts +64 -12
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/package.json +14 -4
  70. package/src/cli/format.ts +15 -23
  71. package/src/evaluator/die.ts +2 -1
  72. package/src/evaluator/env.ts +58 -0
  73. package/src/evaluator/evaluator.ts +192 -70
  74. package/src/evaluator/modifiers/crit-threshold.ts +81 -15
  75. package/src/evaluator/modifiers/die-bound.ts +13 -4
  76. package/src/evaluator/modifiers/explode.ts +21 -6
  77. package/src/evaluator/modifiers/flags.ts +21 -4
  78. package/src/evaluator/modifiers/keep-drop.ts +10 -8
  79. package/src/evaluator/modifiers/reroll.ts +20 -11
  80. package/src/evaluator/modifiers/sort.ts +9 -3
  81. package/src/evaluator/modifiers/success-count.ts +27 -5
  82. package/src/notation.ts +24 -0
  83. package/src/parser/ast.ts +12 -7
  84. package/src/parser/guards.ts +92 -1
  85. package/src/parser/parser.ts +56 -1
  86. package/src/render.ts +394 -0
  87. package/src/types.ts +64 -12
  88. package/src/version.ts +1 -1
@@ -61,7 +61,7 @@ export function unwrapAllTransparent(node: ASTNode): ASTNode {
61
61
  * Returns `true` when `node` or any descendant satisfies `isHit`. Recurses
62
62
  * directly through the entire node vocabulary: arithmetic operands,
63
63
  * modifier-chain targets, `Versus` sides, function arguments, and group
64
- * sub-expressions. This is the shared driver behind the four deep walkers
64
+ * sub-expressions. This is the shared driver behind the deep walkers
65
65
  * below. The shallow walkers (`containsDicePool`, `containsFatePool`) stay
66
66
  * hand-written because their rejection semantics deliberately stop at
67
67
  * arithmetic boundaries.
@@ -112,6 +112,9 @@ const isDicePoolHit = (current: ASTNode): boolean =>
112
112
 
113
113
  const isFateDiceHit = (current: ASTNode): boolean => current.type === 'FateDice';
114
114
 
115
+ const isDiceHit = (current: ASTNode): boolean =>
116
+ current.type === 'Dice' || current.type === 'FateDice';
117
+
115
118
  const isMultiSubGroupHit = (current: ASTNode): boolean =>
116
119
  current.type === 'Group' && current.expressions.length >= 2;
117
120
 
@@ -168,6 +171,94 @@ export function deepContainsDicePool(node: ASTNode): boolean {
168
171
  return someDescendant(node, isDicePoolHit);
169
172
  }
170
173
 
174
+ /**
175
+ * Returns `true` when this node's total provably equals the sum of its kept
176
+ * dice faces: a pool, a chain of pool modifiers over one, or an addition of
177
+ * those. A multi-sub-roll `Group` qualifies when every sub-roll does, since its
178
+ * total is their sum and `evalGroupKeepDrop` re-flags dropped sub-rolls' dice.
179
+ *
180
+ * Guards the single-sub-roll `Group` keep/drop target, whose flat-pool path
181
+ * totals `sumKeptDice`. Anything else in there — a literal, a subtracted or
182
+ * scaled dice term, a function call, a success count — contributes to the
183
+ * group's own total but not to that face sum, so `{2d6+3}kh2` would lose the
184
+ * `+3` and `{2d6-1d4}kh3` would flip the `1d4` from `-3` to `+3` while dropping
185
+ * nothing.
186
+ */
187
+ export function sumsToKeptFaces(node: ASTNode): boolean {
188
+ switch (node.type) {
189
+ case 'Dice':
190
+ case 'FateDice':
191
+ return true;
192
+ case 'KeepDrop':
193
+ case 'Explode':
194
+ case 'Reroll':
195
+ case 'DieBound':
196
+ case 'Sort':
197
+ case 'CritThreshold':
198
+ return sumsToKeptFaces(node.target);
199
+ case 'Grouped':
200
+ return sumsToKeptFaces(node.expression);
201
+ case 'Group':
202
+ return node.expressions.every(sumsToKeptFaces);
203
+ case 'BinaryOp':
204
+ return node.operator === '+' && sumsToKeptFaces(node.left) && sumsToKeptFaces(node.right);
205
+ default:
206
+ return false;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Returns `true` when comparing this node's dice one face at a time is faithful
212
+ * to what it totals. Shaped like `sumsToKeptFaces` but answering a different
213
+ * question, so the two differ on three node types:
214
+ *
215
+ * - `SuccessCount` passes — a nested count re-scores the very same faces.
216
+ * - `Versus` looks only at its roll side — no pool pass ever counts a DC.
217
+ * - a multi-sub-roll `Group` fails everywhere, because its units are subtotals.
218
+ * `sumsToKeptFaces` accepts one, a sum of subtotals still being a face sum.
219
+ *
220
+ * Guards the flat success-count path, whose units are individual dice. The
221
+ * subtotal path takes a direct multi-sub-roll `Group` before this is consulted.
222
+ */
223
+ export function countsPerDie(node: ASTNode): boolean {
224
+ switch (node.type) {
225
+ case 'Dice':
226
+ case 'FateDice':
227
+ return true;
228
+ case 'KeepDrop':
229
+ case 'Explode':
230
+ case 'Reroll':
231
+ case 'DieBound':
232
+ case 'Sort':
233
+ case 'CritThreshold':
234
+ case 'SuccessCount':
235
+ return countsPerDie(node.target);
236
+ case 'Versus':
237
+ return countsPerDie(node.roll);
238
+ case 'Grouped':
239
+ return countsPerDie(node.expression);
240
+ case 'Group':
241
+ return node.expressions.length === 1 && node.expressions.every(countsPerDie);
242
+ case 'BinaryOp':
243
+ return node.operator === '+' && countsPerDie(node.left) && countsPerDie(node.right);
244
+ default:
245
+ return false;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Deep-walks a node to find any descendant `Dice` or `FateDice`. Unlike
251
+ * `deepContainsDicePool`, a multi-sub-roll `Group` is not a hit in its own
252
+ * right — only real dice are.
253
+ *
254
+ * Used by the success-count parser guard, which tallies individual dice rather
255
+ * than subtotals: a literal-only group satisfies `containsDicePool` yet rolls
256
+ * nothing to tally.
257
+ */
258
+ export function containsDice(node: ASTNode): boolean {
259
+ return someDescendant(node, isDiceHit);
260
+ }
261
+
171
262
  /**
172
263
  * Returns `true` if the pool this node resolves to is (or wraps) a `FateDice`
173
264
  * pool. Walks through chained pool modifiers (`KeepDrop` / `Explode` /
@@ -32,11 +32,14 @@ import type {
32
32
  } from './ast.js';
33
33
  import { isCritThreshold, isSuccessCount } from './ast.js';
34
34
  import {
35
+ containsDice,
35
36
  containsDicePool,
36
37
  containsFatePool,
37
38
  containsMultiSubGroup,
38
39
  containsVersus,
40
+ countsPerDie,
39
41
  deepContainsDicePool,
42
+ sumsToKeptFaces,
40
43
  unwrapAllTransparent,
41
44
  unwrapGrouped,
42
45
  } from './guards.js';
@@ -767,6 +770,39 @@ export class Parser {
767
770
  );
768
771
  }
769
772
 
773
+ // ! A single-sub-roll Group is the flat-pool escape hatch, so `containsDicePool`
774
+ // ! deep-walks the arithmetic that `(1d6+5)kh1` rejects outright — but the flat
775
+ // ! path totals `sumKeptDice`, faces only. `{2d6+3}kh2` and `{2d6-1d4}kh3` are
776
+ // ! exactly the drop that reject exists to prevent.
777
+ const base = unwrapAllTransparent(target);
778
+ if (base.type === 'Group' && base.expressions.length === 1) {
779
+ const inner = base.expressions[0];
780
+ if (inner != null) {
781
+ // Single-sub Groups hide a count from the shallow reject at the top of this
782
+ // method, so peel them to any depth and `{4d6>=5}kh1`, `{{4d6>=5}}kh1`, and
783
+ // `(4d6>=5)kh1` all report one code. Peeling only picks which error the
784
+ // caller sees — `sumsToKeptFaces` below refuses a count either way.
785
+ let innermost = inner;
786
+ while (true) {
787
+ const peeled = unwrapGrouped(innermost);
788
+ if (peeled.type !== 'Group' || peeled.expressions.length !== 1) break;
789
+ const next = peeled.expressions[0];
790
+ if (next == null) break;
791
+ innermost = next;
792
+ }
793
+ this.rejectSuccessCountTarget(innermost, token);
794
+
795
+ if (!sumsToKeptFaces(inner)) {
796
+ throw new ParseError(
797
+ `Keep/drop on a single-sub-roll group requires added dice terms only`,
798
+ 'INVALID_KEEP_DROP_TARGET',
799
+ token.position,
800
+ token,
801
+ );
802
+ }
803
+ }
804
+ }
805
+
770
806
  const kind =
771
807
  token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
772
808
 
@@ -1087,7 +1123,12 @@ export class Parser {
1087
1123
  // Success counting reads a raw pool, so arithmetic or composition wrappers
1088
1124
  // (`1>=3`, `(1+2)>=3`, `(1d6*2)>=10`, `(1d20 vs 15)>=1`) would be silently
1089
1125
  // ignored.
1090
- if (!containsDicePool(target)) {
1126
+ //
1127
+ // ! Both checks are load-bearing. `containsDicePool` rejects those wrappers
1128
+ // ! despite the dice; `containsDice` rejects a group holding none
1129
+ // ! (`{3, 5, 7}>=4`), which the multi-sub-roll rule accepts — that rule is
1130
+ // ! written for keep/drop, whose units are subtotals, not dice.
1131
+ if (!containsDicePool(target) || !containsDice(target)) {
1091
1132
  throw new ParseError(
1092
1133
  `Success counting requires a dice pool target`,
1093
1134
  'INVALID_SUCCESS_COUNT_TARGET',
@@ -1096,6 +1137,20 @@ export class Parser {
1096
1137
  );
1097
1138
  }
1098
1139
 
1140
+ // ! A multi-sub-roll group is counted by subtotal, and only `evalSuccessCount`
1141
+ // ! knows how — it needs the group as its direct target. Reached any other way
1142
+ // ! (`{{2d6, 2d8}}>=4`, `({2d6, 2d8})>=4`, `{2d6, 2d8}kh1>=4`) the subtotals are
1143
+ // ! gone by the time the count runs, leaving loose faces to compare.
1144
+ const isSubtotalGroup = target.type === 'Group' && target.expressions.length >= 2;
1145
+ if (!isSubtotalGroup && !countsPerDie(target)) {
1146
+ throw new ParseError(
1147
+ `Success counting requires a target whose dice can be counted one face at a time`,
1148
+ 'INVALID_SUCCESS_COUNT_TARGET',
1149
+ token.position,
1150
+ token,
1151
+ );
1152
+ }
1153
+
1099
1154
  const operator = this.getCompareOp(token);
1100
1155
  // Threshold binds at `BP.DICE_LEFT` — see `parseComparePoint` TSDoc.
1101
1156
  const value = this.parseExpression(BP.DICE_LEFT);
package/src/render.ts ADDED
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Breakdown rendering with consumer-supplied markers.
3
+ *
4
+ * `RollResult.rendered` bakes one markdown dialect into a string. This module
5
+ * rebuilds the same breakdown from `RollResult.parts`, letting the caller
6
+ * decide how each die is marked — HTML spans, ANSI codes, Telegram
7
+ * MarkdownV2, or nothing at all. Import it from `roll-parser/render`.
8
+ *
9
+ * @module render
10
+ */
11
+
12
+ import { joinModifierCode } from './notation.js';
13
+ import type {
14
+ DieResult,
15
+ KeepDropSpec,
16
+ ResolvedComparePoint,
17
+ ResolvedCritThreshold,
18
+ RollPart,
19
+ RollResult,
20
+ } from './types.js';
21
+ import { DegreeOfSuccess } from './types.js';
22
+
23
+ /**
24
+ * Per-die markers. Every slot is optional; an omitted slot leaves the die's
25
+ * text untouched, so `{}` renders a breakdown with no markup whatsoever.
26
+ *
27
+ * `text` arrives as the die's value already wrapped by any inner mark, and
28
+ * composition order is fixed: `critical` then `fumble` innermost, then
29
+ * exactly one of `dropped`, `success`, or `failure` outermost — matching the
30
+ * priority `RollResult.rendered` uses, where a dropped die is never also
31
+ * shown as a success.
32
+ *
33
+ * @example A die that is both critical and dropped
34
+ * ```typescript
35
+ * { critical: (_die, text) => `<b>${text}</b>`, dropped: (_die, text) => `<s>${text}</s>` }
36
+ * // renders <s><b>20</b></s>
37
+ * ```
38
+ *
39
+ * @category Rendering
40
+ */
41
+ export type DieMarks = {
42
+ /** Excluded from the total by `kh`/`kl`/`dh`/`dl`, a reroll, or group selection. */
43
+ dropped?: (die: DieResult, text: string) => string;
44
+ /** Met a success-count threshold. */
45
+ success?: (die: DieResult, text: string) => string;
46
+ /** Met a failure threshold. */
47
+ failure?: (die: DieResult, text: string) => string;
48
+ /** `DieResult.critical` — the default rule or an explicit `cs` threshold. */
49
+ critical?: (die: DieResult, text: string) => string;
50
+ /** `DieResult.fumble` — the default rule or an explicit `cf` threshold. */
51
+ fumble?: (die: DieResult, text: string) => string;
52
+ /**
53
+ * Wraps a whole sub-roll dropped by group selection (`{1d8, 1d10}kh1`).
54
+ *
55
+ * Inside it, `dropped`, `success`, and `failure` are suppressed — the
56
+ * wrapper already carries the verdict, and marking a dropped die inside a
57
+ * dropped sub-roll says nothing extra. `critical` and `fumble` still apply:
58
+ * they describe the face, not the selection. A nested `droppedGroup` also
59
+ * survives, so `{{1d6, 1d8}kh1, 1d10}kh1` can wrap twice.
60
+ */
61
+ droppedGroup?: (inner: string) => string;
62
+ };
63
+
64
+ /**
65
+ * The marks `RollResult.rendered` itself uses. Applied when
66
+ * {@link renderBreakdown} is called without a `marks` argument; spread it to
67
+ * override one slot while keeping the rest markdown.
68
+ *
69
+ * @category Rendering
70
+ */
71
+ export const MARKDOWN_MARKS: DieMarks = {
72
+ dropped: (_die, text) => `~~${text}~~`,
73
+ success: (_die, text) => `**${text}**`,
74
+ failure: (_die, text) => `__${text}__`,
75
+ droppedGroup: (inner) => `~~${inner}~~`,
76
+ };
77
+
78
+ const KEEP_DROP_CODES = {
79
+ keep: { highest: 'kh', lowest: 'kl' },
80
+ drop: { highest: 'dh', lowest: 'dl' },
81
+ } as const;
82
+
83
+ const EXPLODE_MARKERS = {
84
+ standard: '!',
85
+ compound: '!!',
86
+ penetrating: '!p',
87
+ } as const;
88
+
89
+ const DEGREE_LABELS: Record<DegreeOfSuccess, string> = {
90
+ [DegreeOfSuccess.CriticalFailure]: 'Critical Failure',
91
+ [DegreeOfSuccess.Failure]: 'Failure',
92
+ [DegreeOfSuccess.Success]: 'Success',
93
+ [DegreeOfSuccess.CriticalSuccess]: 'Critical Success',
94
+ };
95
+
96
+ /** Bare identifier grammar; anything else was written `@{like this}`. */
97
+ const BARE_VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/;
98
+
99
+ function comparePointCode(point: ResolvedComparePoint): string {
100
+ return `${point.operator}${point.value}`;
101
+ }
102
+
103
+ function critCode(prefix: 'cs' | 'cf', threshold: ResolvedCritThreshold): string {
104
+ return threshold === 'default' ? prefix : `${prefix}${comparePointCode(threshold)}`;
105
+ }
106
+
107
+ function keepDropCode(spec: KeepDropSpec): string {
108
+ return `${KEEP_DROP_CODES[spec.kind][spec.selector]}${spec.count}`;
109
+ }
110
+
111
+ /** `'='` is elided because bare `fN` parses back to it — `f3`, `f<3`, `f>=3`. */
112
+ function failCode(point: ResolvedComparePoint): string {
113
+ return point.operator === '=' ? `f${point.value}` : `f${comparePointCode(point)}`;
114
+ }
115
+
116
+ /**
117
+ * Rebuilds the normalized expression a part contributes to
118
+ * `RollResult.expression` — the prefix every dice bracket hangs off.
119
+ *
120
+ * Meta-expressions are already resolved to numbers here, so `4d6kh(1d2)`
121
+ * comes back as `4d6kh1`, exactly as the evaluator spells it.
122
+ */
123
+ function expr(part: RollPart): string {
124
+ switch (part.type) {
125
+ case 'literal':
126
+ case 'variable':
127
+ return String(part.value);
128
+ case 'dice':
129
+ return `${part.count}d${part.sides}`;
130
+ case 'fateDice':
131
+ return `${part.count}dF`;
132
+ case 'grouped':
133
+ return `(${expr(part.inner)})`;
134
+ case 'binaryOp':
135
+ return `${expr(part.left)} ${part.operator} ${expr(part.right)}`;
136
+ case 'unaryOp':
137
+ return `-${expr(part.operand)}`;
138
+ case 'group':
139
+ return `{${part.parts.map(expr).join(', ')}}`;
140
+ case 'functionCall':
141
+ return `${part.name}(${part.args.map(expr).join(', ')})`;
142
+ case 'keepDrop':
143
+ return joinModifierCode(expr(part.target), part.specs.map(keepDropCode).join(''));
144
+ case 'explode':
145
+ return `${expr(part.target)}${explodeCode(part)}`;
146
+ case 'reroll':
147
+ return joinModifierCode(expr(part.target), rerollCode(part));
148
+ case 'dieBound':
149
+ return joinModifierCode(expr(part.target), dieBoundCode(part));
150
+ case 'sort':
151
+ return joinModifierCode(expr(part.target), part.order === 'ascending' ? 's' : 'sd');
152
+ case 'critThreshold':
153
+ return joinModifierCode(expr(part.target), critThresholdCode(part));
154
+ case 'successCount':
155
+ return `${expr(part.target)}${successCountCode(part)}`;
156
+ case 'versus':
157
+ return `${expr(part.roll)} vs ${expr(part.dc)}`;
158
+ }
159
+ }
160
+
161
+ function explodeCode(part: Extract<RollPart, { type: 'explode' }>): string {
162
+ const marker = EXPLODE_MARKERS[part.variant];
163
+ return part.threshold == null ? marker : `${marker}${comparePointCode(part.threshold)}`;
164
+ }
165
+
166
+ function rerollCode(part: Extract<RollPart, { type: 'reroll' }>): string {
167
+ return `${part.once ? 'ro' : 'r'}${comparePointCode(part.condition)}`;
168
+ }
169
+
170
+ /** Negative bounds are parenthesized so the expression re-parses. */
171
+ function dieBoundCode(part: Extract<RollPart, { type: 'dieBound' }>): string {
172
+ return part.value < 0 ? `${part.bound}(${part.value})` : `${part.bound}${part.value}`;
173
+ }
174
+
175
+ function critThresholdCode(part: Extract<RollPart, { type: 'critThreshold' }>): string {
176
+ return [
177
+ ...part.successThresholds.map((threshold) => critCode('cs', threshold)),
178
+ ...part.failThresholds.map((threshold) => critCode('cf', threshold)),
179
+ ].reduce(joinModifierCode, '');
180
+ }
181
+
182
+ function successCountCode(part: Extract<RollPart, { type: 'successCount' }>): string {
183
+ const fail = part.failThreshold == null ? '' : failCode(part.failThreshold);
184
+ return `${comparePointCode(part.threshold)}${fail}`;
185
+ }
186
+
187
+ /**
188
+ * Collects the dice a part's subtree produced, in evaluation order.
189
+ *
190
+ * `sort`, `explode`, `reroll`, and `successCount` carry their own pool and
191
+ * stop the descent: a sorted pool is reordered, and an exploded or rerolled
192
+ * one holds dice that exist nowhere under `target`.
193
+ */
194
+ function collectDice(part: RollPart, out: DieResult[]): void {
195
+ switch (part.type) {
196
+ case 'dice':
197
+ case 'fateDice':
198
+ case 'sort':
199
+ case 'explode':
200
+ case 'reroll':
201
+ case 'successCount':
202
+ for (const die of part.rolls) out.push(die);
203
+ return;
204
+ case 'grouped':
205
+ collectDice(part.inner, out);
206
+ return;
207
+ case 'unaryOp':
208
+ collectDice(part.operand, out);
209
+ return;
210
+ case 'binaryOp':
211
+ collectDice(part.left, out);
212
+ collectDice(part.right, out);
213
+ return;
214
+ case 'keepDrop':
215
+ case 'dieBound':
216
+ case 'critThreshold':
217
+ collectDice(part.target, out);
218
+ return;
219
+ case 'group':
220
+ for (const sub of part.parts) collectDice(sub, out);
221
+ return;
222
+ case 'functionCall':
223
+ for (const arg of part.args) collectDice(arg, out);
224
+ return;
225
+ case 'versus':
226
+ collectDice(part.roll, out);
227
+ collectDice(part.dc, out);
228
+ return;
229
+ case 'literal':
230
+ case 'variable':
231
+ return;
232
+ }
233
+ }
234
+
235
+ function poolOf(part: RollPart): DieResult[] {
236
+ const dice: DieResult[] = [];
237
+ collectDice(part, dice);
238
+ return dice;
239
+ }
240
+
241
+ /**
242
+ * Marks one die. `plain` suppresses the three state marks inside a dropped
243
+ * sub-roll — the rule {@link DieMarks.droppedGroup} documents.
244
+ */
245
+ function markDie(die: DieResult, marks: DieMarks, plain: boolean): string {
246
+ let text = String(die.result);
247
+
248
+ if (die.critical) text = marks.critical?.(die, text) ?? text;
249
+ if (die.fumble) text = marks.fumble?.(die, text) ?? text;
250
+ if (plain) return text;
251
+
252
+ const { modifiers } = die;
253
+ if (modifiers.includes('dropped')) return marks.dropped?.(die, text) ?? text;
254
+ if (modifiers.includes('success')) return marks.success?.(die, text) ?? text;
255
+ if (modifiers.includes('failure')) return marks.failure?.(die, text) ?? text;
256
+
257
+ return text;
258
+ }
259
+
260
+ /** `'meta'` dice were rolled to resolve a parameter — they are never shown. */
261
+ function renderPool(dice: readonly DieResult[], marks: DieMarks, plain: boolean): string {
262
+ const shown: string[] = [];
263
+
264
+ for (const die of dice) {
265
+ if (die.modifiers.includes('meta')) continue;
266
+ shown.push(markDie(die, marks, plain));
267
+ }
268
+
269
+ return `[${shown.join(', ')}]`;
270
+ }
271
+
272
+ /**
273
+ * A modifier renders as `<target expression><code><pool>`, replacing whatever
274
+ * bracket the target would have shown on its own. An empty pool means the
275
+ * target rolled nothing at all, and the bracket is dropped with it.
276
+ */
277
+ function renderModifier(
278
+ target: RollPart,
279
+ code: string,
280
+ dice: readonly DieResult[],
281
+ marks: DieMarks,
282
+ plain: boolean,
283
+ ): string {
284
+ const pool = dice.length === 0 ? '' : renderPool(dice, marks, plain);
285
+ return `${joinModifierCode(expr(target), code)}${pool}`;
286
+ }
287
+
288
+ function renderPart(part: RollPart, marks: DieMarks, plain: boolean): string {
289
+ switch (part.type) {
290
+ case 'literal':
291
+ return String(part.value);
292
+ case 'variable': {
293
+ const display = BARE_VARIABLE.test(part.name) ? `@${part.name}` : `@{${part.name}}`;
294
+ return `${display}[${part.value}]`;
295
+ }
296
+ case 'dice':
297
+ case 'fateDice':
298
+ return `${expr(part)}${renderPool(part.rolls, marks, plain)}`;
299
+ case 'grouped':
300
+ return `(${renderPart(part.inner, marks, plain)})`;
301
+ case 'binaryOp':
302
+ return `${renderPart(part.left, marks, plain)} ${part.operator} ${renderPart(part.right, marks, plain)}`;
303
+ case 'unaryOp':
304
+ return `-${renderPart(part.operand, marks, plain)}`;
305
+ case 'functionCall': {
306
+ const args = part.args.map((arg) => renderPart(arg, marks, plain)).join(', ');
307
+ return `${part.name}(${args})`;
308
+ }
309
+ case 'versus':
310
+ return `${renderPart(part.roll, marks, plain)} vs ${renderPart(part.dc, marks, plain)}`;
311
+ case 'group':
312
+ return renderGroup(part, marks, plain);
313
+ case 'keepDrop':
314
+ // Sub-roll selection renders through the group, which strikes whole
315
+ // sub-rolls; only the flat-pool form collapses into one bracket.
316
+ return part.target.type === 'group' && part.target.keptIndices != null
317
+ ? renderPart(part.target, marks, plain)
318
+ : `${expr(part.target)}${renderPool(poolOf(part.target), marks, plain)}`;
319
+ case 'explode':
320
+ return renderModifier(part.target, explodeCode(part), part.rolls, marks, plain);
321
+ case 'reroll':
322
+ return renderModifier(part.target, rerollCode(part), part.rolls, marks, plain);
323
+ case 'successCount':
324
+ // Subtotal counting renders through the group, which shows each sub-roll's
325
+ // own dice; only the flat form collapses into one bracket.
326
+ return part.target.type === 'group' && part.target.parts.length >= 2
327
+ ? `${renderPart(part.target, marks, plain)}${successCountCode(part)}`
328
+ : renderModifier(part.target, successCountCode(part), part.rolls, marks, plain);
329
+ // These three render as their own normalized expression plus a pool.
330
+ case 'dieBound':
331
+ return `${expr(part)}${renderPool(poolOf(part.target), marks, plain)}`;
332
+ case 'sort':
333
+ return `${expr(part)}${renderPool(part.rolls, marks, plain)}`;
334
+ case 'critThreshold':
335
+ return `${expr(part)}${renderPool(poolOf(part.target), marks, plain)}`;
336
+ }
337
+ }
338
+
339
+ function renderGroup(
340
+ part: Extract<RollPart, { type: 'group' }>,
341
+ marks: DieMarks,
342
+ plain: boolean,
343
+ ): string {
344
+ const { keptIndices } = part;
345
+ const subRolls = part.parts.map((sub, index) => {
346
+ if (keptIndices == null || keptIndices.includes(index)) {
347
+ return renderPart(sub, marks, plain);
348
+ }
349
+ const inner = renderPart(sub, marks, true);
350
+ return marks.droppedGroup?.(inner) ?? inner;
351
+ });
352
+
353
+ return `{${subRolls.join(', ')}}`;
354
+ }
355
+
356
+ /**
357
+ * Rebuilds a roll's breakdown from `RollResult.parts`, applying `marks` to
358
+ * every die.
359
+ *
360
+ * With no `marks` the output is byte-identical to `RollResult.rendered` — a
361
+ * property test pins that over generated notation. Pass any object to take
362
+ * over: omitted slots render plain, so `{}` strips markup entirely and
363
+ * `{ ...MARKDOWN_MARKS, critical: … }` keeps the rest of the markdown.
364
+ *
365
+ * The trailing `= <total>` is included, and becomes the degree label for a
366
+ * `vs` roll, exactly as `rendered` does.
367
+ *
368
+ * @param result - A finished result from `roll` or `evaluate`
369
+ * @param marks - Per-die markers; defaults to {@link MARKDOWN_MARKS}
370
+ * @returns The rendered breakdown
371
+ *
372
+ * @example
373
+ * ```typescript
374
+ * import { roll } from 'roll-parser';
375
+ * import { renderBreakdown } from 'roll-parser/render';
376
+ * import { createMockRng } from 'roll-parser/testing';
377
+ *
378
+ * const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });
379
+ *
380
+ * renderBreakdown(result); // '4d6[3, 6, ~~2~~, 5] = 14'
381
+ * renderBreakdown(result, {}); // '4d6[3, 6, 2, 5] = 14'
382
+ * renderBreakdown(result, {
383
+ * dropped: (_die, text) => `<s>${text}</s>`,
384
+ * critical: (_die, text) => `<b>${text}</b>`,
385
+ * }); // '4d6[3, <b>6</b>, <s>2</s>, 5] = 14'
386
+ * ```
387
+ *
388
+ * @category Rendering
389
+ */
390
+ export function renderBreakdown(result: RollResult, marks: DieMarks = MARKDOWN_MARKS): string {
391
+ const trailing = result.degree == null ? String(result.total) : DEGREE_LABELS[result.degree];
392
+
393
+ return `${renderPart(result.parts, marks, false)} = ${trailing}`;
394
+ }