roll-parser 3.0.0-beta.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 (159) hide show
  1. package/CHANGELOG.md +107 -9
  2. package/MIGRATION.md +147 -0
  3. package/README.md +960 -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 +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 -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 +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 +41 -75
  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.js +15 -0
  37. package/dist/evaluator/modifiers/compare.js.map +1 -0
  38. package/dist/evaluator/modifiers/crit-threshold.d.ts +0 -1
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +14 -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 +103 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +18 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  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 +62 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +4 -0
  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 +2 -6
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +24 -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 +314 -160
  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/rng/mock.d.ts +73 -12
  95. package/dist/rng/mock.d.ts.map +1 -1
  96. package/dist/rng/mock.js +30 -0
  97. package/dist/rng/mock.js.map +1 -0
  98. package/dist/rng/seeded.d.ts +141 -9
  99. package/dist/rng/seeded.d.ts.map +1 -1
  100. package/dist/rng/seeded.js +138 -0
  101. package/dist/rng/seeded.js.map +1 -0
  102. package/dist/rng/types.d.ts +57 -0
  103. package/dist/rng/types.d.ts.map +1 -1
  104. package/dist/rng/types.js +2 -0
  105. package/dist/rng/types.js.map +1 -0
  106. package/dist/roll.d.ts +58 -28
  107. package/dist/roll.d.ts.map +1 -1
  108. package/dist/roll.js +8 -0
  109. package/dist/roll.js.map +1 -0
  110. package/dist/testing.d.ts +5 -4
  111. package/dist/testing.d.ts.map +1 -1
  112. package/dist/testing.js +2 -41
  113. package/dist/testing.js.map +1 -11
  114. package/dist/types.d.ts +306 -44
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/types.js +8 -0
  117. package/dist/types.js.map +1 -0
  118. package/dist/version.d.ts +2 -0
  119. package/dist/version.d.ts.map +1 -0
  120. package/dist/version.js +2 -0
  121. package/dist/version.js.map +1 -0
  122. package/package.json +83 -34
  123. package/src/cli/args.ts +66 -10
  124. package/src/cli/format.ts +22 -3
  125. package/src/cli/index.ts +27 -84
  126. package/src/cli/main.ts +129 -0
  127. package/src/errors.ts +480 -27
  128. package/src/evaluator/die.ts +50 -0
  129. package/src/evaluator/env.ts +73 -0
  130. package/src/evaluator/evaluator.ts +653 -421
  131. package/src/evaluator/modifiers/crit-threshold.ts +3 -6
  132. package/src/evaluator/modifiers/die-bound.ts +39 -0
  133. package/src/evaluator/modifiers/explode.ts +60 -58
  134. package/src/evaluator/modifiers/flags.ts +61 -0
  135. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  136. package/src/evaluator/modifiers/reroll.ts +28 -49
  137. package/src/evaluator/modifiers/sort.ts +14 -1
  138. package/src/evaluator/modifiers/success-count.ts +5 -8
  139. package/src/index.ts +56 -35
  140. package/src/lexer/lexer.ts +107 -34
  141. package/src/lexer/tokens.ts +31 -6
  142. package/src/parser/ast.ts +323 -341
  143. package/src/parser/guards.ts +248 -0
  144. package/src/parser/parser.ts +419 -242
  145. package/src/rng/mock.ts +74 -13
  146. package/src/rng/seeded.ts +299 -64
  147. package/src/rng/types.ts +57 -0
  148. package/src/roll.ts +64 -47
  149. package/src/testing.ts +5 -9
  150. package/src/types.ts +310 -43
  151. package/src/version.ts +2 -0
  152. package/dist/cli.js +0 -2608
  153. package/dist/cli.js.map +0 -28
  154. package/dist/evaluator/index.d.ts +0 -8
  155. package/dist/evaluator/index.d.ts.map +0 -1
  156. package/dist/rng/index.d.ts +0 -8
  157. package/dist/rng/index.d.ts.map +0 -1
  158. package/src/evaluator/index.ts +0 -14
  159. 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,12 @@ 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
+ rewriteFlags,
59
+ SELECTION_AND_TALLY_FLAGS,
60
+ SELECTION_FLAGS,
61
+ } from './modifiers/flags.js';
62
+ import { markDroppedIndices, sumKeptDice } from './modifiers/keep-drop.js';
56
63
  import {
57
64
  applyRecursiveReroll,
58
65
  applyRerollOnce,
@@ -61,74 +68,71 @@ import {
61
68
  import { sortDice } from './modifiers/sort.js';
62
69
  import { countSuccesses } from './modifiers/success-count.js';
63
70
 
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 };
74
+
75
+ //
76
+ // * Limits
77
+ //
78
+
64
79
  /**
65
- * Error thrown during AST evaluation.
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
66
88
  */
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
89
  export const DEFAULT_MAX_DICE = 10_000;
88
90
 
89
- 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;
90
98
 
91
99
  /**
92
- * Per-evaluation shared environment (created once, shared across all branches).
100
+ * Resolves a user-supplied evaluation limit, failing closed.
93
101
  *
94
- * Exported for use by modifier implementations under `./modifiers/*`. Not part
95
- * 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.
96
107
  */
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
- };
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
+ //
127
129
 
128
130
  /**
129
131
  * Per-branch mutable accumulator for tracking rolls and output during recursion.
130
132
  *
131
- * @internal exported for targeted `mergeMetaRolls` tests only; not a public API.
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.
132
136
  */
