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
@@ -15,13 +15,14 @@ import type {
15
15
  CritThreshold,
16
16
  CritThresholdNode,
17
17
  DiceNode,
18
+ DieBoundNode,
18
19
  ExplodeNode,
19
20
  FateDiceNode,
20
21
  FunctionCallNode,
21
22
  GroupedNode,
22
23
  GroupNode,
24
+ KeepDropNode,
23
25
  LiteralNode,
24
- ModifierNode,
25
26
  RerollNode,
26
27
  SortNode,
27
28
  SuccessCountNode,
@@ -29,29 +30,61 @@ import type {
29
30
  VariableNode,
30
31
  VersusNode,
31
32
  } from './ast.js';
33
+ import { isCritThreshold, isSuccessCount } from './ast.js';
32
34
  import {
33
35
  containsDicePool,
34
36
  containsFatePool,
35
37
  containsMultiSubGroup,
36
38
  containsVersus,
37
39
  deepContainsDicePool,
38
- isCritThreshold,
39
- isSuccessCount,
40
- unwrapTransparent,
41
- } from './ast.js';
40
+ unwrapAllTransparent,
41
+ unwrapGrouped,
42
+ } from './guards.js';
42
43
 
43
44
  /**
44
45
  * Error thrown when the parser encounters invalid syntax.
46
+ *
47
+ * `position` is a zero-based UTF-16 offset into the notation. It is
48
+ * deliberately absent from `message` — read it from the field, or uniformly
49
+ * across all roll-parser errors via `getErrorSpan`.
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * import { ParseError, roll, TokenType } from 'roll-parser';
54
+ *
55
+ * try {
56
+ * roll('4d6d1');
57
+ * } catch (error) {
58
+ * const typed = error as ParseError;
59
+ * typed.code; // 'AMBIGUOUS_DICE_CHAIN'
60
+ * typed.position; // 3
61
+ * typed.token?.type === TokenType.DICE; // true
62
+ * }
63
+ * ```
64
+ *
65
+ * @category Errors
45
66
  */
