roll-parser 3.0.0-alpha.0 → 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 (80) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +166 -30
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/format.d.ts +1 -1
  5. package/dist/cli/format.d.ts.map +1 -1
  6. package/dist/cli.js +1110 -277
  7. package/dist/cli.js.map +28 -0
  8. package/dist/errors.d.ts +7 -4
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/evaluator/evaluator.d.ts +63 -7
  11. package/dist/evaluator/evaluator.d.ts.map +1 -1
  12. package/dist/evaluator/index.d.ts +2 -2
  13. package/dist/evaluator/index.d.ts.map +1 -1
  14. package/dist/evaluator/modifiers/compare.d.ts +1 -1
  15. package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
  16. package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
  17. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  18. package/dist/evaluator/modifiers/explode.d.ts +8 -4
  19. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  20. package/dist/evaluator/modifiers/keep-drop.d.ts +1 -1
  21. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  22. package/dist/evaluator/modifiers/reroll.d.ts +4 -4
  23. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  24. package/dist/evaluator/modifiers/sort.d.ts +23 -0
  25. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  26. package/dist/evaluator/modifiers/success-count.d.ts +1 -1
  27. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  28. package/dist/index.d.ts +13 -13
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1112 -275
  31. package/dist/index.js.map +26 -0
  32. package/dist/lexer/lexer.d.ts +18 -3
  33. package/dist/lexer/lexer.d.ts.map +1 -1
  34. package/dist/lexer/tokens.d.ts +22 -2
  35. package/dist/lexer/tokens.d.ts.map +1 -1
  36. package/dist/parser/ast.d.ts +209 -24
  37. package/dist/parser/ast.d.ts.map +1 -1
  38. package/dist/parser/parser.d.ts +48 -5
  39. package/dist/parser/parser.d.ts.map +1 -1
  40. package/dist/rng/index.d.ts +2 -2
  41. package/dist/rng/index.d.ts.map +1 -1
  42. package/dist/rng/mock.d.ts +1 -1
  43. package/dist/rng/mock.d.ts.map +1 -1
  44. package/dist/rng/seeded.d.ts +8 -1
  45. package/dist/rng/seeded.d.ts.map +1 -1
  46. package/dist/roll.d.ts +6 -2
  47. package/dist/roll.d.ts.map +1 -1
  48. package/dist/testing.d.ts +1 -1
  49. package/dist/testing.d.ts.map +1 -1
  50. package/dist/testing.js +3 -0
  51. package/dist/testing.js.map +11 -0
  52. package/dist/types.d.ts +143 -2
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +22 -18
  55. package/src/cli/args.ts +2 -1
  56. package/src/cli/format.ts +8 -4
  57. package/src/cli/index.ts +22 -5
  58. package/src/errors.ts +15 -3
  59. package/src/evaluator/evaluator.ts +826 -111
  60. package/src/evaluator/index.ts +2 -2
  61. package/src/evaluator/modifiers/compare.ts +1 -1
  62. package/src/evaluator/modifiers/crit-threshold.ts +59 -0
  63. package/src/evaluator/modifiers/explode.ts +29 -25
  64. package/src/evaluator/modifiers/keep-drop.ts +1 -1
  65. package/src/evaluator/modifiers/reroll.ts +18 -25
  66. package/src/evaluator/modifiers/sort.ts +30 -0
  67. package/src/evaluator/modifiers/success-count.ts +2 -2
  68. package/src/index.ts +33 -15
  69. package/src/lexer/lexer.ts +101 -8
  70. package/src/lexer/tokens.ts +42 -2
  71. package/src/parser/ast.ts +397 -30
  72. package/src/parser/parser.ts +590 -67
  73. package/src/rng/index.ts +2 -2
  74. package/src/rng/mock.ts +1 -1
  75. package/src/rng/seeded.ts +31 -1
  76. package/src/roll.ts +14 -6
  77. package/src/testing.ts +1 -1
  78. package/src/types.ts +127 -2
  79. package/dist/index.mjs +0 -1724
  80. package/dist/testing.mjs +0 -39
@@ -4,32 +4,47 @@
4
4
  * @module evaluator/evaluator
