roll-parser 2.3.2 → 3.0.0-beta.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 (109) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +175 -170
  3. package/dist/cli/args.d.ts +35 -0
  4. package/dist/cli/args.d.ts.map +1 -0
  5. package/dist/cli/format.d.ts +20 -0
  6. package/dist/cli/format.d.ts.map +1 -0
  7. package/dist/cli/index.d.ts +8 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli.js +2608 -0
  10. package/dist/cli.js.map +28 -0
  11. package/dist/errors.d.ts +42 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/evaluator/evaluator.d.ts +121 -0
  14. package/dist/evaluator/evaluator.d.ts.map +1 -0
  15. package/dist/evaluator/index.d.ts +8 -0
  16. package/dist/evaluator/index.d.ts.map +1 -0
  17. package/dist/evaluator/modifiers/compare.d.ts +15 -0
  18. package/dist/evaluator/modifiers/compare.d.ts.map +1 -0
  19. package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
  20. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  21. package/dist/evaluator/modifiers/explode.d.ts +48 -0
  22. package/dist/evaluator/modifiers/explode.d.ts.map +1 -0
  23. package/dist/evaluator/modifiers/keep-drop.d.ts +44 -0
  24. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -0
  25. package/dist/evaluator/modifiers/reroll.d.ts +33 -0
  26. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -0
  27. package/dist/evaluator/modifiers/sort.d.ts +23 -0
  28. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  29. package/dist/evaluator/modifiers/success-count.d.ts +28 -0
  30. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -0
  31. package/dist/index.d.ts +20 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +2560 -0
  34. package/dist/index.js.map +26 -0
  35. package/dist/lexer/lexer.d.ts +90 -0
  36. package/dist/lexer/lexer.d.ts.map +1 -0
  37. package/dist/lexer/tokens.d.ts +107 -0
  38. package/dist/lexer/tokens.d.ts.map +1 -0
  39. package/dist/parser/ast.d.ts +368 -0
  40. package/dist/parser/ast.d.ts.map +1 -0
  41. package/dist/parser/parser.d.ts +145 -0
  42. package/dist/parser/parser.d.ts.map +1 -0
  43. package/dist/rng/index.d.ts +8 -0
  44. package/dist/rng/index.d.ts.map +1 -0
  45. package/dist/rng/mock.d.ts +36 -0
  46. package/dist/rng/mock.d.ts.map +1 -0
  47. package/dist/rng/seeded.d.ts +40 -0
  48. package/dist/rng/seeded.d.ts.map +1 -0
  49. package/dist/rng/types.d.ts +24 -0
  50. package/dist/rng/types.d.ts.map +1 -0
  51. package/dist/roll.d.ts +51 -0
  52. package/dist/roll.d.ts.map +1 -0
  53. package/dist/testing.d.ts +11 -0
  54. package/dist/testing.d.ts.map +1 -0
  55. package/dist/testing.js +41 -0
  56. package/dist/testing.js.map +11 -0
  57. package/dist/types.d.ts +246 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/package.json +66 -43
  60. package/src/cli/args.ts +73 -0
  61. package/src/cli/format.ts +49 -0
  62. package/src/cli/index.ts +92 -0
  63. package/src/errors.ts +90 -0
  64. package/src/evaluator/evaluator.ts +1552 -0
  65. package/src/evaluator/index.ts +14 -0
  66. package/src/evaluator/modifiers/compare.ts +29 -0
  67. package/src/evaluator/modifiers/crit-threshold.ts +59 -0
  68. package/src/evaluator/modifiers/explode.ts +219 -0
  69. package/src/evaluator/modifiers/keep-drop.ts +151 -0
  70. package/src/evaluator/modifiers/reroll.ts +165 -0
  71. package/src/evaluator/modifiers/sort.ts +30 -0
  72. package/src/evaluator/modifiers/success-count.ts +59 -0
  73. package/src/index.ts +90 -0
  74. package/src/lexer/lexer.ts +374 -0
  75. package/src/lexer/tokens.ts +187 -0
  76. package/src/parser/ast.ts +628 -0
  77. package/src/parser/parser.ts +1239 -0
  78. package/src/rng/index.ts +8 -0
  79. package/src/rng/mock.ts +65 -0
  80. package/src/rng/seeded.ts +157 -0
  81. package/src/rng/types.ts +25 -0
  82. package/src/roll.ts +74 -0
  83. package/src/testing.ts +16 -0
  84. package/src/types.ts +237 -0
  85. package/bin/help.txt +0 -19
  86. package/bin/roll-parser.js +0 -23
  87. package/dist/roll-parser.js +0 -697
  88. package/dist/roll-parser.min.js +0 -1
  89. package/index.js +0 -24
  90. package/src/complex/parse.js +0 -37
  91. package/src/complex/parseAndRoll.js +0 -21
  92. package/src/complex/parseAndRollClassic.js +0 -20
  93. package/src/complex/parseAndRollSimple.js +0 -19
  94. package/src/complex/parseAndRollWod.js +0 -20
  95. package/src/complex/parseClassicRoll.js +0 -22
  96. package/src/complex/parseSimpleRoll.js +0 -21
  97. package/src/complex/parseWodRoll.js +0 -21
  98. package/src/converter.js +0 -45
  99. package/src/grammar.js +0 -14
  100. package/src/mapper.js +0 -50
  101. package/src/normalizer.js +0 -51
  102. package/src/object/Result.js +0 -23
  103. package/src/object/Roll.js +0 -26
  104. package/src/object/Type.js +0 -5
  105. package/src/object/WodRoll.js +0 -29
  106. package/src/parser.js +0 -43
  107. package/src/random.js +0 -22
  108. package/src/roller.js +0 -106
  109. package/src/stringifier.js +0 -33