46
67
  export class ParseError extends RollParserError {
68
+ /** Zero-based UTF-16 offset in the notation where parsing failed. */
47
69
  readonly position: number;
70
+ /**
71
+ * The token the parser was looking at. `undefined` when the failure is not
72
+ * anchored to a token — currently only `UNEXPECTED_END`, raised when the
73
+ * input stops short.
74
+ */
48
75
  readonly token: Token | undefined;
49
76
 
50
- constructor(message: string, code: RollParserErrorCode, position: number, token?: Token) {
51
- super(`${message} at position ${position}`, code);
77
+ constructor(
78
+ message: string,
79
+ code: RollParserErrorCode,
80
+ position: number,
81
+ token?: Token,
82
+ options?: ErrorOptions,
83
+ ) {
84
+ super(message, code, options);
52
85
  this.name = 'ParseError';
53
86
  this.position = position;
54
- this.token = token ?? undefined;
87
+ this.token = token;
55
88
  }
56
89
  }
57
90
 
@@ -61,6 +94,7 @@ export class ParseError extends RollParserError {
61
94
  *
62
95
  * Precedence order (lowest to highest):
63
96
  * - Versus (`vs`): 2-3 (lowest — full expressions on both sides)
97
+ * - Comparison (success-count LED): 8
64
98
  * - Addition/subtraction: 10
65
99
  * - Multiplication/division/modulo: 20
66
100
  * - Unary minus: 25 (binds to complete dice expr: -1d4 = -(1d4))
@@ -69,27 +103,24 @@ export class ParseError extends RollParserError {
69
103
  * - Dice: 40-41
70
104
  */
71
105
  const BP = {
72
- // Versus (left-associative, lowest precedence `1d20+10 vs 25+10` = `(1d20+10) vs (25+10)`)
106
+ // Lowest, so both sides take a full expression: `1d20+10 vs 25+10` =
107
+ // `(1d20+10) vs (25+10)`.
73
108
  VS_LEFT: 2,
74
109
  VS_RIGHT: 3,
75
- // Comparison operators used as the success-count LED. Must be below
76
- // ADD/MUL so `XdY+N>T` parses as `(XdY+N)>T` and fails the pool-target
77
- // guard with a clear error, instead of the `>` stealing `N` from the `+`.
110
+ // Below ADD/MUL so `XdY+N>T` parses as `(XdY+N)>T` and fails the pool-target
111
+ // guard with a clear error, instead of `>` stealing `N` from the `+`.
78
112
  COMPARE: 8,
79
- // Addition/subtraction (left-associative)
80
113
  ADD_LEFT: 10,
81
114
  ADD_RIGHT: 11,
82
- // Multiplication/division/modulo (left-associative)
83
115
  MUL_LEFT: 20,
84
116
  MUL_RIGHT: 21,
85
- // Unary minus: between mul and power so -1d4 = -(1d4) not (-1)d4
117
+ // Between MUL and POW so `-1d4` = `-(1d4)`, not `(-1)d4`.
86
118
  UNARY: 25,
87
- // Power (right-associative: left > right)
88
119
  POW_LEFT: 31,
89
120
  POW_RIGHT: 30,
90
- // Postfix modifiers: must be < DICE_RIGHT so they bind to complete dice expr
121
+ // Must stay below DICE_RIGHT so a postfix modifier binds to the whole dice
122
+ // expression: `4d6kh3` = `(4d6)kh3`.
91
123
  MODIFIER: 35,
92
- // Dice operator (highest math precedence)
93
124
  DICE_LEFT: 40,
94
125
  DICE_RIGHT: 41,
95
126
  } as const;
@@ -100,17 +131,43 @@ const BP = {
100
131
  * `ParseError` instead of an uncaught `RangeError` stack overflow (which
101
132
  * would also break the `isRollParserError` contract). Bounding parse depth
102
133
  * also bounds AST depth, protecting the recursive AST walkers and evaluator.
134
+ *
135
+ * Not configurable — unlike the {@link EvaluationOptions} caps, this one always
136
+ * applies, so untrusted notation can never blow the stack.
137
+ *
138
+ * @example
139
+ * ```typescript
140
+ * import { isRollParserError, MAX_PARSE_DEPTH, parse } from 'roll-parser';
141
+ *
142
+ * MAX_PARSE_DEPTH; // 128
143
+ *
144
+ * try {
145
+ * parse('('.repeat(20_000) + '1d6' + ')'.repeat(20_000));
146
+ * } catch (error) {
147
+ * isRollParserError(error) && error.code; // 'MAX_DEPTH_EXCEEDED'
148
+ * }
149
+ * ```
150
+ *
151
+ * @category Limits
103
152
  */
104
- const MAX_PARSE_DEPTH = 128;
153
+ export const MAX_PARSE_DEPTH = 128;
105
154
 
106
- /** Human-readable symbols for tokens named in `expect()` error messages. */
107
- const TOKEN_DISPLAY: Partial<Record<TokenType, string>> = {
155
+ /**
156
+ * Human-readable symbols for the tokens `expect()` can be asked for.
157
+ *
158
+ * `satisfies` keeps the key literals, so `ExpectableToken` narrows `expect()`
159
+ * to exactly these tokens and the lookup can never miss.
160
+ */
161
+ const TOKEN_DISPLAY = {
108
162
  [TokenType.LPAREN]: `'('`,
109
163
  [TokenType.RPAREN]: `')'`,
110
164
  [TokenType.LBRACE]: `'{'`,
111
165
  [TokenType.RBRACE]: `'}'`,
112
166
  [TokenType.COMMA]: `','`,
113
- };
167
+ } satisfies Partial<Record<TokenType, string>>;
168
+
169
+ /** Token types that carry a display symbol and may be passed to `expect()`. */
170
+ type ExpectableToken = keyof typeof TOKEN_DISPLAY;
114
171
 
115
172
  /**
116
173
  * Arity table for math functions. `min` and `max` are inclusive.
@@ -121,10 +178,26 @@ const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
121
178
  ceil: { min: 1, max: 1 },
122
179
  round: { min: 1, max: 1 },
123
180
  abs: { min: 1, max: 1 },
181
+ sqrt: { min: 1, max: 1 },
182
+ pow: { min: 2, max: 2 },
124
183
  max: { min: 2, max: Number.POSITIVE_INFINITY },
125
184
  min: { min: 2, max: Number.POSITIVE_INFINITY },
126
185
  };
127
186
 
187
+ /** Renders an arity range for the `INVALID_FUNCTION_ARITY` message. */
188
+ function formatArity(arity: { min: number; max: number }): string {
189
+ if (arity.max === Number.POSITIVE_INFINITY) return `at least ${arity.min}`;
190
+ if (arity.min === arity.max) return `${arity.min}`;
191
+ return `${arity.min}–${arity.max}`;
192
+ }
193
+
194
+ /** Explode variant produced by each explode token. */
195
+ const EXPLODE_VARIANTS: Partial<Record<TokenType, ExplodeNode['variant']>> = {
196
+ [TokenType.EXPLODE]: 'standard',
197
+ [TokenType.EXPLODE_COMPOUND]: 'compound',
198
+ [TokenType.EXPLODE_PENETRATING]: 'penetrating',
199
+ };
200
+
128
201
  /**
129
202
  * Pratt parser for dice notation.
130
203
  *
@@ -150,7 +223,6 @@ export class Parser {
150
223
 
151
224
  const ast = this.parseExpression(0);
152
225
 
153
- // Ensure we consumed all tokens
154
226
  if (this.peek().type !== TokenType.EOF) {
155
227
  const token = this.peek();
156
228
  throw new ParseError(
@@ -168,15 +240,9 @@ export class Parser {
168
240
  * Parse an expression with minimum binding power.
169
241
  */
170
242
  private parseExpression(minBp: number): ASTNode {
243
+ const entryDepth = this.depth;
171
244
  this.depth += 1;
172
- if (this.depth > MAX_PARSE_DEPTH) {
173
- throw new ParseError(
174
- `Expression nesting exceeds the maximum depth of ${MAX_PARSE_DEPTH}`,
175
- 'MAX_DEPTH_EXCEEDED',
176
- this.peek().position,
177
- this.peek(),
178
- );
179
- }
245
+ this.guardDepth();
180
246
 
181
247
  try {
182
248
  let left = this.parseNud();
@@ -189,11 +255,31 @@ export class Parser {
189
255
 
190
256
  this.advance();
191
257
  left = this.parseLed(left, token);
258
+ // ! Each continuation wraps `left`, so the AST is one level deeper even
259
+ // ! though this loop never recursed. Drop this and the bound stops
260
+ // ! holding for left-associative chains.
261
+ this.depth += 1;
262
+ this.guardDepth();
192
263
  }
193
264
 
194
265
  return left;
195
266
  } finally {
196
- this.depth -= 1;
267
+ this.depth = entryDepth;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Throw once the AST under construction is deeper than the recursive walkers
273
+ * can safely descend.
274
+ */
275
+ private guardDepth(): void {
276
+ if (this.depth > MAX_PARSE_DEPTH) {
277
+ throw new ParseError(
278
+ `Expression nesting exceeds the maximum depth of ${MAX_PARSE_DEPTH}`,
279
+ 'MAX_DEPTH_EXCEEDED',
280
+ this.peek().position,
281
+ this.peek(),
282
+ );
197
283
  }
198
284
  }
199
285
 
@@ -272,7 +358,7 @@ export class Parser {
272
358
  case TokenType.KEEP_LOW:
273
359
  case TokenType.DROP_HIGH:
274
360
  case TokenType.DROP_LOW:
275
- return this.parseModifier(left, token);
361
+ return this.parseKeepDrop(left, token);
276
362
 
277
363
  case TokenType.EXPLODE:
278
364
  case TokenType.EXPLODE_COMPOUND:
@@ -291,6 +377,11 @@ export class Parser {
291
377
  case TokenType.CRIT_FAIL:
292
378
  return this.parseCritThreshold(left, token);
293
379
 
380
+ case TokenType.FUNCTION:
381
+ // Only `min`/`max` reach LED position — `getLeftBp` keeps every
382
+ // other function name at -1, ending the Pratt loop before dispatch.
383
+ return this.parseDieBound(left, token);
384
+
294
385
  case TokenType.GREATER:
295
386
  case TokenType.GREATER_EQUAL:
296
387
  case TokenType.LESS:
@@ -311,7 +402,9 @@ export class Parser {
311
402
  }
312
403
  }
313
404
 
405
+ //
314
406
  // * Node parsers
407
+ //
315
408
 
316
409
  /**
317
410
  * Zero-width span for synthetic nodes (implicit counts, `d%` sides) that
@@ -343,10 +436,9 @@ export class Parser {
343
436
  }
344
437
 
345
438
  private parsePrefixDice(token: Token): DiceNode {
346
- // d20 → Dice(1, 20)
347
439
  const sides = this.parseExpression(BP.DICE_RIGHT);
348
440
  this.rejectSuccessCountTarget(sides, token);
349
- this.rejectVersusTarget(sides, token);
441
+ this.rejectVersusMetaOperand(sides, token);
350
442
  return {
351
443
  type: 'Dice',
352
444
  count: Parser.syntheticLiteral(1, token),
@@ -357,13 +449,12 @@ export class Parser {
357
449
  }
358
450
 
359
451
  private parseInfixDice(left: ASTNode, token: Token): DiceNode {
360
- // 4d6 → Dice(4, 6)
361
452
  this.rejectSuccessCountTarget(left, token);
362
- this.rejectVersusTarget(left, token);
453
+ this.rejectVersusMetaOperand(left, token);
363
454
  this.rejectBareDiceChain(left, token);
364
455
  const sides = this.parseExpression(BP.DICE_RIGHT);
365
456
  this.rejectSuccessCountTarget(sides, token);
366
- this.rejectVersusTarget(sides, token);
457
+ this.rejectVersusMetaOperand(sides, token);
367
458
  return {
368
459
  type: 'Dice',
369
460
  count: left,
@@ -374,7 +465,6 @@ export class Parser {
374
465
  }
375
466
 
376
467
  private parsePrefixDicePercent(token: Token): DiceNode {
377
- // d% → Dice(1, 100)
378
468
  return {
379
469
  type: 'Dice',
380
470
  count: Parser.syntheticLiteral(1, token),
@@ -385,9 +475,8 @@ export class Parser {
385
475
  }
386
476
 
387
477
  private parseInfixDicePercent(left: ASTNode, token: Token): DiceNode {
388
- // 2d% → Dice(2, 100)
389
478
  this.rejectSuccessCountTarget(left, token);
390
- this.rejectVersusTarget(left, token);
479
+ this.rejectVersusMetaOperand(left, token);
391
480
  this.rejectBareDiceChain(left, token);
392
481
  return {
393
482
  type: 'Dice',
@@ -399,7 +488,6 @@ export class Parser {
399
488
  }
400
489
 
401
490
  private parsePrefixFateDice(token: Token): FateDiceNode {
402
- // dF → FateDice(1)
403
491
  return {
404
492
  type: 'FateDice',
405
493
  count: Parser.syntheticLiteral(1, token),
@@ -409,11 +497,10 @@ export class Parser {
409
497
  }
410
498
 
411
499
  private parseInfixFateDice(left: ASTNode, token: Token): FateDiceNode {
412
- // 4dF FateDice(4). Unlike parseInfixDice, there is no sides sub-parse,
413
- // so modifiers (`kh`, `dl`, …) naturally bind at the outer Pratt loop
414
- // without BP competition against a right-operand.
500
+ // No sides sub-parse, unlike `parseInfixDice`, so modifiers (`kh`, `dl`, …)
501
+ // bind at the outer Pratt loop with no BP competition from a right operand.
415
502
  this.rejectSuccessCountTarget(left, token);
416
- this.rejectVersusTarget(left, token);
503
+ this.rejectVersusMetaOperand(left, token);
417
504
  this.rejectBareDiceChain(left, token);
418
505
  return {
419
506
  type: 'FateDice',
@@ -430,9 +517,9 @@ export class Parser {
430
517
  }
431
518
 
432
519
  private parseGroup(startToken: Token): GroupNode {
433
- // ? `LBRACE`/`RBRACE`/`COMMA` all have `getLeftBp === -1`, so inner
434
- // `parseExpression(0)` calls terminate at the first `,` or `}` without
435
- // competing with modifier/arithmetic BPs.
520
+ // `LBRACE`/`RBRACE`/`COMMA` all have `getLeftBp === -1`, so inner
521
+ // `parseExpression(0)` calls stop at the first `,` or `}` without competing
522
+ // with modifier/arithmetic BPs.
436
523
  if (this.peek().type === TokenType.RBRACE) {
437
524
  throw new ParseError('Empty group', 'UNEXPECTED_TOKEN', startToken.position, startToken);
438
525
  }
@@ -462,9 +549,8 @@ export class Parser {
462
549
  }
463
550
 
464
551
  private parseFunctionCall(token: Token): FunctionCallNode {
465
- // `FUNCTION` has BP = -1 so callers stop here; `COMMA` and `RPAREN` also
466
- // terminate inner `parseExpression(0)` calls, so argument boundaries are
467
- // natural.
552
+ // `FUNCTION`, `COMMA`, and `RPAREN` all sit at BP -1, so argument boundaries
553
+ // fall out of the inner `parseExpression(0)` calls terminating on their own.
468
554
  this.expect(TokenType.LPAREN);
469
555
 
470
556
  const args: ASTNode[] = [];
@@ -483,10 +569,9 @@ export class Parser {
483
569
  const close = this.expect(TokenType.RPAREN);
484
570
 
485
571
  const arity = FUNCTION_ARITY[token.value];
486
- if (arity === undefined) {
487
- // ? Unreachable in practice: lexer only emits FUNCTION for registered
488
- // names. Kept defensive to keep parser/evaluator error-code contract
489
- // symmetrical.
572
+ if (arity == null) {
573
+ // Unreachable: the lexer only emits `FUNCTION` for registered names. Kept
574
+ // so the parser/evaluator error-code contract stays symmetrical.
490
575
  throw new ParseError(
491
576
  `Unknown function '${token.value}'`,
492
577
  'UNKNOWN_FUNCTION',
@@ -496,12 +581,7 @@ export class Parser {
496
581
  }
497
582
 
498
583
  if (args.length < arity.min || args.length > arity.max) {
499
- const expected =
500
- arity.max === Number.POSITIVE_INFINITY
501
- ? `at least ${arity.min}`
502
- : arity.min === arity.max
503
- ? `${arity.min}`
504
- : `${arity.min}–${arity.max}`;
584
+ const expected = formatArity(arity);
505
585
  throw new ParseError(
506
586
  `Function '${token.value}' expects ${expected} argument${arity.min === 1 && arity.max === 1 ? '' : 's'}, got ${args.length}`,
507
587
  'INVALID_FUNCTION_ARITY',
@@ -552,7 +632,8 @@ export class Parser {
552
632
  case 'FateDice':
553
633
  case 'Explode':
554
634
  case 'Reroll':
555
- case 'Modifier':
635
+ case 'DieBound':
636
+ case 'KeepDrop':
556
637
  case 'Sort':
557
638
  case 'CritThreshold':
558
639
  throw new ParseError(
@@ -567,11 +648,10 @@ export class Parser {
567
648
  }
568
649
 
569
650
  private rejectSuccessCountTarget(target: ASTNode, token: Token): void {
570
- // ? Narrow unwrap: only `Grouped`. A `SuccessCount` cannot live inside
571
- // `Modifier`/`Sort`/`CritThreshold` because each of those parsers calls
572
- // this same reject on their target before constructing the wrapper
573
- // so widening the set here would never match.
574
- const node = unwrapTransparent(target, ['Grouped']);
651
+ // Narrow unwrap (only `Grouped`): `KeepDrop`/`Sort`/`CritThreshold` each run
652
+ // this same reject before building their wrapper, so a `SuccessCount` can
653
+ // never hide inside one and widening the set would never match.
654
+ const node = unwrapGrouped(target);
575
655
  if (isSuccessCount(node)) {
576
656
  throw new ParseError(
577
657
  `Cannot apply modifier after success counting`,
@@ -586,12 +666,11 @@ export class Parser {
586
666
  * Rejects `GroupNode` (or a wrapper-cloaked group) as the target of `token`.
587
667
  * Explode, reroll, and crit-threshold wrap bare dice pools only — a group
588
668
  * is a container of sub-expressions, so these modifiers have no defined
589
- * semantics. Walks `Grouped`/`Modifier`/`Sort`/`CritThreshold` so wrappers
669
+ * semantics. Walks `Grouped`/`KeepDrop`/`Sort`/`CritThreshold` so wrappers
590
670
  * cannot smuggle a group past the check (`{1d6}kh1cs>5`, `({1d6})!`,
591
671
  * `{1d6}scs>5` all reject the same as `{1d6}!`/`{1d6}cs>5`).
592
672
  *
593
- * `singleSubRollPasses` opts the caller into the Stage 3 single-sub-roll
594
- * passthrough rule (STAGE3.md "Group Semantics: Single vs Multi Sub-Roll"):
673
+ * `singleSubRollPasses` opts the caller into the single-sub-roll passthrough:
595
674
  * a `Group` with one expression is the user's explicit flat-pool escape
596
675
  * hatch and is equivalent to its unwrapped form. Currently only
597
676
  * `parseCritThreshold` opts in — explode/reroll keep the strict reject so
@@ -604,14 +683,14 @@ export class Parser {
604
683
  code: RollParserErrorCode,
605
684
  singleSubRollPasses = false,
606
685
  ): void {
607
- const node = unwrapTransparent(target, ['Grouped', 'Modifier', 'Sort', 'CritThreshold']);
686
+ const node = unwrapAllTransparent(target);
608
687
  if (node.type !== 'Group') return;
609
688
  if (singleSubRollPasses && node.expressions.length === 1) {
610
- // ! Deep-walk the inner sub-expression — `unwrapTransparent` only peels
611
- // `Grouped`/`Modifier`/`Sort`/`CritThreshold`, so a multi-sub Group
612
- // buried under arithmetic (`{{1d6,2d8}+0}cs>5`), function calls
613
- // (`{abs({1d6,2d8})}cs>5`), or unary ops would otherwise revive the
614
- // exact dropped-die-flag bug from #97.
689
+ // ! Deep-walk the inner sub-expression — `unwrapAllTransparent` only peels
690
+ // ! `Grouped`/`KeepDrop`/`Sort`/`CritThreshold`, so a multi-sub Group
691
+ // ! buried under arithmetic (`{{1d6,2d8}+0}cs>5`), a function call
692
+ // ! (`{abs({1d6,2d8})}cs>5`), or a unary op would reach the evaluator and
693
+ // ! override `critical`/`fumble` on dice from dropped sub-rolls.
615
694
  const inner = node.expressions[0];
616
695
  if (inner != null && containsMultiSubGroup(inner)) {
617
696
  throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
@@ -621,65 +700,74 @@ export class Parser {
621
700
  throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
622
701
  }
623
702
 
703
+ /**
704
+ * Rejects a Versus anywhere inside a meta operand — a dice count, dice sides,
705
+ * a modifier count, or a threshold/bound value.
706
+ *
707
+ * Deep, unlike {@link rejectVersusTarget}: `evalMetaOperand` reduces the
708
+ * operand to a scalar and forwards rolls but not `versusMetadata`, so
709
+ * `floor(1d20 vs 15)` or `(1d20 vs 15)+0` buried in a count loses the degree
710
+ * exactly as a bare one does.
711
+ */
712
+ private rejectVersusMetaOperand(operand: ASTNode, token: Token): void {
713
+ if (containsVersus(operand)) Parser.throwVersusMetaExpression(token);
714
+ }
715
+
716
+ /**
717
+ * The wording the meta-expression rejections share. Not every `NESTED_VERSUS`
718
+ * throw — `parseVersus` says "Cannot chain versus operators" for `a vs b vs c`,
719
+ * a different condition. Routing that one through here would silently rewrite
720
+ * its message, and `expectRollError` asserts only the code.
721
+ */
722
+ private static throwVersusMetaExpression(token: Token): never {
723
+ throw new ParseError(
724
+ `Versus cannot be used as a meta-expression`,
725
+ 'NESTED_VERSUS',
726
+ token.position,
727
+ token,
728
+ );
729
+ }
730
+
624
731
  private rejectVersusTarget(target: ASTNode, token: Token): void {
625
- // ? Versus produces a PF2e degree outcome a terminal scalar, not a
626
- // valid input to dice count/sides/thresholds. Symmetric with
627
- // `rejectSuccessCountTarget`; wrappers like `floor(vs)+0` still
628
- // propagate `versusMetadata` via `mergeContext`, so this only blocks
629
- // `mergeMetaRolls` sites where metadata would silently vanish.
630
- // ? Narrow unwrap: only `Grouped`. `Modifier`/`Sort`/`CritThreshold`
631
- // cannot wrap a `Versus` because `containsDicePool` does not recurse
632
- // into `Versus`, so each of those parsers rejects the wrap upstream.
633
- const node = unwrapTransparent(target, ['Grouped']);
634
- if (node.type === 'Versus') {
635
- throw new ParseError(
636
- `Versus cannot be used as a meta-expression`,
637
- 'NESTED_VERSUS',
638
- token.position,
639
- token,
640
- );
641
- }
642
- // ! Single-sub-roll `Group` passthrough is the new sibling route —
643
- // `containsDicePool` recurses into Group's single sub-expression via
644
- // `deepContainsDicePool`, which traverses Versus's `roll`/`dc`. Deep
645
- // walk for any descendant Versus so `{1d20 vs 15}cs>18`,
646
- // `{1+(1d20 vs 15)}cs>18`, and `4d6>={abs(1d20 vs 15)}` reject too.
732
+ // A Versus degree is a terminal scalar, never a valid modifier target —
733
+ // every one of these modifiers drops `versusMetadata` on the way through.
734
+ // Shallow on purpose: a multi-sub group like `{1d20 vs 15, 1d6}kh1` is
735
+ // legal, and `evalGroupKeepDrop` propagates the degree from kept sub-rolls.
736
+ // Narrow unwrap (only `Grouped`): `containsDicePool` does not recurse into
737
+ // `Versus`, so `KeepDrop`/`Sort`/`CritThreshold` reject the wrap upstream.
738
+ const node = unwrapGrouped(target);
739
+ if (node.type === 'Versus') Parser.throwVersusMetaExpression(token);
740
+ // ! `containsDicePool` recurses into a single-sub-roll Group via
741
+ // ! `deepContainsDicePool`, which traverses Versus's `roll`/`dc` — so that
742
+ // ! route gets a Versus past the shallow guards. Deep-walk for any descendant
743
+ // ! Versus, or `{1d20 vs 15}cs>18`, `{1+(1d20 vs 15)}cs>18`, and
744
+ // ! `4d6>={abs(1d20 vs 15)}` would parse while `(1d20 vs 15)cs>18` rejects.
647
745
  if (node.type === 'Group' && node.expressions.length === 1) {
648
746
  const inner = node.expressions[0];
649
- if (inner != null && containsVersus(inner)) {
650
- throw new ParseError(
651
- `Versus cannot be used as a meta-expression`,
652
- 'NESTED_VERSUS',
653
- token.position,
654
- token,
655
- );
656
- }
747
+ if (inner != null && containsVersus(inner)) Parser.throwVersusMetaExpression(token);
657
748
  }
658
749
  }
659
750
 
660
- private parseModifier(target: ASTNode, token: Token): ModifierNode {
751
+ private parseKeepDrop(target: ASTNode, token: Token): KeepDropNode {
661
752
  this.rejectSuccessCountTarget(target, token);
662
- // ! Mirror `parseSort`/`parseCritThreshold` keep/drop applied to a
663
- // Versus target silently drops `degree`/`natural` metadata. Pre-existing
664
- // for `(1d20 vs 15)kh1` (caught upstream by `containsDicePool` with a
665
- // different error code), but the single-sub-roll Group passthrough makes
666
- // `{1d20 vs 15}kh1` reachable past `containsDicePool` (Group's deep
667
- // walk recurses into Versus's roll/dc), so the explicit reject is the
668
- // only thing that closes the metadata-drop hole.
753
+ // ! Keep/drop on a Versus target silently drops `degree`/`natural` metadata,
754
+ // ! and `{1d20 vs 15}kh1` gets past `containsDicePool` whose Group deep
755
+ // ! walk recurses into Versus's `roll`/`dc` so this reject is the only
756
+ // ! thing closing that hole. Mirrors `parseSort`/`parseCritThreshold`.
669
757
  this.rejectVersusTarget(target, token);
670
758
 
671
- // Keep/drop modifiers need a dice pool to select from. Wrapping arithmetic
672
- // (e.g. `(1d6+5)kh1`, `4d6+2kh3`) would silently drop user math.
759
+ // Keep/drop needs a pool to select from; `(1d6+5)kh1` or `4d6+2kh3` would
760
+ // silently drop the user's arithmetic.
673
761
  if (!containsDicePool(target)) {
674
762
  throw new ParseError(
675
763
  `Keep/drop modifiers require a dice pool target`,
676
- 'INVALID_MODIFIER_TARGET',
764
+ 'INVALID_KEEP_DROP_TARGET',
677
765
  token.position,
678
766
  token,
679
767
  );
680
768
  }
681
769
 
682
- const modifier =
770
+ const kind =
683
771
  token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
684
772
 
685
773
  const selector =
@@ -687,7 +775,6 @@ export class Parser {
687
775
  ? 'highest'
688
776
  : 'lowest';
689
777
 
690
- // Default to 1 when no explicit count follows the modifier (e.g., 4d6kh → 4d6kh1)
691
778
  const nextToken = this.peek().type;
692
779
  const hasExplicitCount =
693
780
  nextToken === TokenType.NUMBER ||
@@ -697,11 +784,11 @@ export class Parser {
697
784
  ? this.parseExpression(BP.DICE_LEFT)
698
785
  : Parser.syntheticLiteral(1, token);
699
786
  this.rejectSuccessCountTarget(count, token);
700
- this.rejectVersusTarget(count, token);
787
+ this.rejectVersusMetaOperand(count, token);
701
788
 
702
789
  return {
703
- type: 'Modifier',
704
- modifier,
790
+ type: 'KeepDrop',
791
+ kind,
705
792
  selector,
706
793
  count,
707
794
  target,
@@ -713,13 +800,12 @@ export class Parser {
713
800
  private parseExplode(target: ASTNode, token: Token): ExplodeNode {
714
801
  this.rejectSuccessCountTarget(target, token);
715
802
 
716
- // Groups have no explode semantics — reject outright. Must come before
717
- // `containsDicePool`, which recurses into `Group` and would otherwise
718
- // let `{4d6}!` slip through.
803
+ // Groups have no explode semantics. Must precede `containsDicePool`, which
804
+ // recurses into `Group` and would let `{4d6}!` slip through.
719
805
  this.rejectGroupTarget(target, token, 'explode', 'INVALID_EXPLODE_TARGET');
720
806
 
721
- // Explode needs a dice pool to explode on. Wrapping arithmetic (e.g.
722
- // `(1d6+5)!`, `floor(1d6/2)!`) would silently drop user math.
807
+ // Arithmetic wrappers like `(1d6+5)!` or `floor(1d6/2)!` would silently drop
808
+ // the user's math.
723
809
  if (!containsDicePool(target)) {
724
810
  throw new ParseError(
725
811
  `Explode modifier requires a dice pool target`,
@@ -729,9 +815,8 @@ export class Parser {
729
815
  );
730
816
  }
731
817
 
732
- // Fate dice (sides = 0) cannot explode — the symmetric -1/0/+1 range has
733
- // no natural "max" trigger, so semantics are undefined. Reject at parse
734
- // time rather than silently no-op in the evaluator.
818
+ // Fate's symmetric -1/0/+1 range has no max face to trigger on, so exploding
819
+ // it has no defined semantics reject here rather than no-op in the evaluator.
735
820
  if (containsFatePool(target)) {
736
821
  throw new ParseError(
737
822
  `Fate dice cannot explode`,
@@ -741,8 +826,7 @@ export class Parser {
741
826
  );
742
827
  }
743
828
 
744
- // ? Reject nested explodes (e.g., `1d6!!!`) a second explode token atop
745
- // an ExplodeNode has no meaningful semantics and is rejected per spec.
829
+ // A second explode token atop an `Explode` (`1d6!!!`) has no semantics.
746
830
  if (target.type === 'Explode') {
747
831
  throw new ParseError(
748
832
  `Cannot chain explode modifiers`,
@@ -752,36 +836,36 @@ export class Parser {
752
836
  );
753
837
  }
754
838
 
755
- const variant: ExplodeNode['variant'] =
756
- token.type === TokenType.EXPLODE
757
- ? 'standard'
758
- : token.type === TokenType.EXPLODE_COMPOUND
759
- ? 'compound'
760
- : 'penetrating';
839
+ // `parseLed` reaches here only for the three explode tokens, so the lookup
840
+ // always hits; the fallback exists to keep the type non-optional.
841
+ const variant: ExplodeNode['variant'] = EXPLODE_VARIANTS[token.type] ?? 'penetrating';
761
842
 
762
- const node: ExplodeNode = {
843
+ const start = target.start ?? token.position;
844
+ if (!this.isComparePointAhead()) {
845
+ return { type: 'Explode', variant, target, start, end: token.end };
846
+ }
847
+
848
+ const threshold = this.parseComparePoint();
849
+
850
+ return {
763
851
  type: 'Explode',
764
852
  variant,
765
853
  target,
766
- start: target.start ?? token.position,
767
- end: token.end,
854
+ threshold,
855
+ start,
856
+ end: threshold.value.end ?? token.end,
768
857
  };
769
- if (this.isComparePointAhead()) {
770
- node.threshold = this.parseComparePoint();
771
- node.end = node.threshold.value.end ?? token.end;
772
- }
773
- return node;
774
858
  }
775
859
 
776
860
  private parseReroll(target: ASTNode, token: Token): RerollNode {
777
861
  this.rejectSuccessCountTarget(target, token);
778
862
 
779
- // Groups have no reroll semantics — reject outright. Must come before
780
- // `containsDicePool`, which recurses into `Group`.
863
+ // Groups have no reroll semantics. Must precede `containsDicePool`, which
864
+ // recurses into `Group`.
781
865
  this.rejectGroupTarget(target, token, 'reroll', 'INVALID_REROLL_TARGET');
782
866
 
783
- // Reroll needs a dice pool to inspect. Wrapping arithmetic (e.g.
784
- // `(1d6+5)r<3`, `floor(1d6/2)ro<3`) would silently drop user math.
867
+ // Arithmetic wrappers like `(1d6+5)r<3` or `floor(1d6/2)ro<3` would silently
868
+ // drop the user's math.
785
869
  if (!containsDicePool(target)) {
786
870
  throw new ParseError(
787
871
  `Reroll modifier requires a dice pool target`,
@@ -791,7 +875,6 @@ export class Parser {
791
875
  );
792
876
  }
793
877
 
794
- // A reroll token must be followed by a comparison — bare `r` / `ro` is invalid.
795
878
  if (!this.isComparePointAhead()) {
796
879
  throw new ParseError(
797
880
  `Expected comparison operator after '${token.value}'`,
@@ -814,14 +897,73 @@ export class Parser {
814
897
  };
815
898
  }
816
899
 
900
+ private parseDieBound(target: ASTNode, token: Token): DieBoundNode {
901
+ this.rejectSuccessCountTarget(target, token);
902
+ this.rejectVersusTarget(target, token);
903
+
904
+ // Groups have no per-die clamp semantics — a group's "dice" are sub-roll
905
+ // subtotals. Must come before `containsDicePool`, which recurses into
906
+ // `Group`.
907
+ this.rejectGroupTarget(target, token, `apply '${token.value}' to`, 'INVALID_DIE_BOUND_TARGET');
908
+
909
+ // A clamp needs a dice pool to act on. Wrapping arithmetic (e.g.
910
+ // `(1d6+5)min3`) would silently drop user math.
911
+ if (!containsDicePool(target)) {
912
+ throw new ParseError(
913
+ `Die bound modifier requires a dice pool target`,
914
+ 'INVALID_DIE_BOUND_TARGET',
915
+ token.position,
916
+ token,
917
+ );
918
+ }
919
+
920
+ // Fate dice roll a fixed {-1, 0, +1}; clamping those faces has no
921
+ // established semantics. Mirrors the Fate-explosion rejection.
922
+ if (containsFatePool(target)) {
923
+ throw new ParseError(
924
+ `Fate dice cannot be clamped`,
925
+ 'INVALID_DIE_BOUND_TARGET',
926
+ token.position,
927
+ token,
928
+ );
929
+ }
930
+
931
+ // A bound must be explicit — bare `4d6min` has nothing to clamp to.
932
+ const nextToken = this.peek().type;
933
+ const hasExplicitValue =
934
+ nextToken === TokenType.NUMBER ||
935
+ nextToken === TokenType.LPAREN ||
936
+ nextToken === TokenType.AT;
937
+ if (!hasExplicitValue) {
938
+ throw new ParseError(
939
+ `Expected value after '${token.value}'`,
940
+ 'EXPECTED_TOKEN',
941
+ token.position,
942
+ token,
943
+ );
944
+ }
945
+
946
+ const value = this.parseExpression(BP.DICE_LEFT);
947
+ this.rejectSuccessCountTarget(value, token);
948
+ this.rejectVersusMetaOperand(value, token);
949
+
950
+ return {
951
+ type: 'DieBound',
952
+ bound: token.value === 'min' ? 'min' : 'max',
953
+ value,
954
+ target,
955
+ start: target.start ?? token.position,
956
+ end: value.end ?? token.end,
957
+ };
958
+ }
959
+
817
960
  private parseSort(target: ASTNode, token: Token): SortNode {
818
961
  this.rejectSuccessCountTarget(target, token);
819
962
  this.rejectVersusTarget(target, token);
820
963
 
821
- // Sort is purely visual but still needs a dice pool to reorder —
822
- // `5s`, `(1+2)s`, or `floor(5)s` have no dice to touch. Uses the deep
823
- // guard so arithmetic-wrapped pools like `(1d6+2d8)s` are accepted per
824
- // Stage 3 spec, while pure literals/arithmetic reject.
964
+ // Deep guard, unlike explode/reroll: arithmetic-wrapped pools like
965
+ // `(1d6+2d8)s` are sortable, while `5s`, `(1+2)s`, and `floor(5)s` have no
966
+ // dice to reorder.
825
967
  if (!deepContainsDicePool(target)) {
826
968
  throw new ParseError(
827
969
  `Sort modifier requires a dice pool target`,
@@ -831,14 +973,12 @@ export class Parser {
831
973
  );
832
974
  }
833
975
 
834
- // ! Multi-sub-roll groups (`{a, b}s`, `({a, b})s`) need hierarchical
835
- // sort per Stage 3 spec §3 (sort dice within each sub-roll, then sort
836
- // sub-rolls by total) — `evalSort` only flat-sorts, so accepting the
837
- // syntax would silently ship non-spec behaviour. Reject at parse time
838
- // until the deferred Stage 4 implementation lands. Single-sub Groups
839
- // keep passing through (the unwrap returns a `Group` with one
840
- // expression, which is the user's flat-pool escape hatch).
841
- const base = unwrapTransparent(target, ['Grouped', 'Modifier', 'Sort', 'CritThreshold']);
976
+ // ! Multi-sub-roll groups (`{a, b}s`, `({a, b})s`) require hierarchical sort —
977
+ // ! dice within each sub-roll, then sub-rolls by total — but `evalSort` only
978
+ // ! flat-sorts, so accepting the syntax would silently ship wrong output.
979
+ // ! Single-sub Groups still pass; that is the flat-pool escape hatch.
980
+ // TODO: Implement hierarchical group sort, then drop this reject.
981
+ const base = unwrapAllTransparent(target);
842
982
  if (base.type === 'Group' && base.expressions.length >= 2) {
843
983
  throw new ParseError(
844
984
  `Sort modifier does not yet support multi-sub-roll groups`,
@@ -850,9 +990,8 @@ export class Parser {
850
990
 
851
991
  const order: SortNode['order'] = token.type === TokenType.SORT_ASC ? 'ascending' : 'descending';
852
992
 
853
- // ? Chained sorts (`4d6ss`, `4d6sasd`) are allowed sort is idempotent
854
- // when repeated in the same direction; a later `sd` after `s` just
855
- // overrides the order since both pass over the same pool.
993
+ // Chained sorts (`4d6ss`, `4d6sasd`) are deliberately allowed: repeats are
994
+ // idempotent and a later `sd` just overrides the order.
856
995
  return {
857
996
  type: 'Sort',
858
997
  order,
@@ -866,12 +1005,11 @@ export class Parser {
866
1005
  this.rejectSuccessCountTarget(target, token);
867
1006
  this.rejectVersusTarget(target, token);
868
1007
 
869
- // Multi-sub-roll groups have no crit-threshold semantics per Stage 3 spec
870
- // a group there is a container of sub-roll subtotals, not a dice pool,
871
- // and applying cs/cf would override `critical`/`fumble` on dropped
872
- // sub-roll dice. Single-sub-roll groups pass through under the documented
873
- // flat-pool rule (`{1d20}kh1cs>18` `(1d20)kh1cs>18`). Must run before
874
- // `containsDicePool`, which recurses into `Group`.
1008
+ // A multi-sub-roll group is a container of sub-roll subtotals, not a pool:
1009
+ // `cs`/`cf` there would override `critical`/`fumble` on dropped sub-roll
1010
+ // dice. Single-sub-roll groups pass through as the flat-pool form
1011
+ // (`{1d20}kh1cs>18` ≡ `(1d20)kh1cs>18`). Must run before `containsDicePool`,
1012
+ // which recurses into `Group`.
875
1013
  this.rejectGroupTarget(
876
1014
  target,
877
1015
  token,
@@ -880,10 +1018,9 @@ export class Parser {
880
1018
  true,
881
1019
  );
882
1020
 
883
- // Shallow dice-pool check (bare dice only) — rejects `(1d6+2d8)cs>5`,
884
- // `5cs`, `(1+2)cs`, `floor(5)cs`. Matches explode/reroll behavior.
885
- // Note: if `target` already is a `CritThresholdNode`, `containsDicePool`
886
- // recurses into its `target`, so chained `cs`/`cf` naturally pass.
1021
+ // Shallow check like explode/reroll: `(1d6+2d8)cs>5`, `5cs`, `(1+2)cs`, and
1022
+ // `floor(5)cs` reject. Chained `cs`/`cf` still pass, since `containsDicePool`
1023
+ // recurses into a `CritThreshold`'s own target.
887
1024
  if (!containsDicePool(target)) {
888
1025
  throw new ParseError(
889
1026
  `Crit threshold modifier requires a dice pool target`,
@@ -893,11 +1030,9 @@ export class Parser {
893
1030
  );
894
1031
  }
895
1032
 
896
- // Bare `cs`/`cf` resolve to a per-die default that assumes max-side / 1
897
- // semantics — incompatible with Fate dice (`{-1, 0, +1}`), where the bare
898
- // fumble check would flip the best face (`+1`) into a fumble. Custom
899
- // thresholds with explicit ComparePoints remain accepted. Mirrors the
900
- // Fate-explosion rejection above.
1033
+ // The bare `cs`/`cf` per-die default assumes max-side/1 semantics, which on
1034
+ // Fate's `{-1, 0, +1}` would flag the best face (`+1`) as a fumble. Explicit
1035
+ // ComparePoints stay accepted. Mirrors the Fate-explosion reject above.
901
1036
  if (!this.isComparePointAhead() && containsFatePool(target)) {
902
1037
  throw new ParseError(
903
1038
  `Bare cs/cf cannot apply to Fate dice`,
@@ -912,23 +1047,27 @@ export class Parser {
912
1047
  : 'default';
913
1048
  const end = threshold === 'default' ? token.end : (threshold.value.end ?? token.end);
914
1049
 
915
- // ? Unwrap parens so `(1d20cs>19)cs=1` chains into the inner node. Without
916
- // unwrapping, the outer `cs` would create a second CritThresholdNode
917
- // wrapping the Grouped the collapse-into-single-node design decision
918
- // from STAGE3.md §"CritThreshold Collects Multiple Thresholds" would
919
- // be subtly broken for any user who parenthesizes the chain.
1050
+ // Unwrap parens so `(1d20cs>19)cs=1` collapses into the inner node instead of
1051
+ // nesting a second `CritThreshold` around the `Grouped` every threshold in
1052
+ // a chain must collect on one node, which parenthesizing would break.
920
1053
  let chainTarget: ASTNode = target;
921
1054
  while (chainTarget.type === 'Grouped') {
922
1055
  chainTarget = chainTarget.expression;
923
1056
  }
924
1057
  if (isCritThreshold(chainTarget)) {
925
- if (token.type === TokenType.CRIT_SUCCESS) {
926
- chainTarget.successThresholds.push(threshold);
927
- } else {
928
- chainTarget.failThresholds.push(threshold);
929
- }
930
- chainTarget.end = end;
931
- return chainTarget;
1058
+ // Rebuilt, not mutated: `NodeSpan` is readonly, and the discarded `Grouped`
1059
+ // wrapper must not leave a stale `end`.
1060
+ const isSuccess = token.type === TokenType.CRIT_SUCCESS;
1061
+ return {
1062
+ ...chainTarget,
1063
+ successThresholds: isSuccess
1064
+ ? [...chainTarget.successThresholds, threshold]
1065
+ : chainTarget.successThresholds,
1066
+ failThresholds: isSuccess
1067
+ ? chainTarget.failThresholds
1068
+ : [...chainTarget.failThresholds, threshold],
1069
+ end,
1070
+ };
932
1071
  }
933
1072
 
934
1073
  return {
@@ -945,9 +1084,9 @@ export class Parser {
945
1084
  // Success counting is terminal: chaining (`>=5>=3`) has no semantics.
946
1085
  this.rejectSuccessCountTarget(target, token);
947
1086
 
948
- // Reject non-pool targets like `1>=3`, `(1+2)>=3`, `(1d6*2)>=10`, or
949
- // `(1d20 vs 15)>=1`. Success counting operates on a raw dice pool; any
950
- // arithmetic or composition wrapping would be silently ignored.
1087
+ // Success counting reads a raw pool, so arithmetic or composition wrappers
1088
+ // (`1>=3`, `(1+2)>=3`, `(1d6*2)>=10`, `(1d20 vs 15)>=1`) would be silently
1089
+ // ignored.
951
1090
  if (!containsDicePool(target)) {
952
1091
  throw new ParseError(
953
1092
  `Success counting requires a dice pool target`,
@@ -958,42 +1097,49 @@ export class Parser {
958
1097
  }
959
1098
 
960
1099
  const operator = this.getCompareOp(token);
961
- // ? Threshold binding: `BP.DICE_LEFT` — see `parseComparePoint` JSDoc.
1100
+ // Threshold binds at `BP.DICE_LEFT` — see `parseComparePoint` TSDoc.
962
1101
  const value = this.parseExpression(BP.DICE_LEFT);
963
1102
  this.rejectSuccessCountTarget(value, token);
964
- this.rejectVersusTarget(value, token);
965
- const node: SuccessCountNode = {
1103
+ this.rejectVersusMetaOperand(value, token);
1104
+ const start = target.start ?? token.position;
1105
+ const end = value.end ?? token.end;
1106
+
1107
+ if (this.peek().type !== TokenType.FAIL) {
1108
+ return { type: 'SuccessCount', target, threshold: { operator, value }, start, end };
1109
+ }
1110
+
1111
+ this.advance();
1112
+ const failThreshold = this.parseFailThreshold(token);
1113
+
1114
+ return {
966
1115
  type: 'SuccessCount',
967
1116
  target,
968
1117
  threshold: { operator, value },
969
- start: target.start ?? token.position,
970
- end: value.end ?? token.end,
1118
+ failThreshold,
1119
+ start,
1120
+ end: failThreshold.value.end ?? end,
971
1121
  };
1122
+ }
972
1123
 
973
- if (this.peek().type === TokenType.FAIL) {
974
- this.advance();
975
- if (this.isComparePointAhead()) {
976
- node.failThreshold = this.parseComparePoint();
977
- } else {
978
- // ? Same threshold binding as above (BP.DICE_LEFT).
979
- const failValue = this.parseExpression(BP.DICE_LEFT);
980
- this.rejectSuccessCountTarget(failValue, token);
981
- this.rejectVersusTarget(failValue, token);
982
- node.failThreshold = { operator: '=', value: failValue };
983
- }
984
- node.end = node.failThreshold.value.end ?? node.end ?? token.end;
985
- }
1124
+ /** Parses the `f...` suffix of a success count. Bare `fN` means `f=N`. */
1125
+ private parseFailThreshold(token: Token): ComparePoint {
1126
+ if (this.isComparePointAhead()) return this.parseComparePoint();
1127
+
1128
+ // Same threshold binding as `parseComparePoint` (BP.DICE_LEFT).
1129
+ const failValue = this.parseExpression(BP.DICE_LEFT);
1130
+ this.rejectSuccessCountTarget(failValue, token);
1131
+ this.rejectVersusMetaOperand(failValue, token);
986
1132
 
987
- return node;
1133
+ return { operator: '=', value: failValue };
988
1134
  }
989
1135
 
990
1136
  private parseVersus(left: ASTNode, token: Token): VersusNode {
991
1137
  this.rejectSuccessCountTarget(left, token);
992
1138
 
993
- // ? Chained `a vs b vs c` has no semantics — a degree is a scalar, not a
994
- // comparable. Unwrap `Grouped` so `(a vs b) vs c` rejects at parse
995
- // time like the bare form; paren-nested DC (`a vs (b vs c)`) is still
996
- // caught by the evaluator via `mergeContext`.
1139
+ // A degree is a scalar, not a comparable, so `a vs b vs c` has no semantics.
1140
+ // Unwrapping `Grouped` makes `(a vs b) vs c` reject here like the bare form;
1141
+ // a paren-nested DC (`a vs (b vs c)`) is caught later by the evaluator's
1142
+ // `mergeContext`.
997
1143
  let leftChain: ASTNode = left;
998
1144
  while (leftChain.type === 'Grouped') {
999
1145
  leftChain = leftChain.expression;
@@ -1014,12 +1160,14 @@ export class Parser {
1014
1160
  };
1015
1161
  }
1016
1162
 
1163
+ //
1017
1164
  // * Compare point utilities
1165
+ //
1018
1166
 
1019
1167
  /**
1020
1168
  * Checks whether the next token is a comparison operator.
1021
1169
  */
1022
- isComparePointAhead(): boolean {
1170
+ private isComparePointAhead(): boolean {
1023
1171
  const type = this.peek().type;
1024
1172
  return (
1025
1173
  type === TokenType.GREATER ||
@@ -1044,7 +1192,7 @@ export class Parser {
1044
1192
  * @returns A ComparePoint with the operator and value AST node
1045
1193
  * @throws {ParseError} If the next token is not a comparison operator
1046
1194
  */
1047
- parseComparePoint(): ComparePoint {
1195
+ private parseComparePoint(): ComparePoint {
1048
1196
  const token = this.peek();
1049
1197
  const operator = this.getCompareOp(token);
1050
1198
 
@@ -1052,7 +1200,7 @@ export class Parser {
1052
1200
 
1053
1201
  const value = this.parseExpression(BP.DICE_LEFT);
1054
1202
  this.rejectSuccessCountTarget(value, token);
1055
- this.rejectVersusTarget(value, token);
1203
+ this.rejectVersusMetaOperand(value, token);
1056
1204
 
1057
1205
  return { operator, value };
1058
1206
  }
@@ -1079,7 +1227,9 @@ export class Parser {
1079
1227
  }
1080
1228
  }
1081
1229
 
1230
+ //
1082
1231
  // * Helpers
1232
+ //
1083
1233
 
1084
1234
  private getOperatorSymbol(token: Token): '+' | '-' | '*' | '/' | '%' | '**' {
1085
1235
  switch (token.type) {
@@ -1136,26 +1286,24 @@ export class Parser {
1136
1286
  case TokenType.CRIT_SUCCESS:
1137
1287
  case TokenType.CRIT_FAIL:
1138
1288
  return BP.MODIFIER;
1139
- // Comparison operators act as LED-dispatched success-count modifiers
1140
- // at the Pratt level. Sit below ADD/MUL so arithmetic on a dice pool
1141
- // completes before success counting wraps it `5d6+2>4` parses as
1142
- // `(5d6+2)>4` and then cleanly fails the pool-target guard, rather
1143
- // than the `>` stealing `2` from the `+`. Inside `parseComparePoint`
1144
- // (called manually by explode/reroll) this BP is bypassed.
1289
+ // Comparison operators act as LED-dispatched success-count modifiers here;
1290
+ // `parseComparePoint`, called manually by explode/reroll, bypasses this BP.
1291
+ // See `BP.COMPARE` for why it sits below ADD/MUL.
1145
1292
  case TokenType.GREATER:
1146
1293
  case TokenType.GREATER_EQUAL:
1147
1294
  case TokenType.LESS:
1148
1295
  case TokenType.LESS_EQUAL:
1149
1296
  case TokenType.EQUAL:
1150
1297
  return BP.COMPARE;
1298
+ // `min`/`max` double as postfix per-die clamps (`4d6min2`); every
1299
+ // other function name terminates the expression like punctuation.
1300
+ case TokenType.FUNCTION:
1301
+ return token.value === 'min' || token.value === 'max' ? BP.MODIFIER : -1;
1151
1302
  case TokenType.RPAREN:
1152
1303
  case TokenType.EOF:
1153
- // Punctuation and keywords that terminate expressions
1154
1304
  case TokenType.COMMA:
1155
- case TokenType.FUNCTION:
1156
- // Group boundaries: `}` closes the current group (consumed inside
1157
- // `parseGroup`), and a stray `{` after a complete expression is an
1158
- // error. Both terminate the outer Pratt loop at -1.
1305
+ // `}` is consumed inside `parseGroup`, and a stray `{` after a complete
1306
+ // expression is an error — both must terminate the outer Pratt loop.
1159
1307
  case TokenType.LBRACE:
1160
1308
  case TokenType.RBRACE:
1161
1309
  return -1;
@@ -1196,10 +1344,10 @@ export class Parser {
1196
1344
  return token;
1197
1345
  }
1198
1346
 
1199
- private expect(type: TokenType): Token {
1347
+ private expect(type: ExpectableToken): Token {
1200
1348
  const token = this.peek();
1201
1349
  if (token.type !== type) {
1202
- const expected = TOKEN_DISPLAY[type] ?? TokenType[type];
1350
+ const expected = TOKEN_DISPLAY[type];
1203
1351
  const got = token.type === TokenType.EOF ? 'end of input' : `'${token.value}'`;
1204
1352
  throw new ParseError(
1205
1353
  `Expected ${expected} but got ${got}`,
@@ -1217,20 +1365,49 @@ export class Parser {
1217
1365
  }
1218
1366
 
1219
1367
  /**
1220
- * Parse a dice notation string into an AST.
1368
+ * Parses a dice notation string into an {@link ASTNode} tree, lexing it first.
1369
+ *
1370
+ * {@link evaluate} never mutates the AST, so parse once and evaluate many times
1371
+ * when rolling the same notation repeatedly — that skips the lexer and parser
1372
+ * on every roll after the first. Use `parse` alone to validate notation
1373
+ * without consuming randomness.
1221
1374
  *
1222
1375
  * @param notation - The dice notation to parse
1223
- * @returns The root AST node
1376
+ * @returns The root AST node, with `start`/`end` spans on every node
1224
1377
  * @throws {LexerError} If the input contains invalid characters
1225
1378
  * @throws {ParseError} If the input has invalid syntax
1379
+ * @throws {RollParserError} `INVALID_NOTATION_TYPE` when `notation` is not a
1380
+ * string — raised before lexing, so it carries no position
1226
1381
  *
1227
1382
  * @example
1228
1383
  * ```typescript
1229
- * const ast = parse('2d6+3');
1230
- * // { type: 'BinaryOp', operator: '+',
1231
- * // left: { type: 'Dice', count: 2, sides: 6 },
1232
- * // right: { type: 'Literal', value: 3 } }
1384
+ * import { parse } from 'roll-parser';
1385
+ *
1386
+ * parse('2d6+3');
1387
+ * // {
1388
+ * // type: 'BinaryOp', operator: '+', start: 0, end: 5,
1389
+ * // left: {
1390
+ * // type: 'Dice', start: 0, end: 3,
1391
+ * // count: { type: 'Literal', value: 2, start: 0, end: 1 },
1392
+ * // sides: { type: 'Literal', value: 6, start: 2, end: 3 },
1393
+ * // },
1394
+ * // right: { type: 'Literal', value: 3, start: 4, end: 5 },
1395
+ * // }
1396
+ * ```
1397
+ *
1398
+ * `count` and `sides` are full nodes, not numbers — that is what makes
1399
+ * computed dice like `(1d4)d6` expressible.
1400
+ *
1401
+ * @example Parse once, roll many
1402
+ * ```typescript
1403
+ * import { evaluate, parse, SeededRNG } from 'roll-parser';
1404
+ *
1405
+ * const ast = parse('4d6kh3');
1406
+ * const rng = new SeededRNG('demo');
1407
+ * const scores = Array.from({ length: 6 }, () => evaluate(ast, rng).total);
1233
1408
  * ```
1409
+ *
1410
+ * @category Core
1234
1411
  */
1235
1412
  export function parse(notation: string): ASTNode {
1236
1413
  const tokens = lex(notation);