5
5
  */
6
6
 
7
- import type { RollParserErrorCode } from '../errors';
8
- import { RollParserError } from '../errors';
7
+ import type { RollParserErrorCode } from '../errors.js';
8
+ import { RollParserError } from '../errors.js';
9
9
  import type {
10
10
  ASTNode,
11
11
  BinaryOpNode,
12
+ CritThreshold,
13
+ CritThresholdNode,
12
14
  DiceNode,
13
15
  ExplodeNode,
14
16
  FateDiceNode,
15
17
  FunctionCallNode,
18
+ GroupedNode,
19
+ GroupNode,
20
+ LiteralNode,
16
21
  ModifierNode,
17
22
  RerollNode,
23
+ SortNode,
18
24
  SuccessCountNode,
19
25
  UnaryOpNode,
26
+ VariableNode,
20
27
  VersusNode,
21
- } from '../parser/ast';
22
- import { isModifier } from '../parser/ast';
23
- import type { RNG } from '../rng/types';
24
- import type { ComparePoint, DieResult, EvaluateOptions, RollResult } from '../types';
25
- import { DegreeOfSuccess } from '../types';
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';
26
41
  import {
27
42
  applyCompoundExplode,
28
43
  applyPenetratingExplode,
29
44
  applyStandardExplode,
30
45
  buildShouldExplode,
31
46
  DEFAULT_MAX_EXPLODE_ITERATIONS,
32
- } from './modifiers/explode';
47
+ } from './modifiers/explode.js';
33
48
  import {
34
49
  applyDropHighest,
35
50
  applyDropLowest,
@@ -37,24 +52,34 @@ import {
37
52
  applyKeepLowest,
38
53
  markAllKept,
39
54
  sumKeptDice,
40
- } from './modifiers/keep-drop';
55
+ } from './modifiers/keep-drop.js';
41
56
  import {
42
57
  applyRecursiveReroll,
43
58
  applyRerollOnce,
44
59
  DEFAULT_MAX_REROLL_ITERATIONS,
45
- } from './modifiers/reroll';
46
- import { countSuccesses } from './modifiers/success-count';
60
+ } from './modifiers/reroll.js';
61
+ import { sortDice } from './modifiers/sort.js';
62
+ import { countSuccesses } from './modifiers/success-count.js';
47
63
 
48
64
  /**
49
65
  * Error thrown during AST evaluation.
50
66
  */
51
67
  export class EvaluatorError extends RollParserError {
52
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;
53
76
 
54
77
  constructor(message: string, code: RollParserErrorCode, nodeType?: string) {
55
78
  super(message, code);
56
79
  this.name = 'EvaluatorError';
57
80
  this.nodeType = nodeType ?? undefined;
81
+ this.start = undefined;
82
+ this.end = undefined;
58
83
  }
59
84
  }
60
85
 
@@ -87,12 +112,25 @@ export type EvalEnv = {
87
112
  * left-chain check.
88
113
  */
89
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';
90
126
  };
91
127
 
92
128
  /**
93
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.
94
132
  */