133
137
  export type EvalContext = {
134
138
  rolls: DieResult[];
@@ -147,16 +151,20 @@ export type EvalContext = {
147
151
  };
148
152
  };
149
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
+
150
163
  /**
151
164
  * Flattened representation of a keep/drop modifier for chain evaluation.
152
- * Superset of the public `ModifierSpec` (adds the notation `code`).
165
+ * Superset of the public `KeepDropSpec` (adds the notation `code`).
153
166
  */
154
- type ModifierSpec = {
155
- modifier: 'keep' | 'drop';
156
- selector: 'highest' | 'lowest';
157
- count: number;
158
- code: string;
159
- };
167
+ type KeepDropChainEntry = KeepDropSpec & { code: string };
160
168
 
161
169
  /**
162
170
  * Every branch returns its numeric total AND the `RollPart` it contributes
@@ -168,49 +176,36 @@ type EvalResult = {
168
176
  part: RollPart;
169
177
  };
170
178
 
179
+ //
180
+ // * Shared helpers
181
+ //
182
+
171
183
  /**
172
184
  * Copies an AST node's source span onto a part (spread into the literal).
173
185
  * Empty when the AST was built without parser spans.
174
186
  */
175
187
  function partSpan(node: ASTNode): { start?: number; end?: number } {
176
- if (node.start === undefined || node.end === undefined) return {};
188
+ if (node.start == null || node.end == null) return {};
177
189
  return { start: node.start, end: node.end };
178
190
  }
179
191
 
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
192
  /**
188
- * Creates a new die result with critical/fumble detection.
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.
189
199
  */
190
- function createDieResult(sides: number, result: number): DieResult {
191
- // ? `sides > 1` guards both flags — a d1 always rolls 1, so it is neither
192
- // an exceptional max (critical) nor an exceptional min (fumble).
193
- return {
194
- sides,
195
- result,
196
- modifiers: [],
197
- critical: result === sides && sides > 1,
198
- fumble: result === 1 && sides > 1,
199
- };
200
+ function appendAll<T>(target: T[], source: readonly T[]): void {
201
+ for (const item of source) {
202
+ target.push(item);
203
+ }
200
204
  }
201
205
 
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
- };
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);
214
209
  }
215
210
 
216
211
  /**
@@ -223,21 +218,40 @@ function createFateDieResult(result: number): DieResult {
223
218
  * `RollResult.rolls` for audit, not for display.
224
219
  */
