roll-parser 3.0.0-beta.0 → 3.1.0

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