@@ -0,0 +1,1552 @@
1
+ /**
2
+ * AST evaluator - transforms parsed AST into roll results.
3
+ *
4
+ * @module evaluator/evaluator
5
+ */
6
+
7
+ import type { RollParserErrorCode } from '../errors.js';
8
+ import { RollParserError } from '../errors.js';
9
+ import type {
10
+ ASTNode,
11
+ BinaryOpNode,
12
+ CritThreshold,
13
+ CritThresholdNode,
14
+ DiceNode,
15
+ ExplodeNode,
16
+ FateDiceNode,
17
+ FunctionCallNode,
18
+ GroupedNode,
19
+ GroupNode,
20
+ LiteralNode,
21
+ ModifierNode,
22
+ RerollNode,
23
+ SortNode,
24
+ SuccessCountNode,
25
+ UnaryOpNode,
26
+ VariableNode,
27
+ VersusNode,
28
+ } from '../parser/ast.js';
29
+ import { isModifier } from '../parser/ast.js';
30
+ import type { RNG } from '../rng/types.js';
31
+ import type {
32
+ ComparePoint,
33
+ DieResult,
34
+ EvaluateOptions,
35
+ ResolvedComparePoint,
36
+ RollPart,
37
+ RollResult,
38
+ } from '../types.js';
39
+ import { DegreeOfSuccess } from '../types.js';
40
+ import { applyCritThresholds, type ResolvedCritThreshold } from './modifiers/crit-threshold.js';
41
+ import {
42
+ applyCompoundExplode,
43
+ applyPenetratingExplode,
44
+ applyStandardExplode,
45
+ buildShouldExplode,
46
+ DEFAULT_MAX_EXPLODE_ITERATIONS,
47
+ } from './modifiers/explode.js';
48
+ import {
49
+ applyDropHighest,
50
+ applyDropLowest,
51
+ applyKeepHighest,
52
+ applyKeepLowest,
53
+ markAllKept,
54
+ sumKeptDice,
55
+ } from './modifiers/keep-drop.js';
56
+ import {
57
+ applyRecursiveReroll,
58
+ applyRerollOnce,
59
+ DEFAULT_MAX_REROLL_ITERATIONS,
60
+ } from './modifiers/reroll.js';
61
+ import { sortDice } from './modifiers/sort.js';
62
+ import { countSuccesses } from './modifiers/success-count.js';
63
+
64
+ /**
65
+ * Error thrown during AST evaluation.
66
+ */
67
+ export class EvaluatorError extends RollParserError {
68
+ readonly nodeType: string | undefined;
69
+ /**
70
+ * Source span of the tightest AST node that was being evaluated when the
71
+ * error was thrown — stamped by `evalNode` on the way up, so the innermost
72
+ * node wins. `undefined` when the AST was built without parser spans.
73
+ */
74
+ start: number | undefined;
75
+ end: number | undefined;
76
+
77
+ constructor(message: string, code: RollParserErrorCode, nodeType?: string) {
78
+ super(message, code);
79
+ this.name = 'EvaluatorError';
80
+ this.nodeType = nodeType ?? undefined;
81
+ this.start = undefined;
82
+ this.end = undefined;
83
+ }
84
+ }
85
+
86
+ /** Default maximum total dice allowed per evaluation. */
87
+ export const DEFAULT_MAX_DICE = 10_000;
88
+
89
+ export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
90
+
91
+ /**
92
+ * Per-evaluation shared environment (created once, shared across all branches).
93
+ *
94
+ * Exported for use by modifier implementations under `./modifiers/*`. Not part
95
+ * of the public library API.
96
+ */
97
+ export type EvalEnv = {
98
+ readonly maxDice: number;
99
+ readonly maxExplodeIterations: number;
100
+ readonly maxRerollIterations: number;
101
+ totalDiceRolled: number;
102
+ /**
103
+ * Set to `true` by `evalSuccessCount`. Propagates through the shared env
104
+ * so `evaluate()` can include `successes`/`failures` fields even when no
105
+ * die was tagged (impossible threshold).
106
+ */
107
+ hasSuccessCount: boolean;
108
+ /**
109
+ * `true` while the evaluator is inside a `VersusNode`'s roll or DC
110
+ * sub-evaluation. `evalVersus` rejects nesting via this flag — catches
111
+ * paren-nested versus (`1d20 vs (5 vs 3)`) that slip past the parser's
112
+ * left-chain check.
113
+ */
114
+ insideVersus: boolean;
115
+ /**
116
+ * User-supplied variable map for `@name` / `@{name}` references. Always
117
+ * defined — `evaluate()` defaults to an empty object so lookups can be
118
+ * branch-free on presence.
119
+ */
120
+ readonly context: Readonly<Record<string, number>>;
121
+ /**
122
+ * Behavior when a referenced variable is missing from `context`. Always
123
+ * defined — `evaluate()` defaults to `'throw'`.
124
+ */
125
+ readonly onMissingVariable: 'throw' | 'zero';
126
+ };
127
+
128
+ /**
129
+ * Per-branch mutable accumulator for tracking rolls and output during recursion.
130
+ *
131
+ * @internal exported for targeted `mergeMetaRolls` tests only; not a public API.
132
+ */
133
+ export type EvalContext = {
134
+ rolls: DieResult[];
135
+ expressionParts: string[];
136
+ renderedParts: string[];
137
+ /**
138
+ * Populated by `evalVersus` with the resolved degree and natural value.
139
+ * `evaluate()` reads this from the top-level ctx to surface `degree` and
140
+ * `natural` on the final `RollResult`. Only populated when `vs` is the
141
+ * root of the expression.
142
+ */
143
+ versusMetadata?: {
144
+ degree: DegreeOfSuccess;
145
+ natural: number | undefined;
146
+ dcTotal: number;
147
+ };
148
+ };
149
+
150
+ /**
151
+ * Flattened representation of a keep/drop modifier for chain evaluation.
152
+ * Superset of the public `ModifierSpec` (adds the notation `code`).
153
+ */
154
+ type ModifierSpec = {
155
+ modifier: 'keep' | 'drop';
156
+ selector: 'highest' | 'lowest';
157
+ count: number;
158
+ code: string;
159
+ };
160
+
161
+ /**
162
+ * Every branch returns its numeric total AND the `RollPart` it contributes
163
+ * to the structured breakdown — TypeScript exhaustiveness guarantees no
164
+ * branch can forget to produce a part.
165
+ */
166
+ type EvalResult = {
167
+ total: number;
168
+ part: RollPart;
169
+ };
170
+
171
+ /**
172
+ * Copies an AST node's source span onto a part (spread into the literal).
173
+ * Empty when the AST was built without parser spans.
174
+ */
175
+ function partSpan(node: ASTNode): { start?: number; end?: number } {
176
+ if (node.start === undefined || node.end === undefined) return {};
177
+ return { start: node.start, end: node.end };
178
+ }
179
+
180
+ /** Maps internal modifier specs to the public `RollPart` spec shape. */
181
+ function toPublicSpecs(
182
+ specs: ModifierSpec[],
183
+ ): { kind: 'keep' | 'drop'; selector: 'highest' | 'lowest'; count: number }[] {
184
+ return specs.map((spec) => ({ kind: spec.modifier, selector: spec.selector, count: spec.count }));
185
+ }
186
+
187
+ /**
188
+ * Creates a new die result with critical/fumble detection.
189
+ */
190
+ function createDieResult(sides: number, result: number): DieResult {
191
+ // ? `sides > 1` guards both flags — a d1 always rolls 1, so it is neither
192
+ // an exceptional max (critical) nor an exceptional min (fumble).
193
+ return {
194
+ sides,
195
+ result,
196
+ modifiers: [],
197
+ critical: result === sides && sides > 1,
198
+ fumble: result === 1 && sides > 1,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Creates a Fate/Fudge die result. Uses `sides = 0` as a sentinel — Fate dice
204
+ * have no max-face concept, so `critical` and `fumble` are always `false`.
205
+ */
206
+ function createFateDieResult(result: number): DieResult {
207
+ return {
208
+ sides: 0,
209
+ result,
210
+ modifiers: [],
211
+ critical: false,
212
+ fumble: false,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Renders dice results for display. Marker priority: dropped wins over
218
+ * success/failure (dropped dice are never counted), success wins over
219
+ * failure (a die cannot be both). Example: `[~~1~~, **6**, __1__, 3]`.
220
+ *
221
+ * Dice tagged `'meta'` (rolled to compute sub-expression parameters such as
222
+ * count/sides/threshold) are hidden from the rendered output — they exist in
223
+ * `RollResult.rolls` for audit, not for display.
224
+ */
225
+ function renderDice(dice: DieResult[]): string {
226
+ const parts = dice
227
+ .filter((die) => !die.modifiers.includes('meta'))
228
+ .map((die) => {
229
+ if (die.modifiers.includes('dropped')) {
230
+ return `~~${die.result}~~`;
231
+ }
232
+ if (die.modifiers.includes('success')) {
233
+ return `**${die.result}**`;
234
+ }
235
+ if (die.modifiers.includes('failure')) {
236
+ return `__${die.result}__`;
237
+ }
238
+ return String(die.result);
239
+ });
240
+ return `[${parts.join(', ')}]`;
241
+ }
242
+
243
+ /**
244
+ * Forwards rolls from a throwaway sub-expression context into the parent
245
+ * audit trail, tagging them as `'meta'` + `'dropped'`. Meta dice are dice
246
+ * rolled to compute parameters (dice count, sides, threshold, modifier
247
+ * count) — they consume RNG and count against `maxDice`, so they must be
248
+ * inspectable. Tagging them `'dropped'` keeps totals correct via
249
+ * `sumKeptDice`; `'meta'` lets renderers hide them and lets callers
250
+ * distinguish them from ordinary pool dice.
251
+ *
252
+ * `'success'`/`'failure'` tags are stripped here as defense-in-depth against
253
+ * a SuccessCount leaking into a meta sub-expression (parser rejects all such
254
+ * wrappings; this strip ensures a future parse regression cannot leak tags
255
+ * into the top-level `successes`/`failures` scan).
256
+ */
257
+ /** @internal exported for targeted defense-in-depth tests only. */
258
+ export function mergeMetaRolls(parent: EvalContext, source: EvalContext): void {
259
+ for (const die of source.rolls) {
260
+ parent.rolls.push({
261
+ ...die,
262
+ modifiers: [
263
+ ...die.modifiers.filter(
264
+ (m) => m !== 'kept' && m !== 'dropped' && m !== 'success' && m !== 'failure',
265
+ ),
266
+ 'meta',
267
+ 'dropped',
268
+ ],
269
+ });
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Evaluates an AST node, returning its total and `RollPart` while updating
275
+ * the context.
276
+ *
277
+ * Errors bubbling up get the source span of the tightest node that was being
278
+ * evaluated — the innermost `evalNode` frame stamps first, outer frames leave
279
+ * an already-stamped error untouched.
280
+ */
281
+ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
282
+ try {
283
+ return evalNodeInner(node, rng, ctx, env);
284
+ } catch (error) {
285
+ if (error instanceof EvaluatorError && error.start === undefined && node.start !== undefined) {
286
+ error.start = node.start;
287
+ error.end = node.end;
288
+ }
289
+ throw error;
290
+ }
291
+ }
292
+
293
+ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
294
+ switch (node.type) {
295
+ case 'Literal':
296
+ return evalLiteral(node, ctx);
297
+
298
+ case 'Dice':
299
+ return evalDice(node, rng, ctx, env);
300
+
301
+ case 'FateDice':
302
+ return evalFateDice(node, rng, ctx, env);
303
+
304
+ case 'BinaryOp':
305
+ return evalBinaryOp(node, rng, ctx, env);
306
+
307
+ case 'UnaryOp':
308
+ return evalUnaryOp(node, rng, ctx, env);
309
+
310
+ case 'Modifier':
311
+ return evalModifier(node, rng, ctx, env);
312
+
313
+ case 'Explode':
314
+ return evalExplode(node, rng, ctx, env);
315
+
316
+ case 'Reroll':
317
+ return evalReroll(node, rng, ctx, env);
318
+
319
+ case 'SuccessCount':
320
+ return evalSuccessCount(node, rng, ctx, env);
321
+
322
+ case 'Versus':
323
+ return evalVersus(node, rng, ctx, env);
324
+
325
+ case 'FunctionCall':
326
+ return evalFunctionCall(node, rng, ctx, env);
327
+
328
+ case 'Grouped':
329
+ return evalGrouped(node, rng, ctx, env);
330
+
331
+ case 'Group':
332
+ return evalGroup(node, rng, ctx, env);
333
+
334
+ case 'Sort':
335
+ return evalSort(node, rng, ctx, env);
336
+
337
+ case 'CritThreshold':
338
+ return evalCritThreshold(node, rng, ctx, env);
339
+
340
+ case 'Variable':
341
+ return evalVariable(node, ctx, env);
342
+
343
+ default: {
344
+ const exhaustive: never = node;
345
+ throw new EvaluatorError(
346
+ `Unknown node type: ${(exhaustive as ASTNode).type}`,
347
+ 'UNKNOWN_NODE_TYPE',
348
+ (exhaustive as ASTNode).type,
349
+ );
350
+ }
351
+ }
352
+ }
353
+
354
+ function evalLiteral(node: LiteralNode, ctx: EvalContext): EvalResult {
355
+ const { value } = node;
356
+ ctx.expressionParts.push(String(value));
357
+ ctx.renderedParts.push(String(value));
358
+ return { total: value, part: { type: 'literal', value, total: value, ...partSpan(node) } };
359
+ }
360
+
361
+ /**
362
+ * Re-derives whether a variable name needs braces in its rendered form.
363
+ *
364
+ * The lexer accepts `@name` (bare) or `@{name with spaces}` (braced) but
365
+ * strips the braces from the captured value. To round-trip through `rendered`
366
+ * we re-derive bracedness from the name shape — anything outside the bare
367
+ * identifier grammar implies the user wrote braces (or would need them).
368
+ */
369
+ function variableNeedsBraces(name: string): boolean {
370
+ return !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
371
+ }
372
+
373
+ /**
374
+ * Looks up a variable in `env.context` and resolves missing keys per
375
+ * `env.onMissingVariable`. The resolved scalar is the variable's value;
376
+ * `expression` shows the resolved number (mirrors how literals render),
377
+ * while `rendered` keeps the original `@name` (or `@{name}`) annotated with
378
+ * the resolved value in brackets so readers can attribute the number.
379
+ */
380
+ function evalVariable(node: VariableNode, ctx: EvalContext, env: EvalEnv): EvalResult {
381
+ const present = Object.hasOwn(env.context, node.name);
382
+ if (!present) {
383
+ if (env.onMissingVariable === 'throw') {
384
+ throw new EvaluatorError(
385
+ `Undefined variable: ${node.name}`,
386
+ 'UNDEFINED_VARIABLE',
387
+ 'Variable',
388
+ );
389
+ }
390
+ const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
391
+ ctx.expressionParts.push('0');
392
+ ctx.renderedParts.push(`${display}[0]`);
393
+ return {
394
+ total: 0,
395
+ part: { type: 'variable', name: node.name, value: 0, total: 0, ...partSpan(node) },
396
+ };
397
+ }
398
+
399
+ const value = env.context[node.name] as number;
400
+ if (!Number.isFinite(value)) {
401
+ throw new EvaluatorError(
402
+ `Invalid variable value: ${node.name} = ${value}`,
403
+ 'INVALID_VARIABLE_VALUE',
404
+ 'Variable',
405
+ );
406
+ }
407
+ const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
408
+ ctx.expressionParts.push(String(value));
409
+ ctx.renderedParts.push(`${display}[${value}]`);
410
+ return {
411
+ total: value,
412
+ part: { type: 'variable', name: node.name, value, total: value, ...partSpan(node) },
413
+ };
414
+ }
415
+
416
+ /**
417
+ * RNG draw order: `count` expression → `sides` expression → pool dice
418
+ * (one `nextInt` per die, left-to-right). Meta-expressions on `count`/`sides`
419
+ * (e.g. `(1+1)d(3*2)`) draw before the pool. For modifier-argument
420
+ * meta-expressions like `4d6kh(1d2)`, `flattenModifierChain` draws the
421
+ * modifier args first, then `evalModifier` calls `evalDice` for the base
422
+ * pool. See `.claude/rules/rng.md` for the full draw-order spec.
423
+ */
424
+ function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
425
+ const countCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
426
+ const count = evalNode(node.count, rng, countCtx, env).total;
427
+ mergeMetaRolls(ctx, countCtx);
428
+
429
+ const sidesCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
430
+ const sides = evalNode(node.sides, rng, sidesCtx, env).total;
431
+ mergeMetaRolls(ctx, sidesCtx);
432
+
433
+ if (!Number.isInteger(count) || count < 0) {
434
+ throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'Dice');
435
+ }
436
+ if (!Number.isInteger(sides) || sides < 1) {
437
+ throw new EvaluatorError(`Invalid dice sides: ${sides}`, 'INVALID_DICE_SIDES', 'Dice');
438
+ }
439
+
440
+ if (env.totalDiceRolled + count > env.maxDice) {
441
+ throw new EvaluatorError(
442
+ `Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`,
443
+ 'DICE_LIMIT_EXCEEDED',
444
+ 'Dice',
445
+ );
446
+ }
447
+ env.totalDiceRolled += count;
448
+
449
+ const dice: DieResult[] = [];
450
+ for (let i = 0; i < count; i++) {
451
+ const result = rng.nextInt(1, sides);
452
+ dice.push(createDieResult(sides, result));
453
+ }
454
+
455
+ const markedDice = markAllKept(dice);
456
+ ctx.rolls.push(...markedDice);
457
+
458
+ const total = sumKeptDice(markedDice);
459
+ const notation = `${count}d${sides}`;
460
+
461
+ ctx.expressionParts.push(notation);
462
+ ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
463
+
464
+ return {
465
+ total,
466
+ part: { type: 'dice', count, sides, rolls: markedDice, total, ...partSpan(node) },
467
+ };
468
+ }
469
+
470
+ function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
471
+ const countCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
472
+ const count = evalNode(node.count, rng, countCtx, env).total;
473
+ mergeMetaRolls(ctx, countCtx);
474
+
475
+ if (!Number.isInteger(count) || count < 0) {
476
+ throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'FateDice');
477
+ }
478
+
479
+ if (env.totalDiceRolled + count > env.maxDice) {
480
+ throw new EvaluatorError(
481
+ `Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`,
482
+ 'DICE_LIMIT_EXCEEDED',
483
+ 'FateDice',
484
+ );
485
+ }
486
+ env.totalDiceRolled += count;
487
+
488
+ const dice: DieResult[] = [];
489
+ for (let i = 0; i < count; i++) {
490
+ const result = rng.nextInt(-1, 1);
491
+ dice.push(createFateDieResult(result));
492
+ }
493
+
494
+ const markedDice = markAllKept(dice);
495
+ ctx.rolls.push(...markedDice);
496
+
497
+ const total = sumKeptDice(markedDice);
498
+ const notation = `${count}dF`;
499
+
500
+ ctx.expressionParts.push(notation);
501
+ ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
502
+
503
+ return {
504
+ total,
505
+ part: { type: 'fateDice', count, rolls: markedDice, total, ...partSpan(node) },
506
+ };
507
+ }
508
+
509
+ /**
510
+ * Propagates `versusMetadata` onto a parent so `degree`/`natural` survive
511
+ * wrappers like `floor(...)`, `(vs) + 0`, or `-(vs)`. Throws `NESTED_VERSUS`
512
+ * if the parent already carries metadata — two versus results cannot occupy
513
+ * the same `RollResult`. No-op when `metadata` is `undefined`.
514
+ *
515
+ * Use this directly when the caller has already pushed (or transformed) `rolls`
516
+ * itself — e.g. `evalSort`, `evalCritThreshold`, `evalGroupModifier`. For the
517
+ * default case where the child's raw rolls flow up unchanged, use
518
+ * `mergeContext` instead.
519
+ */
520
+ function propagateMetadata(parent: EvalContext, metadata: EvalContext['versusMetadata']): void {
521
+ if (!metadata) return;
522
+ if (parent.versusMetadata) {
523
+ throw new EvaluatorError(
524
+ 'Multiple versus operators in the same expression',
525
+ 'NESTED_VERSUS',
526
+ 'Versus',
527
+ );
528
+ }
529
+ parent.versusMetadata = metadata;
530
+ }
531
+
532
+ /**
533
+ * Merges a child sub-context back into its parent. Copies `rolls` and
534
+ * delegates `versusMetadata` propagation to `propagateMetadata`.
535
+ *
536
+ * Does not merge `expressionParts` / `renderedParts` — each wrapper formats
537
+ * those with its own operator/function syntax.
538
+ */
539
+ function mergeContext(parent: EvalContext, child: EvalContext): void {
540
+ parent.rolls.push(...child.rolls);
541
+ propagateMetadata(parent, child.versusMetadata);
542
+ }
543
+
544
+ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
545
+ const leftCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
546
+ const rightCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
547
+
548
+ const left = evalNode(node.left, rng, leftCtx, env);
549
+ const right = evalNode(node.right, rng, rightCtx, env);
550
+
551
+ mergeContext(ctx, leftCtx);
552
+ mergeContext(ctx, rightCtx);
553
+
554
+ const leftExpr = leftCtx.expressionParts.join('');
555
+ const rightExpr = rightCtx.expressionParts.join('');
556
+ const leftRendered = leftCtx.renderedParts.join('');
557
+ const rightRendered = rightCtx.renderedParts.join('');
558
+
559
+ ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`);
560
+ ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`);
561
+
562
+ const total = applyBinaryOperator(node.operator, left.total, right.total);
563
+
564
+ return {
565
+ total,
566
+ part: {
567
+ type: 'binaryOp',
568
+ operator: node.operator,
569
+ left: left.part,
570
+ right: right.part,
571
+ total,
572
+ ...partSpan(node),
573
+ },
574
+ };
575
+ }
576
+
577
+ function applyBinaryOperator(
578
+ operator: BinaryOpNode['operator'],
579
+ left: number,
580
+ right: number,
581
+ ): number {
582
+ switch (operator) {
583
+ case '+':
584
+ return left + right;
585
+ case '-':
586
+ return left - right;
587
+ case '*':
588
+ return left * right;
589
+ case '/':
590
+ if (right === 0) {
591
+ throw new EvaluatorError('Division by zero', 'DIVISION_BY_ZERO', 'BinaryOp');
592
+ }
593
+ return left / right;
594
+ case '%':
595
+ if (right === 0) {
596
+ throw new EvaluatorError('Modulo by zero', 'MODULO_BY_ZERO', 'BinaryOp');
597
+ }
598
+ return left % right;
599
+ case '**':
600
+ return left ** right;
601
+ default: {
602
+ const exhaustive: never = operator;
603
+ throw new EvaluatorError(`Unknown operator: ${exhaustive}`, 'UNKNOWN_OPERATOR', 'BinaryOp');
604
+ }
605
+ }
606
+ }
607
+
608
+ function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
609
+ const innerCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
610
+ const inner = evalNode(node.operand, rng, innerCtx, env);
611
+
612
+ mergeContext(ctx, innerCtx);
613
+
614
+ const innerExpr = innerCtx.expressionParts.join('');
615
+ const innerRendered = innerCtx.renderedParts.join('');
616
+
617
+ ctx.expressionParts.push(`-${innerExpr}`);
618
+ ctx.renderedParts.push(`-${innerRendered}`);
619
+
620
+ const total = -inner.total;
621
+
622
+ return {
623
+ total,
624
+ part: { type: 'unaryOp', operator: '-', operand: inner.part, total, ...partSpan(node) },
625
+ };
626
+ }
627
+
628
+ function evalGrouped(node: GroupedNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
629
+ const innerCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
630
+ const inner = evalNode(node.expression, rng, innerCtx, env);
631
+
632
+ mergeContext(ctx, innerCtx);
633
+
634
+ ctx.expressionParts.push(`(${innerCtx.expressionParts.join('')})`);
635
+ ctx.renderedParts.push(`(${innerCtx.renderedParts.join('')})`);
636
+
637
+ return {
638
+ total: inner.total,
639
+ part: { type: 'grouped', inner: inner.part, total: inner.total, ...partSpan(node) },
640
+ };
641
+ }
642
+
643
+ /**
644
+ * Evaluates a grouped roll `{expr1, expr2, ...}`.
645
+ *
646
+ * Each sub-expression is evaluated in an isolated context, then its rolls
647
+ * and `versusMetadata` propagate up via `mergeContext`. Sub-roll subtotals
648
+ * sum to the group's total. When the group is the base target of a
649
+ * keep/drop modifier with `expressions.length >= 2`, `evalModifier`
650
+ * intercepts first and never calls this function — dual semantics
651
+ * (flat-pool vs sub-roll) are decided there.
652
+ */
653
+ function evalGroup(node: GroupNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
654
+ const subExprs: string[] = [];
655
+ const subRendered: string[] = [];
656
+ const subParts: RollPart[] = [];
657
+ let total = 0;
658
+
659
+ for (const expr of node.expressions) {
660
+ const subCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
661
+ const sub = evalNode(expr, rng, subCtx, env);
662
+ mergeContext(ctx, subCtx);
663
+ subExprs.push(subCtx.expressionParts.join(''));
664
+ subRendered.push(subCtx.renderedParts.join(''));
665
+ subParts.push(sub.part);
666
+ total += sub.total;
667
+ }
668
+
669
+ ctx.expressionParts.push(`{${subExprs.join(', ')}}`);
670
+ ctx.renderedParts.push(`{${subRendered.join(', ')}}`);
671
+
672
+ // ? No `keptIndices` — bare groups (and single-sub passthroughs) perform
673
+ // no sub-roll selection; only `evalGroupModifier` sets it.
674
+ return { total, part: { type: 'group', parts: subParts, total, ...partSpan(node) } };
675
+ }
676
+
677
+ function evalFunctionCall(
678
+ node: FunctionCallNode,
679
+ rng: RNG,
680
+ ctx: EvalContext,
681
+ env: EvalEnv,
682
+ ): EvalResult {
683
+ const argCtxs: EvalContext[] = [];
684
+ const argResults: EvalResult[] = [];
685
+
686
+ for (const arg of node.args) {
687
+ const argCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
688
+ argResults.push(evalNode(arg, rng, argCtx, env));
689
+ argCtxs.push(argCtx);
690
+ }
691
+
692
+ for (const argCtx of argCtxs) {
693
+ mergeContext(ctx, argCtx);
694
+ }
695
+
696
+ const argExprs = argCtxs.map((c) => c.expressionParts.join(''));
697
+ const argRendereds = argCtxs.map((c) => c.renderedParts.join(''));
698
+
699
+ ctx.expressionParts.push(`${node.name}(${argExprs.join(', ')})`);
700
+ ctx.renderedParts.push(`${node.name}(${argRendereds.join(', ')})`);
701
+
702
+ const total = applyFunction(
703
+ node.name,
704
+ argResults.map((r) => r.total),
705
+ );
706
+
707
+ return {
708
+ total,
709
+ part: {
710
+ type: 'functionCall',
711
+ name: node.name,
712
+ args: argResults.map((r) => r.part),
713
+ total,
714
+ ...partSpan(node),
715
+ },
716
+ };
717
+ }
718
+
719
+ function applyFunction(name: string, values: number[]): number {
720
+ switch (name) {
721
+ case 'floor':
722
+ return Math.floor(requireUnaryArg(name, values));
723
+ case 'ceil':
724
+ return Math.ceil(requireUnaryArg(name, values));
725
+ case 'round':
726
+ // ? Delegates to `Math.round`, which rounds half-values toward +∞
727
+ // (IEEE-754 half-up). So `round(2.5) === 3` but `round(-2.5) === -2`,
728
+ // not `-3`. Users needing symmetric rounding must compose via `floor`.
729
+ return Math.round(requireUnaryArg(name, values));
730
+ case 'abs':
731
+ return Math.abs(requireUnaryArg(name, values));
732
+ case 'max':
733
+ return Math.max(...values);
734
+ case 'min':
735
+ return Math.min(...values);
736
+ default:
737
+ throw new EvaluatorError(`Unknown function: ${name}`, 'UNKNOWN_FUNCTION', 'FunctionCall');
738
+ }
739
+ }
740
+
741
+ function requireUnaryArg(name: string, values: number[]): number {
742
+ const [x] = values;
743
+ if (x === undefined) {
744
+ // ? Unreachable: parser validates arity before evaluation. Defensive for
745
+ // `noNonNullAssertion`.
746
+ throw new EvaluatorError(
747
+ `Function '${name}' requires an argument`,
748
+ 'UNKNOWN_FUNCTION',
749
+ 'FunctionCall',
750
+ );
751
+ }
752
+ return x;
753
+ }
754
+
755
+ /**
756
+ * Walks a nested ModifierNode chain, collecting specs outermost-first,
757
+ * then reverses to notation order (innermost-first).
758
+ */
759
+ function flattenModifierChain(
760
+ node: ModifierNode,
761
+ rng: RNG,
762
+ ctx: EvalContext,
763
+ env: EvalEnv,
764
+ ): { specs: ModifierSpec[]; baseTarget: ASTNode } {
765
+ const specs: ModifierSpec[] = [];
766
+ let current: ASTNode = node;
767
+
768
+ while (isModifier(current)) {
769
+ const countCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
770
+ const modCount = evalNode(current.count, rng, countCtx, env).total;
771
+ mergeMetaRolls(ctx, countCtx);
772
+
773
+ if (!Number.isInteger(modCount) || modCount < 0) {
774
+ throw new EvaluatorError(
775
+ `Invalid modifier count: ${modCount}`,
776
+ 'INVALID_MODIFIER_COUNT',
777
+ 'Modifier',
778
+ );
779
+ }
780
+
781
+ const code =
782
+ current.modifier === 'keep'
783
+ ? current.selector === 'highest'
784
+ ? 'kh'
785
+ : 'kl'
786
+ : current.selector === 'highest'
787
+ ? 'dh'
788
+ : 'dl';
789
+
790
+ specs.push({ modifier: current.modifier, selector: current.selector, count: modCount, code });
791
+ current = current.target;
792
+ }
793
+
794
+ specs.reverse();
795
+ return { specs, baseTarget: current };
796
+ }
797
+
798
+ /**
799
+ * Applies a single modifier spec to a dice pool.
800
+ */
801
+ function applyModifierSpec(dice: DieResult[], spec: ModifierSpec): DieResult[] {
802
+ if (spec.modifier === 'keep') {
803
+ return spec.selector === 'highest'
804
+ ? applyKeepHighest(dice, spec.count)
805
+ : applyKeepLowest(dice, spec.count);
806
+ }
807
+ return spec.selector === 'highest'
808
+ ? applyDropHighest(dice, spec.count)
809
+ : applyDropLowest(dice, spec.count);
810
+ }
811
+
812
+ /**
813
+ * Applies each modifier independently to the full dice pool
814
+ * and merges drop sets via union. A die is dropped if ANY modifier dropped it.
815
+ *
816
+ * Mutates each die's flags in place — the same `DieResult` objects are
817
+ * shared between `RollResult.rolls` and the `RollPart` tree. The per-spec
818
+ * appliers still work on copies internally (each spec must see the
819
+ * unmodified pool), only the merged outcome is written back.
820
+ */
821
+ function mergeDropSets(baseDice: DieResult[], specs: ModifierSpec[]): DieResult[] {
822
+ const droppedIndices = new Set<number>();
823
+
824
+ for (const spec of specs) {
825
+ const result = applyModifierSpec(baseDice, spec);
826
+ for (let i = 0; i < result.length; i++) {
827
+ if (result[i]?.modifiers.includes('dropped')) {
828
+ droppedIndices.add(i);
829
+ }
830
+ }
831
+ }
832
+
833
+ baseDice.forEach((die, index) => {
834
+ die.modifiers = droppedIndices.has(index)
835
+ ? [...die.modifiers.filter((m) => m !== 'kept' && m !== 'dropped'), 'dropped']
836
+ : [...die.modifiers.filter((m) => m !== 'dropped' && m !== 'kept'), 'kept'];
837
+ });
838
+
839
+ return baseDice;
840
+ }
841
+
842
+ /**
843
+ * Builds the notation string for an explode modifier, e.g. `!`, `!!>=3`, `!p>5`.
844
+ */
845
+ function formatExplodeCode(
846
+ variant: ExplodeNode['variant'],
847
+ threshold: ComparePoint | undefined,
848
+ thresholdValue: number | undefined,
849
+ ): string {
850
+ const marker = variant === 'standard' ? '!' : variant === 'compound' ? '!!' : '!p';
851
+ if (threshold == null || thresholdValue == null) return marker;
852
+ return `${marker}${threshold.operator}${thresholdValue}`;
853
+ }
854
+
855
+ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
856
+ const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
857
+ const target = evalNode(node.target, rng, targetCtx, env);
858
+ const targetExpr = targetCtx.expressionParts.join('');
859
+
860
+ let thresholdValue: number | undefined;
861
+ if (node.threshold != null) {
862
+ const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
863
+ thresholdValue = evalNode(node.threshold.value, rng, thresholdCtx, env).total;
864
+ mergeMetaRolls(ctx, thresholdCtx);
865
+ }
866
+
867
+ const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
868
+
869
+ const buildPart = (total: number): RollPart => {
870
+ const part: RollPart = {
871
+ type: 'explode',
872
+ variant: node.variant,
873
+ target: target.part,
874
+ total,
875
+ ...partSpan(node),
876
+ };
877
+ if (node.threshold != null && thresholdValue !== undefined) {
878
+ part.threshold = { operator: node.threshold.operator, value: thresholdValue };
879
+ }
880
+ return part;
881
+ };
882
+
883
+ // No-op when the target produced no dice (e.g., `(1+2)!`).
884
+ if (targetCtx.rolls.length === 0) {
885
+ ctx.expressionParts.push(`${targetExpr}${code}`);
886
+ ctx.renderedParts.push(`${targetExpr}${code}`);
887
+ return { total: target.total, part: buildPart(target.total) };
888
+ }
889
+
890
+ const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
891
+
892
+ const expanded =
893
+ node.variant === 'standard'
894
+ ? applyStandardExplode(targetCtx.rolls, shouldExplode, rng, env)
895
+ : node.variant === 'compound'
896
+ ? applyCompoundExplode(targetCtx.rolls, shouldExplode, rng, env)
897
+ : applyPenetratingExplode(targetCtx.rolls, shouldExplode, rng, env);
898
+
899
+ ctx.rolls.push(...expanded);
900
+ ctx.expressionParts.push(`${targetExpr}${code}`);
901
+ // ? Include the explode code in rendered output so readers can attribute
902
+ // the extra dice. Modifier rendering (kh/dl) skips its code because
903
+ // dropped dice are visible; explosion-origin is otherwise invisible.
904
+ ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
905
+
906
+ const total = sumKeptDice(expanded);
907
+ return { total, part: buildPart(total) };
908
+ }
909
+
910
+ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
911
+ const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
912
+ const target = evalNode(node.target, rng, targetCtx, env);
913
+ const targetExpr = targetCtx.expressionParts.join('');
914
+
915
+ const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
916
+ const thresholdValue = evalNode(node.condition.value, rng, thresholdCtx, env).total;
917
+ mergeMetaRolls(ctx, thresholdCtx);
918
+
919
+ const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
920
+ const condition: ResolvedComparePoint = {
921
+ operator: node.condition.operator,
922
+ value: thresholdValue,
923
+ };
924
+
925
+ // No-op when the target produced no dice (e.g., `(1+2)r<5`).
926
+ if (targetCtx.rolls.length === 0) {
927
+ ctx.expressionParts.push(`${targetExpr}${code}`);
928
+ ctx.renderedParts.push(`${targetExpr}${code}`);
929
+ const total = sumKeptDice(targetCtx.rolls);
930
+ return {
931
+ total,
932
+ part: {
933
+ type: 'reroll',
934
+ once: node.once,
935
+ condition,
936
+ target: target.part,
937
+ total,
938
+ ...partSpan(node),
939
+ },
940
+ };
941
+ }
942
+
943
+ const pool = node.once
944
+ ? applyRerollOnce(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env)
945
+ : applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
946
+
947
+ ctx.rolls.push(...pool);
948
+ ctx.expressionParts.push(`${targetExpr}${code}`);
949
+ ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
950
+
951
+ const total = sumKeptDice(pool);
952
+ return {
953
+ total,
954
+ part: {
955
+ type: 'reroll',
956
+ once: node.once,
957
+ condition,
958
+ target: target.part,
959
+ total,
960
+ ...partSpan(node),
961
+ },
962
+ };
963
+ }
964
+
965
+ /**
966
+ * Evaluates a sort modifier. Purely visual — sorts the dice produced by
967
+ * `node.target` in ascending or descending order of `result` without
968
+ * changing the total or any die-level flag. Dropped dice participate in
969
+ * the sort alongside kept dice, preserving their `'dropped'` marker.
970
+ *
971
+ * Rendering mirrors `evalExplode` / `evalModifier`: emits
972
+ * `<targetExpr><code>[<sortedDice>]`, replacing any inline dice brackets
973
+ * the target itself rendered. Multi-sub-roll Group targets (`{4d6, 3d6}s`)
974
+ * are rejected at parse time with `INVALID_SORT_TARGET` until hierarchical
975
+ * per-sub-roll sorting (Stage 3 spec §3 "Group interaction") is implemented.
976
+ */
977
+ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
978
+ const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
979
+ const target = evalNode(node.target, rng, targetCtx, env);
980
+
981
+ const sortedRolls = sortDice(targetCtx.rolls, node.order);
982
+
983
+ ctx.rolls.push(...sortedRolls);
984
+ propagateMetadata(ctx, targetCtx.versusMetadata);
985
+
986
+ const code = node.order === 'ascending' ? 's' : 'sd';
987
+ const targetExpr = targetCtx.expressionParts.join('');
988
+
989
+ ctx.expressionParts.push(`${targetExpr}${code}`);
990
+ ctx.renderedParts.push(`${targetExpr}${code}${renderDice(sortedRolls)}`);
991
+
992
+ return {
993
+ total: target.total,
994
+ part: {
995
+ type: 'sort',
996
+ order: node.order,
997
+ target: target.part,
998
+ total: target.total,
999
+ ...partSpan(node),
1000
+ },
1001
+ };
1002
+ }
1003
+
1004
+ /**
1005
+ * Evaluates a critical/fumble threshold modifier. Pure post-processing:
1006
+ * evaluates the target in an isolated context, resolves each threshold's
1007
+ * ComparePoint value (including meta-expressions, which consume RNG draws
1008
+ * AFTER the target pool), then overrides `critical`/`fumble` flags on the
1009
+ * produced dice in place.
1010
+ *
1011
+ * Independent overrides (Roll20 semantics): `cs` thresholds replace only the
1012
+ * crit criteria and `cf` thresholds replace only the fumble criteria. When a
1013
+ * side has no explicit threshold, the `'default'` rule applies — so
1014
+ * `1d20cf<3` keeps the default nat-20 crit. Bare `cs`/`cf` uses the
1015
+ * `'default'` sentinel resolved per-die to `result === sides` or
1016
+ * `result === 1`.
1017
+ *
1018
+ * Renders `<targetExpr><codes>[<dice>]`, mirroring `evalSort`/`evalExplode`.
1019
+ */
1020
+ function evalCritThreshold(
1021
+ node: CritThresholdNode,
1022
+ rng: RNG,
1023
+ ctx: EvalContext,
1024
+ env: EvalEnv,
1025
+ ): EvalResult {
1026
+ const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1027
+ const target = evalNode(node.target, rng, targetCtx, env);
1028
+
1029
+ const successResolved = node.successThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
1030
+ const failResolved = node.failThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
1031
+
1032
+ // Independent overrides: a side with no explicit threshold falls back to
1033
+ // the default rule instead of being wiped by the other side's override.
1034
+ const successApplied: ResolvedCritThreshold[] =
1035
+ successResolved.length > 0 ? successResolved : ['default'];
1036
+ const failApplied: ResolvedCritThreshold[] = failResolved.length > 0 ? failResolved : ['default'];
1037
+
1038
+ applyCritThresholds(targetCtx.rolls, successApplied, failApplied);
1039
+
1040
+ ctx.rolls.push(...targetCtx.rolls);
1041
+ propagateMetadata(ctx, targetCtx.versusMetadata);
1042
+
1043
+ const targetExpr = targetCtx.expressionParts.join('');
1044
+ const codes = [
1045
+ ...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
1046
+ ...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
1047
+ ].join('');
1048
+
1049
+ ctx.expressionParts.push(`${targetExpr}${codes}`);
1050
+ ctx.renderedParts.push(`${targetExpr}${codes}${renderDice(targetCtx.rolls)}`);
1051
+
1052
+ return {
1053
+ total: target.total,
1054
+ part: {
1055
+ type: 'critThreshold',
1056
+ successThresholds: successResolved,
1057
+ failThresholds: failResolved,
1058
+ target: target.part,
1059
+ total: target.total,
1060
+ ...partSpan(node),
1061
+ },
1062
+ };
1063
+ }
1064
+
1065
+ function resolveCritThreshold(
1066
+ threshold: CritThreshold,
1067
+ rng: RNG,
1068
+ ctx: EvalContext,
1069
+ env: EvalEnv,
1070
+ ): ResolvedCritThreshold {
1071
+ if (threshold === 'default') return 'default';
1072
+ const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1073
+ const resolved = evalNode(threshold.value, rng, thresholdCtx, env).total;
1074
+ mergeMetaRolls(ctx, thresholdCtx);
1075
+ if (!Number.isFinite(resolved)) {
1076
+ throw new EvaluatorError(
1077
+ `Invalid crit threshold: ${resolved}`,
1078
+ 'INVALID_THRESHOLD',
1079
+ 'CritThreshold',
1080
+ );
1081
+ }
1082
+ return { operator: threshold.operator, value: resolved };
1083
+ }
1084
+
1085
+ function evalModifier(node: ModifierNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1086
+ const { specs, baseTarget } = flattenModifierChain(node, rng, ctx, env);
1087
+
1088
+ // Multi-sub-roll group: keep/drop operates on sub-roll subtotals as if
1089
+ // each subtotal were a compound die. Single-sub-roll groups fall through
1090
+ // to the flat-pool path below — `evalGroup` evaluates the inner expression
1091
+ // into `targetCtx.rolls`, and `mergeDropSets` selects individual dice.
1092
+ if (baseTarget.type === 'Group' && baseTarget.expressions.length >= 2) {
1093
+ return evalGroupModifier(node, baseTarget, specs, rng, ctx, env);
1094
+ }
1095
+
1096
+ const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1097
+ const target = evalNode(baseTarget, rng, targetCtx, env);
1098
+
1099
+ const mergedDice = mergeDropSets(targetCtx.rolls, specs);
1100
+
1101
+ ctx.rolls.push(...mergedDice);
1102
+
1103
+ const total = sumKeptDice(mergedDice);
1104
+
1105
+ const targetExpr = targetCtx.expressionParts.join('');
1106
+ const modifierCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1107
+
1108
+ ctx.expressionParts.push(`${targetExpr}${modifierCodes}`);
1109
+ ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
1110
+
1111
+ return {
1112
+ total,
1113
+ part: {
1114
+ type: 'modifier',
1115
+ specs: toPublicSpecs(specs),
1116
+ target: target.part,
1117
+ total,
1118
+ ...partSpan(node),
1119
+ },
1120
+ };
1121
+ }
1122
+
1123
+ /**
1124
+ * Strips per-die markdown markers from an already-rendered sub-roll string.
1125
+ * Used when a whole group sub-roll is dropped: the outer `~~...~~` wrap
1126
+ * supersedes inner success (`**`), failure (`__`), and dropped (`~~`)
1127
+ * markers, and leaving them in place would nest strikethroughs or show
1128
+ * success highlights inside a dropped span.
1129
+ */
1130
+ function stripInnerMarkers(rendered: string): string {
1131
+ return rendered
1132
+ .replace(/\*\*(-?\d+)\*\*/g, '$1')
1133
+ .replace(/__(-?\d+)__/g, '$1')
1134
+ .replace(/~~(-?\d+)~~/g, '$1');
1135
+ }
1136
+
1137
+ /**
1138
+ * Evaluates a keep/drop modifier chain whose base target is a multi
1139
+ * sub-roll group. Each sub-roll is evaluated in isolation so its subtotal
1140
+ * and dice are captured separately. Synthetic dice — one per sub-roll,
1141
+ * `result = subtotal` — feed `mergeDropSets` to pick kept/dropped indices.
1142
+ * Dropped sub-rolls' inner dice are re-flagged `'dropped'` so `sumKeptDice`
1143
+ * on the propagated rolls still agrees with the group total, and the
1144
+ * rendered form wraps them in strikethrough `~~...~~`.
1145
+ */
1146
+ function evalGroupModifier(
1147
+ node: ModifierNode,
1148
+ group: GroupNode,
1149
+ specs: ModifierSpec[],
1150
+ rng: RNG,
1151
+ ctx: EvalContext,
1152
+ env: EvalEnv,
1153
+ ): EvalResult {
1154
+ type SubRoll = {
1155
+ subtotal: number;
1156
+ part: RollPart;
1157
+ rolls: DieResult[];
1158
+ expr: string;
1159
+ rendered: string;
1160
+ versusMetadata: EvalContext['versusMetadata'];
1161
+ };
1162
+
1163
+ const subRolls: SubRoll[] = group.expressions.map((expr) => {
1164
+ const subCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1165
+ const sub = evalNode(expr, rng, subCtx, env);
1166
+ return {
1167
+ subtotal: sub.total,
1168
+ part: sub.part,
1169
+ rolls: subCtx.rolls,
1170
+ expr: subCtx.expressionParts.join(''),
1171
+ rendered: subCtx.renderedParts.join(''),
1172
+ versusMetadata: subCtx.versusMetadata,
1173
+ };
1174
+ });
1175
+
1176
+ // ? Synthetic dice carry `sides = 0` as a sentinel — they're never
1177
+ // surfaced in `ctx.rolls`, only used as input to `mergeDropSets`. Any
1178
+ // `critical`/`fumble` flags would be meaningless for a subtotal.
1179
+ const syntheticDice: DieResult[] = subRolls.map((sub) => ({
1180
+ sides: 0,
1181
+ result: sub.subtotal,
1182
+ modifiers: [],
1183
+ critical: false,
1184
+ fumble: false,
1185
+ }));
1186
+
1187
+ const mergedSynthetic = mergeDropSets(syntheticDice, specs);
1188
+
1189
+ const outerRendered: string[] = [];
1190
+ const keptIndices: number[] = [];
1191
+ let total = 0;
1192
+
1193
+ for (let i = 0; i < subRolls.length; i++) {
1194
+ const sub = subRolls[i] as SubRoll;
1195
+ const synth = mergedSynthetic[i] as DieResult;
1196
+ const isDropped = synth.modifiers.includes('dropped');
1197
+
1198
+ if (isDropped) {
1199
+ // Flag every inner die dropped so propagated rolls match the total.
1200
+ // Keep existing `'kept'` stripped — the sub-roll didn't contribute,
1201
+ // so its die-level kept annotation no longer applies. `'success'` /
1202
+ // `'failure'` are stripped too: the top-level successes/failures scan
1203
+ // must not count dice from a sub-roll that was dropped. Mutated in
1204
+ // place — the same objects live in the sub-roll's RollPart.
1205
+ for (const die of sub.rolls) {
1206
+ die.modifiers = [
1207
+ ...die.modifiers.filter(
1208
+ (m) => m !== 'kept' && m !== 'dropped' && m !== 'success' && m !== 'failure',
1209
+ ),
1210
+ 'dropped',
1211
+ ];
1212
+ }
1213
+ ctx.rolls.push(...sub.rolls);
1214
+ outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
1215
+ } else {
1216
+ keptIndices.push(i);
1217
+ ctx.rolls.push(...sub.rolls);
1218
+ outerRendered.push(sub.rendered);
1219
+ total += sub.subtotal;
1220
+ }
1221
+
1222
+ // Propagate Versus metadata only from kept sub-rolls — `RollResult.degree`
1223
+ // must reflect dice that contributed to `RollResult.total`. Two kept
1224
+ // versus sub-rolls still collide here via the `NESTED_VERSUS` guard
1225
+ // inside `propagateMetadata`.
1226
+ if (!isDropped) {
1227
+ propagateMetadata(ctx, sub.versusMetadata);
1228
+ }
1229
+ }
1230
+
1231
+ const subExprStrs = subRolls.map((s) => s.expr);
1232
+ const modifierCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1233
+
1234
+ ctx.expressionParts.push(`{${subExprStrs.join(', ')}}${modifierCodes}`);
1235
+ // ? Mirror `evalModifier`'s flat-pool rendering: modifier codes live in
1236
+ // `expressionParts` only — the per-sub strikethrough already signals
1237
+ // which sub-rolls were kept vs dropped.
1238
+ ctx.renderedParts.push(`{${outerRendered.join(', ')}}`);
1239
+
1240
+ // ? `keptIndices` lives on the inner `group` part (it describes sub-roll
1241
+ // selection), even though the outer modifier evaluation computed it —
1242
+ // the accepted model leak from STAGE3.md §5. Dropped sub-rolls keep
1243
+ // their complete parts; consumers filter by `keptIndices`.
1244
+ const groupPart: RollPart = {
1245
+ type: 'group',
1246
+ parts: subRolls.map((s) => s.part),
1247
+ keptIndices,
1248
+ total,
1249
+ ...partSpan(group),
1250
+ };
1251
+
1252
+ return {
1253
+ total,
1254
+ part: {
1255
+ type: 'modifier',
1256
+ specs: toPublicSpecs(specs),
1257
+ target: groupPart,
1258
+ total,
1259
+ ...partSpan(node),
1260
+ },
1261
+ };
1262
+ }
1263
+
1264
+ function resolveThreshold(
1265
+ value: ASTNode,
1266
+ rng: RNG,
1267
+ ctx: EvalContext,
1268
+ env: EvalEnv,
1269
+ role: 'threshold' | 'fail threshold',
1270
+ ): number {
1271
+ const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1272
+ const resolved = evalNode(value, rng, thresholdCtx, env).total;
1273
+ mergeMetaRolls(ctx, thresholdCtx);
1274
+
1275
+ if (!Number.isFinite(resolved)) {
1276
+ throw new EvaluatorError(`Invalid ${role}: ${resolved}`, 'INVALID_THRESHOLD', 'SuccessCount');
1277
+ }
1278
+
1279
+ return resolved;
1280
+ }
1281
+
1282
+ function evalSuccessCount(
1283
+ node: SuccessCountNode,
1284
+ rng: RNG,
1285
+ ctx: EvalContext,
1286
+ env: EvalEnv,
1287
+ ): EvalResult {
1288
+ // ? Flag tracks syntactic presence of success-count notation, not pool size —
1289
+ // set before any early return so empty pools still populate successes/failures.
1290
+ env.hasSuccessCount = true;
1291
+
1292
+ const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1293
+ const target = evalNode(node.target, rng, targetCtx, env);
1294
+ const targetExpr = targetCtx.expressionParts.join('');
1295
+
1296
+ const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
1297
+ const failValue =
1298
+ node.failThreshold != null
1299
+ ? resolveThreshold(node.failThreshold.value, rng, ctx, env, 'fail threshold')
1300
+ : undefined;
1301
+
1302
+ const code = `${node.threshold.operator}${thresholdValue}${
1303
+ failValue != null ? `f${failValue}` : ''
1304
+ }`;
1305
+
1306
+ const buildPart = (total: number, successes: number, failures: number): RollPart => {
1307
+ const part: RollPart = {
1308
+ type: 'successCount',
1309
+ threshold: { operator: node.threshold.operator, value: thresholdValue },
1310
+ target: target.part,
1311
+ successes,
1312
+ failures,
1313
+ total,
1314
+ ...partSpan(node),
1315
+ };
1316
+ if (failValue != null && node.failThreshold != null) {
1317
+ part.failThreshold = { operator: node.failThreshold.operator, value: failValue };
1318
+ }
1319
+ return part;
1320
+ };
1321
+
1322
+ // No-op when the target produced no dice in its pool (`0d6>=4`).
1323
+ // `containsDicePool` should already reject dice-less targets at parse
1324
+ // time, but guard defensively. `targetValue` is 0 for an empty pool, so
1325
+ // the `total === successes - failures` invariant holds.
1326
+ if (targetCtx.rolls.length === 0) {
1327
+ ctx.expressionParts.push(`${targetExpr}${code}`);
1328
+ ctx.renderedParts.push(`${targetExpr}${code}`);
1329
+ return { total: target.total, part: buildPart(target.total, 0, 0) };
1330
+ }
1331
+
1332
+ const result = countSuccesses(
1333
+ targetCtx.rolls,
1334
+ { operator: node.threshold.operator, value: thresholdValue },
1335
+ failValue != null && node.failThreshold != null
1336
+ ? { operator: node.failThreshold.operator, value: failValue }
1337
+ : undefined,
1338
+ );
1339
+
1340
+ ctx.rolls.push(...targetCtx.rolls);
1341
+ ctx.expressionParts.push(`${targetExpr}${code}`);
1342
+ ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
1343
+
1344
+ return {
1345
+ total: result.total,
1346
+ part: buildPart(result.total, result.successes, result.failures),
1347
+ };
1348
+ }
1349
+
1350
+ /**
1351
+ * Extracts the "natural" d20 value from a roll-side dice pool. Returns the
1352
+ * single value when exactly one primary kept d20 is present; otherwise
1353
+ * `undefined`.
1354
+ *
1355
+ * Excludes dropped (`kh`/`kl`/`dh`/`dl`/`r`/`ro`) dice — these aren't the
1356
+ * final kept result. Explosion continuation dice (appended by standard/
1357
+ * penetrating explode, tagged `'exploded'` with no `initialResult`) are not
1358
+ * primaries either — `1d20! vs DC` keeps the natural from the original d20.
1359
+ * Compound explode accumulates into the original die and sets
1360
+ * `initialResult`, so it stays a primary and the raw first face is used.
1361
+ * Multiple primary kept d20s (e.g., `1d20+1d20`) yield `undefined` so no
1362
+ * ambiguous upgrade/downgrade is applied.
1363
+ */
1364
+ function extractNatural(rolls: DieResult[]): number | undefined {
1365
+ // Rerolled intermediates are always stamped `['rerolled', 'dropped']`
1366
+ // (see `modifiers/reroll.ts`), so filtering by `'dropped'` covers them.
1367
+ const primaries = rolls.filter(
1368
+ (d) =>
1369
+ d.sides === 20 &&
1370
+ !d.modifiers.includes('dropped') &&
1371
+ !(d.modifiers.includes('exploded') && d.initialResult === undefined),
1372
+ );
1373
+ if (primaries.length !== 1) return undefined;
1374
+ const die = primaries[0];
1375
+ return die?.initialResult ?? die?.result;
1376
+ }
1377
+
1378
+ /**
1379
+ * PF2e degree of success: compares `total` to `dc` at three thresholds and
1380
+ * applies natural 20 upgrade / natural 1 downgrade with clamping.
1381
+ */
1382
+ function calculateDegree(total: number, dc: number, natural: number | undefined): DegreeOfSuccess {
1383
+ let degree: DegreeOfSuccess;
1384
+ if (total >= dc + 10) degree = DegreeOfSuccess.CriticalSuccess;
1385
+ else if (total >= dc) degree = DegreeOfSuccess.Success;
1386
+ else if (total > dc - 10) degree = DegreeOfSuccess.Failure;
1387
+ else degree = DegreeOfSuccess.CriticalFailure;
1388
+
1389
+ if (natural === 20 && degree < DegreeOfSuccess.CriticalSuccess) degree++;
1390
+ if (natural === 1 && degree > DegreeOfSuccess.CriticalFailure) degree--;
1391
+
1392
+ return degree;
1393
+ }
1394
+
1395
+ function degreeLabel(degree: DegreeOfSuccess): string {
1396
+ switch (degree) {
1397
+ case DegreeOfSuccess.CriticalFailure:
1398
+ return 'Critical Failure';
1399
+ case DegreeOfSuccess.Failure:
1400
+ return 'Failure';
1401
+ case DegreeOfSuccess.Success:
1402
+ return 'Success';
1403
+ case DegreeOfSuccess.CriticalSuccess:
1404
+ return 'Critical Success';
1405
+ }
1406
+ }
1407
+
1408
+ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1409
+ if (env.insideVersus) {
1410
+ throw new EvaluatorError('Cannot nest versus operators', 'NESTED_VERSUS', 'Versus');
1411
+ }
1412
+
1413
+ env.insideVersus = true;
1414
+ try {
1415
+ const rollCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1416
+ const rollResult = evalNode(node.roll, rng, rollCtx, env);
1417
+ // ? Extract natural from rollCtx directly — the roll-side pool is isolated
1418
+ // here, so no index slicing on the merged parent pool is needed.
1419
+ const natural = extractNatural(rollCtx.rolls);
1420
+
1421
+ const dcCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1422
+ const dcResult = evalNode(node.dc, rng, dcCtx, env);
1423
+
1424
+ const degree = calculateDegree(rollResult.total, dcResult.total, natural);
1425
+
1426
+ ctx.rolls.push(...rollCtx.rolls, ...dcCtx.rolls);
1427
+
1428
+ const rollExpr = rollCtx.expressionParts.join('');
1429
+ const dcExpr = dcCtx.expressionParts.join('');
1430
+ const rollRendered = rollCtx.renderedParts.join('');
1431
+ const dcRendered = dcCtx.renderedParts.join('');
1432
+
1433
+ ctx.expressionParts.push(`${rollExpr} vs ${dcExpr}`);
1434
+ ctx.renderedParts.push(`${rollRendered} vs ${dcRendered}`);
1435
+ ctx.versusMetadata = { degree, natural, dcTotal: dcResult.total };
1436
+
1437
+ return {
1438
+ total: rollResult.total,
1439
+ part: {
1440
+ type: 'versus',
1441
+ roll: rollResult.part,
1442
+ dc: dcResult.part,
1443
+ degree,
1444
+ total: rollResult.total,
1445
+ ...partSpan(node),
1446
+ },
1447
+ };
1448
+ } finally {
1449
+ env.insideVersus = false;
1450
+ }
1451
+ }
1452
+
1453
+ /**
1454
+ * Evaluates a parsed AST and returns the roll result.
1455
+ *
1456
+ * @param ast - The parsed AST node
1457
+ * @param rng - Random number generator to use for dice rolls
1458
+ * @param options - Optional evaluation options
1459
+ * @returns Complete roll result with total and metadata
1460
+ *
1461
+ * @example
1462
+ * ```typescript
1463
+ * const ast = parse('2d6+3');
1464
+ * const rng = new SeededRNG('test');
1465
+ * const result = evaluate(ast, rng);
1466
+ * console.log(result.total); // Sum of dice plus 3
1467
+ * ```
1468
+ */
1469
+ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}): RollResult {
1470
+ const maxDice =
1471
+ options.maxDice != null && Number.isFinite(options.maxDice) && options.maxDice > 0
1472
+ ? Math.floor(options.maxDice)
1473
+ : DEFAULT_MAX_DICE;
1474
+
1475
+ const maxExplodeIterations =
1476
+ options.maxExplodeIterations != null &&
1477
+ Number.isFinite(options.maxExplodeIterations) &&
1478
+ options.maxExplodeIterations >= 0
1479
+ ? Math.floor(options.maxExplodeIterations)
1480
+ : DEFAULT_MAX_EXPLODE_ITERATIONS;
1481
+
1482
+ const maxRerollIterations =
1483
+ options.maxRerollIterations != null &&
1484
+ Number.isFinite(options.maxRerollIterations) &&
1485
+ options.maxRerollIterations >= 0
1486
+ ? Math.floor(options.maxRerollIterations)
1487
+ : DEFAULT_MAX_REROLL_ITERATIONS;
1488
+
1489
+ const context = options.context ?? {};
1490
+ const onMissingVariable = options.onMissingVariable ?? 'throw';
1491
+
1492
+ const env: EvalEnv = {
1493
+ maxDice,
1494
+ maxExplodeIterations,
1495
+ maxRerollIterations,
1496
+ totalDiceRolled: 0,
1497
+ hasSuccessCount: false,
1498
+ insideVersus: false,
1499
+ context,
1500
+ onMissingVariable,
1501
+ };
1502
+ const ctx: EvalContext = {
1503
+ rolls: [],
1504
+ expressionParts: [],
1505
+ renderedParts: [],
1506
+ };
1507
+
1508
+ const { total, part } = evalNode(ast, rng, ctx, env);
1509
+
1510
+ if (!Number.isFinite(total)) {
1511
+ throw new EvaluatorError(
1512
+ `Result is not a finite number: ${total}`,
1513
+ 'NON_FINITE_RESULT',
1514
+ ast.type,
1515
+ );
1516
+ }
1517
+
1518
+ const expression = ctx.expressionParts.join('');
1519
+ // ? Versus replaces the numeric total with the degree label in the rendered
1520
+ // form; `RollResult.total` remains the numeric roll total.
1521
+ const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
1522
+ const rendered = `${ctx.renderedParts.join('')} = ${trailing}`;
1523
+
1524
+ const result: RollResult = {
1525
+ total,
1526
+ notation: options.notation ?? expression,
1527
+ expression,
1528
+ rendered,
1529
+ rolls: ctx.rolls,
1530
+ parts: part,
1531
+ };
1532
+
1533
+ if (env.hasSuccessCount) {
1534
+ let successes = 0;
1535
+ let failures = 0;
1536
+ for (const die of ctx.rolls) {
1537
+ if (die.modifiers.includes('success')) successes += 1;
1538
+ else if (die.modifiers.includes('failure')) failures += 1;
1539
+ }
1540
+ result.successes = successes;
1541
+ result.failures = failures;
1542
+ }
1543
+
1544
+ if (ctx.versusMetadata) {
1545
+ result.degree = ctx.versusMetadata.degree;
1546
+ if (ctx.versusMetadata.natural !== undefined) {
1547
+ result.natural = ctx.versusMetadata.natural;
1548
+ }
1549
+ }
1550
+
1551
+ return result;
1552
+ }