225
220
  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(', ')}]`;
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;
236
+ }
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);
241
255
  }
242
256
 
243
257
  /**
@@ -253,23 +267,27 @@ function renderDice(dice: DieResult[]): string {
253
267
  * a SuccessCount leaking into a meta sub-expression (parser rejects all such
254
268
  * wrappings; this strip ensures a future parse regression cannot leak tags
255
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.
256
274
  */
257
- /** @internal exported for targeted defense-in-depth tests only. */
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.
258
278
  export function mergeMetaRolls(parent: EvalContext, source: EvalContext): void {
259
279
  for (const die of source.rolls) {
260
280
  parent.rolls.push({
261
281
  ...die,
262
- modifiers: [
263
- ...die.modifiers.filter(
264
- (m) => m !== 'kept' && m !== 'dropped' && m !== 'success' && m !== 'failure',
265
- ),
266
- 'meta',
267
- 'dropped',
268
- ],
282
+ modifiers: rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'meta', 'dropped'),
269
283
  });
270
284
  }
271
285
  }
272
286
 
287
+ //
288
+ // * Node dispatch
289
+ //
290
+
273
291
  /**
274
292
  * Evaluates an AST node, returning its total and `RollPart` while updating
275
293
  * the context.
@@ -282,9 +300,8 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eval
282
300
  try {
283
301
  return evalNodeInner(node, rng, ctx, env);
284
302
  } catch (error) {
285
- if (error instanceof EvaluatorError && error.start === undefined && node.start !== undefined) {
286
- error.start = node.start;
287
- error.end = node.end;
303
+ if (error instanceof EvaluatorError && node.start != null) {
304
+ stampEvaluatorSpan(error, node.start, node.end);
288
305
  }
289
306
  throw error;
290
307
  }
@@ -307,8 +324,8 @@ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
307
324
  case 'UnaryOp':
308
325
  return evalUnaryOp(node, rng, ctx, env);
309
326
 
310
- case 'Modifier':
311
- return evalModifier(node, rng, ctx, env);
327
+ case 'KeepDrop':
328
+ return evalKeepDrop(node, rng, ctx, env);
312
329
 
313
330
  case 'Explode':
314
331
  return evalExplode(node, rng, ctx, env);
@@ -316,6 +333,9 @@ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
316
333
  case 'Reroll':
317
334
  return evalReroll(node, rng, ctx, env);
318
335
 
336
+ case 'DieBound':
337
+ return evalDieBound(node, rng, ctx, env);
338
+
319
339
  case 'SuccessCount':
320
340
  return evalSuccessCount(node, rng, ctx, env);
321
341
 
@@ -351,6 +371,10 @@ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
351
371
  }
352
372
  }
353
373
 
374
+ //
375
+ // * Leaf nodes
376
+ //
377
+
354
378
  function evalLiteral(node: LiteralNode, ctx: EvalContext): EvalResult {
355
379
  const { value } = node;
356
380
  ctx.expressionParts.push(String(value));
@@ -413,99 +437,149 @@ function evalVariable(node: VariableNode, ctx: EvalContext, env: EvalEnv): EvalR
413
437
  };
414
438
  }
415
439
 
440
+ //
441
+ // * Dice pools
442
+ //
443
+
416
444
  /**
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.
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.
423
463
  */
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);
464
+ function evalMetaOperand(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
465
+ if (node.type === 'Literal') return node.value;
428
466
 
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');
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;
438
475
  }
439
476
 
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
- );
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);
446
487
  }
447
- env.totalDiceRolled += count;
488
+ }
448
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[] } {
449
508
  const dice: DieResult[] = [];
509
+ let total = 0;
510
+
450
511
  for (let i = 0; i < count; i++) {
451
- const result = rng.nextInt(1, sides);
452
- dice.push(createDieResult(sides, result));
512
+ const die = rollDie();
513
+ dice.push(die);
514
+ total += die.result;
453
515
  }
454
516
 
455
- const markedDice = markAllKept(dice);
456
- ctx.rolls.push(...markedDice);
457
-
458
- const total = sumKeptDice(markedDice);
459
- const notation = `${count}d${sides}`;
517
+ appendAll(ctx.rolls, dice);
460
518
 
461
519
  ctx.expressionParts.push(notation);
462
- ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
520
+ ctx.renderedParts.push(`${notation}${renderDice(dice)}`);
463
521
 
464
- return {
465
- total,
466
- part: { type: 'dice', count, sides, rolls: markedDice, total, ...partSpan(node) },
467
- };
522
+ return { total, rolls: dice };
468
523
  }
469
524
 
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);
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);
474
536
 
475
- if (!Number.isInteger(count) || count < 0) {
476
- 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');
477
540
  }
478
-
479
- if (env.totalDiceRolled + count > env.maxDice) {
541
+ if (sides > MAX_DICE_SIDES) {
480
542
  throw new EvaluatorError(
481
- `Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`,
482
- 'DICE_LIMIT_EXCEEDED',
483
- 'FateDice',
543
+ `Dice sides ${sides} exceeds maximum of ${MAX_DICE_SIDES}`,
544
+ 'INVALID_DICE_SIDES',
545
+ 'Dice',
484
546
  );
485
547
  }
486
- env.totalDiceRolled += count;
487
548
 
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
- }
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
+ );
493
559
 
494
- const markedDice = markAllKept(dice);
495
- ctx.rolls.push(...markedDice);
560
+ return { total, part: { type: 'dice', count, sides, rolls, total, ...partSpan(node) } };
561
+ }
496
562
 
497
- const total = sumKeptDice(markedDice);
498
- 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);
499
565
 
500
- ctx.expressionParts.push(notation);
501
- ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
566
+ requireDiceCount(count, 'FateDice');
567
+ chargeDice(env, count, 'FateDice');
502
568
 
503
- return {
504
- total,
505
- part: { type: 'fateDice', count, rolls: markedDice, total, ...partSpan(node) },
506
- };
569
+ const { total, rolls } = rollPool(
570
+ count,
571
+ `${count}dF`,
572
+ () => createFateDieResult(rng.nextInt(-1, 1), ['kept']),
573
+ ctx,
574
+ );
575
+
576
+ return { total, part: { type: 'fateDice', count, rolls, total, ...partSpan(node) } };
507
577
  }
508
578
 
579
+ //
580
+ // * Context merging
581
+ //
582
+
509
583
  /**
510
584
  * Propagates `versusMetadata` onto a parent so `degree`/`natural` survive
511
585
  * wrappers like `floor(...)`, `(vs) + 0`, or `-(vs)`. Throws `NESTED_VERSUS`
@@ -513,9 +587,27 @@ function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalE
513
587
  * the same `RollResult`. No-op when `metadata` is `undefined`.
514
588
  *
515
589
  * 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.
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.
519
611
  */
520
612
  function propagateMetadata(parent: EvalContext, metadata: EvalContext['versusMetadata']): void {
521
613
  if (!metadata) return;
@@ -537,13 +629,17 @@ function propagateMetadata(parent: EvalContext, metadata: EvalContext['versusMet
537
629
  * those with its own operator/function syntax.
538
630
  */
539
631
  function mergeContext(parent: EvalContext, child: EvalContext): void {
540
- parent.rolls.push(...child.rolls);
632
+ appendAll(parent.rolls, child.rolls);
541
633
  propagateMetadata(parent, child.versusMetadata);
542
634
  }
543
635
 
636
+ //
637
+ // * Arithmetic
638
+ //
639
+
544
640
  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: [] };
641
+ const leftCtx = createContext();
642
+ const rightCtx = createContext();
547
643
 
548
644
  const left = evalNode(node.left, rng, leftCtx, env);
549
645
  const right = evalNode(node.right, rng, rightCtx, env);
@@ -606,7 +702,7 @@ function applyBinaryOperator(
606
702
  }
607
703
 
608
704
  function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
609
- const innerCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
705
+ const innerCtx = createContext();
610
706
  const inner = evalNode(node.operand, rng, innerCtx, env);
611
707
 
612
708
  mergeContext(ctx, innerCtx);
@@ -625,8 +721,12 @@ function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv
625
721
  };
626
722
  }
627
723
 
724
+ //
725
+ // * Groups and functions
726
+ //
727
+
628
728
  function evalGrouped(node: GroupedNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
629
- const innerCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
729
+ const innerCtx = createContext();
630
730
  const inner = evalNode(node.expression, rng, innerCtx, env);
631
731
 
632
732
  mergeContext(ctx, innerCtx);
@@ -646,7 +746,7 @@ function evalGrouped(node: GroupedNode, rng: RNG, ctx: EvalContext, env: EvalEnv
646
746
  * Each sub-expression is evaluated in an isolated context, then its rolls
647
747
  * and `versusMetadata` propagate up via `mergeContext`. Sub-roll subtotals
648
748
  * sum to the group's total. When the group is the base target of a
649
- * keep/drop modifier with `expressions.length >= 2`, `evalModifier`
749
+ * keep/drop modifier with `expressions.length >= 2`, `evalKeepDrop`
650
750
  * intercepts first and never calls this function — dual semantics
651
751
  * (flat-pool vs sub-roll) are decided there.
652
752
  */
@@ -657,7 +757,7 @@ function evalGroup(node: GroupNode, rng: RNG, ctx: EvalContext, env: EvalEnv): E
657
757
  let total = 0;
658
758
 
659
759
  for (const expr of node.expressions) {
660
- const subCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
760
+ const subCtx = createContext();
661
761
  const sub = evalNode(expr, rng, subCtx, env);
662
762
  mergeContext(ctx, subCtx);
663
763
  subExprs.push(subCtx.expressionParts.join(''));
@@ -669,8 +769,8 @@ function evalGroup(node: GroupNode, rng: RNG, ctx: EvalContext, env: EvalEnv): E
669
769
  ctx.expressionParts.push(`{${subExprs.join(', ')}}`);
670
770
  ctx.renderedParts.push(`{${subRendered.join(', ')}}`);
671
771
 
672
- // ? No `keptIndices` — bare groups (and single-sub passthroughs) perform
673
- // no sub-roll selection; only `evalGroupModifier` sets it.
772
+ // No `keptIndices` — bare groups (and single-sub passthroughs) perform
773
+ // no sub-roll selection; only `evalGroupKeepDrop` sets it.
674
774
  return { total, part: { type: 'group', parts: subParts, total, ...partSpan(node) } };
675
775
  }
676
776
 
@@ -684,7 +784,7 @@ function evalFunctionCall(
684
784
  const argResults: EvalResult[] = [];
685
785
 
686
786
  for (const arg of node.args) {
687
- const argCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
787
+ const argCtx = createContext();
688
788
  argResults.push(evalNode(arg, rng, argCtx, env));
689
789
  argCtxs.push(argCtx);
690
790
  }
@@ -723,26 +823,52 @@ function applyFunction(name: string, values: number[]): number {
723
823
  case 'ceil':
724
824
  return Math.ceil(requireUnaryArg(name, values));
725
825
  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`.
