roll-parser 3.0.0-alpha.0 → 3.0.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 (163) hide show
  1. package/CHANGELOG.md +189 -0
  2. package/MIGRATION.md +147 -0
  3. package/README.md +985 -43
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +16 -4
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +17 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -14
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +26 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +58 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +62 -40
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +906 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.d.ts +1 -1
  37. package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
  38. package/dist/evaluator/modifiers/compare.js +15 -0
  39. package/dist/evaluator/modifiers/compare.js.map +1 -0
  40. package/dist/evaluator/modifiers/crit-threshold.d.ts +27 -0
  41. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  42. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  43. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  45. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  46. package/dist/evaluator/modifiers/die-bound.js +14 -0
  47. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  48. package/dist/evaluator/modifiers/explode.d.ts +18 -6
  49. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  50. package/dist/evaluator/modifiers/explode.js +103 -0
  51. package/dist/evaluator/modifiers/explode.js.map +1 -0
  52. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  53. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  54. package/dist/evaluator/modifiers/flags.js +18 -0
  55. package/dist/evaluator/modifiers/flags.js.map +1 -0
  56. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  57. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  58. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  59. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  60. package/dist/evaluator/modifiers/reroll.d.ts +14 -6
  61. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  62. package/dist/evaluator/modifiers/reroll.js +62 -0
  63. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  64. package/dist/evaluator/modifiers/sort.d.ts +27 -0
  65. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  66. package/dist/evaluator/modifiers/sort.js +13 -0
  67. package/dist/evaluator/modifiers/sort.js.map +1 -0
  68. package/dist/evaluator/modifiers/success-count.d.ts +2 -6
  69. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  70. package/dist/evaluator/modifiers/success-count.js +24 -0
  71. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  72. package/dist/index.d.ts +35 -13
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +12 -1723
  75. package/dist/index.js.map +1 -0
  76. package/dist/lexer/lexer.d.ts +68 -8
  77. package/dist/lexer/lexer.d.ts.map +1 -1
  78. package/dist/lexer/lexer.js +260 -0
  79. package/dist/lexer/lexer.js.map +1 -0
  80. package/dist/lexer/tokens.d.ts +52 -7
  81. package/dist/lexer/tokens.d.ts.map +1 -1
  82. package/dist/lexer/tokens.js +42 -0
  83. package/dist/lexer/tokens.js.map +1 -0
  84. package/dist/parser/ast.d.ts +419 -80
  85. package/dist/parser/ast.d.ts.map +1 -1
  86. package/dist/parser/ast.js +52 -0
  87. package/dist/parser/ast.js.map +1 -0
  88. package/dist/parser/guards.d.ts +106 -0
  89. package/dist/parser/guards.d.ts.map +1 -0
  90. package/dist/parser/guards.js +121 -0
  91. package/dist/parser/guards.js.map +1 -0
  92. package/dist/parser/parser.d.ts +162 -15
  93. package/dist/parser/parser.d.ts.map +1 -1
  94. package/dist/parser/parser.js +751 -0
  95. package/dist/parser/parser.js.map +1 -0
  96. package/dist/rng/mock.d.ts +74 -13
  97. package/dist/rng/mock.d.ts.map +1 -1
  98. package/dist/rng/mock.js +30 -0
  99. package/dist/rng/mock.js.map +1 -0
  100. package/dist/rng/seeded.d.ts +149 -10
  101. package/dist/rng/seeded.d.ts.map +1 -1
  102. package/dist/rng/seeded.js +138 -0
  103. package/dist/rng/seeded.js.map +1 -0
  104. package/dist/rng/types.d.ts +57 -0
  105. package/dist/rng/types.d.ts.map +1 -1
  106. package/dist/rng/types.js +2 -0
  107. package/dist/rng/types.js.map +1 -0
  108. package/dist/roll.d.ts +59 -25
  109. package/dist/roll.d.ts.map +1 -1
  110. package/dist/roll.js +8 -0
  111. package/dist/roll.js.map +1 -0
  112. package/dist/testing.d.ts +5 -4
  113. package/dist/testing.d.ts.map +1 -1
  114. package/dist/testing.js +2 -38
  115. package/dist/testing.js.map +1 -0
  116. package/dist/types.d.ts +427 -24
  117. package/dist/types.d.ts.map +1 -1
  118. package/dist/types.js +8 -0
  119. package/dist/types.js.map +1 -0
  120. package/dist/version.d.ts +2 -0
  121. package/dist/version.d.ts.map +1 -0
  122. package/dist/version.js +2 -0
  123. package/dist/version.js.map +1 -0
  124. package/package.json +93 -40
  125. package/src/cli/args.ts +66 -9
  126. package/src/cli/format.ts +30 -7
  127. package/src/cli/index.ts +27 -67
  128. package/src/cli/main.ts +129 -0
  129. package/src/errors.ts +488 -23
  130. package/src/evaluator/die.ts +50 -0
  131. package/src/evaluator/env.ts +73 -0
  132. package/src/evaluator/evaluator.ts +1296 -349
  133. package/src/evaluator/modifiers/compare.ts +1 -1
  134. package/src/evaluator/modifiers/crit-threshold.ts +56 -0
  135. package/src/evaluator/modifiers/die-bound.ts +39 -0
  136. package/src/evaluator/modifiers/explode.ts +82 -76
  137. package/src/evaluator/modifiers/flags.ts +61 -0
  138. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  139. package/src/evaluator/modifiers/reroll.ts +36 -64
  140. package/src/evaluator/modifiers/sort.ts +43 -0
  141. package/src/evaluator/modifiers/success-count.ts +6 -9
  142. package/src/index.ts +73 -34
  143. package/src/lexer/lexer.ts +201 -35
  144. package/src/lexer/tokens.ts +72 -7
  145. package/src/parser/ast.ts +453 -104
  146. package/src/parser/guards.ts +248 -0
  147. package/src/parser/parser.ts +835 -135
  148. package/src/rng/mock.ts +75 -14
  149. package/src/rng/seeded.ts +323 -58
  150. package/src/rng/types.ts +57 -0
  151. package/src/roll.ts +66 -41
  152. package/src/testing.ts +5 -9
  153. package/src/types.ts +416 -24
  154. package/src/version.ts +2 -0
  155. package/dist/cli.js +0 -1775
  156. package/dist/evaluator/index.d.ts +0 -8
  157. package/dist/evaluator/index.d.ts.map +0 -1
  158. package/dist/index.mjs +0 -1724
  159. package/dist/rng/index.d.ts +0 -8
  160. package/dist/rng/index.d.ts.map +0 -1
  161. package/dist/testing.mjs +0 -39
  162. package/src/evaluator/index.ts +0 -14
  163. package/src/rng/index.ts +0 -8
@@ -4,95 +4,137 @@
4
4
  * @module evaluator/evaluator
5
5
  */
6
6
 
7
- import type { RollParserErrorCode } from '../errors';
8
- import { RollParserError } from '../errors';
7
+ import { describeValue, EvaluatorError, RollParserError, stampEvaluatorSpan } from '../errors.js';
9
8
  import type {
10
9
  ASTNode,
11
10
  BinaryOpNode,
11
+ CritThreshold,
12
+ CritThresholdNode,
12
13
  DiceNode,
14
+ DieBoundNode,
13
15
  ExplodeNode,
14
16
  FateDiceNode,
15
17
  FunctionCallNode,
16
- ModifierNode,
18
+ GroupedNode,
19
+ GroupNode,
20
+ KeepDropNode,
21
+ LiteralNode,
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 { isKeepDrop } from '../parser/ast.js';
30
+ import { containsVersus } from '../parser/guards.js';
31
+ import type { RNG } from '../rng/types.js';
32
+ import type {
33
+ CompareOp,
34
+ ComparePoint,
35
+ DieModifier,
36
+ DieResult,
37
+ EvaluateOptions,
38
+ KeepDropSpec,
39
+ ResolvedComparePoint,
40
+ ResolvedCritThreshold,
41
+ RollPart,
42
+ RollResult,
43
+ } from '../types.js';
44
+ import { DegreeOfSuccess } from '../types.js';
45
+ import { createDieResult, createFateDieResult } from './die.js';
46
+ import { chargeDice, type EvalEnv } from './env.js';
47
+ import { applyCritThresholds } from './modifiers/crit-threshold.js';
48
+ import { applyDieBound } from './modifiers/die-bound.js';
26
49
  import {
27
50
  applyCompoundExplode,
28
51
  applyPenetratingExplode,
29
52
  applyStandardExplode,
30
53
  buildShouldExplode,
31
54
  DEFAULT_MAX_EXPLODE_ITERATIONS,
32
- } from './modifiers/explode';
55
+ } from './modifiers/explode.js';
33
56
  import {
34
- applyDropHighest,
35
- applyDropLowest,
36
- applyKeepHighest,
37
- applyKeepLowest,
38
- markAllKept,
39
- sumKeptDice,
40
- } from './modifiers/keep-drop';
57
+ isVersusDc,
58
+ rewriteFlags,
59
+ SELECTION_AND_TALLY_FLAGS,
60
+ SELECTION_FLAGS,
61
+ } from './modifiers/flags.js';
62
+ import { markDroppedIndices, sumKeptDice } from './modifiers/keep-drop.js';
41
63
  import {
42
64
  applyRecursiveReroll,
43
65
  applyRerollOnce,
44
66
  DEFAULT_MAX_REROLL_ITERATIONS,
45
- } from './modifiers/reroll';
46
- import { countSuccesses } from './modifiers/success-count';
67
+ } from './modifiers/reroll.js';
68
+ import { sortDice } from './modifiers/sort.js';
69
+ import { countSuccesses } from './modifiers/success-count.js';
47
70
 
48
- /**
49
- * Error thrown during AST evaluation.
50
- */
51
- export class EvaluatorError extends RollParserError {
52
- readonly nodeType: string | undefined;
71
+ // Defined in `errors.ts` so the modifier modules can throw it without an ESM
72
+ // value cycle back through here; re-exported for importers that expect it here.
73
+ export { EvaluatorError };
53
74
 
54
- constructor(message: string, code: RollParserErrorCode, nodeType?: string) {
55
- super(message, code);
56
- this.name = 'EvaluatorError';
57
- this.nodeType = nodeType ?? undefined;
58
- }
59
- }
75
+ //
76
+ // * Limits
77
+ //
60
78
 
61
- /** Default maximum total dice allowed per evaluation. */
79
+ /**
80
+ * Default value of `EvaluationOptions.maxDice`: the number of dice a single
81
+ * evaluation may roll before `DICE_LIMIT_EXCEEDED` is thrown.
82
+ *
83
+ * Counted across the whole expression, not per pool, so `6000d6+6000d6`
84
+ * breaches it. Includes dice rolled by explosions, rerolls, and
85
+ * meta-expressions.
86
+ *
87
+ * @category Limits
88
+ */
62
89
  export const DEFAULT_MAX_DICE = 10_000;
63
90
 
64
- export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
91
+ /**
92
+ * Largest rollable `sides` value. `SeededRNG.nextInt` cannot sample ranges
93
+ * above 2^53 without bias and throws a bare `RangeError` there, so cap one
94
+ * below that and report an `EvaluatorError` instead — every failure the
95
+ * library raises must satisfy `isRollParserError`.
96
+ */
97
+ const MAX_DICE_SIDES = Number.MAX_SAFE_INTEGER;
65
98
 
