roll-parser 3.0.0-beta.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 (164) hide show
  1. package/CHANGELOG.md +142 -9
  2. package/MIGRATION.md +219 -0
  3. package/README.md +1026 -154
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +15 -3
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +18 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -17
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +27 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +89 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +41 -75
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +914 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.js +15 -0
  37. package/dist/evaluator/modifiers/compare.js.map +1 -0
  38. package/dist/evaluator/modifiers/crit-threshold.d.ts +57 -5
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +42 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +29 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +17 -0
  45. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  46. package/dist/evaluator/modifiers/explode.d.ts +13 -5
  47. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  48. package/dist/evaluator/modifiers/explode.js +105 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +47 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +25 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +15 -29
  55. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  56. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  57. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  58. package/dist/evaluator/modifiers/reroll.d.ts +12 -4
  59. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  60. package/dist/evaluator/modifiers/reroll.js +68 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +5 -1
  63. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  64. package/dist/evaluator/modifiers/sort.js +13 -0
  65. package/dist/evaluator/modifiers/sort.js.map +1 -0
  66. package/dist/evaluator/modifiers/success-count.d.ts +6 -7
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +25 -0
  69. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  70. package/dist/index.d.ts +33 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +12 -2560
  73. package/dist/index.js.map +1 -26
  74. package/dist/lexer/lexer.d.ts +50 -5
  75. package/dist/lexer/lexer.d.ts.map +1 -1
  76. package/dist/lexer/lexer.js +260 -0
  77. package/dist/lexer/lexer.js.map +1 -0
  78. package/dist/lexer/tokens.d.ts +31 -6
  79. package/dist/lexer/tokens.d.ts.map +1 -1
  80. package/dist/lexer/tokens.js +42 -0
  81. package/dist/lexer/tokens.js.map +1 -0
  82. package/dist/parser/ast.d.ts +324 -165
  83. package/dist/parser/ast.d.ts.map +1 -1
  84. package/dist/parser/ast.js +52 -0
  85. package/dist/parser/ast.js.map +1 -0
  86. package/dist/parser/guards.d.ts +106 -0
  87. package/dist/parser/guards.d.ts.map +1 -0
  88. package/dist/parser/guards.js +121 -0
  89. package/dist/parser/guards.js.map +1 -0
  90. package/dist/parser/parser.d.ts +118 -14
  91. package/dist/parser/parser.d.ts.map +1 -1
  92. package/dist/parser/parser.js +751 -0
  93. package/dist/parser/parser.js.map +1 -0
  94. package/dist/render.d.ts +95 -0
  95. package/dist/render.d.ts.map +1 -0
  96. package/dist/render.js +227 -0
  97. package/dist/render.js.map +1 -0
  98. package/dist/rng/mock.d.ts +73 -12
  99. package/dist/rng/mock.d.ts.map +1 -1
  100. package/dist/rng/mock.js +30 -0
  101. package/dist/rng/mock.js.map +1 -0
  102. package/dist/rng/seeded.d.ts +141 -9
  103. package/dist/rng/seeded.d.ts.map +1 -1
  104. package/dist/rng/seeded.js +138 -0
  105. package/dist/rng/seeded.js.map +1 -0
  106. package/dist/rng/types.d.ts +57 -0
  107. package/dist/rng/types.d.ts.map +1 -1
  108. package/dist/rng/types.js +2 -0
  109. package/dist/rng/types.js.map +1 -0
  110. package/dist/roll.d.ts +58 -28
  111. package/dist/roll.d.ts.map +1 -1
  112. package/dist/roll.js +8 -0
  113. package/dist/roll.js.map +1 -0
  114. package/dist/testing.d.ts +5 -4
  115. package/dist/testing.d.ts.map +1 -1
  116. package/dist/testing.js +2 -41
  117. package/dist/testing.js.map +1 -11
  118. package/dist/types.d.ts +349 -47
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/types.js +8 -0
  121. package/dist/types.js.map +1 -0
  122. package/dist/version.d.ts +2 -0
  123. package/dist/version.d.ts.map +1 -0
  124. package/dist/version.js +2 -0
  125. package/dist/version.js.map +1 -0
  126. package/package.json +93 -34
  127. package/src/cli/args.ts +66 -10
  128. package/src/cli/format.ts +37 -26
  129. package/src/cli/index.ts +27 -84
  130. package/src/cli/main.ts +129 -0
  131. package/src/errors.ts +480 -27
  132. package/src/evaluator/die.ts +51 -0
  133. package/src/evaluator/env.ts +105 -0
  134. package/src/evaluator/evaluator.ts +693 -434
  135. package/src/evaluator/modifiers/crit-threshold.ts +96 -14
  136. package/src/evaluator/modifiers/die-bound.ts +48 -0
  137. package/src/evaluator/modifiers/explode.ts +70 -62
  138. package/src/evaluator/modifiers/flags.ts +78 -0
  139. package/src/evaluator/modifiers/keep-drop.ts +129 -127
  140. package/src/evaluator/modifiers/reroll.ts +44 -56
  141. package/src/evaluator/modifiers/sort.ts +21 -2
  142. package/src/evaluator/modifiers/success-count.ts +24 -12
  143. package/src/index.ts +56 -35
  144. package/src/lexer/lexer.ts +107 -34
  145. package/src/lexer/tokens.ts +31 -6
  146. package/src/parser/ast.ts +333 -346
  147. package/src/parser/guards.ts +248 -0
  148. package/src/parser/parser.ts +419 -242
  149. package/src/render.ts +392 -0
  150. package/src/rng/mock.ts +74 -13
  151. package/src/rng/seeded.ts +299 -64
  152. package/src/rng/types.ts +57 -0
  153. package/src/roll.ts +64 -47
  154. package/src/testing.ts +5 -9
  155. package/src/types.ts +353 -46
  156. package/src/version.ts +2 -0
  157. package/dist/cli.js +0 -2608
  158. package/dist/cli.js.map +0 -28
  159. package/dist/evaluator/index.d.ts +0 -8
  160. package/dist/evaluator/index.d.ts.map +0 -1
  161. package/dist/rng/index.d.ts +0 -8
  162. package/dist/rng/index.d.ts.map +0 -1
  163. package/src/evaluator/index.ts +0 -14
  164. package/src/rng/index.ts +0 -8
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/rng/mock.ts CHANGED
@@ -7,12 +7,44 @@
7
7
  import type { RNG } from './types.js';