826
+ // `Math.round` breaks halves toward +∞: `round(2.5) === 3` but
827
+ // `round(-2.5) === -2`. Symmetric rounding must be composed via `floor`.
729
828
  return Math.round(requireUnaryArg(name, values));
730
829
  case 'abs':
731
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
+ }
732
839
  case 'max':
733
- return Math.max(...values);
840
+ return extremumOf(values, 'max');
734
841
  case 'min':
735
- return Math.min(...values);
842
+ return extremumOf(values, 'min');
736
843
  default:
737
844
  throw new EvaluatorError(`Unknown function: ${name}`, 'UNKNOWN_FUNCTION', 'FunctionCall');
738
845
  }
739
846
  }
740
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
+
741
867
  function requireUnaryArg(name: string, values: number[]): number {
742
868
  const [x] = values;
743
- if (x === undefined) {
744
- // ? Unreachable: parser validates arity before evaluation. Defensive for
745
- // `noNonNullAssertion`.
869
+ if (x == null) {
870
+ // Unreachable: the parser validates arity before evaluation. Present to
871
+ // satisfy `noNonNullAssertion`.
746
872
  throw new EvaluatorError(
747
873
  `Function '${name}' requires an argument`,
748
874
  'UNKNOWN_FUNCTION',
@@ -752,42 +878,64 @@ function requireUnaryArg(name: string, values: number[]): number {
752
878
  return x;
753
879
  }
754
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
+
755
909
  /**
756
- * Walks a nested ModifierNode chain, collecting specs outermost-first,
910
+ * Walks a nested KeepDropNode chain, collecting specs outermost-first,
757
911
  * then reverses to notation order (innermost-first).
758
912
  */
759
- function flattenModifierChain(
760
- node: ModifierNode,
913
+ function flattenKeepDropChain(
914
+ node: KeepDropNode,
761
915
  rng: RNG,
762
916
  ctx: EvalContext,
763
917
  env: EvalEnv,
764
- ): { specs: ModifierSpec[]; baseTarget: ASTNode } {
765
- const specs: ModifierSpec[] = [];
918
+ ): { specs: KeepDropChainEntry[]; baseTarget: ASTNode } {
919
+ const specs: KeepDropChainEntry[] = [];
766
920
  let current: ASTNode = node;
767
921
 
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);
922
+ while (isKeepDrop(current)) {
923
+ const modCount = evalMetaOperand(current.count, rng, ctx, env);
772
924
 
773
925
  if (!Number.isInteger(modCount) || modCount < 0) {
774
926
  throw new EvaluatorError(
775
- `Invalid modifier count: ${modCount}`,
776
- 'INVALID_MODIFIER_COUNT',
777
- 'Modifier',
927
+ `Invalid keep/drop count: ${modCount}`,
928
+ 'INVALID_KEEP_DROP_COUNT',
929
+ 'KeepDrop',
778
930
  );
779
931
  }
780
932
 
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 });
933
+ specs.push({
934
+ kind: current.kind,
935
+ selector: current.selector,
936
+ count: modCount,
937
+ code: keepDropCode(current.kind, current.selector),
938
+ });
791
939
  current = current.target;
792
940
  }
793
941
 
@@ -795,50 +943,57 @@ function flattenModifierChain(
795
943
  return { specs, baseTarget: current };
796
944
  }
797
945
 
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
946
  /**
813
947
  * Applies each modifier independently to the full dice pool
814
948
  * and merges drop sets via union. A die is dropped if ANY modifier dropped it.
815
949
  *
816
950
  * 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.
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.
820
954
  */
821
- function mergeDropSets(baseDice: DieResult[], specs: ModifierSpec[]): DieResult[] {
822
- const droppedIndices = new Set<number>();
955
+ function mergeDropSets(baseDice: DieResult[], specs: KeepDropChainEntry[]): DieResult[] {
956
+ const droppedMask = new Uint8Array(baseDice.length);
823
957
 
824
958
  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
- }
959
+ markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask);
831
960
  }
832
961
 
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
- });
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
+ }
838
975
 