66
99
  /**
67
- * Per-evaluation shared environment (created once, shared across all branches).
100
+ * Resolves a user-supplied evaluation limit, failing closed.
68
101
  *
69
- * Exported for use by modifier implementations under `./modifiers/*`. Not part
70
- * of the public library API.
102
+ * Absent (`undefined` / `null`) takes the library default the no-options
103
+ * path. Anything else must be a safe integer at or above `min`; strings,
104
+ * `NaN`, `±Infinity`, negatives, and fractions all throw — substituting the
105
+ * default for a rejected value would hand the caller a *higher* limit than
106
+ * they asked for.
71
107
  */
72
- export type EvalEnv = {
73
- readonly maxDice: number;
74
- readonly maxExplodeIterations: number;
75
- readonly maxRerollIterations: number;
76
- totalDiceRolled: number;
77
- /**
78
- * Set to `true` by `evalSuccessCount`. Propagates through the shared env
79
- * so `evaluate()` can include `successes`/`failures` fields even when no
80
- * die was tagged (impossible threshold).
81
- */
82
- hasSuccessCount: boolean;
83
- /**
84
- * `true` while the evaluator is inside a `VersusNode`'s roll or DC
85
- * sub-evaluation. `evalVersus` rejects nesting via this flag — catches
86
- * paren-nested versus (`1d20 vs (5 vs 3)`) that slip past the parser's
87
- * left-chain check.
88
- */
89
- insideVersus: boolean;
90
- };
108
+ function resolveLimit(
109
+ value: number | undefined,
110
+ option: string,
111
+ fallback: number,
112
+ min: number,
113
+ ): number {
114
+ if (value == null) return fallback;
115
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min) {
116
+ throw new RollParserError(
117
+ `Option '${option}' must be an integer >= ${min}, received ${describeValue(value)}`,
118
+ 'INVALID_EVALUATION_LIMIT',
119
+ );
120
+ }
121
+ return value;
122
+ }
123
+
124
+ export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
125
+
126
+ //
127
+ // * Context
128
+ //
91
129
 
92
130
  /**
93
131
  * Per-branch mutable accumulator for tracking rolls and output during recursion.
132
+ *
133
+ * Module-level export, deliberately absent from `src/index.ts` — the package
134
+ * surface never mentions it. See {@link mergeMetaRolls} for why the export
135
+ * exists at all.
94
136
  */