95
- type EvalContext = {
133
+ export type EvalContext = {
96
134
  rolls: DieResult[];
97
135
  expressionParts: string[];
98
136
  renderedParts: string[];
@@ -111,6 +149,7 @@ type EvalContext = {
111
149
 
112
150
  /**
113
151
  * Flattened representation of a keep/drop modifier for chain evaluation.
152
+ * Superset of the public `ModifierSpec` (adds the notation `code`).
114
153
  */
115
154
  type ModifierSpec = {
116
155
  modifier: 'keep' | 'drop';
@@ -119,16 +158,44 @@ type ModifierSpec = {
119
158
  code: string;
120
159
  };
121
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
+
122
187
  /**
123
188
  * Creates a new die result with critical/fumble detection.
124
189
  */
125
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).
126
193
  return {
127
194
  sides,
128
195
  result,
129
196
  modifiers: [],
130
197
  critical: result === sides && sides > 1,
131
- fumble: result === 1,
198
+ fumble: result === 1 && sides > 1,
132
199
  };
133
200
  }
134
201
 
@@ -150,30 +217,83 @@ function createFateDieResult(result: number): DieResult {
150
217
  * Renders dice results for display. Marker priority: dropped wins over
151
218
  * success/failure (dropped dice are never counted), success wins over
152
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.
153
224
  */
154
225
  function renderDice(dice: DieResult[]): string {
155
- const parts = dice.map((die) => {
156
- if (die.modifiers.includes('dropped')) {
157
- return `~~${die.result}~~`;
158
- }
159
- if (die.modifiers.includes('success')) {
160
- return `**${die.result}**`;
161
- }
162
- if (die.modifiers.includes('failure')) {
163
- return `__${die.result}__`;
164
- }
165
- return String(die.result);
166
- });
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
+ });
167
240
  return `[${parts.join(', ')}]`;
168
241
  }
169
242
 
170
243
  /**
171
- * Evaluates an AST node, returning value and updating context.
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.
172
280
  */
173
- function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
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 {
174
294
  switch (node.type) {
175
295
  case 'Literal':
176
- return evalLiteral(node.value, ctx);
296
+ return evalLiteral(node, ctx);
177
297
 
178
298
  case 'Dice':
179
299
  return evalDice(node, rng, ctx, env);
@@ -205,6 +325,21 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
205
325
  case 'FunctionCall':
206
326
  return evalFunctionCall(node, rng, ctx, env);
207
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
+
208
343
  default: {
209
344
  const exhaustive: never = node;
210
345
  throw new EvaluatorError(
@@ -216,25 +351,84 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
216
351
  }
217
352
  }
218
353
 
219
- function evalLiteral(value: number, ctx: EvalContext): number {
354
+ function evalLiteral(node: LiteralNode, ctx: EvalContext): EvalResult {
355
+ const { value } = node;
220
356
  ctx.expressionParts.push(String(value));
221
357
  ctx.renderedParts.push(String(value));
222
- return value;
358
+ return { total: value, part: { type: 'literal', value, total: value, ...partSpan(node) } };
223
359
  }
224
360
 
225
- function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
226
- const count = evalNode(
227
- node.count,
228
- rng,
229
- { rolls: [], expressionParts: [], renderedParts: [] },
230
- env,
231
- );
232
- const sides = evalNode(
233
- node.sides,
234
- rng,
235
- { rolls: [], expressionParts: [], renderedParts: [] },
236
- env,
237
- );
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);
238
432
 
239
433
  if (!Number.isInteger(count) || count < 0) {
240
434
  throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'Dice');
@@ -267,16 +461,16 @@ function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): num
267
461
  ctx.expressionParts.push(notation);
268
462
  ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
269
463
 
270
- return total;
464
+ return {
465
+ total,
466
+ part: { type: 'dice', count, sides, rolls: markedDice, total, ...partSpan(node) },
467
+ };
271
468
  }
272
469
 
273
- function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
274
- const count = evalNode(
275
- node.count,
276
- rng,
277
- { rolls: [], expressionParts: [], renderedParts: [] },
278
- env,
279
- );
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);
280
474
 
281
475
  if (!Number.isInteger(count) || count < 0) {
282
476
  throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'FateDice');
@@ -306,17 +500,56 @@ function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalE
306
500
  ctx.expressionParts.push(notation);
307
501
  ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
308
502
 
309
- return total;
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;
310
530
  }
311
531
 
312
- function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
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 {
313
545
  const leftCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
314
546
  const rightCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
315
547
 
316
548
  const left = evalNode(node.left, rng, leftCtx, env);
317
549
  const right = evalNode(node.right, rng, rightCtx, env);
318
550
 
319
- ctx.rolls.push(...leftCtx.rolls, ...rightCtx.rolls);
551
+ mergeContext(ctx, leftCtx);
552
+ mergeContext(ctx, rightCtx);
320
553
 
321
554
  const leftExpr = leftCtx.expressionParts.join('');
322
555
  const rightExpr = rightCtx.expressionParts.join('');
@@ -326,7 +559,27 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
326
559
  ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`);
327
560
  ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`);
328
561
 