839
976
  return baseDice;
840
977
  }
841
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
+
842
997
  /**
843
998
  * Builds the notation string for an explode modifier, e.g. `!`, `!!>=3`, `!p>5`.
844
999
  */
@@ -847,21 +1002,19 @@ function formatExplodeCode(
847
1002
  threshold: ComparePoint | undefined,
848
1003
  thresholdValue: number | undefined,
849
1004
  ): string {
850
- const marker = variant === 'standard' ? '!' : variant === 'compound' ? '!!' : '!p';
1005
+ const marker = EXPLODE_MARKERS[variant];
851
1006
  if (threshold == null || thresholdValue == null) return marker;
852
1007
  return `${marker}${threshold.operator}${thresholdValue}`;
853
1008
  }
854
1009
 
855
1010
  function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
856
- const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1011
+ const targetCtx = createContext();
857
1012
  const target = evalNode(node.target, rng, targetCtx, env);
858
1013
  const targetExpr = targetCtx.expressionParts.join('');
859
1014
 
860
1015
  let thresholdValue: number | undefined;
861
1016
  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);
1017
+ thresholdValue = evalMetaOperand(node.threshold.value, rng, ctx, env);
865
1018
  }
866
1019
 
867
1020
  const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
@@ -874,7 +1027,7 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
874
1027
  total,
875
1028
  ...partSpan(node),
876
1029
  };
877
- if (node.threshold != null && thresholdValue !== undefined) {
1030
+ if (node.threshold != null && thresholdValue != null) {
878
1031
  part.threshold = { operator: node.threshold.operator, value: thresholdValue };
879
1032
  }
880
1033
  return part;
@@ -889,18 +1042,12 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
889
1042
 
890
1043
  const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
891
1044
 
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);
1045
+ const expanded = EXPLODE_APPLIERS[node.variant](targetCtx.rolls, shouldExplode, rng, env);
898
1046
 
899
- ctx.rolls.push(...expanded);
1047
+ appendAll(ctx.rolls, expanded);
900
1048
  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.
1049
+ // Rendered form carries the explode code unlike kh/dl, whose dropped dice
1050
+ // are self-evident, explosion origin is otherwise invisible.
904
1051
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
905
1052
 
906
1053
  const total = sumKeptDice(expanded);
@@ -908,13 +1055,11 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
908
1055
  }
909
1056
 
910
1057
  function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
911
- const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1058
+ const targetCtx = createContext();
912
1059
  const target = evalNode(node.target, rng, targetCtx, env);
913
1060
  const targetExpr = targetCtx.expressionParts.join('');
914
1061
 
915
- const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
916
- const thresholdValue = evalNode(node.condition.value, rng, thresholdCtx, env).total;
917
- mergeMetaRolls(ctx, thresholdCtx);
1062
+ const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
918
1063
 
919
1064
  const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
920
1065
  const condition: ResolvedComparePoint = {
@@ -944,7 +1089,7 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
944
1089
  ? applyRerollOnce(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env)
945
1090
  : applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
946
1091
 
947
- ctx.rolls.push(...pool);
1092
+ appendAll(ctx.rolls, pool);
948
1093
  ctx.expressionParts.push(`${targetExpr}${code}`);
949
1094
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
950
1095
 
@@ -962,25 +1107,77 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
962
1107
  };
963
1108
  }
