roll-parser 3.0.0 → 3.1.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 (74) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/MIGRATION.md +73 -1
  3. package/README.md +85 -19
  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 +31 -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 +29 -21
  15. package/dist/evaluator/evaluator.js.map +1 -1
  16. package/dist/evaluator/modifiers/crit-threshold.d.ts +57 -4
  17. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  18. package/dist/evaluator/modifiers/crit-threshold.js +27 -8
  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 +7 -5
  26. package/dist/evaluator/modifiers/explode.js.map +1 -1
  27. package/dist/evaluator/modifiers/flags.d.ts +10 -0
  28. package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
  29. package/dist/evaluator/modifiers/flags.js +7 -0
  30. package/dist/evaluator/modifiers/flags.js.map +1 -1
  31. package/dist/evaluator/modifiers/keep-drop.d.ts +4 -2
  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 +5 -2
  43. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  44. package/dist/evaluator/modifiers/success-count.js +7 -6
  45. package/dist/evaluator/modifiers/success-count.js.map +1 -1
  46. package/dist/parser/ast.d.ts +12 -7
  47. package/dist/parser/ast.d.ts.map +1 -1
  48. package/dist/parser/ast.js.map +1 -1
  49. package/dist/render.d.ts +95 -0
  50. package/dist/render.d.ts.map +1 -0
  51. package/dist/render.js +227 -0
  52. package/dist/render.js.map +1 -0
  53. package/dist/types.d.ts +49 -9
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js.map +1 -1
  56. package/dist/version.d.ts +1 -1
  57. package/dist/version.js +1 -1
  58. package/package.json +11 -1
  59. package/src/cli/format.ts +15 -23
  60. package/src/evaluator/die.ts +2 -1
  61. package/src/evaluator/env.ts +32 -0
  62. package/src/evaluator/evaluator.ts +48 -21
  63. package/src/evaluator/modifiers/crit-threshold.ts +95 -10
  64. package/src/evaluator/modifiers/die-bound.ts +13 -4
  65. package/src/evaluator/modifiers/explode.ts +12 -6
  66. package/src/evaluator/modifiers/flags.ts +17 -0
  67. package/src/evaluator/modifiers/keep-drop.ts +9 -5
  68. package/src/evaluator/modifiers/reroll.ts +20 -11
  69. package/src/evaluator/modifiers/sort.ts +9 -3
  70. package/src/evaluator/modifiers/success-count.ts +21 -6
  71. package/src/parser/ast.ts +12 -7
  72. package/src/render.ts +392 -0
  73. package/src/types.ts +49 -9
  74. package/src/version.ts +1 -1
@@ -26,17 +26,23 @@ import { isVersusDc } from './flags.js';
26
26
  * Relies on `Array.prototype.sort` being stable — equal-valued dice retain
27
27
  * their original insertion order.
28
28
  */