8
8
 
9
9
  /**
10
- * Error thrown when MockRNG exhausts its predefined values.
10
+ * Error thrown when a mock RNG runs out of predefined values.
11
11
  *
12
- * This is intentional behavior to catch incorrect roll counts in tests.
13
- * If you see this error, your test is consuming more random values than expected.
12
+ * This is intentional behavior, not a limitation: a mock that wrapped around
13
+ * would silently pass a test whose expression rolls more dice than the author
14
+ * thought. Seeing this error means the notation consumed more draws than the
15
+ * sequence supplied — count the dice, including explosions, rerolls, and
16
+ * meta-expressions, and check the draw order in the roll-parser README.
17
+ *
18
+ * Deliberately outside the `RollParserError` hierarchy — no `code`, and
19
+ * `isRollParserError` answers `false` for it. An exhausted mock is a bug in the
20
+ * test fixture, not a failure mode of the notation, so the
21
+ * `if (!isRollParserError(error)) throw error` line a consumer writes around
22
+ * `roll()` rethrows it and fails the test instead of routing it to a "bad dice"
23
+ * message. `instanceof` is the check here — there is no `code` to branch on.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * import { roll } from 'roll-parser';
28
+ * import { createMockRng, MockRNGExhaustedError } from 'roll-parser/testing';
29
+ *
30
+ * try {
31
+ * roll('4d6', { rng: createMockRng([1, 2, 3]) });
32
+ * } catch (error) {
33
+ * error instanceof MockRNGExhaustedError; // true
34
+ * (error as MockRNGExhaustedError).consumed; // 3
35
+ * (error as Error).message;
36
+ * // 'MockRNG exhausted: consumed 3 values, no more available'
37
+ * }
38
+ * ```
39
+ *
40
+ * @category Testing
14
41
  */