964
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
+ };
1156
+ }
1157
+
1158
+ //
1159
+ // * Sort and crit thresholds
1160
+ //
1161
+
965
1162
  /**
966
1163
  * Evaluates a sort modifier. Purely visual — sorts the dice produced by
967
1164
  * `node.target` in ascending or descending order of `result` without
968
1165
  * changing the total or any die-level flag. Dropped dice participate in
969
1166
  * the sort alongside kept dice, preserving their `'dropped'` marker.
970
1167
  *
971
- * Rendering mirrors `evalExplode` / `evalModifier`: emits
1168
+ * Rendering mirrors `evalExplode` / `evalKeepDrop`: emits
972
1169
  * `<targetExpr><code>[<sortedDice>]`, replacing any inline dice brackets
973
1170
  * the target itself rendered. Multi-sub-roll Group targets (`{4d6, 3d6}s`)
974
1171
  * are rejected at parse time with `INVALID_SORT_TARGET` until hierarchical
975
1172
  * per-sub-roll sorting (Stage 3 spec §3 "Group interaction") is implemented.
976
1173
  */
977
1174
  function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
978
- const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1175
+ const targetCtx = createContext();
979
1176
  const target = evalNode(node.target, rng, targetCtx, env);
980
1177
 
981
1178
  const sortedRolls = sortDice(targetCtx.rolls, node.order);
982
1179
 
983
- ctx.rolls.push(...sortedRolls);
1180
+ appendAll(ctx.rolls, sortedRolls);
984
1181
  propagateMetadata(ctx, targetCtx.versusMetadata);
985
1182
 
986
1183
  const code = node.order === 'ascending' ? 's' : 'sd';