329
- switch (node.operator) {
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) {
330
583
  case '+':
331
584
  return left + right;
332
585
  case '-':
@@ -346,17 +599,17 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
346
599
  case '**':
347
600
  return left ** right;
348
601
  default: {
349
- const exhaustive: never = node.operator;
602
+ const exhaustive: never = operator;
350
603
  throw new EvaluatorError(`Unknown operator: ${exhaustive}`, 'UNKNOWN_OPERATOR', 'BinaryOp');
351
604
  }
352
605
  }
353
606
  }
354
607
 
355
- function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
608
+ function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
356
609
  const innerCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
357
- const value = evalNode(node.operand, rng, innerCtx, env);
610
+ const inner = evalNode(node.operand, rng, innerCtx, env);
358
611
 
359
- ctx.rolls.push(...innerCtx.rolls);
612
+ mergeContext(ctx, innerCtx);
360
613
 
361
614
  const innerExpr = innerCtx.expressionParts.join('');
362
615
  const innerRendered = innerCtx.renderedParts.join('');
@@ -364,7 +617,61 @@ function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv
364
617
  ctx.expressionParts.push(`-${innerExpr}`);
365
618
  ctx.renderedParts.push(`-${innerRendered}`);
366
619
 
367
- return -value;
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) } };
368
675
  }
369
676
 