29
- export function sortDice(dice: DieResult[], order: 'ascending' | 'descending'): DieResult[] {
29
+ export function sortDice(
30
+ dice: DieResult[],
31
+ order: 'ascending' | 'descending',
32
+ hasVersusDc: boolean,
33
+ ): DieResult[] {
30
34
  const cmp =
31
35
  order === 'ascending'
32
36
  ? (a: DieResult, b: DieResult) => a.result - b.result
33
37
  : (a: DieResult, b: DieResult) => b.result - a.result;
34
38
 
35
- const sortable = dice.filter((die) => !isVersusDc(die));
36
- if (sortable.length === dice.length) return [...dice].sort(cmp);
39
+ // Scan before allocating: the `filter` this replaced built a throwaway array
40
+ // on every sort to serve a case only a `vs` can produce (#281).
41
+ if (!hasVersusDc || !dice.some(isVersusDc)) return [...dice].sort(cmp);
37
42
 
38
43
  // Sort only the pool members, then lay them back into the slots they came
39
44
  // from, leaving every DC die exactly where it was.
45
+ const sortable = dice.filter((die) => !isVersusDc(die));
40
46
  sortable.sort(cmp);
41
47
  let next = 0;
42
48
  return dice.map((die) => (isVersusDc(die) ? die : (sortable[next++] as DieResult)));
@@ -10,7 +10,10 @@
10
10
  * excluded from counting and are never tagged.
11
11
  *
12
12
  * Mutates the input pool in place to add `'success'` / `'failure'` modifier
13
- * flags — mirrors the mutation pattern of explode and reroll modifiers.
13
+ * flags — mirrors the mutation pattern of explode and reroll modifiers. Each
14
+ * tag is written at most once per die: a group counted after its members
15
+ * (`{4d6>=5}>=1`) runs this pass twice over the same dice, and the parse-time
16
+ * reject that blocks a direct `4d6>=5>=4` does not reach through a group.
14
17
  *
15
18
  * @module evaluator/modifiers/success-count
16
19
  */
@@ -28,17 +31,29 @@ export type SuccessCountResult = {
28
31
  export function countSuccesses(
29
32
  dice: DieResult[],
30
33
  threshold: ResolvedComparePoint,
31
- failThreshold?: ResolvedComparePoint,
34
+ failThreshold: ResolvedComparePoint | undefined,
35
+ hasVersusDc: boolean,
32
36
  ): SuccessCountResult {
33
37
  let successes = 0;
34
38
  let failures = 0;
35
39
 
36
- for (const die of dice) {
37
- if (isVersusDc(die)) continue;
40
+ // ! Excluded up front, never inside the loop below. Even short-circuited on a
41
+ // ! false flag, a `hasVersusDc && isVersusDc(die)` guard in this loop costs
42
+ // ! ~9% on `10d10>=6f1` — measured, not assumed (#281). Filtering keeps the
43
+ // ! hot body identical to the pre-exclusion one and pays an allocation only
44
+ // ! on the `vs` path. The dice are the same objects either way, so the
45
+ // ! `'success'` / `'failure'` tags written below still land on the pool.
46
+ const pool = hasVersusDc ? dice.filter((die) => !isVersusDc(die)) : dice;
47
+
48
+ // ! The guards below only stop the *same* tag being written twice. A nested
49
+ // ! count with a different threshold still appends to the first pass's tags,
50
+ // ! so a die can end up both `'success'` and `'failure'`, and the returned
51
+ // ! counts then disagree with the tags in `rolls`.
52
+ for (const die of pool) {
38
53
  if (die.modifiers.includes('dropped')) continue;
39
54
 
40
55
  if (matchesCondition(die.result, threshold.operator, threshold.value)) {
41
- die.modifiers.push('success');
56
+ if (!die.modifiers.includes('success')) die.modifiers.push('success');
42
57
  successes += 1;
43
58
  continue;
44
59
  }
@@ -47,7 +62,7 @@ export function countSuccesses(
47
62
  failThreshold != null &&
48
63
  matchesCondition(die.result, failThreshold.operator, failThreshold.value)
49
64
  ) {
50
- die.modifiers.push('failure');
65
+ if (!die.modifiers.includes('failure')) die.modifiers.push('failure');
51
66
  failures += 1;
52
67
  }
53
68
  }
package/src/parser/ast.ts CHANGED
@@ -61,8 +61,11 @@ export type DiceNode = NodeSpan & {
61
61
  * Fate/Fudge dice node (`dF`).
62
62
  * Each die produces a result in {-1, 0, +1}. No configurable sides.
63
63
  *
64
- * Fate dice carry `sides: 0` as a sentinel in their `DieResult`, and are
65
- * never `critical` or `fumble`there is no maximum face to hit.
64
+ * Fate dice carry `sides: 0` as a sentinel in their `DieResult`, so the
65
+ * default `critical`/`fumble` rule never fires it is guarded on `sides > 1`,
66
+ * which also stops a `+1` face from reading as a fumbled 1. An explicit
67
+ * threshold still applies: `4dFcs>0` and `4dFcf=-1` set the flags, while the
68
+ * bare `cs`/`cf` forms are rejected at parse time.
66
69
  *
67
70
  * @category AST
68
71
  */
@@ -314,9 +317,10 @@ export type SortNode = NodeSpan & {
314
317
  };
315
318
 
316
319
  /**
317
- * Sentinel for bare `cs` / `cf` without a ComparePoint. Resolved to
318
- * `result === sides` (for critical) or `result === 1` (for fumble) at
319
- * evaluation time, using each die's own `sides`.
320
+ * Sentinel for bare `cs` / `cf` without a ComparePoint. Resolved at
321
+ * evaluation time against each die's own `sides` and its natural face
322
+ * (`initialResult ?? result`): critical when that face equals `sides`,
323
+ * fumble when it equals 1.
320
324
  *
321
325
  * @category AST
322
326
  */
@@ -325,8 +329,9 @@ export type CritThreshold = ComparePoint | 'default';
325
329
  /**
326
330
  * Critical threshold modifier node (`cs`, `cf`).
327
331
  *
328
- * Overrides the default `critical`/`fumble` flag logic for the dice
329
- * produced by `target`. Bare `cs`/`cf` uses the `'default'` sentinel
332
+ * Overrides the default `critical`/`fumble` flag logic for `target`'s dice
333
+ * pool including dice an enclosing `!` or `r` adds to it afterwards, which
334
+ * `target` never produced. Bare `cs`/`cf` uses the `'default'` sentinel
330
335
  * (max face / 1). Custom thresholds accept any ComparePoint. Chaining
331
336
  * collapses into a single node — `1d20cs=20cs=1cf>18` has two success
332
337
  * and one fail threshold. Display-only: does not change `total`,
package/src/render.ts ADDED
@@ -0,0 +1,392 @@
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 type {
13
+ DieResult,
14
+ KeepDropSpec,
15
+ ResolvedComparePoint,
16
+ ResolvedCritThreshold,
17
+ RollPart,
18
+ RollResult,
19
+ } from './types.js';
20
+ import { DegreeOfSuccess } from './types.js';
21
+
22
+ /**
23
+ * Per-die markers. Every slot is optional; an omitted slot leaves the die's
24
+ * text untouched, so `{}` renders a breakdown with no markup whatsoever.
25
+ *
26
+ * `text` arrives as the die's value already wrapped by any inner mark, and
27
+ * composition order is fixed: `critical` then `fumble` innermost, then
28
+ * exactly one of `dropped`, `success`, or `failure` outermost — matching the
29
+ * priority `RollResult.rendered` uses, where a dropped die is never also
30
+ * shown as a success.
31
+ *
32
+ * @example A die that is both critical and dropped
33
+ * ```typescript
34
+ * { critical: (_die, text) => `<b>${text}</b>`, dropped: (_die, text) => `<s>${text}</s>` }
35
+ * // renders <s><b>20</b></s>
36
+ * ```
37
+ *
38
+ * @category Rendering
39
+ */
40
+ export type DieMarks = {
41
+ /** Excluded from the total by `kh`/`kl`/`dh`/`dl`, a reroll, or group selection. */
42
+ dropped?: (die: DieResult, text: string) => string;
43
+ /** Met a success-count threshold. */
44
+ success?: (die: DieResult, text: string) => string;
45
+ /** Met a failure threshold. */
46
+ failure?: (die: DieResult, text: string) => string;
47
+ /** `DieResult.critical` — the default rule or an explicit `cs` threshold. */
48
+ critical?: (die: DieResult, text: string) => string;
49
+ /** `DieResult.fumble` — the default rule or an explicit `cf` threshold. */
50
+ fumble?: (die: DieResult, text: string) => string;
51
+ /**
52
+ * Wraps a whole sub-roll dropped by group selection (`{1d8, 1d10}kh1`).
53
+ *
54
+ * Inside it, `dropped`, `success`, and `failure` are suppressed — the
55
+ * wrapper already carries the verdict, and marking a dropped die inside a
56
+ * dropped sub-roll says nothing extra. `critical` and `fumble` still apply:
57
+ * they describe the face, not the selection. A nested `droppedGroup` also
58
+ * survives, so `{{1d6, 1d8}kh1, 1d10}kh1` can wrap twice.
59
+ */
60
+ droppedGroup?: (inner: string) => string;
61
+ };
62
+
63
+ /**
64
+ * The marks `RollResult.rendered` itself uses. Applied when
65
+ * {@link renderBreakdown} is called without a `marks` argument; spread it to
66
+ * override one slot while keeping the rest markdown.
67
+ *
68
+ * @category Rendering
69
+ */
70
+ export const MARKDOWN_MARKS: DieMarks = {
71
+ dropped: (_die, text) => `~~${text}~~`,
72
+ success: (_die, text) => `**${text}**`,
73
+ failure: (_die, text) => `__${text}__`,
74
+ droppedGroup: (inner) => `~~${inner}~~`,
75
+ };
76
+
77
+ const KEEP_DROP_CODES = {
78
+ keep: { highest: 'kh', lowest: 'kl' },
79
+ drop: { highest: 'dh', lowest: 'dl' },
80
+ } as const;
81
+
82
+ const EXPLODE_MARKERS = {
83
+ standard: '!',
84
+ compound: '!!',
85
+ penetrating: '!p',
86
+ } as const;
87
+
88
+ const DEGREE_LABELS: Record<DegreeOfSuccess, string> = {
89
+ [DegreeOfSuccess.CriticalFailure]: 'Critical Failure',
90
+ [DegreeOfSuccess.Failure]: 'Failure',
91
+ [DegreeOfSuccess.Success]: 'Success',
92
+ [DegreeOfSuccess.CriticalSuccess]: 'Critical Success',
93
+ };
94
+
95
+ /** Bare identifier grammar; anything else was written `@{like this}`. */
96
+ const BARE_VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/;
97
+
98
+ function comparePointCode(point: ResolvedComparePoint): string {
99
+ return `${point.operator}${point.value}`;
100
+ }
101
+
102
+ function critCode(prefix: 'cs' | 'cf', threshold: ResolvedCritThreshold): string {
103
+ return threshold === 'default' ? prefix : `${prefix}${comparePointCode(threshold)}`;
104
+ }
105
+
106
+ function keepDropCode(spec: KeepDropSpec): string {
107
+ return `${KEEP_DROP_CODES[spec.kind][spec.selector]}${spec.count}`;
108
+ }
109
+
110
+ /** `'='` is elided because bare `fN` parses back to it — `f3`, `f<3`, `f>=3`. */
111
+ function failCode(point: ResolvedComparePoint): string {
112
+ return point.operator === '=' ? `f${point.value}` : `f${comparePointCode(point)}`;
113
+ }
114
+
115
+ /**
116
+ * Rebuilds the normalized expression a part contributes to
117
+ * `RollResult.expression` — the prefix every dice bracket hangs off.
118
+ *
119
+ * Meta-expressions are already resolved to numbers here, so `4d6kh(1d2)`
120
+ * comes back as `4d6kh1`, exactly as the evaluator spells it.
121
+ */
122
+ function expr(part: RollPart): string {
123
+ switch (part.type) {
124
+ case 'literal':
125
+ return String(part.value);
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 `${expr(part.target)}${part.specs.map(keepDropCode).join('')}`;
144
+ case 'explode':
145
+ return `${expr(part.target)}${explodeCode(part)}`;
146
+ case 'reroll':
147
+ return `${expr(part.target)}${part.once ? 'ro' : 'r'}${comparePointCode(part.condition)}`;
148
+ case 'dieBound':
149
+ return `${expr(part.target)}${dieBoundCode(part)}`;
150
+ case 'sort':
151
+ return `${expr(part.target)}${part.order === 'ascending' ? 's' : 'sd'}`;
152
+ case 'critThreshold':
153
+ return `${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
+ /** Negative bounds are parenthesized so the expression re-parses. */
167
+ function dieBoundCode(part: Extract<RollPart, { type: 'dieBound' }>): string {
168
+ return part.value < 0 ? `${part.bound}(${part.value})` : `${part.bound}${part.value}`;
169
+ }
170
+
171
+ function critThresholdCode(part: Extract<RollPart, { type: 'critThreshold' }>): string {
172
+ return [
173
+ ...part.successThresholds.map((threshold) => critCode('cs', threshold)),
174
+ ...part.failThresholds.map((threshold) => critCode('cf', threshold)),
175
+ ].join('');
176
+ }
177
+
178
+ function successCountCode(part: Extract<RollPart, { type: 'successCount' }>): string {
179
+ const fail = part.failThreshold == null ? '' : failCode(part.failThreshold);
180
+ return `${comparePointCode(part.threshold)}${fail}`;
181
+ }
182
+
183
+ /**
184
+ * Collects the dice a part's subtree produced, in evaluation order.
185
+ *
186
+ * `sort`, `explode`, `reroll`, and `successCount` carry their own pool and
187
+ * stop the descent: a sorted pool is reordered, and an exploded or rerolled
188
+ * one holds dice that exist nowhere under `target`.
189
+ */
190
+ function collectDice(part: RollPart, out: DieResult[]): void {
191
+ switch (part.type) {
192
+ case 'dice':
193
+ case 'fateDice':
194
+ case 'sort':
195
+ case 'explode':
196
+ case 'reroll':
197
+ case 'successCount':
198
+ for (const die of part.rolls) out.push(die);
199
+ return;
200
+ case 'grouped':
201
+ collectDice(part.inner, out);
202
+ return;
203
+ case 'unaryOp':
204
+ collectDice(part.operand, out);
205
+ return;
206
+ case 'binaryOp':
207
+ collectDice(part.left, out);
208
+ collectDice(part.right, out);
209
+ return;
210
+ case 'keepDrop':
211
+ case 'dieBound':
212
+ case 'critThreshold':
213
+ collectDice(part.target, out);
214
+ return;
215
+ case 'group':
216
+ for (const sub of part.parts) collectDice(sub, out);
217
+ return;
218
+ case 'functionCall':
219
+ for (const arg of part.args) collectDice(arg, out);
220
+ return;
221
+ case 'versus':
222
+ collectDice(part.roll, out);
223
+ collectDice(part.dc, out);
224
+ return;
225
+ case 'literal':
226
+ case 'variable':
227
+ return;
228
+ }
229
+ }
230
+
231
+ function poolOf(part: RollPart): DieResult[] {
232
+ const dice: DieResult[] = [];
233
+ collectDice(part, dice);
234
+ return dice;
235
+ }
236
+
237
+ /**
238
+ * Marks one die. `plain` suppresses the three state marks for dice inside a
239
+ * dropped sub-roll, where the group wrapper already carries the verdict —
240
+ * crit and fumble survive, since they describe the face, not the selection.
241
+ */
242
+ function markDie(die: DieResult, marks: DieMarks, plain: boolean): string {
243
+ let text = String(die.result);
244
+
245
+ if (die.critical) text = marks.critical?.(die, text) ?? text;
246
+ if (die.fumble) text = marks.fumble?.(die, text) ?? text;
247
+ if (plain) return text;
248
+
249
+ const { modifiers } = die;
250
+ if (modifiers.includes('dropped')) return marks.dropped?.(die, text) ?? text;
251
+ if (modifiers.includes('success')) return marks.success?.(die, text) ?? text;
252
+ if (modifiers.includes('failure')) return marks.failure?.(die, text) ?? text;
253
+
254
+ return text;
255
+ }
256
+
257
+ /** `'meta'` dice were rolled to resolve a parameter — they are never shown. */
258
+ function renderPool(dice: readonly DieResult[], marks: DieMarks, plain: boolean): string {
259
+ const shown: string[] = [];
260
+
261
+ for (const die of dice) {
262
+ if (die.modifiers.includes('meta')) continue;
263
+ shown.push(markDie(die, marks, plain));
264
+ }
265
+
266
+ return `[${shown.join(', ')}]`;
267
+ }
268
+
269
+ /**
270
+ * A modifier renders as `<target expression><code><pool>`, replacing whatever
271
+ * bracket the target would have shown on its own. An empty pool means the
272
+ * target rolled nothing at all, and the bracket is dropped with it.
273
+ */
274
+ function renderModifier(
275
+ target: RollPart,
276
+ code: string,
277
+ dice: readonly DieResult[],
278
+ marks: DieMarks,
279
+ plain: boolean,
280
+ ): string {
281
+ const pool = dice.length === 0 ? '' : renderPool(dice, marks, plain);
282
+ return `${expr(target)}${code}${pool}`;
283
+ }
284
+
285
+ function renderPart(part: RollPart, marks: DieMarks, plain: boolean): string {
286
+ switch (part.type) {
287
+ case 'literal':
288
+ return String(part.value);
289
+ case 'variable': {
290
+ const display = BARE_VARIABLE.test(part.name) ? `@${part.name}` : `@{${part.name}}`;
291
+ return `${display}[${part.value}]`;
292
+ }
293
+ case 'dice':
294
+ case 'fateDice':
295
+ return `${expr(part)}${renderPool(part.rolls, marks, plain)}`;
296
+ case 'grouped':
297
+ return `(${renderPart(part.inner, marks, plain)})`;
298
+ case 'binaryOp':
299
+ return `${renderPart(part.left, marks, plain)} ${part.operator} ${renderPart(part.right, marks, plain)}`;
300
+ case 'unaryOp':
301
+ return `-${renderPart(part.operand, marks, plain)}`;
302
+ case 'functionCall': {
303
+ const args = part.args.map((arg) => renderPart(arg, marks, plain)).join(', ');
304
+ return `${part.name}(${args})`;
305
+ }
306
+ case 'versus':
307
+ return `${renderPart(part.roll, marks, plain)} vs ${renderPart(part.dc, marks, plain)}`;
308
+ case 'group':
309
+ return renderGroup(part, marks, plain);
310
+ case 'keepDrop':
311
+ // Sub-roll selection renders through the group, which strikes whole
312
+ // sub-rolls; only the flat-pool form collapses into one bracket.
313
+ return part.target.type === 'group' && part.target.keptIndices != null
314
+ ? renderPart(part.target, marks, plain)
315
+ : `${expr(part.target)}${renderPool(poolOf(part.target), marks, plain)}`;
316
+ case 'explode':
317
+ return renderModifier(part.target, explodeCode(part), part.rolls, marks, plain);
318
+ case 'reroll':
319
+ return renderModifier(
320
+ part.target,
321
+ `${part.once ? 'ro' : 'r'}${comparePointCode(part.condition)}`,
322
+ part.rolls,
323
+ marks,
324
+ plain,
325
+ );
326
+ case 'successCount':
327
+ return renderModifier(part.target, successCountCode(part), part.rolls, marks, plain);
328
+ case 'dieBound':
329
+ return `${expr(part.target)}${dieBoundCode(part)}${renderPool(poolOf(part.target), marks, plain)}`;
330
+ case 'sort':
331
+ return `${expr(part.target)}${part.order === 'ascending' ? 's' : 'sd'}${renderPool(part.rolls, marks, plain)}`;
332
+ case 'critThreshold':
333
+ return `${expr(part.target)}${critThresholdCode(part)}${renderPool(poolOf(part.target), marks, plain)}`;
334
+ }
335
+ }
336
+
337
+ function renderGroup(
338
+ part: Extract<RollPart, { type: 'group' }>,
339
+ marks: DieMarks,
340
+ plain: boolean,
341
+ ): string {
342
+ const { keptIndices } = part;
343
+ const subRolls = part.parts.map((sub, index) => {
344
+ if (keptIndices == null || keptIndices.includes(index)) {
345
+ return renderPart(sub, marks, plain);
346
+ }
347
+ const inner = renderPart(sub, marks, true);
348
+ return marks.droppedGroup?.(inner) ?? inner;
349
+ });
350
+
351
+ return `{${subRolls.join(', ')}}`;
352
+ }
353
+
354
+ /**
355
+ * Rebuilds a roll's breakdown from `RollResult.parts`, applying `marks` to
356
+ * every die.
357
+ *
358
+ * With no `marks` the output is byte-identical to `RollResult.rendered` — a
359
+ * property test pins that over generated notation. Pass any object to take
360
+ * over: omitted slots render plain, so `{}` strips markup entirely and
361
+ * `{ ...MARKDOWN_MARKS, critical: … }` keeps the rest of the markdown.
362
+ *
363
+ * The trailing `= <total>` is included, and becomes the degree label for a
364
+ * `vs` roll, exactly as `rendered` does.
365
+ *
366
+ * @param result - A finished result from `roll` or `evaluate`
367
+ * @param marks - Per-die markers; defaults to {@link MARKDOWN_MARKS}
368
+ * @returns The rendered breakdown
369
+ *
370
+ * @example
371
+ * ```typescript
372
+ * import { roll } from 'roll-parser';
373
+ * import { renderBreakdown } from 'roll-parser/render';
374
+ * import { createMockRng } from 'roll-parser/testing';
375
+ *
376
+ * const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });
377
+ *
378
+ * renderBreakdown(result); // '4d6[3, 6, ~~2~~, 5] = 14'
379
+ * renderBreakdown(result, {}); // '4d6[3, 6, 2, 5] = 14'
380
+ * renderBreakdown(result, {
381
+ * dropped: (_die, text) => `<s>${text}</s>`,
382
+ * critical: (_die, text) => `<b>${text}</b>`,
383
+ * }); // '4d6[3, <b>6</b>, <s>2</s>, 5] = 14'
384
+ * ```
385
+ *
386
+ * @category Rendering
387
+ */
388
+ export function renderBreakdown(result: RollResult, marks: DieMarks = MARKDOWN_MARKS): string {
389
+ const trailing = result.degree == null ? String(result.total) : DEGREE_LABELS[result.degree];
390
+
391
+ return `${renderPart(result.parts, marks, false)} = ${trailing}`;
392
+ }
package/src/types.ts CHANGED
@@ -42,9 +42,12 @@ export type ResolvedComparePoint = {
42
42
  };
43
43
 
44
44
  /**
45
- * A resolved crit threshold — `'default'` means the per-die default rule
46
- * (`result === sides` for critical, `result === 1` for fumble), which is what
47
- * bare `cs` / `cf` produce.
45
+ * A resolved crit threshold — `'default'` means the per-die default rule,
46
+ * which is what bare `cs` / `cf` produce. It reads the natural face
47
+ * (`initialResult ?? result`): critical when it equals `sides`, fumble when
48
+ * it equals 1, both only for `sides > 1`. An explicit ComparePoint instead
49
+ * reads the die's current `result`, so a preceding modifier that rewrote it
50
+ * is visible to the comparison.
48
51
  *
49
52
  * @category Results
50
53
  */
@@ -70,10 +73,14 @@ export type ResolvedCritThreshold = ResolvedComparePoint | 'default';
70
73
  *
71
74
  * `'meta'` is the one tag with no counterpart in the notation. Dice counts,
72
75
  * sides, modifier counts and computed thresholds may themselves be dice
73
- * (`(1d4)d6`, `4d6kh(1d2)`, `1d6!>(1d2+3)`). Those inner dice are not part of
74
- * any pool, so they never appear in a {@link RollPart}; they are appended to
75
- * `RollResult.rolls` tagged `'meta'` so an audit log can still show what the
76
- * meta-expression rolled. Filter them out when summing or displaying a pool.
76
+ * (`(1d4)d6`, `4d6kh(1d2)`, `1d6!>(1d2+3)`). Those inner dice belong to no
77
+ * pool: they never appear on a `dice` or `fateDice` part, and they are
78
+ * appended to `RollResult.rolls` tagged `'meta'` so an audit log can still
79
+ * show what the meta-expression rolled.
80
+ *
81
+ * The four whole-pool views — `sort`, `explode`, `reroll`, and `successCount`
82
+ * `rolls` — are snapshots of an evaluation context rather than of a pool, so
83
+ * they do carry meta dice. Filter them out when summing or displaying a pool.
77
84
  *
78
85
  * `'dc'` marks the DC side of a `vs` comparison. Unlike `'meta'` these dice do
79
86
  * render — `1d20[3] vs 2d10[5, 6]` shows both sides — but they are not part of
@@ -166,9 +173,19 @@ export type DieResult = {
166
173
  initialResult?: number;
167
174
  /** Modifiers applied to this die */
168
175
  modifiers: DieModifier[];
169
- /** True if rolled the maximum value (always false for Fate dice) */
176
+ /**
177
+ * True if the die met its critical criteria — by default, rolling the
178
+ * maximum face on a die with more than one side. That default never fires
179
+ * on `d1` or on Fate dice (`sides = 0` has no maximum face), but an
180
+ * explicit `cs` threshold does: `4dFcs>0` flags every `+1`.
181
+ */
170
182
  critical: boolean;
171
- /** True if rolled 1 (always false for Fate dice) */
183
+ /**
184
+ * True if the die met its fumble criteria — by default, rolling a 1 on a
185
+ * die with more than one side. As with {@link DieResult.critical}, the
186
+ * default never fires on `d1` or Fate dice, while an explicit `cf`
187
+ * threshold does: `4dFcf=-1` flags every `-1`.
188
+ */
172
189
  fumble: boolean;
173
190
  };
174
191
 
@@ -299,12 +316,30 @@ export type RollPart =
299
316
  type: 'explode';
300
317
  variant: 'standard' | 'compound' | 'penetrating';
301
318
  threshold?: ResolvedComparePoint;
319
+ /**
320
+ * The expanded pool. Standard and penetrating explosions append dice
321
+ * that exist nowhere under `target`, so this is the only view of the
322
+ * pool the modifier actually produced. Compound explosions accumulate
323
+ * in place, making it the same dice as `target` carries.
324
+ *
325
+ * Like `RollResult.rolls` — and unlike the pool under `target` — this
326
+ * keeps `'meta'` dice. Filter them out before counting or displaying.
327
+ */
328
+ rolls: DieResult[];
302
329
  target: RollPart;
303
330
  })
304
331
  | (RollPartBase & {
305
332
  type: 'reroll';
306
333
  once: boolean;
307
334
  condition: ResolvedComparePoint;
335
+ /**
336
+ * The post-reroll pool: discarded intermediates (`'rerolled'` +
337
+ * `'dropped'`) alongside their replacements. Both are appended rather
338
+ * than substituted, so neither appears under `target`.
339
+ *
340
+ * Keeps `'meta'` dice, as `RollResult.rolls` does.
341
+ */
342
+ rolls: DieResult[];
308
343
  target: RollPart;
309
344
  })
310
345
  | (RollPartBase & { type: 'dieBound'; bound: 'min' | 'max'; value: number; target: RollPart })
@@ -312,6 +347,11 @@ export type RollPart =
312
347
  type: 'successCount';
313
348
  threshold: ResolvedComparePoint;
314
349
  failThreshold?: ResolvedComparePoint;
350
+ /**
351
+ * The tallied pool, sharing `DieResult` references with `target`.
352
+ * Keeps `'meta'` dice, as `RollResult.rolls` does.
353
+ */
354
+ rolls: DieResult[];
315
355
  target: RollPart;
316
356
  successes: number;
317
357
  failures: number;
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by `bun run generate:version` from package.json — do not edit.
2
- export const version = '3.0.0';
2
+ export const version = '3.1.0';