@@ -994,6 +1191,7 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
994
1191
  part: {
995
1192
  type: 'sort',
996
1193
  order: node.order,
1194
+ rolls: sortedRolls,
997
1195
  target: target.part,
998
1196
  total: target.total,
999
1197
  ...partSpan(node),
@@ -1023,21 +1221,21 @@ function evalCritThreshold(
1023
1221
  ctx: EvalContext,
1024
1222
  env: EvalEnv,
1025
1223
  ): EvalResult {
1026
- const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1224
+ const targetCtx = createContext();
1027
1225
  const target = evalNode(node.target, rng, targetCtx, env);
1028
1226
 
1029
1227
  const successResolved = node.successThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
1030
1228
  const failResolved = node.failThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
1031
1229
 
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.
1230
+ // A side with no explicit threshold falls back to the default rule rather
1231
+ // than being wiped by the other side's override.
1034
1232
  const successApplied: ResolvedCritThreshold[] =
1035
1233
  successResolved.length > 0 ? successResolved : ['default'];
1036
1234
  const failApplied: ResolvedCritThreshold[] = failResolved.length > 0 ? failResolved : ['default'];
1037
1235
 
1038
1236
  applyCritThresholds(targetCtx.rolls, successApplied, failApplied);
1039
1237
 
1040
- ctx.rolls.push(...targetCtx.rolls);
1238
+ appendAll(ctx.rolls, targetCtx.rolls);
1041
1239
  propagateMetadata(ctx, targetCtx.versusMetadata);
1042
1240
 
1043
1241
  const targetExpr = targetCtx.expressionParts.join('');
@@ -1069,9 +1267,8 @@ function resolveCritThreshold(
1069
1267
  env: EvalEnv,
1070
1268
  ): ResolvedCritThreshold {
1071
1269
  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);
1270
+
1271
+ const resolved = evalMetaOperand(threshold.value, rng, ctx, env);
1075
1272
  if (!Number.isFinite(resolved)) {
1076
1273
  throw new EvaluatorError(
1077
1274
  `Invalid crit threshold: ${resolved}`,
@@ -1082,36 +1279,39 @@ function resolveCritThreshold(
1082
1279
  return { operator: threshold.operator, value: resolved };
1083
1280
  }
1084
1281
 
1085
- function evalModifier(node: ModifierNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1086
- const { specs, baseTarget } = flattenModifierChain(node, rng, ctx, env);
1282
+ //
1283
+ // * Keep/drop evaluation
1284
+ //
1087
1285
 
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.
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.
1092
1292
  if (baseTarget.type === 'Group' && baseTarget.expressions.length >= 2) {
1093
- return evalGroupModifier(node, baseTarget, specs, rng, ctx, env);
1293
+ return evalGroupKeepDrop(node, baseTarget, specs, rng, ctx, env);
1094
1294
  }
1095
1295
 
1096
- const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1296
+ const targetCtx = createContext();
1097
1297
  const target = evalNode(baseTarget, rng, targetCtx, env);
1098
1298
 
1099
1299
  const mergedDice = mergeDropSets(targetCtx.rolls, specs);
1100
1300
 
1101
- ctx.rolls.push(...mergedDice);
1301
+ appendAll(ctx.rolls, mergedDice);
1102
1302
 
1103
1303
  const total = sumKeptDice(mergedDice);
1104
1304
 
1105
1305
  const targetExpr = targetCtx.expressionParts.join('');
1106
- const modifierCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1306
+ const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1107
1307
 
1108
- ctx.expressionParts.push(`${targetExpr}${modifierCodes}`);
1308
+ ctx.expressionParts.push(`${targetExpr}${keepDropCodes}`);
1109
1309
  ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
1110
1310
 
1111
1311
  return {
1112
1312
  total,
1113
1313
  part: {
1114
- type: 'modifier',
1314
+ type: 'keepDrop',
1115
1315
  specs: toPublicSpecs(specs),
1116
1316
  target: target.part,
1117
1317
  total,
@@ -1143,10 +1343,10 @@ function stripInnerMarkers(rendered: string): string {
1143
1343
  * on the propagated rolls still agrees with the group total, and the
1144
1344
  * rendered form wraps them in strikethrough `~~...~~`.
1145
1345
  */
1146
- function evalGroupModifier(
1147
- node: ModifierNode,
1346
+ function evalGroupKeepDrop(
1347
+ node: KeepDropNode,
1148
1348
  group: GroupNode,
1149
- specs: ModifierSpec[],
1349
+ specs: KeepDropChainEntry[],
1150
1350
  rng: RNG,
1151
1351
  ctx: EvalContext,
1152
1352
  env: EvalEnv,
@@ -1161,7 +1361,7 @@ function evalGroupModifier(
1161
1361
  };
1162
1362
 
1163
1363
  const subRolls: SubRoll[] = group.expressions.map((expr) => {
1164
- const subCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1364
+ const subCtx = createContext();
1165
1365
  const sub = evalNode(expr, rng, subCtx, env);
1166
1366
  return {
1167
1367
  subtotal: sub.total,
@@ -1173,9 +1373,8 @@ function evalGroupModifier(
1173
1373
  };
1174
1374
  });
1175
1375
 
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.
1376
+ // `sides = 0` sentinel: synthetic dice only feed `mergeDropSets`, never reach
1377
+ // `ctx.rolls`, and crit/fumble is meaningless for a subtotal.
1179
1378
  const syntheticDice: DieResult[] = subRolls.map((sub) => ({
1180
1379
  sides: 0,
1181
1380
  result: sub.subtotal,
@@ -1196,51 +1395,41 @@ function evalGroupModifier(
1196
1395
  const isDropped = synth.modifiers.includes('dropped');
1197
1396
 
1198
1397
  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.
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`.
1205
1402
  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
- ];
1403
+ die.modifiers = rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'dropped');
1212
1404
  }
1213
- ctx.rolls.push(...sub.rolls);
1405
+ appendAll(ctx.rolls, sub.rolls);
1214
1406
  outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
1215
1407
  } else {
1216
1408
  keptIndices.push(i);
1217
- ctx.rolls.push(...sub.rolls);
1409
+ appendAll(ctx.rolls, sub.rolls);
1218
1410
  outerRendered.push(sub.rendered);
1219
1411
  total += sub.subtotal;
1220
1412
  }
1221
1413
 
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`.
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.
1226
1417
  if (!isDropped) {
1227
1418
  propagateMetadata(ctx, sub.versusMetadata);
1228
1419
  }
1229
1420
  }
1230
1421
 
1231
1422
  const subExprStrs = subRolls.map((s) => s.expr);
1232
- const modifierCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1423
+ const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1233
1424
 
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.
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.
1238
1428
  ctx.renderedParts.push(`{${outerRendered.join(', ')}}`);
1239
1429
 
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`.
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`.
1244
1433
  const groupPart: RollPart = {
1245
1434
  type: 'group',
1246
1435
  parts: subRolls.map((s) => s.part),
@@ -1252,7 +1441,7 @@ function evalGroupModifier(
1252
1441
  return {
1253
1442
  total,
1254
1443
  part: {
1255
- type: 'modifier',
1444
+ type: 'keepDrop',
1256
1445
  specs: toPublicSpecs(specs),
1257
1446
  target: groupPart,
1258
1447
  total,
@@ -1261,6 +1450,10 @@ function evalGroupModifier(
1261
1450
  };
1262
1451
  }
1263
1452
 
1453
+ //
1454
+ // * Success counting
1455
+ //
1456
+
1264
1457
  function resolveThreshold(
1265
1458
  value: ASTNode,
1266
1459
  rng: RNG,
@@ -1268,9 +1461,7 @@ function resolveThreshold(
1268
1461
  env: EvalEnv,
1269
1462
  role: 'threshold' | 'fail threshold',
1270
1463
  ): number {
1271
- const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1272
- const resolved = evalNode(value, rng, thresholdCtx, env).total;
1273
- mergeMetaRolls(ctx, thresholdCtx);
1464
+ const resolved = evalMetaOperand(value, rng, ctx, env);
1274
1465
 
1275
1466
  if (!Number.isFinite(resolved)) {
1276
1467
  throw new EvaluatorError(`Invalid ${role}: ${resolved}`, 'INVALID_THRESHOLD', 'SuccessCount');
@@ -1279,17 +1470,30 @@ function resolveThreshold(
1279
1470
  return resolved;
1280
1471
  }
1281
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
+
1282
1486
  function evalSuccessCount(
1283
1487
  node: SuccessCountNode,
1284
1488
  rng: RNG,
1285
1489
  ctx: EvalContext,
1286
1490
  env: EvalEnv,
1287
1491
  ): 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.
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.
1290
1494
  env.hasSuccessCount = true;
1291
1495
 
1292
- const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1496
+ const targetCtx = createContext();
1293
1497
  const target = evalNode(node.target, rng, targetCtx, env);
1294
1498
  const targetExpr = targetCtx.expressionParts.join('');
1295
1499
 
@@ -1299,9 +1503,12 @@ function evalSuccessCount(
1299
1503
  ? resolveThreshold(node.failThreshold.value, rng, ctx, env, 'fail threshold')
1300
1504
  : undefined;
1301
1505
 
1302
- const code = `${node.threshold.operator}${thresholdValue}${
1303
- failValue != null ? `f${failValue}` : ''
1304
- }`;
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}`;
1305
1512
 
1306
1513
  const buildPart = (total: number, successes: number, failures: number): RollPart => {
1307
1514
  const part: RollPart = {
@@ -1319,10 +1526,9 @@ function evalSuccessCount(
1319
1526
  return part;
1320
1527
  };
1321
1528
 
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.
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.
1326
1532
  if (targetCtx.rolls.length === 0) {
1327
1533
  ctx.expressionParts.push(`${targetExpr}${code}`);
1328
1534
  ctx.renderedParts.push(`${targetExpr}${code}`);
@@ -1337,7 +1543,7 @@ function evalSuccessCount(
1337
1543
  : undefined,
1338
1544
  );
1339
1545
 
1340
- ctx.rolls.push(...targetCtx.rolls);
1546
+ appendAll(ctx.rolls, targetCtx.rolls);
1341
1547
  ctx.expressionParts.push(`${targetExpr}${code}`);
1342
1548
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
1343
1549
 
@@ -1347,6 +1553,10 @@ function evalSuccessCount(
1347
1553
  };
1348
1554
  }
1349
1555
 
1556
+ //
1557
+ // * Versus
1558
+ //
1559
+
1350
1560
  /**
1351
1561
  * Extracts the "natural" d20 value from a roll-side dice pool. Returns the
1352
1562
  * single value when exactly one primary kept d20 is present; otherwise
@@ -1368,7 +1578,7 @@ function extractNatural(rolls: DieResult[]): number | undefined {
1368
1578
  (d) =>
1369
1579
  d.sides === 20 &&
1370
1580
  !d.modifiers.includes('dropped') &&
1371
- !(d.modifiers.includes('exploded') && d.initialResult === undefined),
1581
+ !(d.modifiers.includes('exploded') && d.initialResult == null),
1372
1582
  );
1373
1583
  if (primaries.length !== 1) return undefined;
1374
1584
  const die = primaries[0];
@@ -1412,18 +1622,22 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1412
1622
 
1413
1623
  env.insideVersus = true;
1414
1624
  try {
1415
- const rollCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1625
+ const rollCtx = createContext();
1416
1626
  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
1627
  const natural = extractNatural(rollCtx.rolls);
1420
1628
 
1421
- const dcCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
1629
+ const dcCtx = createContext();
1422
1630
  const dcResult = evalNode(node.dc, rng, dcCtx, env);
1423
1631
 
1424
1632
  const degree = calculateDegree(rollResult.total, dcResult.total, natural);
1425
1633
 
1426
- 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);
1427
1641
 
1428
1642
  const rollExpr = rollCtx.expressionParts.join('');
1429
1643
  const dcExpr = dcCtx.expressionParts.join('');
@@ -1450,41 +1664,61 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1450
1664
  }
1451
1665
  }
1452
1666
 
1667
+ //
1668
+ // * Entry point
1669
+ //
1670
+
1453
1671
  /**
1454
- * Evaluates a parsed AST and returns the roll result.
1672
+ * Evaluates a parsed AST against an {@link RNG} and returns the roll result.
1673
+ *
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.
1455
1680
  *
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
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
1460
1690
  *
1461
1691
  * @example
1462
1692
  * ```typescript
1693
+ * import { evaluate, parse } from 'roll-parser';
1694
+ * import { createMockRng } from 'roll-parser/testing';
1695
+ *
1463
1696
  * 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
1697
+ * const result = evaluate(ast, createMockRng([4, 2]), { notation: '2d6+3' });
1698
+ * result.total; // 9
1699
+ * result.rendered; // '2d6[4, 2] + 3 = 9'
1467
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
1468
1707
  */
1469
1708
  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;
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
+ );
1488
1722
 
1489
1723
  const context = options.context ?? {};
1490
1724
  const onMissingVariable = options.onMissingVariable ?? 'throw';
@@ -1499,11 +1733,7 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1499
1733
  context,
1500
1734
  onMissingVariable,
1501
1735
  };
1502
- const ctx: EvalContext = {
1503
- rolls: [],
1504
- expressionParts: [],
1505
- renderedParts: [],
1506
- };
1736
+ const ctx = createContext();
1507
1737
 
1508
1738
  const { total, part } = evalNode(ast, rng, ctx, env);
1509
1739
 
@@ -1516,37 +1746,39 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1516
1746
  }
1517
1747
 
1518
1748
  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.
1749
+ // Versus replaces the numeric total with the degree label in the rendered
1750
+ // form; `RollResult.total` remains the numeric roll total.
1521
1751
  const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
1522
1752
  const rendered = `${ctx.renderedParts.join('')} = ${trailing}`;
1523
1753
 
1524
- const result: RollResult = {
1754
+ // `RollResult` is `Readonly` — optional fields fold in via conditional spreads.
1755
+ const versus = ctx.versusMetadata;
1756
+
1757
+ return {
1525
1758
  total,
1526
1759
  notation: options.notation ?? expression,
1527
1760
  expression,
1528
1761
  rendered,
1529
1762
  rolls: ctx.rolls,
1530
1763
  parts: part,
1764
+ ...(env.hasSuccessCount ? countTaggedDice(ctx.rolls) : {}),
1765
+ ...(versus ? { degree: versus.degree } : {}),
1766
+ ...(versus?.natural != null ? { natural: versus.natural } : {}),
1531
1767
  };
1768
+ }
1532
1769
 
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
- }
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;
1549
1781
  }
1550
1782
 
1551
- return result;
1783
+ return { successes, failures };
1552
1784
  }