370
677
  function evalFunctionCall(
@@ -372,18 +679,18 @@ function evalFunctionCall(
372
679
  rng: RNG,
373
680
  ctx: EvalContext,
374
681
  env: EvalEnv,
375
- ): number {
682
+ ): EvalResult {
376
683
  const argCtxs: EvalContext[] = [];
377
- const values: number[] = [];
684
+ const argResults: EvalResult[] = [];
378
685
 
379
686
  for (const arg of node.args) {
380
687
  const argCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
381
- values.push(evalNode(arg, rng, argCtx, env));
688
+ argResults.push(evalNode(arg, rng, argCtx, env));
382
689
  argCtxs.push(argCtx);
383
690
  }
384
691
 
385
692
  for (const argCtx of argCtxs) {
386
- ctx.rolls.push(...argCtx.rolls);
693
+ mergeContext(ctx, argCtx);
387
694
  }
388
695
 
389
696
  const argExprs = argCtxs.map((c) => c.expressionParts.join(''));
@@ -392,7 +699,21 @@ function evalFunctionCall(
392
699
  ctx.expressionParts.push(`${node.name}(${argExprs.join(', ')})`);
393
700
  ctx.renderedParts.push(`${node.name}(${argRendereds.join(', ')})`);
394
701
 
395
- return applyFunction(node.name, values);
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
+ };
396
717
  }
397
718
 
398
719
  function applyFunction(name: string, values: number[]): number {
@@ -402,6 +723,9 @@ function applyFunction(name: string, values: number[]): number {
402
723
  case 'ceil':
403
724
  return Math.ceil(requireUnaryArg(name, values));
404
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`.
405
729
  return Math.round(requireUnaryArg(name, values));
406
730
  case 'abs':
407
731
  return Math.abs(requireUnaryArg(name, values));
@@ -435,6 +759,7 @@ function requireUnaryArg(name: string, values: number[]): number {
435
759
  function flattenModifierChain(
436
760
  node: ModifierNode,
437
761
  rng: RNG,
762
+ ctx: EvalContext,
438
763
  env: EvalEnv,
439
764
  ): { specs: ModifierSpec[]; baseTarget: ASTNode } {
440
765
  const specs: ModifierSpec[] = [];
@@ -442,7 +767,8 @@ function flattenModifierChain(
442
767
 
443
768
  while (isModifier(current)) {
444
769
  const countCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
445
- const modCount = evalNode(current.count, rng, countCtx, env);
770
+ const modCount = evalNode(current.count, rng, countCtx, env).total;
771
+ mergeMetaRolls(ctx, countCtx);
446
772
 
447
773
  if (!Number.isInteger(modCount) || modCount < 0) {
448
774
  throw new EvaluatorError(
@@ -486,6 +812,11 @@ function applyModifierSpec(dice: DieResult[], spec: ModifierSpec): DieResult[] {
486
812
  /**
487
813
  * Applies each modifier independently to the full dice pool
488
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.
489
820
  */
490
821
  function mergeDropSets(baseDice: DieResult[], specs: ModifierSpec[]): DieResult[] {
491
822
  const droppedIndices = new Set<number>();
@@ -499,12 +830,13 @@ function mergeDropSets(baseDice: DieResult[], specs: ModifierSpec[]): DieResult[
499
830
  }
500
831
  }
501
832
 
502
- return baseDice.map((die, index) => ({
503
- ...die,
504
- modifiers: droppedIndices.has(index)
833
+ baseDice.forEach((die, index) => {
834
+ die.modifiers = droppedIndices.has(index)
505
835
  ? [...die.modifiers.filter((m) => m !== 'kept' && m !== 'dropped'), 'dropped']
506
- : [...die.modifiers.filter((m) => m !== 'dropped' && m !== 'kept'), 'kept'],
507
- }));
836
+ : [...die.modifiers.filter((m) => m !== 'dropped' && m !== 'kept'), 'kept'];
837
+ });
838
+
839
+ return baseDice;
508
840
  }
509
841
 
510
842
  /**
@@ -520,24 +852,39 @@ function formatExplodeCode(
520
852
  return `${marker}${threshold.operator}${thresholdValue}`;
521
853
  }
522
854
 
523
- function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
855
+ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
524
856
  const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
525
- const targetValue = evalNode(node.target, rng, targetCtx, env);
857
+ const target = evalNode(node.target, rng, targetCtx, env);
526
858
  const targetExpr = targetCtx.expressionParts.join('');
527
859
 
528
860
  let thresholdValue: number | undefined;
529
861
  if (node.threshold != null) {
530
862
  const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
531
- thresholdValue = evalNode(node.threshold.value, rng, thresholdCtx, env);
863
+ thresholdValue = evalNode(node.threshold.value, rng, thresholdCtx, env).total;
864
+ mergeMetaRolls(ctx, thresholdCtx);
532
865
  }
533
866
 
534
867
  const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
535
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
+
536
883
  // No-op when the target produced no dice (e.g., `(1+2)!`).
537
884
  if (targetCtx.rolls.length === 0) {
538
885
  ctx.expressionParts.push(`${targetExpr}${code}`);
539
886
  ctx.renderedParts.push(`${targetExpr}${code}`);
540
- return targetValue;
887
+ return { total: target.total, part: buildPart(target.total) };
541
888
  }
542
889
 
543
890
  const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
@@ -556,24 +903,41 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
556
903
  // dropped dice are visible; explosion-origin is otherwise invisible.
557
904
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
558
905
 
559
- return sumKeptDice(expanded);
906
+ const total = sumKeptDice(expanded);
907
+ return { total, part: buildPart(total) };
560
908
  }
561
909
 
562
- function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
910
+ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
563
911
  const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
564
- evalNode(node.target, rng, targetCtx, env);
912
+ const target = evalNode(node.target, rng, targetCtx, env);
565
913
  const targetExpr = targetCtx.expressionParts.join('');
566
914
 
567
915
  const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
568
- const thresholdValue = evalNode(node.condition.value, rng, thresholdCtx, env);
916
+ const thresholdValue = evalNode(node.condition.value, rng, thresholdCtx, env).total;
917
+ mergeMetaRolls(ctx, thresholdCtx);
569
918
 
570
919
  const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
920
+ const condition: ResolvedComparePoint = {
921
+ operator: node.condition.operator,
922
+ value: thresholdValue,
923
+ };
571
924
 
572
925
  // No-op when the target produced no dice (e.g., `(1+2)r<5`).
573
926
  if (targetCtx.rolls.length === 0) {
574
927
  ctx.expressionParts.push(`${targetExpr}${code}`);
575
928
  ctx.renderedParts.push(`${targetExpr}${code}`);
576
- return sumKeptDice(targetCtx.rolls);
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
+ };
577
941
  }
578
942
 
579
943
  const pool = node.once
@@ -584,14 +948,153 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
584
948
  ctx.expressionParts.push(`${targetExpr}${code}`);
585
949
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
586
950
 
587
- return sumKeptDice(pool);
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 };
588
1083
  }
589
1084
 
590
- function evalModifier(node: ModifierNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
591
- const { specs, baseTarget } = flattenModifierChain(node, rng, env);
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
+ }
592
1095
 
593
1096
  const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
594
- evalNode(baseTarget, rng, targetCtx, env);
1097
+ const target = evalNode(baseTarget, rng, targetCtx, env);
595
1098
 
596
1099
  const mergedDice = mergeDropSets(targetCtx.rolls, specs);
597
1100
 
@@ -605,17 +1108,169 @@ function evalModifier(node: ModifierNode, rng: RNG, ctx: EvalContext, env: EvalE
605
1108
  ctx.expressionParts.push(`${targetExpr}${modifierCodes}`);
606
1109
  ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
607
1110
 
608
- return total;
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
+ };
609
1262
  }
610
1263
 
611
1264
  function resolveThreshold(
612
1265
  value: ASTNode,
613
1266
  rng: RNG,
1267
+ ctx: EvalContext,
614
1268
  env: EvalEnv,
615
1269
  role: 'threshold' | 'fail threshold',
616
1270
  ): number {
617
1271
  const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
618
- const resolved = evalNode(value, rng, thresholdCtx, env);
1272
+ const resolved = evalNode(value, rng, thresholdCtx, env).total;
1273
+ mergeMetaRolls(ctx, thresholdCtx);
619
1274
 
620
1275
  if (!Number.isFinite(resolved)) {
621
1276
  throw new EvaluatorError(`Invalid ${role}: ${resolved}`, 'INVALID_THRESHOLD', 'SuccessCount');
@@ -629,27 +1284,49 @@ function evalSuccessCount(
629
1284
  rng: RNG,
630
1285
  ctx: EvalContext,
631
1286
  env: EvalEnv,
632
- ): number {
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
+
633
1292
  const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
634
- const targetValue = evalNode(node.target, rng, targetCtx, env);
1293
+ const target = evalNode(node.target, rng, targetCtx, env);
635
1294
  const targetExpr = targetCtx.expressionParts.join('');
636
1295
 
637
- const thresholdValue = resolveThreshold(node.threshold.value, rng, env, 'threshold');
1296
+ const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
638
1297
  const failValue =
639
1298
  node.failThreshold != null
640
- ? resolveThreshold(node.failThreshold.value, rng, env, 'fail threshold')
1299
+ ? resolveThreshold(node.failThreshold.value, rng, ctx, env, 'fail threshold')
641
1300
  : undefined;
642
1301
 
643
1302
  const code = `${node.threshold.operator}${thresholdValue}${
644
1303
  failValue != null ? `f${failValue}` : ''
645
1304
  }`;
646
1305
 
647
- // No-op when the target produced no dice in its pool. `containsDice` should
648
- // already reject this at parse time, but guard defensively.
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.
649
1326
  if (targetCtx.rolls.length === 0) {
650
1327
  ctx.expressionParts.push(`${targetExpr}${code}`);
651
1328
  ctx.renderedParts.push(`${targetExpr}${code}`);
652
- return targetValue;
1329
+ return { total: target.total, part: buildPart(target.total, 0, 0) };
653
1330
  }
654
1331
 
655
1332
  const result = countSuccesses(
@@ -660,28 +1337,42 @@ function evalSuccessCount(
660
1337
  : undefined,
661
1338
  );
662
1339
 
663
- env.hasSuccessCount = true;
664
-
665
1340
  ctx.rolls.push(...targetCtx.rolls);
666
1341
  ctx.expressionParts.push(`${targetExpr}${code}`);
667
1342
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
668
1343
 
669
- return result.total;
1344
+ return {
1345
+ total: result.total,
1346
+ part: buildPart(result.total, result.successes, result.failures),
1347
+ };
670
1348
  }
671
1349
 
672
1350
  /**
673
1351
  * Extracts the "natural" d20 value from a roll-side dice pool. Returns the
674
- * single value when exactly one kept d20 is present; otherwise `undefined`.
1352
+ * single value when exactly one primary kept d20 is present; otherwise
1353
+ * `undefined`.
675
1354
  *
676
- * Excludes dropped (`kh`/`kl`/`dh`/`dl`) and rerolled (`r`/`ro`) dice — these
677
- * aren't the final kept result. Multiple kept d20s (e.g., `1d20+1d20`) yield
678
- * `undefined` so no ambiguous upgrade/downgrade is applied.
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.
679
1363
  */
680
1364
  function extractNatural(rolls: DieResult[]): number | undefined {
681
- const keptD20s = rolls.filter(
682
- (d) => d.sides === 20 && !d.modifiers.includes('dropped') && !d.modifiers.includes('rerolled'),
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),
683
1372
  );
684
- return keptD20s.length === 1 ? keptD20s[0]?.result : undefined;
1373
+ if (primaries.length !== 1) return undefined;
1374
+ const die = primaries[0];
1375
+ return die?.initialResult ?? die?.result;
685
1376
  }
686
1377
 
687
1378
  /**
@@ -714,7 +1405,7 @@ function degreeLabel(degree: DegreeOfSuccess): string {
714
1405
  }
715
1406
  }
716
1407
 
717
- function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
1408
+ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
718
1409
  if (env.insideVersus) {
719
1410
  throw new EvaluatorError('Cannot nest versus operators', 'NESTED_VERSUS', 'Versus');
720
1411
  }
@@ -722,15 +1413,15 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
722
1413
  env.insideVersus = true;
723
1414
  try {
724
1415
  const rollCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
725
- const rollTotal = evalNode(node.roll, rng, rollCtx, env);
1416
+ const rollResult = evalNode(node.roll, rng, rollCtx, env);
726
1417
  // ? Extract natural from rollCtx directly — the roll-side pool is isolated
727
1418
  // here, so no index slicing on the merged parent pool is needed.
728
1419
  const natural = extractNatural(rollCtx.rolls);
729
1420
 
730
1421
  const dcCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
731
- const dcTotal = evalNode(node.dc, rng, dcCtx, env);
1422
+ const dcResult = evalNode(node.dc, rng, dcCtx, env);
732
1423
 
733
- const degree = calculateDegree(rollTotal, dcTotal, natural);
1424
+ const degree = calculateDegree(rollResult.total, dcResult.total, natural);
734
1425
 
735
1426
  ctx.rolls.push(...rollCtx.rolls, ...dcCtx.rolls);
736
1427
 
@@ -741,9 +1432,19 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
741
1432
 
742
1433
  ctx.expressionParts.push(`${rollExpr} vs ${dcExpr}`);
743
1434
  ctx.renderedParts.push(`${rollRendered} vs ${dcRendered}`);
744
- ctx.versusMetadata = { degree, natural, dcTotal };
745
-
746
- return rollTotal;
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
+ };
747
1448
  } finally {
748
1449
  env.insideVersus = false;
749
1450
  }
@@ -785,6 +1486,9 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
785
1486
  ? Math.floor(options.maxRerollIterations)
786
1487
  : DEFAULT_MAX_REROLL_ITERATIONS;
787
1488
 
1489
+ const context = options.context ?? {};
1490
+ const onMissingVariable = options.onMissingVariable ?? 'throw';
1491
+
788
1492
  const env: EvalEnv = {
789
1493
  maxDice,
790
1494
  maxExplodeIterations,
@@ -792,6 +1496,8 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
792
1496
  totalDiceRolled: 0,
793
1497
  hasSuccessCount: false,
794
1498
  insideVersus: false,
1499
+ context,
1500
+ onMissingVariable,
795
1501
  };
796
1502
  const ctx: EvalContext = {
797
1503
  rolls: [],
@@ -799,7 +1505,15 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
799
1505
  renderedParts: [],
800
1506
  };
801
1507
 
802
- const total = evalNode(ast, rng, ctx, env);
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
+ }
803
1517
 
804
1518
  const expression = ctx.expressionParts.join('');
805
1519
  // ? Versus replaces the numeric total with the degree label in the rendered
@@ -813,6 +1527,7 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
813
1527
  expression,
814
1528
  rendered,
815
1529
  rolls: ctx.rolls,
1530
+ parts: part,
816
1531
  };
817
1532
 
818
1533
  if (env.hasSuccessCount) {