15
42
  export class MockRNGExhaustedError extends Error {
43
+ /**
44
+ * How many values the sequence handed out before running dry — equivalently,
45
+ * the length of the array that was passed to `createMockRng`. Compare it
46
+ * against the dice you expected to be rolled to find the miscount.
47
+ */
16
48
  readonly consumed: number;
17
49
 
18
50
  constructor(consumed: number) {
@@ -23,29 +55,58 @@ export class MockRNGExhaustedError extends Error {
23
55
  }
24
56
 
25
57
  /**
26
- * Creates a mock RNG that returns predefined values in sequence.
58
+ * Creates a mock {@link RNG} that hands out predefined values in order — the
59
+ * way to write dice tests with exact expected totals.
60
+ *
61
+ * Two deliberate strictnesses, both there to surface a miscounted sequence
62
+ * instead of hiding it:
27
63
  *
28
- * IMPORTANT: Throws MockRNGExhaustedError when all values are consumed.
29
- * This behavior catches incorrect roll counts in tests - it never wraps around.
64
+ * - It never wraps around. Running out throws {@link MockRNGExhaustedError}.
65
+ * - `nextInt` rejects a scripted value outside the requested `[min, max]`
66
+ * with a `RangeError`, so `createMockRng([7])` cannot satisfy a `d6`.
30
67
  *
31
- * @param values - Array of values to return (dice results for nextInt, floats for next)
32
- * @returns RNG instance returning predefined values
68
+ * Both are the only failures that reach a caller of `roll()` without a
69
+ * roll-parser `code`, and only ever when a mock was injected — see
70
+ * {@link MockRNGExhaustedError} for why they stay outside the hierarchy.
71
+ *
72
+ * Draw order matters when the notation contains meta-expressions. Keep/drop
73
+ * counts (`4d6kh(1d2)`) are drawn *before* the pool; threshold expressions
74
+ * (`4d6cs>(1d2)`) are drawn *after* it. The README's Randomness section has
75
+ * the full tables.
76
+ *
77
+ * @param values - Values to return, in draw order (die faces for `nextInt`,
78
+ * floats in `[0, 1)` for `next`)
79
+ * @returns An `RNG` that replays `values`
33
80
  *
34
81
  * @example
35
82
  * ```typescript
83
+ * import { createMockRng } from 'roll-parser/testing';
84
+ *
36
85
  * const rng = createMockRng([4, 2, 6]);
37
- * rng.nextInt(1, 6); // Returns 4
38
- * rng.nextInt(1, 6); // Returns 2
39
- * rng.nextInt(1, 6); // Returns 6
40
- * rng.nextInt(1, 6); // Throws MockRNGExhaustedError
86
+ * rng.nextInt(1, 6); // 4
87
+ * rng.nextInt(1, 6); // 2
88
+ * rng.nextInt(1, 6); // 6
89
+ * rng.nextInt(1, 6); // throws MockRNGExhaustedError
90
+ * ```
91
+ *
92
+ * @example Pinning a roll
93
+ * ```typescript
94
+ * import { roll } from 'roll-parser';
95
+ * import { createMockRng } from 'roll-parser/testing';
96
+ *
97
+ * const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });
98
+ * result.total; // 14
99
+ * result.rendered; // '4d6[3, 6, ~~2~~, 5] = 14'
41
100
  * ```
101
+ *
102
+ * @category Testing
42
103
  */
43
104
  export function createMockRng(values: number[]): RNG {
44
105
  let index = 0;
45
106
 
46
107
  const getNext = (): number => {
47
108
  const value = values[index];
48
- if (value === undefined) {
109
+ if (value == null) {
49
110
  throw new MockRNGExhaustedError(index);
50
111
  }
51
112
  index++;