95
- type EvalContext = {
137
+ export type EvalContext = {
96
138
  rolls: DieResult[];
97
139
  expressionParts: string[];
98
140
  renderedParts: string[];
@@ -109,71 +151,166 @@ type EvalContext = {
109
151
  };
110
152
  };
111
153
 
154
+ /**
155
+ * Creates an empty per-branch accumulator. Every sub-evaluation runs in a
156
+ * fresh context so its rolls and rendered fragments can be merged back on the
157
+ * parent's terms.
158
+ */
159
+ function createContext(): EvalContext {
160
+ return { rolls: [], expressionParts: [], renderedParts: [] };
161
+ }
162
+
112
163
  /**
113
164
  * Flattened representation of a keep/drop modifier for chain evaluation.
165
+ * Superset of the public `KeepDropSpec` (adds the notation `code`).
166
+ */
167
+ type KeepDropChainEntry = KeepDropSpec & { code: string };
168
+
169
+ /**
170
+ * Every branch returns its numeric total AND the `RollPart` it contributes
171
+ * to the structured breakdown — TypeScript exhaustiveness guarantees no
172
+ * branch can forget to produce a part.
114
173
  */
115
- type ModifierSpec = {
116
- modifier: 'keep' | 'drop';
117
- selector: 'highest' | 'lowest';
118
- count: number;
119
- code: string;
174
+ type EvalResult = {
175
+ total: number;
176
+ part: RollPart;
120
177
  };
121
178
 
179
+ //
180
+ // * Shared helpers
181
+ //
182
+
122
183
  /**
123
- * Creates a new die result with critical/fumble detection.
184
+ * Copies an AST node's source span onto a part (spread into the literal).
185
+ * Empty when the AST was built without parser spans.
124
186
  */
125
- function createDieResult(sides: number, result: number): DieResult {
126
- return {
127
- sides,
128
- result,
129
- modifiers: [],
130
- critical: result === sides && sides > 1,
131
- fumble: result === 1,
132
- };
187
+ function partSpan(node: ASTNode): { start?: number; end?: number } {
188
+ if (node.start == null || node.end == null) return {};
189
+ return { start: node.start, end: node.end };
133
190
  }
134
191
 
135
192
  /**
136
- * Creates a Fate/Fudge die result. Uses `sides = 0` as a sentinel — Fate dice
137
- * have no max-face concept, so `critical` and `fumble` are always `false`.
193
+ * Appends every element of `source` to `target`.
194
+ *
195
+ * Replaces `target.push(...source)` — the spread form passes one argument per
196
+ * element and overflows the call stack somewhere above half a million dice,
197
+ * which a user-raised `maxDice` can reach. A `RangeError` there would escape
198
+ * the `isRollParserError` contract.
138
199
  */
139
- function createFateDieResult(result: number): DieResult {
140
- return {
141
- sides: 0,
142
- result,
143
- modifiers: [],
144
- critical: false,
145
- fumble: false,
146
- };
200
+ function appendAll<T>(target: T[], source: readonly T[]): void {
201
+ for (const item of source) {
202
+ target.push(item);
203
+ }
204
+ }
205
+
206
+ /** Drops the internal notation `code`, leaving the public `KeepDropSpec` shape. */
207
+ function toPublicSpecs(specs: KeepDropChainEntry[]): KeepDropSpec[] {
208
+ return specs.map(({ code: _code, ...spec }) => spec);
147
209
  }
148
210
 
149
211
  /**
150
212
  * Renders dice results for display. Marker priority: dropped wins over
151
213
  * success/failure (dropped dice are never counted), success wins over
152
214
  * failure (a die cannot be both). Example: `[~~1~~, **6**, __1__, 3]`.
215
+ *
216
+ * Dice tagged `'meta'` (rolled to compute sub-expression parameters such as
217
+ * count/sides/threshold) are hidden from the rendered output — they exist in
218
+ * `RollResult.rolls` for audit, not for display.
153
219
  */
154
220
  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}**`;
221
+ // Hot path: one pass into a single string — filter + map + join allocated two
222
+ // intermediate arrays plus a string per die.
223
+ let rendered = '[';
224
+ let isFirst = true;
225
+
226
+ for (const die of dice) {
227
+ const { modifiers } = die;
228
+
229
+ // Fast path: a die untouched by any modifier carries exactly ['kept'].
230
+ // Skipping the `meta` scan here and the three in `renderDie` more than
231
+ // halves a plain `1000d6` — rendering dominates large unmodified pools.
232
+ if (modifiers.length === 1 && modifiers[0] === 'kept') {
233
+ rendered = isFirst ? `${rendered}${die.result}` : `${rendered}, ${die.result}`;
234
+ isFirst = false;
235
+ continue;
161
236
  }
162
- if (die.modifiers.includes('failure')) {
163
- return `__${die.result}__`;
164
- }
165
- return String(die.result);
166
- });
167
- return `[${parts.join(', ')}]`;
237
+
238
+ if (modifiers.includes('meta')) continue;
239
+
240
+ if (!isFirst) rendered += ', ';
241
+ isFirst = false;
242
+
243
+ rendered += renderDie(die.result, modifiers);
244
+ }
245
+
246
+ return `${rendered}]`;
247
+ }
248
+
249
+ /** Marker-wrapped spelling of one die, per `renderDice`'s priority order. */
250
+ function renderDie(result: number, modifiers: readonly DieModifier[]): string {
251
+ if (modifiers.includes('dropped')) return `~~${result}~~`;
252
+ if (modifiers.includes('success')) return `**${result}**`;
253
+ if (modifiers.includes('failure')) return `__${result}__`;
254
+ return String(result);
168
255
  }
169
256
 
170
257
  /**
171
- * Evaluates an AST node, returning value and updating context.
258
+ * Forwards rolls from a throwaway sub-expression context into the parent
259
+ * audit trail, tagging them as `'meta'` + `'dropped'`. Meta dice are dice
260
+ * rolled to compute parameters (dice count, sides, threshold, modifier
261
+ * count) — they consume RNG and count against `maxDice`, so they must be
262
+ * inspectable. Tagging them `'dropped'` keeps totals correct via
263
+ * `sumKeptDice`; `'meta'` lets renderers hide them and lets callers
264
+ * distinguish them from ordinary pool dice.
265
+ *
266
+ * `'success'`/`'failure'` tags are stripped here as defense-in-depth against
267
+ * a SuccessCount leaking into a meta sub-expression (parser rejects all such
268
+ * wrappings; this strip ensures a future parse regression cannot leak tags
269
+ * into the top-level `successes`/`failures` scan).
270
+ *
271
+ * `versusMetadata` is deliberately not forwarded — a meta sub-expression
272
+ * resolves to a scalar. `evalMetaOperand` rejects a versus before reaching
273
+ * here, so nothing is lost by the omission.
172
274
  */
173
- function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
275
+ // Exported rather than `@internal`: the tag strip above is unreachable through
276
+ // any parseable notation, so pinning it needs a direct call with a hand-built
277
+ // context — and `@internal` would hide that guarantee from the docs.
278
+ export function mergeMetaRolls(parent: EvalContext, source: EvalContext): void {
279
+ for (const die of source.rolls) {
280
+ parent.rolls.push({
281
+ ...die,
282
+ modifiers: rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'meta', 'dropped'),
283
+ });
284
+ }
285
+ }
286
+
287
+ //
288
+ // * Node dispatch
289
+ //
290
+
291
+ /**
292
+ * Evaluates an AST node, returning its total and `RollPart` while updating
293
+ * the context.
294
+ *
295
+ * Errors bubbling up get the source span of the tightest node that was being
296
+ * evaluated — the innermost `evalNode` frame stamps first, outer frames leave
297
+ * an already-stamped error untouched.
298
+ */
299
+ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
300
+ try {
301
+ return evalNodeInner(node, rng, ctx, env);
302
+ } catch (error) {
303
+ if (error instanceof EvaluatorError && node.start != null) {
304
+ stampEvaluatorSpan(error, node.start, node.end);
305
+ }
306
+ throw error;
307
+ }
308
+ }
309
+
310
+ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
174
311
  switch (node.type) {
175
312
  case 'Literal':
176
- return evalLiteral(node.value, ctx);
313
+ return evalLiteral(node, ctx);
177
314
 
178
315
  case 'Dice':
179
316
  return evalDice(node, rng, ctx, env);
@@ -187,8 +324,8 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
187
324
  case 'UnaryOp':
188
325
  return evalUnaryOp(node, rng, ctx, env);
189
326
 
190
- case 'Modifier':
191
- return evalModifier(node, rng, ctx, env);
327
+ case 'KeepDrop':
328
+ return evalKeepDrop(node, rng, ctx, env);
192
329
 
193
330
  case 'Explode':
194
331
  return evalExplode(node, rng, ctx, env);
@@ -196,6 +333,9 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
196
333
  case 'Reroll':
197
334
  return evalReroll(node, rng, ctx, env);
198
335
 
336
+ case 'DieBound':
337
+ return evalDieBound(node, rng, ctx, env);
338
+
199
339
  case 'SuccessCount':
200
340
  return evalSuccessCount(node, rng, ctx, env);
201
341
 
@@ -205,6 +345,21 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
205
345
  case 'FunctionCall':
206
346
  return evalFunctionCall(node, rng, ctx, env);
207
347
 
348
+ case 'Grouped':
349
+ return evalGrouped(node, rng, ctx, env);
350
+
351
+ case 'Group':
352
+ return evalGroup(node, rng, ctx, env);
353
+
354
+ case 'Sort':
355
+ return evalSort(node, rng, ctx, env);
356
+
357
+ case 'CritThreshold':
358
+ return evalCritThreshold(node, rng, ctx, env);
359
+
360
+ case 'Variable':
361
+ return evalVariable(node, ctx, env);
362
+
208
363
  default: {
209
364
  const exhaustive: never = node;
210
365
  throw new EvaluatorError(
@@ -216,107 +371,281 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
216
371
  }
217
372
  }
218
373
 
219
- function evalLiteral(value: number, ctx: EvalContext): number {
374
+ //
375
+ // * Leaf nodes
376
+ //
377
+
378
+ function evalLiteral(node: LiteralNode, ctx: EvalContext): EvalResult {
379
+ const { value } = node;
220
380
  ctx.expressionParts.push(String(value));
221
381
  ctx.renderedParts.push(String(value));
222
- return value;
382
+ return { total: value, part: { type: 'literal', value, total: value, ...partSpan(node) } };
223
383
  }
224
384
 
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
- );
385
+ /**
386
+ * Re-derives whether a variable name needs braces in its rendered form.
387
+ *
388
+ * The lexer accepts `@name` (bare) or `@{name with spaces}` (braced) but
389
+ * strips the braces from the captured value. To round-trip through `rendered`
390
+ * we re-derive bracedness from the name shape — anything outside the bare
391
+ * identifier grammar implies the user wrote braces (or would need them).
392
+ */
393
+ function variableNeedsBraces(name: string): boolean {
394
+ return !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
395
+ }
238
396
 
239
- if (!Number.isInteger(count) || count < 0) {
240
- throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'Dice');
241
- }
242
- if (!Number.isInteger(sides) || sides < 1) {
243
- throw new EvaluatorError(`Invalid dice sides: ${sides}`, 'INVALID_DICE_SIDES', 'Dice');
397
+ /**
398
+ * Looks up a variable in `env.context` and resolves missing keys per
399
+ * `env.onMissingVariable`. The resolved scalar is the variable's value;
400
+ * `expression` shows the resolved number (mirrors how literals render),
401
+ * while `rendered` keeps the original `@name` (or `@{name}`) annotated with
402
+ * the resolved value in brackets so readers can attribute the number.
403
+ */
404
+ function evalVariable(node: VariableNode, ctx: EvalContext, env: EvalEnv): EvalResult {
405
+ const present = Object.hasOwn(env.context, node.name);
406
+ if (!present) {
407
+ if (env.onMissingVariable === 'throw') {
408
+ throw new EvaluatorError(
409
+ `Undefined variable: ${node.name}`,
410
+ 'UNDEFINED_VARIABLE',
411
+ 'Variable',
412
+ );
413
+ }
414
+ const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
415
+ ctx.expressionParts.push('0');
416
+ ctx.renderedParts.push(`${display}[0]`);
417
+ return {
418
+ total: 0,
419
+ part: { type: 'variable', name: node.name, value: 0, total: 0, ...partSpan(node) },
420
+ };
244
421
  }
245
422
 
246
- if (env.totalDiceRolled + count > env.maxDice) {
423
+ const value = env.context[node.name] as number;
424
+ if (!Number.isFinite(value)) {
247
425
  throw new EvaluatorError(
248
- `Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`,
249
- 'DICE_LIMIT_EXCEEDED',
250
- 'Dice',
426
+ `Invalid variable value: ${node.name} = ${value}`,
427
+ 'INVALID_VARIABLE_VALUE',
428
+ 'Variable',
251
429
  );
252
430
  }
253
- env.totalDiceRolled += count;
431
+ const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
432
+ ctx.expressionParts.push(String(value));
433
+ ctx.renderedParts.push(`${display}[${value}]`);
434
+ return {
435
+ total: value,
436
+ part: { type: 'variable', name: node.name, value, total: value, ...partSpan(node) },
437
+ };
438
+ }
254
439
 
440
+ //
441
+ // * Dice pools
442
+ //
443
+
444
+ /**
445
+ * Evaluates a meta sub-expression (dice count, dice sides, a modifier count, a
446
+ * threshold) in an isolated context and forwards its rolls into `ctx` as meta
447
+ * dice.
448
+ *
449
+ * A `Literal` operand — the overwhelming majority (`3d6`, `4d6kh3`, `1d20!>18`)
450
+ * — is answered from the node without allocating the throwaway context: a
451
+ * literal draws no RNG, produces no rolls, and cannot throw, so the merge has
452
+ * nothing to carry. Draw order is untouched (see README, Randomness).
453
+ *
454
+ * A versus operand is rejected rather than reduced to its total: this forwards
455
+ * rolls but not `versusMetadata`, so consuming one would drop the resolved
456
+ * `degree`/`natural` with no signal. `rejectVersusMetaOperand` refuses the same
457
+ * positions at parse time; this is the backstop for a hand-built AST.
458
+ *
459
+ * The scan is structural and runs first. Watching for surviving `versusMetadata`
460
+ * instead would miss a versus under a wrapper that voids it (`DieBound`,
461
+ * `KeepDrop`), lose the race to any other runtime error in the operand, and burn
462
+ * RNG draws before failing.
463
+ */
464
+ function evalMetaOperand(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
465
+ if (node.type === 'Literal') return node.value;
466
+
467
+ if (containsVersus(node)) {
468
+ const error = new EvaluatorError(
469
+ 'Versus cannot be used as a meta-expression',
470
+ 'NESTED_VERSUS',
471
+ 'Versus',
472
+ );
473
+ if (node.start != null) stampEvaluatorSpan(error, node.start, node.end);
474
+ throw error;
475
+ }
476
+
477
+ const metaCtx = createContext();
478
+ const value = evalNode(node, rng, metaCtx, env).total;
479
+ mergeMetaRolls(ctx, metaCtx);
480
+ return value;
481
+ }
482
+
483
+ /** Rejects dice counts that cannot address a pool. */
484
+ function requireDiceCount(count: number, nodeType: 'Dice' | 'FateDice'): void {
485
+ if (!Number.isInteger(count) || count < 0) {
486
+ throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', nodeType);
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Rolls `count` dice and writes the pool into `ctx` — the tail shared by
492
+ * `evalDice` and `evalFateDice`. `rollDie` produces one die (drawing exactly
493
+ * one RNG value), `notation` is the canonical `NdX` / `NdF` spelling used by
494
+ * both the expression and rendered forms.
495
+ *
496
+ * `rollDie` is responsible for stamping `'kept'` at construction. A fresh pool
497
+ * has no pre-dropped dice, so the `markAllKept` pass this used to run could
498
+ * only ever append that one flag — at the cost of cloning every die. The same
499
+ * reasoning makes the running `total` exact: nothing here is dropped, so it
500
+ * equals `sumKeptDice(dice)`.
501
+ */
502
+ function rollPool(
503
+ count: number,
504
+ notation: string,
505
+ rollDie: () => DieResult,
506
+ ctx: EvalContext,
507
+ ): { total: number; rolls: DieResult[] } {
255
508
  const dice: DieResult[] = [];
509
+ let total = 0;
510
+
256
511
  for (let i = 0; i < count; i++) {
257
- const result = rng.nextInt(1, sides);
258
- dice.push(createDieResult(sides, result));
512
+ const die = rollDie();
513
+ dice.push(die);
514
+ total += die.result;
259
515
  }
260
516
 
261
- const markedDice = markAllKept(dice);
262
- ctx.rolls.push(...markedDice);
263
-
264
- const total = sumKeptDice(markedDice);
265
- const notation = `${count}d${sides}`;
517
+ appendAll(ctx.rolls, dice);
266
518
 
267
519
  ctx.expressionParts.push(notation);
268
- ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
520
+ ctx.renderedParts.push(`${notation}${renderDice(dice)}`);
269
521
 
270
- return total;
522
+ return { total, rolls: dice };
271
523
  }
272
524
 
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
- );
525
+ /**
526
+ * RNG draw order: `count` expression → `sides` expression → pool dice
527
+ * (one `nextInt` per die, left-to-right). Meta-expressions on `count`/`sides`
528
+ * (e.g. `(1+1)d(3*2)`) draw before the pool. For keep/drop-argument
529
+ * meta-expressions like `4d6kh(1d2)`, `flattenKeepDropChain` draws the
530
+ * keep/drop args first, then `evalKeepDrop` calls `evalDice` for the base
531
+ * pool. See README, Randomness → Draw order, for the full spec.
532
+ */
533
+ function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
534
+ const count = evalMetaOperand(node.count, rng, ctx, env);
535
+ const sides = evalMetaOperand(node.sides, rng, ctx, env);
280
536
 
281
- if (!Number.isInteger(count) || count < 0) {
282
- throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'FateDice');
537
+ requireDiceCount(count, 'Dice');
538
+ if (!Number.isInteger(sides) || sides < 1) {
539
+ throw new EvaluatorError(`Invalid dice sides: ${sides}`, 'INVALID_DICE_SIDES', 'Dice');
283
540
  }
284
-
285
- if (env.totalDiceRolled + count > env.maxDice) {
541
+ if (sides > MAX_DICE_SIDES) {
286
542
  throw new EvaluatorError(
287
- `Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`,
288
- 'DICE_LIMIT_EXCEEDED',
289
- 'FateDice',
543
+ `Dice sides ${sides} exceeds maximum of ${MAX_DICE_SIDES}`,
544
+ 'INVALID_DICE_SIDES',
545
+ 'Dice',
290
546
  );
291
547
  }
292
- env.totalDiceRolled += count;
293
548
 
294
- const dice: DieResult[] = [];
295
- for (let i = 0; i < count; i++) {
296
- const result = rng.nextInt(-1, 1);
297
- dice.push(createFateDieResult(result));
298
- }
549
+ chargeDice(env, count, 'Dice');
550
+
551
+ const { total, rolls } = rollPool(
552
+ count,
553
+ `${count}d${sides}`,
554
+ // Fresh `['kept']` per die — `success-count` appends tally flags in
555
+ // place, so a shared literal would tag the whole pool at once.
556
+ () => createDieResult(sides, rng.nextInt(1, sides), ['kept']),
557
+ ctx,
558
+ );
299
559
 
300
- const markedDice = markAllKept(dice);
301
- ctx.rolls.push(...markedDice);
560
+ return { total, part: { type: 'dice', count, sides, rolls, total, ...partSpan(node) } };
561
+ }
302
562
 
303
- const total = sumKeptDice(markedDice);
304
- const notation = `${count}dF`;
563
+ function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
564
+ const count = evalMetaOperand(node.count, rng, ctx, env);
305
565
 
306
- ctx.expressionParts.push(notation);
307
- ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
566
+ requireDiceCount(count, 'FateDice');
567
+ chargeDice(env, count, 'FateDice');
568
+
569
+ const { total, rolls } = rollPool(
570
+ count,
571
+ `${count}dF`,
572
+ () => createFateDieResult(rng.nextInt(-1, 1), ['kept']),
573
+ ctx,
574
+ );
308
575
 
309
- return total;
576
+ return { total, part: { type: 'fateDice', count, rolls, total, ...partSpan(node) } };
310
577
  }
311
578
 
312
- function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
313
- const leftCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
314
- const rightCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
579
+ //
580
+ // * Context merging
581
+ //
582
+
583
+ /**
584
+ * Propagates `versusMetadata` onto a parent so `degree`/`natural` survive
585
+ * wrappers like `floor(...)`, `(vs) + 0`, or `-(vs)`. Throws `NESTED_VERSUS`
586
+ * if the parent already carries metadata — two versus results cannot occupy
587
+ * the same `RollResult`. No-op when `metadata` is `undefined`.
588
+ *
589
+ * Use this directly when the caller has already pushed (or transformed) `rolls`
590
+ * itself. For the default case where the child's raw rolls flow up unchanged,
591
+ * use `mergeContext` instead.
592
+ *
593
+ * A **postfix modifier** may propagate only when it leaves `total` equal to the
594
+ * value the degree was resolved from — `evalVersus` computes `degree` once,
595
+ * against the total it saw, so a modifier that re-totals invalidates it. Among
596
+ * the modifiers only `evalSort` (reorders) and `evalCritThreshold` (tags)
597
+ * qualify. `evalExplode` adds dice, `evalReroll` replaces them, `evalKeepDrop`
598
+ * removes them, `evalDieBound` re-sums after clamping, and `evalSuccessCount`
599
+ * redefines `total` as a success tally, so none of those five may.
600
+ * `evalGroupKeepDrop` applies the rule per sub-roll, propagating only from
601
+ * sub-rolls it kept.
602
+ *
603
+ * Judge a modifier by what it does to `total`, not by which dice survive. The
604
+ * membership reading — "every die still contributes" — admits `evalDieBound`,
605
+ * which drops no dice and still invalidates the degree.
606
+ *
607
+ * Arithmetic wrappers are the deliberate exception named above: `(vs) + 100`
608
+ * reports `degree` beside a total of 103, because the wrapper post-processes a
609
+ * number without altering the comparison the degree came from. That is why they
610
+ * route through `mergeContext` rather than being held to the rule here.
611
+ */
612
+ function propagateMetadata(parent: EvalContext, metadata: EvalContext['versusMetadata']): void {
613
+ if (!metadata) return;
614
+ if (parent.versusMetadata) {
615
+ throw new EvaluatorError(
616
+ 'Multiple versus operators in the same expression',
617
+ 'NESTED_VERSUS',
618
+ 'Versus',
619
+ );
620
+ }
621
+ parent.versusMetadata = metadata;
622
+ }
623
+
624
+ /**
625
+ * Merges a child sub-context back into its parent. Copies `rolls` and
626
+ * delegates `versusMetadata` propagation to `propagateMetadata`.
627
+ *
628
+ * Does not merge `expressionParts` / `renderedParts` — each wrapper formats
629
+ * those with its own operator/function syntax.
630
+ */
631
+ function mergeContext(parent: EvalContext, child: EvalContext): void {
632
+ appendAll(parent.rolls, child.rolls);
633
+ propagateMetadata(parent, child.versusMetadata);
634
+ }
635
+
636
+ //
637
+ // * Arithmetic
638
+ //
639
+
640
+ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
641
+ const leftCtx = createContext();
642
+ const rightCtx = createContext();
315
643
 
316
644
  const left = evalNode(node.left, rng, leftCtx, env);
317
645
  const right = evalNode(node.right, rng, rightCtx, env);
318
646
 
319
- ctx.rolls.push(...leftCtx.rolls, ...rightCtx.rolls);
647
+ mergeContext(ctx, leftCtx);
648
+ mergeContext(ctx, rightCtx);
320
649
 
321
650
  const leftExpr = leftCtx.expressionParts.join('');
322
651
  const rightExpr = rightCtx.expressionParts.join('');
@@ -326,7 +655,27 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
326
655
  ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`);
327
656
  ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`);
328
657
 
329
- switch (node.operator) {
658
+ const total = applyBinaryOperator(node.operator, left.total, right.total);
659
+
660
+ return {
661
+ total,
662
+ part: {
663
+ type: 'binaryOp',
664
+ operator: node.operator,
665
+ left: left.part,
666
+ right: right.part,
667
+ total,
668
+ ...partSpan(node),
669
+ },
670
+ };
671
+ }
672
+
673
+ function applyBinaryOperator(
674
+ operator: BinaryOpNode['operator'],
675
+ left: number,
676
+ right: number,
677
+ ): number {
678
+ switch (operator) {
330
679
  case '+':
331
680
  return left + right;
332
681
  case '-':
@@ -346,17 +695,17 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
346
695
  case '**':
347
696
  return left ** right;
348
697
  default: {
349
- const exhaustive: never = node.operator;
698
+ const exhaustive: never = operator;
350
699
  throw new EvaluatorError(`Unknown operator: ${exhaustive}`, 'UNKNOWN_OPERATOR', 'BinaryOp');
351
700
  }
352
701
  }
353
702
  }
354
703
 
355
- function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
356
- const innerCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
357
- const value = evalNode(node.operand, rng, innerCtx, env);
704
+ function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
705
+ const innerCtx = createContext();
706
+ const inner = evalNode(node.operand, rng, innerCtx, env);
358
707
 
359
- ctx.rolls.push(...innerCtx.rolls);
708
+ mergeContext(ctx, innerCtx);
360
709
 
361
710
  const innerExpr = innerCtx.expressionParts.join('');
362
711
  const innerRendered = innerCtx.renderedParts.join('');
@@ -364,7 +713,65 @@ function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv
364
713
  ctx.expressionParts.push(`-${innerExpr}`);
365
714
  ctx.renderedParts.push(`-${innerRendered}`);
366
715
 
367
- return -value;
716
+ const total = -inner.total;
717
+
718
+ return {
719
+ total,
720
+ part: { type: 'unaryOp', operator: '-', operand: inner.part, total, ...partSpan(node) },
721
+ };
722
+ }
723
+
724
+ //
725
+ // * Groups and functions
726
+ //
727
+
728
+ function evalGrouped(node: GroupedNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
729
+ const innerCtx = createContext();
730
+ const inner = evalNode(node.expression, rng, innerCtx, env);
731
+
732
+ mergeContext(ctx, innerCtx);
733
+
734
+ ctx.expressionParts.push(`(${innerCtx.expressionParts.join('')})`);
735
+ ctx.renderedParts.push(`(${innerCtx.renderedParts.join('')})`);
736
+
737
+ return {
738
+ total: inner.total,
739
+ part: { type: 'grouped', inner: inner.part, total: inner.total, ...partSpan(node) },
740
+ };
741
+ }
742
+
743
+ /**
744
+ * Evaluates a grouped roll `{expr1, expr2, ...}`.
745
+ *
746
+ * Each sub-expression is evaluated in an isolated context, then its rolls
747
+ * and `versusMetadata` propagate up via `mergeContext`. Sub-roll subtotals
748
+ * sum to the group's total. When the group is the base target of a
749
+ * keep/drop modifier with `expressions.length >= 2`, `evalKeepDrop`
750
+ * intercepts first and never calls this function — dual semantics
751
+ * (flat-pool vs sub-roll) are decided there.
752
+ */
753
+ function evalGroup(node: GroupNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
754
+ const subExprs: string[] = [];
755
+ const subRendered: string[] = [];
756
+ const subParts: RollPart[] = [];
757
+ let total = 0;
758
+
759
+ for (const expr of node.expressions) {
760
+ const subCtx = createContext();
761
+ const sub = evalNode(expr, rng, subCtx, env);
762
+ mergeContext(ctx, subCtx);
763
+ subExprs.push(subCtx.expressionParts.join(''));
764
+ subRendered.push(subCtx.renderedParts.join(''));
765
+ subParts.push(sub.part);
766
+ total += sub.total;
767
+ }
768
+
769
+ ctx.expressionParts.push(`{${subExprs.join(', ')}}`);
770
+ ctx.renderedParts.push(`{${subRendered.join(', ')}}`);
771
+
772
+ // No `keptIndices` — bare groups (and single-sub passthroughs) perform
773
+ // no sub-roll selection; only `evalGroupKeepDrop` sets it.
774
+ return { total, part: { type: 'group', parts: subParts, total, ...partSpan(node) } };
368
775
  }
369
776
 
370
777
  function evalFunctionCall(
@@ -372,18 +779,18 @@ function evalFunctionCall(
372
779
  rng: RNG,
373
780
  ctx: EvalContext,
374
781
  env: EvalEnv,
375
- ): number {
782
+ ): EvalResult {
376
783
  const argCtxs: EvalContext[] = [];
377
- const values: number[] = [];
784
+ const argResults: EvalResult[] = [];
378
785
 
379
786
  for (const arg of node.args) {
380
- const argCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
381
- values.push(evalNode(arg, rng, argCtx, env));
787
+ const argCtx = createContext();
788
+ argResults.push(evalNode(arg, rng, argCtx, env));
382
789
  argCtxs.push(argCtx);
383
790
  }
384
791
 
385
792
  for (const argCtx of argCtxs) {
386
- ctx.rolls.push(...argCtx.rolls);
793
+ mergeContext(ctx, argCtx);
387
794
  }
388
795
 
389
796
  const argExprs = argCtxs.map((c) => c.expressionParts.join(''));
@@ -392,7 +799,21 @@ function evalFunctionCall(
392
799
  ctx.expressionParts.push(`${node.name}(${argExprs.join(', ')})`);
393
800
  ctx.renderedParts.push(`${node.name}(${argRendereds.join(', ')})`);
394
801
 
395
- return applyFunction(node.name, values);
802
+ const total = applyFunction(
803
+ node.name,
804
+ argResults.map((r) => r.total),
805
+ );
806
+
807
+ return {
808
+ total,
809
+ part: {
810
+ type: 'functionCall',
811
+ name: node.name,
812
+ args: argResults.map((r) => r.part),
813
+ total,
814
+ ...partSpan(node),
815
+ },
816
+ };
396
817
  }
397
818
 
398
819
  function applyFunction(name: string, values: number[]): number {
@@ -402,23 +823,52 @@ function applyFunction(name: string, values: number[]): number {
402
823
  case 'ceil':
403
824
  return Math.ceil(requireUnaryArg(name, values));
404
825
  case 'round':
826
+ // `Math.round` breaks halves toward +∞: `round(2.5) === 3` but
827
+ // `round(-2.5) === -2`. Symmetric rounding must be composed via `floor`.
405
828
  return Math.round(requireUnaryArg(name, values));
406
829
  case 'abs':
407
830
  return Math.abs(requireUnaryArg(name, values));
831
+ case 'sqrt':
832
+ // A negative argument yields NaN, surfaced as `NON_FINITE_RESULT` by
833
+ // the top-level finiteness check — same policy as `1/0`.
834
+ return Math.sqrt(requireUnaryArg(name, values));
835
+ case 'pow': {
836
+ const [base, exponent] = requireBinaryArgs(name, values);
837
+ return base ** exponent;
838
+ }
408
839
  case 'max':
409
- return Math.max(...values);
840
+ return extremumOf(values, 'max');
410
841
  case 'min':
411
- return Math.min(...values);
842
+ return extremumOf(values, 'min');
412
843
  default:
413
844
  throw new EvaluatorError(`Unknown function: ${name}`, 'UNKNOWN_FUNCTION', 'FunctionCall');
414
845
  }
415
846
  }
416
847
 
848
+ /**
849
+ * Folded replacement for `Math.max(...values)` / `Math.min(...values)`.
850
+ *
851
+ * `max`/`min` are variadic, so a pathological argument count blows the call
852
+ * stack with a bare `RangeError` in the spread form. NaN still poisons the
853
+ * result the way `Math.max` does, so a non-finite argument keeps surfacing as
854
+ * `NON_FINITE_RESULT` rather than being silently skipped.
855
+ */
856
+ function extremumOf(values: number[], kind: 'max' | 'min'): number {
857
+ let result = kind === 'max' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
858
+
859
+ for (const value of values) {
860
+ if (Number.isNaN(value)) return Number.NaN;
861
+ if (kind === 'max' ? value > result : value < result) result = value;
862
+ }
863
+
864
+ return result;
865
+ }
866
+
417
867
  function requireUnaryArg(name: string, values: number[]): number {
418
868
  const [x] = values;
419
- if (x === undefined) {
420
- // ? Unreachable: parser validates arity before evaluation. Defensive for
421
- // `noNonNullAssertion`.
869
+ if (x == null) {
870
+ // Unreachable: the parser validates arity before evaluation. Present to
871
+ // satisfy `noNonNullAssertion`.
422
872
  throw new EvaluatorError(
423
873
  `Function '${name}' requires an argument`,
424
874
  'UNKNOWN_FUNCTION',
@@ -428,40 +878,64 @@ function requireUnaryArg(name: string, values: number[]): number {
428
878
  return x;
429
879
  }
430
880
 
881
+ function requireBinaryArgs(name: string, values: number[]): [number, number] {
882
+ const [a, b] = values;
883
+ if (a == null || b == null) {
884
+ // ? Unreachable: parser validates arity before evaluation. Defensive for
885
+ // `noNonNullAssertion`.
886
+ throw new EvaluatorError(
887
+ `Function '${name}' requires two arguments`,
888
+ 'UNKNOWN_FUNCTION',
889
+ 'FunctionCall',
890
+ );
891
+ }
892
+ return [a, b];
893
+ }
894
+
895
+ //
896
+ // * Keep/drop modifiers
897
+ //
898
+
899
+ /** Notation spelling of each keep/drop combination. */
900
+ const KEEP_DROP_CODES = {
901
+ keep: { highest: 'kh', lowest: 'kl' },
902
+ drop: { highest: 'dh', lowest: 'dl' },
903
+ } as const;
904
+
905
+ function keepDropCode(kind: KeepDropSpec['kind'], selector: KeepDropSpec['selector']): string {
906
+ return KEEP_DROP_CODES[kind][selector];
907
+ }
908
+
431
909
  /**
432
- * Walks a nested ModifierNode chain, collecting specs outermost-first,
910
+ * Walks a nested KeepDropNode chain, collecting specs outermost-first,
433
911
  * then reverses to notation order (innermost-first).
434
912
  */
435
- function flattenModifierChain(
436
- node: ModifierNode,
913
+ function flattenKeepDropChain(
914
+ node: KeepDropNode,
437
915
  rng: RNG,
916
+ ctx: EvalContext,
438
917
  env: EvalEnv,
439
- ): { specs: ModifierSpec[]; baseTarget: ASTNode } {
440
- const specs: ModifierSpec[] = [];
918
+ ): { specs: KeepDropChainEntry[]; baseTarget: ASTNode } {
919
+ const specs: KeepDropChainEntry[] = [];
441
920
  let current: ASTNode = node;
442
921
 
443
- while (isModifier(current)) {
444
- const countCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
445
- const modCount = evalNode(current.count, rng, countCtx, env);
922
+ while (isKeepDrop(current)) {
923
+ const modCount = evalMetaOperand(current.count, rng, ctx, env);
446
924
 
447
925
  if (!Number.isInteger(modCount) || modCount < 0) {
448
926
  throw new EvaluatorError(
449
- `Invalid modifier count: ${modCount}`,
450
- 'INVALID_MODIFIER_COUNT',
451
- 'Modifier',
927
+ `Invalid keep/drop count: ${modCount}`,
928
+ 'INVALID_KEEP_DROP_COUNT',
929
+ 'KeepDrop',
452
930
  );
453
931
  }
454
932
 
455
- const code =
456
- current.modifier === 'keep'
457
- ? current.selector === 'highest'
458
- ? 'kh'
459
- : 'kl'
460
- : current.selector === 'highest'
461
- ? 'dh'
462
- : 'dl';
463
-
464
- specs.push({ modifier: current.modifier, selector: current.selector, count: modCount, code });
933
+ specs.push({
934
+ kind: current.kind,
935
+ selector: current.selector,
936
+ count: modCount,
937
+ code: keepDropCode(current.kind, current.selector),
938
+ });
465
939
  current = current.target;
466
940
  }
467
941
 
@@ -469,44 +943,57 @@ function flattenModifierChain(
469
943
  return { specs, baseTarget: current };
470
944
  }
471
945
 
472
- /**
473
- * Applies a single modifier spec to a dice pool.
474
- */
475
- function applyModifierSpec(dice: DieResult[], spec: ModifierSpec): DieResult[] {
476
- if (spec.modifier === 'keep') {
477
- return spec.selector === 'highest'
478
- ? applyKeepHighest(dice, spec.count)
479
- : applyKeepLowest(dice, spec.count);
480
- }
481
- return spec.selector === 'highest'
482
- ? applyDropHighest(dice, spec.count)
483
- : applyDropLowest(dice, spec.count);
484
- }
485
-
486
946
  /**
487
947
  * Applies each modifier independently to the full dice pool
488
948
  * and merges drop sets via union. A die is dropped if ANY modifier dropped it.
949
+ *
950
+ * Mutates each die's flags in place — the same `DieResult` objects are
951
+ * shared between `RollResult.rolls` and the `RollPart` tree. Each spec still
952
+ * selects against the unmodified pool: `markDroppedIndices` reads results and
953
+ * writes only into `droppedMask`, so no spec can observe another's outcome.
489
954
  */
490
- function mergeDropSets(baseDice: DieResult[], specs: ModifierSpec[]): DieResult[] {
491
- const droppedIndices = new Set<number>();
955
+ function mergeDropSets(baseDice: DieResult[], specs: KeepDropChainEntry[]): DieResult[] {
956
+ const droppedMask = new Uint8Array(baseDice.length);
492
957
 
493
958
  for (const spec of specs) {
494
- const result = applyModifierSpec(baseDice, spec);
495
- for (let i = 0; i < result.length; i++) {
496
- if (result[i]?.modifiers.includes('dropped')) {
497
- droppedIndices.add(i);
498
- }
499
- }
959
+ markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask);
500
960
  }
501
961
 
502
- return baseDice.map((die, index) => ({
503
- ...die,
504
- modifiers: droppedIndices.has(index)
505
- ? [...die.modifiers.filter((m) => m !== 'kept' && m !== 'dropped'), 'dropped']
506
- : [...die.modifiers.filter((m) => m !== 'dropped' && m !== 'kept'), 'kept'],
507
- }));
962
+ for (let index = 0; index < baseDice.length; index++) {
963
+ const die = baseDice[index];
964
+ if (die == null) continue;
965
+
966
+ const marker = droppedMask[index] === 1 ? 'dropped' : 'kept';
967
+ const { modifiers } = die;
968
+
969
+ // Already exactly this slot flag — `rewriteFlags` would rebuild an
970
+ // identical single-element array. Fresh pool dice hit this every time.
971
+ if (modifiers.length === 1 && modifiers[0] === marker) continue;
972
+
973
+ die.modifiers = rewriteFlags(modifiers, SELECTION_FLAGS, marker);
974
+ }
975
+
976
+ return baseDice;
508
977
  }
509
978
 
979
+ //
980
+ // * Explode and reroll
981
+ //
982
+
983
+ /** Notation marker for each explode variant. */
984
+ const EXPLODE_MARKERS: Record<ExplodeNode['variant'], string> = {
985
+ standard: '!',
986
+ compound: '!!',
987
+ penetrating: '!p',
988
+ };
989
+
990
+ /** Pool transform for each explode variant. */
991
+ const EXPLODE_APPLIERS: Record<ExplodeNode['variant'], typeof applyStandardExplode> = {
992
+ standard: applyStandardExplode,
993
+ compound: applyCompoundExplode,
994
+ penetrating: applyPenetratingExplode,
995
+ };
996
+
510
997
  /**
511
998
  * Builds the notation string for an explode modifier, e.g. `!`, `!!>=3`, `!p>5`.
512
999
  */
@@ -515,107 +1002,466 @@ function formatExplodeCode(
515
1002
  threshold: ComparePoint | undefined,
516
1003
  thresholdValue: number | undefined,
517
1004
  ): string {
518
- const marker = variant === 'standard' ? '!' : variant === 'compound' ? '!!' : '!p';
1005
+ const marker = EXPLODE_MARKERS[variant];
519
1006
  if (threshold == null || thresholdValue == null) return marker;
520
1007
  return `${marker}${threshold.operator}${thresholdValue}`;
521
1008
  }
522
1009
 
523
- function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
524
- const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
525
- const targetValue = evalNode(node.target, rng, targetCtx, env);
1010
+ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1011
+ const targetCtx = createContext();
1012
+ const target = evalNode(node.target, rng, targetCtx, env);
526
1013
  const targetExpr = targetCtx.expressionParts.join('');
527
1014
 
528
1015
  let thresholdValue: number | undefined;
529
1016
  if (node.threshold != null) {
530
- const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
531
- thresholdValue = evalNode(node.threshold.value, rng, thresholdCtx, env);
1017
+ thresholdValue = evalMetaOperand(node.threshold.value, rng, ctx, env);
532
1018
  }
533
1019
 
534
1020
  const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
535
1021
 
1022
+ const buildPart = (total: number): RollPart => {
1023
+ const part: RollPart = {
1024
+ type: 'explode',
1025
+ variant: node.variant,
1026
+ target: target.part,
1027
+ total,
1028
+ ...partSpan(node),
1029
+ };
1030
+ if (node.threshold != null && thresholdValue != null) {
1031
+ part.threshold = { operator: node.threshold.operator, value: thresholdValue };
1032
+ }
1033
+ return part;
1034
+ };
1035
+
536
1036
  // No-op when the target produced no dice (e.g., `(1+2)!`).
537
1037
  if (targetCtx.rolls.length === 0) {
538
1038
  ctx.expressionParts.push(`${targetExpr}${code}`);
539
1039
  ctx.renderedParts.push(`${targetExpr}${code}`);
540
- return targetValue;
1040
+ return { total: target.total, part: buildPart(target.total) };
541
1041
  }
542
1042
 
543
1043
  const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
544
1044
 
545
- const expanded =
546
- node.variant === 'standard'
547
- ? applyStandardExplode(targetCtx.rolls, shouldExplode, rng, env)
548
- : node.variant === 'compound'
549
- ? applyCompoundExplode(targetCtx.rolls, shouldExplode, rng, env)
550
- : applyPenetratingExplode(targetCtx.rolls, shouldExplode, rng, env);
1045
+ const expanded = EXPLODE_APPLIERS[node.variant](targetCtx.rolls, shouldExplode, rng, env);
551
1046
 
552
- ctx.rolls.push(...expanded);
1047
+ appendAll(ctx.rolls, expanded);
553
1048
  ctx.expressionParts.push(`${targetExpr}${code}`);
554
- // ? Include the explode code in rendered output so readers can attribute
555
- // the extra dice. Modifier rendering (kh/dl) skips its code because
556
- // dropped dice are visible; explosion-origin is otherwise invisible.
1049
+ // Rendered form carries the explode code unlike kh/dl, whose dropped dice
1050
+ // are self-evident, explosion origin is otherwise invisible.
557
1051
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
558
1052
 
559
- return sumKeptDice(expanded);
1053
+ const total = sumKeptDice(expanded);
1054
+ return { total, part: buildPart(total) };
560
1055
  }
561
1056
 
562
- function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
563
- const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
564
- evalNode(node.target, rng, targetCtx, env);
1057
+ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1058
+ const targetCtx = createContext();
1059
+ const target = evalNode(node.target, rng, targetCtx, env);
565
1060
  const targetExpr = targetCtx.expressionParts.join('');
566
1061
 
567
- const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
568
- const thresholdValue = evalNode(node.condition.value, rng, thresholdCtx, env);
1062
+ const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
569
1063
 
570
1064
  const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
1065
+ const condition: ResolvedComparePoint = {
1066
+ operator: node.condition.operator,
1067
+ value: thresholdValue,
1068
+ };
571
1069
 
572
1070
  // No-op when the target produced no dice (e.g., `(1+2)r<5`).
573
1071
  if (targetCtx.rolls.length === 0) {
574
1072
  ctx.expressionParts.push(`${targetExpr}${code}`);
575
1073
  ctx.renderedParts.push(`${targetExpr}${code}`);
576
- return sumKeptDice(targetCtx.rolls);
1074
+ const total = sumKeptDice(targetCtx.rolls);
1075
+ return {
1076
+ total,
1077
+ part: {
1078
+ type: 'reroll',
1079
+ once: node.once,
1080
+ condition,
1081
+ target: target.part,
1082
+ total,
1083
+ ...partSpan(node),
1084
+ },
1085
+ };
577
1086
  }
578
1087
 
579
1088
  const pool = node.once
580
1089
  ? applyRerollOnce(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env)
581
1090
  : applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
582
1091
 
583
- ctx.rolls.push(...pool);
1092
+ appendAll(ctx.rolls, pool);
584
1093
  ctx.expressionParts.push(`${targetExpr}${code}`);
585
1094
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
586
1095
 
587
- return sumKeptDice(pool);
1096
+ const total = sumKeptDice(pool);
1097
+ return {
1098
+ total,
1099
+ part: {
1100
+ type: 'reroll',
1101
+ once: node.once,
1102
+ condition,
1103
+ target: target.part,
1104
+ total,
1105
+ ...partSpan(node),
1106
+ },
1107
+ };
1108
+ }
1109
+
1110
+ /**
1111
+ * Evaluates a per-die clamp (`minN` / `maxN`). The bound expression draws
1112
+ * *after* the target pool, like other threshold arguments (see README,
1113
+ * Randomness → Draw order). Dice are clamped in place by `applyDieBound`
1114
+ * and the total re-summed, since clamping changes kept-die values.
1115
+ */
1116
+ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1117
+ const targetCtx = createContext();
1118
+ const target = evalNode(node.target, rng, targetCtx, env);
1119
+
1120
+ const boundValue = evalMetaOperand(node.value, rng, ctx, env);
1121
+ if (!Number.isFinite(boundValue)) {
1122
+ throw new EvaluatorError(
1123
+ `Invalid ${node.bound} bound: ${boundValue}`,
1124
+ 'INVALID_THRESHOLD',
1125
+ 'DieBound',
1126
+ );
1127
+ }
1128
+
1129
+ applyDieBound(targetCtx.rolls, node.bound, boundValue);
1130
+
1131
+ appendAll(ctx.rolls, targetCtx.rolls);
1132
+ // ! No `propagateMetadata` here: clamping re-sums, so a propagated `degree`
1133
+ // ! would have been resolved against a total this node just replaced.
1134
+ // ! See the rule on `propagateMetadata`.
1135
+
1136
+ const targetExpr = targetCtx.expressionParts.join('');
1137
+ // Negative bounds render parenthesized so `result.expression` re-parses
1138
+ // (`4d6min-2` is a syntax error; `4d6min(-2)` is not).
1139
+ const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
1140
+
1141
+ ctx.expressionParts.push(`${targetExpr}${code}`);
1142
+ ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
1143
+
1144
+ const total = sumKeptDice(targetCtx.rolls);
1145
+ return {
1146
+ total,
1147
+ part: {
1148
+ type: 'dieBound',
1149
+ bound: node.bound,
1150
+ value: boundValue,
1151
+ target: target.part,
1152
+ total,
1153
+ ...partSpan(node),
1154
+ },
1155
+ };
588
1156
  }
589
1157
 
590
- function evalModifier(node: ModifierNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
591
- const { specs, baseTarget } = flattenModifierChain(node, rng, env);
1158
+ //
1159
+ // * Sort and crit thresholds
1160
+ //
1161
+
1162
+ /**
1163
+ * Evaluates a sort modifier. Purely visual — sorts the dice produced by
1164
+ * `node.target` in ascending or descending order of `result` without
1165
+ * changing the total or any die-level flag. Dropped dice participate in
1166
+ * the sort alongside kept dice, preserving their `'dropped'` marker.
1167
+ *
1168
+ * Rendering mirrors `evalExplode` / `evalKeepDrop`: emits
1169
+ * `<targetExpr><code>[<sortedDice>]`, replacing any inline dice brackets
1170
+ * the target itself rendered. Multi-sub-roll Group targets (`{4d6, 3d6}s`)
1171
+ * are rejected at parse time with `INVALID_SORT_TARGET` until hierarchical
1172
+ * per-sub-roll sorting (Stage 3 spec §3 "Group interaction") is implemented.
1173
+ */
1174
+ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1175
+ const targetCtx = createContext();
1176
+ const target = evalNode(node.target, rng, targetCtx, env);
592
1177
 
593
- const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
594
- evalNode(baseTarget, rng, targetCtx, env);
1178
+ const sortedRolls = sortDice(targetCtx.rolls, node.order);
1179
+
1180
+ appendAll(ctx.rolls, sortedRolls);
1181
+ propagateMetadata(ctx, targetCtx.versusMetadata);
1182
+
1183
+ const code = node.order === 'ascending' ? 's' : 'sd';
1184
+ const targetExpr = targetCtx.expressionParts.join('');
1185
+
1186
+ ctx.expressionParts.push(`${targetExpr}${code}`);
1187
+ ctx.renderedParts.push(`${targetExpr}${code}${renderDice(sortedRolls)}`);
1188
+
1189
+ return {
1190
+ total: target.total,
1191
+ part: {
1192
+ type: 'sort',
1193
+ order: node.order,
1194
+ rolls: sortedRolls,
1195
+ target: target.part,
1196
+ total: target.total,
1197
+ ...partSpan(node),
1198
+ },
1199
+ };
1200
+ }
1201
+
1202
+ /**
1203
+ * Evaluates a critical/fumble threshold modifier. Pure post-processing:
1204
+ * evaluates the target in an isolated context, resolves each threshold's
1205
+ * ComparePoint value (including meta-expressions, which consume RNG draws
1206
+ * AFTER the target pool), then overrides `critical`/`fumble` flags on the
1207
+ * produced dice in place.
1208
+ *
1209
+ * Independent overrides (Roll20 semantics): `cs` thresholds replace only the
1210
+ * crit criteria and `cf` thresholds replace only the fumble criteria. When a
1211
+ * side has no explicit threshold, the `'default'` rule applies — so
1212
+ * `1d20cf<3` keeps the default nat-20 crit. Bare `cs`/`cf` uses the
1213
+ * `'default'` sentinel resolved per-die to `result === sides` or
1214
+ * `result === 1`.
1215
+ *
1216
+ * Renders `<targetExpr><codes>[<dice>]`, mirroring `evalSort`/`evalExplode`.
1217
+ */
1218
+ function evalCritThreshold(
1219
+ node: CritThresholdNode,
1220
+ rng: RNG,
1221
+ ctx: EvalContext,
1222
+ env: EvalEnv,
1223
+ ): EvalResult {
1224
+ const targetCtx = createContext();
1225
+ const target = evalNode(node.target, rng, targetCtx, env);
1226
+
1227
+ const successResolved = node.successThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
1228
+ const failResolved = node.failThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
1229
+
1230
+ // A side with no explicit threshold falls back to the default rule rather
1231
+ // than being wiped by the other side's override.
1232
+ const successApplied: ResolvedCritThreshold[] =
1233
+ successResolved.length > 0 ? successResolved : ['default'];
1234
+ const failApplied: ResolvedCritThreshold[] = failResolved.length > 0 ? failResolved : ['default'];
1235
+
1236
+ applyCritThresholds(targetCtx.rolls, successApplied, failApplied);
1237
+
1238
+ appendAll(ctx.rolls, targetCtx.rolls);
1239
+ propagateMetadata(ctx, targetCtx.versusMetadata);
1240
+
1241
+ const targetExpr = targetCtx.expressionParts.join('');
1242
+ const codes = [
1243
+ ...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
1244
+ ...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
1245
+ ].join('');
1246
+
1247
+ ctx.expressionParts.push(`${targetExpr}${codes}`);
1248
+ ctx.renderedParts.push(`${targetExpr}${codes}${renderDice(targetCtx.rolls)}`);
1249
+
1250
+ return {
1251
+ total: target.total,
1252
+ part: {
1253
+ type: 'critThreshold',
1254
+ successThresholds: successResolved,
1255
+ failThresholds: failResolved,
1256
+ target: target.part,
1257
+ total: target.total,
1258
+ ...partSpan(node),
1259
+ },
1260
+ };
1261
+ }
1262
+
1263
+ function resolveCritThreshold(
1264
+ threshold: CritThreshold,
1265
+ rng: RNG,
1266
+ ctx: EvalContext,
1267
+ env: EvalEnv,
1268
+ ): ResolvedCritThreshold {
1269
+ if (threshold === 'default') return 'default';
1270
+
1271
+ const resolved = evalMetaOperand(threshold.value, rng, ctx, env);
1272
+ if (!Number.isFinite(resolved)) {
1273
+ throw new EvaluatorError(
1274
+ `Invalid crit threshold: ${resolved}`,
1275
+ 'INVALID_THRESHOLD',
1276
+ 'CritThreshold',
1277
+ );
1278
+ }
1279
+ return { operator: threshold.operator, value: resolved };
1280
+ }
1281
+
1282
+ //
1283
+ // * Keep/drop evaluation
1284
+ //
1285
+
1286
+ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1287
+ const { specs, baseTarget } = flattenKeepDropChain(node, rng, ctx, env);
1288
+
1289
+ // Multi-sub-roll group: keep/drop treats each sub-roll subtotal as a compound
1290
+ // die. Single-sub groups fall through to the flat-pool path, where
1291
+ // `mergeDropSets` selects individual dice.
1292
+ if (baseTarget.type === 'Group' && baseTarget.expressions.length >= 2) {
1293
+ return evalGroupKeepDrop(node, baseTarget, specs, rng, ctx, env);
1294
+ }
1295
+
1296
+ const targetCtx = createContext();
1297
+ const target = evalNode(baseTarget, rng, targetCtx, env);
595
1298
 
596
1299
  const mergedDice = mergeDropSets(targetCtx.rolls, specs);
597
1300
 
598
- ctx.rolls.push(...mergedDice);
1301
+ appendAll(ctx.rolls, mergedDice);
599
1302
 
600
1303
  const total = sumKeptDice(mergedDice);
601
1304
 
602
1305
  const targetExpr = targetCtx.expressionParts.join('');
603
- const modifierCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1306
+ const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
604
1307
 
605
- ctx.expressionParts.push(`${targetExpr}${modifierCodes}`);
1308
+ ctx.expressionParts.push(`${targetExpr}${keepDropCodes}`);
606
1309
  ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
607
1310
 
608
- return total;
1311
+ return {
1312
+ total,
1313
+ part: {
1314
+ type: 'keepDrop',
1315
+ specs: toPublicSpecs(specs),
1316
+ target: target.part,
1317
+ total,
1318
+ ...partSpan(node),
1319
+ },
1320
+ };
1321
+ }
1322
+
1323
+ /**
1324
+ * Strips per-die markdown markers from an already-rendered sub-roll string.
1325
+ * Used when a whole group sub-roll is dropped: the outer `~~...~~` wrap
1326
+ * supersedes inner success (`**`), failure (`__`), and dropped (`~~`)
1327
+ * markers, and leaving them in place would nest strikethroughs or show
1328
+ * success highlights inside a dropped span.
1329
+ */
1330
+ function stripInnerMarkers(rendered: string): string {
1331
+ return rendered
1332
+ .replace(/\*\*(-?\d+)\*\*/g, '$1')
1333
+ .replace(/__(-?\d+)__/g, '$1')
1334
+ .replace(/~~(-?\d+)~~/g, '$1');
609
1335
  }
610
1336
 
1337
+ /**
1338
+ * Evaluates a keep/drop modifier chain whose base target is a multi
1339
+ * sub-roll group. Each sub-roll is evaluated in isolation so its subtotal
1340
+ * and dice are captured separately. Synthetic dice — one per sub-roll,
1341
+ * `result = subtotal` — feed `mergeDropSets` to pick kept/dropped indices.
1342
+ * Dropped sub-rolls' inner dice are re-flagged `'dropped'` so `sumKeptDice`
1343
+ * on the propagated rolls still agrees with the group total, and the
1344
+ * rendered form wraps them in strikethrough `~~...~~`.
1345
+ */
1346
+ function evalGroupKeepDrop(
1347
+ node: KeepDropNode,
1348
+ group: GroupNode,
1349
+ specs: KeepDropChainEntry[],
1350
+ rng: RNG,
1351
+ ctx: EvalContext,
1352
+ env: EvalEnv,
1353
+ ): EvalResult {
1354
+ type SubRoll = {
1355
+ subtotal: number;
1356
+ part: RollPart;
1357
+ rolls: DieResult[];
1358
+ expr: string;
1359
+ rendered: string;
1360
+ versusMetadata: EvalContext['versusMetadata'];
1361
+ };
1362
+
1363
+ const subRolls: SubRoll[] = group.expressions.map((expr) => {
1364
+ const subCtx = createContext();
1365
+ const sub = evalNode(expr, rng, subCtx, env);
1366
+ return {
1367
+ subtotal: sub.total,
1368
+ part: sub.part,
1369
+ rolls: subCtx.rolls,
1370
+ expr: subCtx.expressionParts.join(''),
1371
+ rendered: subCtx.renderedParts.join(''),
1372
+ versusMetadata: subCtx.versusMetadata,
1373
+ };
1374
+ });
1375
+
1376
+ // `sides = 0` sentinel: synthetic dice only feed `mergeDropSets`, never reach
1377
+ // `ctx.rolls`, and crit/fumble is meaningless for a subtotal.
1378
+ const syntheticDice: DieResult[] = subRolls.map((sub) => ({
1379
+ sides: 0,
1380
+ result: sub.subtotal,
1381
+ modifiers: [],
1382
+ critical: false,
1383
+ fumble: false,
1384
+ }));
1385
+
1386
+ const mergedSynthetic = mergeDropSets(syntheticDice, specs);
1387
+
1388
+ const outerRendered: string[] = [];
1389
+ const keptIndices: number[] = [];
1390
+ let total = 0;
1391
+
1392
+ for (let i = 0; i < subRolls.length; i++) {
1393
+ const sub = subRolls[i] as SubRoll;
1394
+ const synth = mergedSynthetic[i] as DieResult;
1395
+ const isDropped = synth.modifiers.includes('dropped');
1396
+
1397
+ if (isDropped) {
1398
+ // Flag every inner die dropped so propagated rolls still sum to the
1399
+ // total, stripping `'success'`/`'failure'` too so the top-level tally
1400
+ // cannot count a dropped sub-roll. Mutated in place — the same objects
1401
+ // live in the sub-roll's `RollPart`.
1402
+ for (const die of sub.rolls) {
1403
+ die.modifiers = rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'dropped');
1404
+ }
1405
+ appendAll(ctx.rolls, sub.rolls);
1406
+ outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
1407
+ } else {
1408
+ keptIndices.push(i);
1409
+ appendAll(ctx.rolls, sub.rolls);
1410
+ outerRendered.push(sub.rendered);
1411
+ total += sub.subtotal;
1412
+ }
1413
+
1414
+ // Only kept sub-rolls propagate versus metadata — `degree` must reflect
1415
+ // dice that contributed to the total. Two kept versus sub-rolls still
1416
+ // collide via `propagateMetadata`'s `NESTED_VERSUS` guard.
1417
+ if (!isDropped) {
1418
+ propagateMetadata(ctx, sub.versusMetadata);
1419
+ }
1420
+ }
1421
+
1422
+ const subExprStrs = subRolls.map((s) => s.expr);
1423
+ const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1424
+
1425
+ ctx.expressionParts.push(`{${subExprStrs.join(', ')}}${keepDropCodes}`);
1426
+ // Keep/drop codes live in `expressionParts` only — the per-sub strikethrough
1427
+ // already shows which sub-rolls were kept.
1428
+ ctx.renderedParts.push(`{${outerRendered.join(', ')}}`);
1429
+
1430
+ // `keptIndices` sits on the inner `group` part even though the outer modifier
1431
+ // computed it — it describes sub-roll selection. Dropped sub-rolls keep their
1432
+ // complete parts; consumers filter by `keptIndices`.
1433
+ const groupPart: RollPart = {
1434
+ type: 'group',
1435
+ parts: subRolls.map((s) => s.part),
1436
+ keptIndices,
1437
+ total,
1438
+ ...partSpan(group),
1439
+ };
1440
+
1441
+ return {
1442
+ total,
1443
+ part: {
1444
+ type: 'keepDrop',
1445
+ specs: toPublicSpecs(specs),
1446
+ target: groupPart,
1447
+ total,
1448
+ ...partSpan(node),
1449
+ },
1450
+ };
1451
+ }
1452
+
1453
+ //
1454
+ // * Success counting
1455
+ //
1456
+
611
1457
  function resolveThreshold(
612
1458
  value: ASTNode,
613
1459
  rng: RNG,
1460
+ ctx: EvalContext,
614
1461
  env: EvalEnv,
615
1462
  role: 'threshold' | 'fail threshold',
616
1463
  ): number {
617
- const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
618
- const resolved = evalNode(value, rng, thresholdCtx, env);
1464
+ const resolved = evalMetaOperand(value, rng, ctx, env);
619
1465
 
620
1466
  if (!Number.isFinite(resolved)) {
621
1467
  throw new EvaluatorError(`Invalid ${role}: ${resolved}`, 'INVALID_THRESHOLD', 'SuccessCount');
@@ -624,32 +1470,69 @@ function resolveThreshold(
624
1470
  return resolved;
625
1471
  }
626
1472
 
1473
+ /**
1474
+ * Builds the failure-threshold suffix of a success-count expression, e.g.
1475
+ * `f3`, `f<3`, `f>=3`.
1476
+ *
1477
+ * `'='` is elided because bare `fN` parses back to `{ operator: '=' }`, so
1478
+ * `f3` is the canonical spelling. Every other operator must be emitted —
1479
+ * dropping it silently rewrites `f<3` into `f3`, which counts a different
1480
+ * set of dice as failures.
1481
+ */
1482
+ function formatFailCode(operator: CompareOp, value: number): string {
1483
+ return operator === '=' ? `f${value}` : `f${operator}${value}`;
1484
+ }
1485
+
627
1486
  function evalSuccessCount(
628
1487
  node: SuccessCountNode,
629
1488
  rng: RNG,
630
1489
  ctx: EvalContext,
631
1490
  env: EvalEnv,
632
- ): number {
633
- const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
634
- const targetValue = evalNode(node.target, rng, targetCtx, env);
1491
+ ): EvalResult {
1492
+ // Flag tracks syntactic presence of success-count notation, not pool size
1493
+ // set before any early return so empty pools still populate successes/failures.
1494
+ env.hasSuccessCount = true;
1495
+
1496
+ const targetCtx = createContext();
1497
+ const target = evalNode(node.target, rng, targetCtx, env);
635
1498
  const targetExpr = targetCtx.expressionParts.join('');
636
1499
 
637
- const thresholdValue = resolveThreshold(node.threshold.value, rng, env, 'threshold');
1500
+ const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
638
1501
  const failValue =
639
1502
  node.failThreshold != null
640
- ? resolveThreshold(node.failThreshold.value, rng, env, 'fail threshold')
1503
+ ? resolveThreshold(node.failThreshold.value, rng, ctx, env, 'fail threshold')
641
1504
  : undefined;
642
1505
 
643
- const code = `${node.threshold.operator}${thresholdValue}${
644
- failValue != null ? `f${failValue}` : ''
645
- }`;
1506
+ const failCode =
1507
+ failValue != null && node.failThreshold != null
1508
+ ? formatFailCode(node.failThreshold.operator, failValue)
1509
+ : '';
1510
+
1511
+ const code = `${node.threshold.operator}${thresholdValue}${failCode}`;
1512
+
1513
+ const buildPart = (total: number, successes: number, failures: number): RollPart => {
1514
+ const part: RollPart = {
1515
+ type: 'successCount',
1516
+ threshold: { operator: node.threshold.operator, value: thresholdValue },
1517
+ target: target.part,
1518
+ successes,
1519
+ failures,
1520
+ total,
1521
+ ...partSpan(node),
1522
+ };
1523
+ if (failValue != null && node.failThreshold != null) {
1524
+ part.failThreshold = { operator: node.failThreshold.operator, value: failValue };
1525
+ }
1526
+ return part;
1527
+ };
646
1528
 
647
- // No-op when the target produced no dice in its pool. `containsDice` should
648
- // already reject this at parse time, but guard defensively.
1529
+ // No-op when the target produced no dice (`0d6>=4`); `containsDicePool`
1530
+ // should already reject dice-less targets at parse time. `target.total` is 0
1531
+ // for an empty pool, so `total === successes - failures` still holds.
649
1532
  if (targetCtx.rolls.length === 0) {
650
1533
  ctx.expressionParts.push(`${targetExpr}${code}`);
651
1534
  ctx.renderedParts.push(`${targetExpr}${code}`);
652
- return targetValue;
1535
+ return { total: target.total, part: buildPart(target.total, 0, 0) };
653
1536
  }
654
1537
 
655
1538
  const result = countSuccesses(
@@ -660,28 +1543,46 @@ function evalSuccessCount(
660
1543
  : undefined,
661
1544
  );
662
1545
 
663
- env.hasSuccessCount = true;
664
-
665
- ctx.rolls.push(...targetCtx.rolls);
1546
+ appendAll(ctx.rolls, targetCtx.rolls);
666
1547
  ctx.expressionParts.push(`${targetExpr}${code}`);
667
1548
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
668
1549
 
669
- return result.total;
1550
+ return {
1551
+ total: result.total,
1552
+ part: buildPart(result.total, result.successes, result.failures),
1553
+ };
670
1554
  }
671
1555
 
1556
+ //
1557
+ // * Versus
1558
+ //
1559
+
672
1560
  /**
673
1561
  * 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`.
1562
+ * single value when exactly one primary kept d20 is present; otherwise
1563
+ * `undefined`.
675
1564
  *
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.
1565
+ * Excludes dropped (`kh`/`kl`/`dh`/`dl`/`r`/`ro`) dice — these aren't the
1566
+ * final kept result. Explosion continuation dice (appended by standard/
1567
+ * penetrating explode, tagged `'exploded'` with no `initialResult`) are not
1568
+ * primaries either — `1d20! vs DC` keeps the natural from the original d20.
1569
+ * Compound explode accumulates into the original die and sets
1570
+ * `initialResult`, so it stays a primary and the raw first face is used.
1571
+ * Multiple primary kept d20s (e.g., `1d20+1d20`) yield `undefined` so no
1572
+ * ambiguous upgrade/downgrade is applied.
679
1573
  */
680
1574
  function extractNatural(rolls: DieResult[]): number | undefined {
681
- const keptD20s = rolls.filter(
682
- (d) => d.sides === 20 && !d.modifiers.includes('dropped') && !d.modifiers.includes('rerolled'),
1575
+ // Rerolled intermediates are always stamped `['rerolled', 'dropped']`
1576
+ // (see `modifiers/reroll.ts`), so filtering by `'dropped'` covers them.
1577
+ const primaries = rolls.filter(
1578
+ (d) =>
1579
+ d.sides === 20 &&
1580
+ !d.modifiers.includes('dropped') &&
1581
+ !(d.modifiers.includes('exploded') && d.initialResult == null),
683
1582
  );
684
- return keptD20s.length === 1 ? keptD20s[0]?.result : undefined;
1583
+ if (primaries.length !== 1) return undefined;
1584
+ const die = primaries[0];
1585
+ return die?.initialResult ?? die?.result;
685
1586
  }
686
1587
 
687
1588
  /**
@@ -714,25 +1615,29 @@ function degreeLabel(degree: DegreeOfSuccess): string {
714
1615
  }
715
1616
  }
716
1617
 
717
- function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
1618
+ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
718
1619
  if (env.insideVersus) {
719
1620
  throw new EvaluatorError('Cannot nest versus operators', 'NESTED_VERSUS', 'Versus');
720
1621
  }
721
1622
 
722
1623
  env.insideVersus = true;
723
1624
  try {
724
- const rollCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
725
- const rollTotal = evalNode(node.roll, rng, rollCtx, env);
726
- // ? Extract natural from rollCtx directly — the roll-side pool is isolated
727
- // here, so no index slicing on the merged parent pool is needed.
1625
+ const rollCtx = createContext();
1626
+ const rollResult = evalNode(node.roll, rng, rollCtx, env);
728
1627
  const natural = extractNatural(rollCtx.rolls);
729
1628
 
730
- const dcCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
731
- const dcTotal = evalNode(node.dc, rng, dcCtx, env);
1629
+ const dcCtx = createContext();
1630
+ const dcResult = evalNode(node.dc, rng, dcCtx, env);
732
1631
 
733
- const degree = calculateDegree(rollTotal, dcTotal, natural);
1632
+ const degree = calculateDegree(rollResult.total, dcResult.total, natural);
734
1633
 
735
- ctx.rolls.push(...rollCtx.rolls, ...dcCtx.rolls);
1634
+ appendAll(ctx.rolls, rollCtx.rolls);
1635
+ // ! Tag before merging: past this point the DC dice are indistinguishable
1636
+ // ! from the roll side, and every pool modifier walks the merged array.
1637
+ for (const die of dcCtx.rolls) {
1638
+ die.modifiers.push('dc');
1639
+ }
1640
+ appendAll(ctx.rolls, dcCtx.rolls);
736
1641
 
737
1642
  const rollExpr = rollCtx.expressionParts.join('');
738
1643
  const dcExpr = dcCtx.expressionParts.join('');
@@ -741,49 +1646,82 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
741
1646
 
742
1647
  ctx.expressionParts.push(`${rollExpr} vs ${dcExpr}`);
743
1648
  ctx.renderedParts.push(`${rollRendered} vs ${dcRendered}`);
744
- ctx.versusMetadata = { degree, natural, dcTotal };
745
-
746
- return rollTotal;
1649
+ ctx.versusMetadata = { degree, natural, dcTotal: dcResult.total };
1650
+
1651
+ return {
1652
+ total: rollResult.total,
1653
+ part: {
1654
+ type: 'versus',
1655
+ roll: rollResult.part,
1656
+ dc: dcResult.part,
1657
+ degree,
1658
+ total: rollResult.total,
1659
+ ...partSpan(node),
1660
+ },
1661
+ };
747
1662
  } finally {
748
1663
  env.insideVersus = false;
749
1664
  }
750
1665
  }
751
1666
 
1667
+ //
1668
+ // * Entry point
1669
+ //
1670
+
752
1671
  /**
753
- * Evaluates a parsed AST and returns the roll result.
1672
+ * Evaluates a parsed AST against an {@link RNG} and returns the roll result.
754
1673
  *
755
- * @param ast - The parsed AST node
756
- * @param rng - Random number generator to use for dice rolls
757
- * @param options - Optional evaluation options
758
- * @returns Complete roll result with total and metadata
1674
+ * The second half of the pipeline — {@link roll} is `evaluate(parse(...))`.
1675
+ * Call it directly to reuse one AST across many rolls, or to drive a
1676
+ * hand-built AST.
1677
+ *
1678
+ * Unlike `roll`, the RNG is required: `evaluate` never invents a randomness
1679
+ * source, so a caller can never accidentally get an unseeded roll.
1680
+ *
1681
+ * @param ast - The AST to evaluate, from {@link parse} or hand-built
1682
+ * @param rng - Randomness source; one `nextInt` call per die
1683
+ * @param options - Evaluation limits plus the original `notation` string,
1684
+ * which the AST cannot supply
1685
+ * @returns Complete {@link RollResult}
1686
+ * @throws {EvaluatorError} On a limit breach, division by zero, an undefined
1687
+ * variable, or a non-finite total
1688
+ * @throws {RollParserError} `INVALID_EVALUATION_LIMIT` when a supplied limit is
1689
+ * not an integer in range — raised before any die is rolled
759
1690
  *
760
1691
  * @example
761
1692
  * ```typescript
1693
+ * import { evaluate, parse } from 'roll-parser';
1694
+ * import { createMockRng } from 'roll-parser/testing';
1695
+ *
762
1696
  * const ast = parse('2d6+3');
763
- * const rng = new SeededRNG('test');
764
- * const result = evaluate(ast, rng);
765
- * console.log(result.total); // Sum of dice plus 3
1697
+ * const result = evaluate(ast, createMockRng([4, 2]), { notation: '2d6+3' });
1698
+ * result.total; // 9
1699
+ * result.rendered; // '2d6[4, 2] + 3 = 9'
766
1700
  * ```
1701
+ *
1702
+ * Omitting `notation` falls back to the normalized `expression`, which is
1703
+ * reconstructed from the AST — so `RollResult.notation` is always a string,
1704
+ * just not necessarily the one the user typed.
1705
+ *
1706
+ * @category Core
767
1707
  */
768
1708
  export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}): RollResult {
769
- const maxDice =
770
- options.maxDice != null && Number.isFinite(options.maxDice) && options.maxDice > 0
771
- ? Math.floor(options.maxDice)
772
- : DEFAULT_MAX_DICE;
773
-
774
- const maxExplodeIterations =
775
- options.maxExplodeIterations != null &&
776
- Number.isFinite(options.maxExplodeIterations) &&
777
- options.maxExplodeIterations >= 0
778
- ? Math.floor(options.maxExplodeIterations)
779
- : DEFAULT_MAX_EXPLODE_ITERATIONS;
780
-
781
- const maxRerollIterations =
782
- options.maxRerollIterations != null &&
783
- Number.isFinite(options.maxRerollIterations) &&
784
- options.maxRerollIterations >= 0
785
- ? Math.floor(options.maxRerollIterations)
786
- : DEFAULT_MAX_REROLL_ITERATIONS;
1709
+ const maxDice = resolveLimit(options.maxDice, 'maxDice', DEFAULT_MAX_DICE, 1);
1710
+ const maxExplodeIterations = resolveLimit(
1711
+ options.maxExplodeIterations,
1712
+ 'maxExplodeIterations',
1713
+ DEFAULT_MAX_EXPLODE_ITERATIONS,
1714
+ 0,
1715
+ );
1716
+ const maxRerollIterations = resolveLimit(
1717
+ options.maxRerollIterations,
1718
+ 'maxRerollIterations',
1719
+ DEFAULT_MAX_REROLL_ITERATIONS,
1720
+ 0,
1721
+ );
1722
+
1723
+ const context = options.context ?? {};
1724
+ const onMissingVariable = options.onMissingVariable ?? 'throw';
787
1725
 
788
1726
  const env: EvalEnv = {
789
1727
  maxDice,
@@ -792,46 +1730,55 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
792
1730
  totalDiceRolled: 0,
793
1731
  hasSuccessCount: false,
794
1732
  insideVersus: false,
1733
+ context,
1734
+ onMissingVariable,
795
1735
  };
796
- const ctx: EvalContext = {
797
- rolls: [],
798
- expressionParts: [],
799
- renderedParts: [],
800
- };
1736
+ const ctx = createContext();
801
1737
 
802
- const total = evalNode(ast, rng, ctx, env);
1738
+ const { total, part } = evalNode(ast, rng, ctx, env);
1739
+
1740
+ if (!Number.isFinite(total)) {
1741
+ throw new EvaluatorError(
1742
+ `Result is not a finite number: ${total}`,
1743
+ 'NON_FINITE_RESULT',
1744
+ ast.type,
1745
+ );
1746
+ }
803
1747
 
804
1748
  const expression = ctx.expressionParts.join('');
805
- // ? Versus replaces the numeric total with the degree label in the rendered
806
- // form; `RollResult.total` remains the numeric roll total.
1749
+ // Versus replaces the numeric total with the degree label in the rendered
1750
+ // form; `RollResult.total` remains the numeric roll total.
807
1751
  const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
808
1752
  const rendered = `${ctx.renderedParts.join('')} = ${trailing}`;
809
1753
 
810
- const result: RollResult = {
1754
+ // `RollResult` is `Readonly` — optional fields fold in via conditional spreads.
1755
+ const versus = ctx.versusMetadata;
1756
+
1757
+ return {
811
1758
  total,
812
1759
  notation: options.notation ?? expression,
813
1760
  expression,
814
1761
  rendered,
815
1762
  rolls: ctx.rolls,
1763
+ parts: part,
1764
+ ...(env.hasSuccessCount ? countTaggedDice(ctx.rolls) : {}),
1765
+ ...(versus ? { degree: versus.degree } : {}),
1766
+ ...(versus?.natural != null ? { natural: versus.natural } : {}),
816
1767
  };
1768
+ }
817
1769
 
818
- if (env.hasSuccessCount) {
819
- let successes = 0;
820
- let failures = 0;
821
- for (const die of ctx.rolls) {
822
- if (die.modifiers.includes('success')) successes += 1;
823
- else if (die.modifiers.includes('failure')) failures += 1;
824
- }
825
- result.successes = successes;
826
- result.failures = failures;
827
- }
828
-
829
- if (ctx.versusMetadata) {
830
- result.degree = ctx.versusMetadata.degree;
831
- if (ctx.versusMetadata.natural !== undefined) {
832
- result.natural = ctx.versusMetadata.natural;
833
- }
1770
+ /** Tallies the `'success'` / `'failure'` tags across a whole roll. */
1771
+ function countTaggedDice(rolls: DieResult[]): { successes: number; failures: number } {
1772
+ let successes = 0;
1773
+ let failures = 0;
1774
+
1775
+ for (const die of rolls) {
1776
+ // A success-count inside the DC sub-expression tags its own dice before
1777
+ // `evalVersus` marks them `'dc'`, so they arrive here already tagged.
1778
+ if (isVersusDc(die)) continue;
1779
+ if (die.modifiers.includes('success')) successes += 1;
1780
+ else if (die.modifiers.includes('failure')) failures += 1;
834
1781
  }
835
1782
 
836
- return result;
1783
+ return { successes, failures };
837
1784
  }