roll-parser 2.3.2 → 3.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +175 -170
  3. package/dist/cli/args.d.ts +35 -0
  4. package/dist/cli/args.d.ts.map +1 -0
  5. package/dist/cli/format.d.ts +20 -0
  6. package/dist/cli/format.d.ts.map +1 -0
  7. package/dist/cli/index.d.ts +8 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli.js +2608 -0
  10. package/dist/cli.js.map +28 -0
  11. package/dist/errors.d.ts +42 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/evaluator/evaluator.d.ts +121 -0
  14. package/dist/evaluator/evaluator.d.ts.map +1 -0
  15. package/dist/evaluator/index.d.ts +8 -0
  16. package/dist/evaluator/index.d.ts.map +1 -0
  17. package/dist/evaluator/modifiers/compare.d.ts +15 -0
  18. package/dist/evaluator/modifiers/compare.d.ts.map +1 -0
  19. package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
  20. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  21. package/dist/evaluator/modifiers/explode.d.ts +48 -0
  22. package/dist/evaluator/modifiers/explode.d.ts.map +1 -0
  23. package/dist/evaluator/modifiers/keep-drop.d.ts +44 -0
  24. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -0
  25. package/dist/evaluator/modifiers/reroll.d.ts +33 -0
  26. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -0
  27. package/dist/evaluator/modifiers/sort.d.ts +23 -0
  28. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  29. package/dist/evaluator/modifiers/success-count.d.ts +28 -0
  30. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -0
  31. package/dist/index.d.ts +20 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +2560 -0
  34. package/dist/index.js.map +26 -0
  35. package/dist/lexer/lexer.d.ts +90 -0
  36. package/dist/lexer/lexer.d.ts.map +1 -0
  37. package/dist/lexer/tokens.d.ts +107 -0
  38. package/dist/lexer/tokens.d.ts.map +1 -0
  39. package/dist/parser/ast.d.ts +368 -0
  40. package/dist/parser/ast.d.ts.map +1 -0
  41. package/dist/parser/parser.d.ts +145 -0
  42. package/dist/parser/parser.d.ts.map +1 -0
  43. package/dist/rng/index.d.ts +8 -0
  44. package/dist/rng/index.d.ts.map +1 -0
  45. package/dist/rng/mock.d.ts +36 -0
  46. package/dist/rng/mock.d.ts.map +1 -0
  47. package/dist/rng/seeded.d.ts +40 -0
  48. package/dist/rng/seeded.d.ts.map +1 -0
  49. package/dist/rng/types.d.ts +24 -0
  50. package/dist/rng/types.d.ts.map +1 -0
  51. package/dist/roll.d.ts +51 -0
  52. package/dist/roll.d.ts.map +1 -0
  53. package/dist/testing.d.ts +11 -0
  54. package/dist/testing.d.ts.map +1 -0
  55. package/dist/testing.js +41 -0
  56. package/dist/testing.js.map +11 -0
  57. package/dist/types.d.ts +246 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/package.json +66 -43
  60. package/src/cli/args.ts +73 -0
  61. package/src/cli/format.ts +49 -0
  62. package/src/cli/index.ts +92 -0
  63. package/src/errors.ts +90 -0
  64. package/src/evaluator/evaluator.ts +1552 -0
  65. package/src/evaluator/index.ts +14 -0
  66. package/src/evaluator/modifiers/compare.ts +29 -0
  67. package/src/evaluator/modifiers/crit-threshold.ts +59 -0
  68. package/src/evaluator/modifiers/explode.ts +219 -0
  69. package/src/evaluator/modifiers/keep-drop.ts +151 -0
  70. package/src/evaluator/modifiers/reroll.ts +165 -0
  71. package/src/evaluator/modifiers/sort.ts +30 -0
  72. package/src/evaluator/modifiers/success-count.ts +59 -0
  73. package/src/index.ts +90 -0
  74. package/src/lexer/lexer.ts +374 -0
  75. package/src/lexer/tokens.ts +187 -0
  76. package/src/parser/ast.ts +628 -0
  77. package/src/parser/parser.ts +1239 -0
  78. package/src/rng/index.ts +8 -0
  79. package/src/rng/mock.ts +65 -0
  80. package/src/rng/seeded.ts +157 -0
  81. package/src/rng/types.ts +25 -0
  82. package/src/roll.ts +74 -0
  83. package/src/testing.ts +16 -0
  84. package/src/types.ts +237 -0
  85. package/bin/help.txt +0 -19
  86. package/bin/roll-parser.js +0 -23
  87. package/dist/roll-parser.js +0 -697
  88. package/dist/roll-parser.min.js +0 -1
  89. package/index.js +0 -24
  90. package/src/complex/parse.js +0 -37
  91. package/src/complex/parseAndRoll.js +0 -21
  92. package/src/complex/parseAndRollClassic.js +0 -20
  93. package/src/complex/parseAndRollSimple.js +0 -19
  94. package/src/complex/parseAndRollWod.js +0 -20
  95. package/src/complex/parseClassicRoll.js +0 -22
  96. package/src/complex/parseSimpleRoll.js +0 -21
  97. package/src/complex/parseWodRoll.js +0 -21
  98. package/src/converter.js +0 -45
  99. package/src/grammar.js +0 -14
  100. package/src/mapper.js +0 -50
  101. package/src/normalizer.js +0 -51
  102. package/src/object/Result.js +0 -23
  103. package/src/object/Roll.js +0 -26
  104. package/src/object/Type.js +0 -5
  105. package/src/object/WodRoll.js +0 -29
  106. package/src/parser.js +0 -43
  107. package/src/random.js +0 -22
  108. package/src/roller.js +0 -106
  109. package/src/stringifier.js +0 -33
@@ -0,0 +1,1239 @@
1
+ /**
2
+ * Pratt parser for dice notation.
3
+ *
4
+ * @module parser/parser
5
+ */
6
+
7
+ import type { RollParserErrorCode } from '../errors.js';
8
+ import { RollParserError } from '../errors.js';
9
+ import { lex } from '../lexer/lexer.js';
10
+ import { type Token, TokenType } from '../lexer/tokens.js';
11
+ import type { CompareOp, ComparePoint } from '../types.js';
12
+ import type {
13
+ ASTNode,
14
+ BinaryOpNode,
15
+ CritThreshold,
16
+ CritThresholdNode,
17
+ DiceNode,
18
+ ExplodeNode,
19
+ FateDiceNode,
20
+ FunctionCallNode,
21
+ GroupedNode,
22
+ GroupNode,
23
+ LiteralNode,
24
+ ModifierNode,
25
+ RerollNode,
26
+ SortNode,
27
+ SuccessCountNode,
28
+ UnaryOpNode,
29
+ VariableNode,
30
+ VersusNode,
31
+ } from './ast.js';
32
+ import {
33
+ containsDicePool,
34
+ containsFatePool,
35
+ containsMultiSubGroup,
36
+ containsVersus,
37
+ deepContainsDicePool,
38
+ isCritThreshold,
39
+ isSuccessCount,
40
+ unwrapTransparent,
41
+ } from './ast.js';
42
+
43
+ /**
44
+ * Error thrown when the parser encounters invalid syntax.
45
+ */
46
+ export class ParseError extends RollParserError {
47
+ readonly position: number;
48
+ readonly token: Token | undefined;
49
+
50
+ constructor(message: string, code: RollParserErrorCode, position: number, token?: Token) {
51
+ super(`${message} at position ${position}`, code);
52
+ this.name = 'ParseError';
53
+ this.position = position;
54
+ this.token = token ?? undefined;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Binding power constants for operators.
60
+ * Higher values bind tighter. Right < Left for right-associativity.
61
+ *
62
+ * Precedence order (lowest to highest):
63
+ * - Versus (`vs`): 2-3 (lowest — full expressions on both sides)
64
+ * - Addition/subtraction: 10
65
+ * - Multiplication/division/modulo: 20
66
+ * - Unary minus: 25 (binds to complete dice expr: -1d4 = -(1d4))
67
+ * - Power: 30-31
68
+ * - Modifiers (postfix): 35 (must be < DICE_RIGHT to bind to complete dice expr)
69
+ * - Dice: 40-41
70
+ */
71
+ const BP = {
72
+ // Versus (left-associative, lowest precedence — `1d20+10 vs 25+10` = `(1d20+10) vs (25+10)`)
73
+ VS_LEFT: 2,
74
+ 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 `+`.
78
+ COMPARE: 8,
79
+ // Addition/subtraction (left-associative)
80
+ ADD_LEFT: 10,
81
+ ADD_RIGHT: 11,
82
+ // Multiplication/division/modulo (left-associative)
83
+ MUL_LEFT: 20,
84
+ MUL_RIGHT: 21,
85
+ // Unary minus: between mul and power so -1d4 = -(1d4) not (-1)d4
86
+ UNARY: 25,
87
+ // Power (right-associative: left > right)
88
+ POW_LEFT: 31,
89
+ POW_RIGHT: 30,
90
+ // Postfix modifiers: must be < DICE_RIGHT so they bind to complete dice expr
91
+ MODIFIER: 35,
92
+ // Dice operator (highest math precedence)
93
+ DICE_LEFT: 40,
94
+ DICE_RIGHT: 41,
95
+ } as const;
96
+
97
+ /**
98
+ * Maximum expression nesting depth. Far beyond any human-authored notation —
99
+ * exists so adversarial input like 20,000 nested parens throws a typed
100
+ * `ParseError` instead of an uncaught `RangeError` stack overflow (which
101
+ * would also break the `isRollParserError` contract). Bounding parse depth
102
+ * also bounds AST depth, protecting the recursive AST walkers and evaluator.
103
+ */
104
+ const MAX_PARSE_DEPTH = 128;
105
+
106
+ /** Human-readable symbols for tokens named in `expect()` error messages. */
107
+ const TOKEN_DISPLAY: Partial<Record<TokenType, string>> = {
108
+ [TokenType.LPAREN]: `'('`,
109
+ [TokenType.RPAREN]: `')'`,
110
+ [TokenType.LBRACE]: `'{'`,
111
+ [TokenType.RBRACE]: `'}'`,
112
+ [TokenType.COMMA]: `','`,
113
+ };
114
+
115
+ /**
116
+ * Arity table for math functions. `min` and `max` are inclusive.
117
+ * `POSITIVE_INFINITY` means unbounded (variadic).
118
+ */
119
+ const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
120
+ floor: { min: 1, max: 1 },
121
+ ceil: { min: 1, max: 1 },
122
+ round: { min: 1, max: 1 },
123
+ abs: { min: 1, max: 1 },
124
+ max: { min: 2, max: Number.POSITIVE_INFINITY },
125
+ min: { min: 2, max: Number.POSITIVE_INFINITY },
126
+ };
127
+
128
+ /**
129
+ * Pratt parser for dice notation.
130
+ *
131
+ * Uses binding power (precedence) to handle operator associativity and
132
+ * precedence without left recursion issues.
133
+ */
134
+ export class Parser {
135
+ private readonly tokens: Token[];
136
+ private pos = 0;
137
+ private depth = 0;
138
+
139
+ constructor(tokens: Token[]) {
140
+ this.tokens = tokens;
141
+ }
142
+
143
+ /**
144
+ * Parse the token stream into an AST.
145
+ */
146
+ parse(): ASTNode {
147
+ if (this.peek().type === TokenType.EOF) {
148
+ throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', this.peek().position);
149
+ }
150
+
151
+ const ast = this.parseExpression(0);
152
+
153
+ // Ensure we consumed all tokens
154
+ if (this.peek().type !== TokenType.EOF) {
155
+ const token = this.peek();
156
+ throw new ParseError(
157
+ `Unexpected token '${token.value}'`,
158
+ 'UNEXPECTED_TOKEN',
159
+ token.position,
160
+ token,
161
+ );
162
+ }
163
+
164
+ return ast;
165
+ }
166
+
167
+ /**
168
+ * Parse an expression with minimum binding power.
169
+ */
170
+ private parseExpression(minBp: number): ASTNode {
171
+ 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
+ }
180
+
181
+ try {
182
+ let left = this.parseNud();
183
+
184
+ while (this.hasTokens()) {
185
+ const token = this.peek();
186
+ const leftBp = this.getLeftBp(token);
187
+
188
+ if (leftBp < minBp) break;
189
+
190
+ this.advance();
191
+ left = this.parseLed(left, token);
192
+ }
193
+
194
+ return left;
195
+ } finally {
196
+ this.depth -= 1;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * NUD - Null Denotation.
202
+ * Handles tokens that appear at the start of an expression (prefix position).
203
+ */
204
+ private parseNud(): ASTNode {
205
+ const token = this.advance();
206
+
207
+ switch (token.type) {
208
+ case TokenType.NUMBER:
209
+ return this.parseLiteral(token);
210
+
211
+ case TokenType.MINUS:
212
+ return this.parseUnaryMinus(token);
213
+
214
+ case TokenType.DICE:
215
+ return this.parsePrefixDice(token);
216
+
217
+ case TokenType.DICE_PERCENT:
218
+ return this.parsePrefixDicePercent(token);
219
+
220
+ case TokenType.DICE_FATE:
221
+ return this.parsePrefixFateDice(token);
222
+
223
+ case TokenType.LPAREN:
224
+ return this.parseGrouped(token);
225
+
226
+ case TokenType.LBRACE:
227
+ return this.parseGroup(token);
228
+
229
+ case TokenType.FUNCTION:
230
+ return this.parseFunctionCall(token);
231
+
232
+ case TokenType.AT:
233
+ return this.parseVariable(token);
234
+
235
+ case TokenType.EOF:
236
+ throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', token.position);
237
+
238
+ default:
239
+ throw new ParseError(
240
+ `Unexpected token '${token.value}'`,
241
+ 'UNEXPECTED_TOKEN',
242
+ token.position,
243
+ token,
244
+ );
245
+ }
246
+ }
247
+
248
+ /**
249
+ * LED - Left Denotation.
250
+ * Handles tokens that appear between expressions (infix/postfix position).
251
+ */
252
+ private parseLed(left: ASTNode, token: Token): ASTNode {
253
+ switch (token.type) {
254
+ case TokenType.DICE:
255
+ return this.parseInfixDice(left, token);
256
+
257
+ case TokenType.DICE_PERCENT:
258
+ return this.parseInfixDicePercent(left, token);
259
+
260
+ case TokenType.DICE_FATE:
261
+ return this.parseInfixFateDice(left, token);
262
+
263
+ case TokenType.PLUS:
264
+ case TokenType.MINUS:
265
+ case TokenType.MULTIPLY:
266
+ case TokenType.DIVIDE:
267
+ case TokenType.MODULO:
268
+ case TokenType.POWER:
269
+ return this.parseBinaryOp(left, token);
270
+
271
+ case TokenType.KEEP_HIGH:
272
+ case TokenType.KEEP_LOW:
273
+ case TokenType.DROP_HIGH:
274
+ case TokenType.DROP_LOW:
275
+ return this.parseModifier(left, token);
276
+
277
+ case TokenType.EXPLODE:
278
+ case TokenType.EXPLODE_COMPOUND:
279
+ case TokenType.EXPLODE_PENETRATING:
280
+ return this.parseExplode(left, token);
281
+
282
+ case TokenType.REROLL:
283
+ case TokenType.REROLL_ONCE:
284
+ return this.parseReroll(left, token);
285
+
286
+ case TokenType.SORT_ASC:
287
+ case TokenType.SORT_DESC:
288
+ return this.parseSort(left, token);
289
+
290
+ case TokenType.CRIT_SUCCESS:
291
+ case TokenType.CRIT_FAIL:
292
+ return this.parseCritThreshold(left, token);
293
+
294
+ case TokenType.GREATER:
295
+ case TokenType.GREATER_EQUAL:
296
+ case TokenType.LESS:
297
+ case TokenType.LESS_EQUAL:
298
+ case TokenType.EQUAL:
299
+ return this.parseSuccessCount(left, token);
300
+
301
+ case TokenType.VS:
302
+ return this.parseVersus(left, token);
303
+
304
+ default:
305
+ throw new ParseError(
306
+ `Unexpected infix token '${token.value}'`,
307
+ 'UNEXPECTED_TOKEN',
308
+ token.position,
309
+ token,
310
+ );
311
+ }
312
+ }
313
+
314
+ // * Node parsers
315
+
316
+ /**
317
+ * Zero-width span for synthetic nodes (implicit counts, `d%` sides) that
318
+ * have no source text of their own — anchored at the governing token.
319
+ */
320
+ private static syntheticLiteral(value: number, token: Token): LiteralNode {
321
+ return { type: 'Literal', value, start: token.position, end: token.position };
322
+ }
323
+
324
+ private parseLiteral(token: Token): LiteralNode {
325
+ return {
326
+ type: 'Literal',
327
+ value: Number.parseFloat(token.value),
328
+ start: token.position,
329
+ end: token.end,
330
+ };
331
+ }
332
+
333
+ private parseUnaryMinus(token: Token): UnaryOpNode {
334
+ const operand = this.parseExpression(BP.UNARY);
335
+ this.rejectSuccessCountTarget(operand, token);
336
+ return {
337
+ type: 'UnaryOp',
338
+ operator: '-',
339
+ operand,
340
+ start: token.position,
341
+ end: operand.end ?? token.end,
342
+ };
343
+ }
344
+
345
+ private parsePrefixDice(token: Token): DiceNode {
346
+ // d20 → Dice(1, 20)
347
+ const sides = this.parseExpression(BP.DICE_RIGHT);
348
+ this.rejectSuccessCountTarget(sides, token);
349
+ this.rejectVersusTarget(sides, token);
350
+ return {
351
+ type: 'Dice',
352
+ count: Parser.syntheticLiteral(1, token),
353
+ sides,
354
+ start: token.position,
355
+ end: sides.end ?? token.end,
356
+ };
357
+ }
358
+
359
+ private parseInfixDice(left: ASTNode, token: Token): DiceNode {
360
+ // 4d6 → Dice(4, 6)
361
+ this.rejectSuccessCountTarget(left, token);
362
+ this.rejectVersusTarget(left, token);
363
+ this.rejectBareDiceChain(left, token);
364
+ const sides = this.parseExpression(BP.DICE_RIGHT);
365
+ this.rejectSuccessCountTarget(sides, token);
366
+ this.rejectVersusTarget(sides, token);
367
+ return {
368
+ type: 'Dice',
369
+ count: left,
370
+ sides,
371
+ start: left.start ?? token.position,
372
+ end: sides.end ?? token.end,
373
+ };
374
+ }
375
+
376
+ private parsePrefixDicePercent(token: Token): DiceNode {
377
+ // d% → Dice(1, 100)
378
+ return {
379
+ type: 'Dice',
380
+ count: Parser.syntheticLiteral(1, token),
381
+ sides: Parser.syntheticLiteral(100, token),
382
+ start: token.position,
383
+ end: token.end,
384
+ };
385
+ }
386
+
387
+ private parseInfixDicePercent(left: ASTNode, token: Token): DiceNode {
388
+ // 2d% → Dice(2, 100)
389
+ this.rejectSuccessCountTarget(left, token);
390
+ this.rejectVersusTarget(left, token);
391
+ this.rejectBareDiceChain(left, token);
392
+ return {
393
+ type: 'Dice',
394
+ count: left,
395
+ sides: Parser.syntheticLiteral(100, token),
396
+ start: left.start ?? token.position,
397
+ end: token.end,
398
+ };
399
+ }
400
+
401
+ private parsePrefixFateDice(token: Token): FateDiceNode {
402
+ // dF → FateDice(1)
403
+ return {
404
+ type: 'FateDice',
405
+ count: Parser.syntheticLiteral(1, token),
406
+ start: token.position,
407
+ end: token.end,
408
+ };
409
+ }
410
+
411
+ 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.
415
+ this.rejectSuccessCountTarget(left, token);
416
+ this.rejectVersusTarget(left, token);
417
+ this.rejectBareDiceChain(left, token);
418
+ return {
419
+ type: 'FateDice',
420
+ count: left,
421
+ start: left.start ?? token.position,
422
+ end: token.end,
423
+ };
424
+ }
425
+
426
+ private parseGrouped(token: Token): GroupedNode {
427
+ const expression = this.parseExpression(0);
428
+ const close = this.expect(TokenType.RPAREN);
429
+ return { type: 'Grouped', expression, start: token.position, end: close.end };
430
+ }
431
+
432
+ 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.
436
+ if (this.peek().type === TokenType.RBRACE) {
437
+ throw new ParseError('Empty group', 'UNEXPECTED_TOKEN', startToken.position, startToken);
438
+ }
439
+
440
+ const expressions: ASTNode[] = [this.parseExpression(0)];
441
+ while (this.peek().type === TokenType.COMMA) {
442
+ this.advance();
443
+ expressions.push(this.parseExpression(0));
444
+ }
445
+
446
+ if (this.peek().type !== TokenType.RBRACE) {
447
+ const unterminated = this.peek();
448
+ throw new ParseError(
449
+ `Unterminated group: expected '}' or ','`,
450
+ 'EXPECTED_TOKEN',
451
+ unterminated.position,
452
+ unterminated,
453
+ );
454
+ }
455
+ const close = this.advance();
456
+
457
+ return { type: 'Group', expressions, start: startToken.position, end: close.end };
458
+ }
459
+
460
+ private parseVariable(token: Token): VariableNode {
461
+ return { type: 'Variable', name: token.value, start: token.position, end: token.end };
462
+ }
463
+
464
+ 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.
468
+ this.expect(TokenType.LPAREN);
469
+
470
+ const args: ASTNode[] = [];
471
+ if (this.peek().type !== TokenType.RPAREN) {
472
+ const first = this.parseExpression(0);
473
+ this.rejectSuccessCountTarget(first, token);
474
+ args.push(first);
475
+ while (this.peek().type === TokenType.COMMA) {
476
+ this.advance();
477
+ const next = this.parseExpression(0);
478
+ this.rejectSuccessCountTarget(next, token);
479
+ args.push(next);
480
+ }
481
+ }
482
+
483
+ const close = this.expect(TokenType.RPAREN);
484
+
485
+ 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.
490
+ throw new ParseError(
491
+ `Unknown function '${token.value}'`,
492
+ 'UNKNOWN_FUNCTION',
493
+ token.position,
494
+ token,
495
+ );
496
+ }
497
+
498
+ 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}`;
505
+ throw new ParseError(
506
+ `Function '${token.value}' expects ${expected} argument${arity.min === 1 && arity.max === 1 ? '' : 's'}, got ${args.length}`,
507
+ 'INVALID_FUNCTION_ARITY',
508
+ token.position,
509
+ token,
510
+ );
511
+ }
512
+
513
+ return {
514
+ type: 'FunctionCall',
515
+ name: token.value,
516
+ args,
517
+ start: token.position,
518
+ end: close.end,
519
+ };
520
+ }
521
+
522
+ private parseBinaryOp(left: ASTNode, token: Token): BinaryOpNode {
523
+ this.rejectSuccessCountTarget(left, token);
524
+
525
+ const operator = this.getOperatorSymbol(token);
526
+ const rightBp = this.getRightBp(token);
527
+ const right = this.parseExpression(rightBp);
528
+
529
+ this.rejectSuccessCountTarget(right, token);
530
+
531
+ return {
532
+ type: 'BinaryOp',
533
+ operator,
534
+ left,
535
+ right,
536
+ start: left.start ?? token.position,
537
+ end: right.end ?? token.end,
538
+ };
539
+ }
540
+
541
+ /**
542
+ * Rejects a dice token whose count operand is itself a bare (unparenthesized)
543
+ * dice expression. `4d6d1` would otherwise silently parse as `(4d6)d1` —
544
+ * roll 4d6, then use the result as a count of d1 dice — which is almost
545
+ * never intended: every major dice dialect reads `4d6d1` as "drop lowest 1".
546
+ * Both meanings stay reachable through explicit forms: `4d6dl1` to drop,
547
+ * `(4d6)d1` for nested dice.
548
+ */
549
+ private rejectBareDiceChain(left: ASTNode, token: Token): void {
550
+ switch (left.type) {
551
+ case 'Dice':
552
+ case 'FateDice':
553
+ case 'Explode':
554
+ case 'Reroll':
555
+ case 'Modifier':
556
+ case 'Sort':
557
+ case 'CritThreshold':
558
+ throw new ParseError(
559
+ `Ambiguous dice chain: use 'dl'/'dh' to drop dice (4d6dl1) or parentheses for nested dice ((4d6)d1)`,
560
+ 'AMBIGUOUS_DICE_CHAIN',
561
+ token.position,
562
+ token,
563
+ );
564
+ default:
565
+ return;
566
+ }
567
+ }
568
+
569
+ 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']);
575
+ if (isSuccessCount(node)) {
576
+ throw new ParseError(
577
+ `Cannot apply modifier after success counting`,
578
+ 'INVALID_SUCCESS_COUNT_TARGET',
579
+ token.position,
580
+ token,
581
+ );
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Rejects `GroupNode` (or a wrapper-cloaked group) as the target of `token`.
587
+ * Explode, reroll, and crit-threshold wrap bare dice pools only — a group
588
+ * is a container of sub-expressions, so these modifiers have no defined
589
+ * semantics. Walks `Grouped`/`Modifier`/`Sort`/`CritThreshold` so wrappers
590
+ * cannot smuggle a group past the check (`{1d6}kh1cs>5`, `({1d6})!`,
591
+ * `{1d6}scs>5` all reject the same as `{1d6}!`/`{1d6}cs>5`).
592
+ *
593
+ * `singleSubRollPasses` opts the caller into the Stage 3 single-sub-roll
594
+ * passthrough rule (STAGE3.md "Group Semantics: Single vs Multi Sub-Roll"):
595
+ * a `Group` with one expression is the user's explicit flat-pool escape
596
+ * hatch and is equivalent to its unwrapped form. Currently only
597
+ * `parseCritThreshold` opts in — explode/reroll keep the strict reject so
598
+ * existing notation contracts don't shift.
599
+ */
600
+ private rejectGroupTarget(
601
+ target: ASTNode,
602
+ token: Token,
603
+ action: string,
604
+ code: RollParserErrorCode,
605
+ singleSubRollPasses = false,
606
+ ): void {
607
+ const node = unwrapTransparent(target, ['Grouped', 'Modifier', 'Sort', 'CritThreshold']);
608
+ if (node.type !== 'Group') return;
609
+ 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.
615
+ const inner = node.expressions[0];
616
+ if (inner != null && containsMultiSubGroup(inner)) {
617
+ throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
618
+ }
619
+ return;
620
+ }
621
+ throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
622
+ }
623
+
624
+ 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.
647
+ if (node.type === 'Group' && node.expressions.length === 1) {
648
+ 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
+ }
657
+ }
658
+ }
659
+
660
+ private parseModifier(target: ASTNode, token: Token): ModifierNode {
661
+ 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.
669
+ this.rejectVersusTarget(target, token);
670
+
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.
673
+ if (!containsDicePool(target)) {
674
+ throw new ParseError(
675
+ `Keep/drop modifiers require a dice pool target`,
676
+ 'INVALID_MODIFIER_TARGET',
677
+ token.position,
678
+ token,
679
+ );
680
+ }
681
+
682
+ const modifier =
683
+ token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
684
+
685
+ const selector =
686
+ token.type === TokenType.KEEP_HIGH || token.type === TokenType.DROP_HIGH
687
+ ? 'highest'
688
+ : 'lowest';
689
+
690
+ // Default to 1 when no explicit count follows the modifier (e.g., 4d6kh → 4d6kh1)
691
+ const nextToken = this.peek().type;
692
+ const hasExplicitCount =
693
+ nextToken === TokenType.NUMBER ||
694
+ nextToken === TokenType.LPAREN ||
695
+ nextToken === TokenType.AT;
696
+ const count: ASTNode = hasExplicitCount
697
+ ? this.parseExpression(BP.DICE_LEFT)
698
+ : Parser.syntheticLiteral(1, token);
699
+ this.rejectSuccessCountTarget(count, token);
700
+ this.rejectVersusTarget(count, token);
701
+
702
+ return {
703
+ type: 'Modifier',
704
+ modifier,
705
+ selector,
706
+ count,
707
+ target,
708
+ start: target.start ?? token.position,
709
+ end: hasExplicitCount ? (count.end ?? token.end) : token.end,
710
+ };
711
+ }
712
+
713
+ private parseExplode(target: ASTNode, token: Token): ExplodeNode {
714
+ this.rejectSuccessCountTarget(target, token);
715
+
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.
719
+ this.rejectGroupTarget(target, token, 'explode', 'INVALID_EXPLODE_TARGET');
720
+
721
+ // Explode needs a dice pool to explode on. Wrapping arithmetic (e.g.
722
+ // `(1d6+5)!`, `floor(1d6/2)!`) would silently drop user math.
723
+ if (!containsDicePool(target)) {
724
+ throw new ParseError(
725
+ `Explode modifier requires a dice pool target`,
726
+ 'INVALID_EXPLODE_TARGET',
727
+ token.position,
728
+ token,
729
+ );
730
+ }
731
+
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.
735
+ if (containsFatePool(target)) {
736
+ throw new ParseError(
737
+ `Fate dice cannot explode`,
738
+ 'INVALID_EXPLODE_TARGET',
739
+ token.position,
740
+ token,
741
+ );
742
+ }
743
+
744
+ // ? Reject nested explodes (e.g., `1d6!!!`) — a second explode token atop
745
+ // an ExplodeNode has no meaningful semantics and is rejected per spec.
746
+ if (target.type === 'Explode') {
747
+ throw new ParseError(
748
+ `Cannot chain explode modifiers`,
749
+ 'INVALID_EXPLODE_TARGET',
750
+ token.position,
751
+ token,
752
+ );
753
+ }
754
+
755
+ const variant: ExplodeNode['variant'] =
756
+ token.type === TokenType.EXPLODE
757
+ ? 'standard'
758
+ : token.type === TokenType.EXPLODE_COMPOUND
759
+ ? 'compound'
760
+ : 'penetrating';
761
+
762
+ const node: ExplodeNode = {
763
+ type: 'Explode',
764
+ variant,
765
+ target,
766
+ start: target.start ?? token.position,
767
+ end: token.end,
768
+ };
769
+ if (this.isComparePointAhead()) {
770
+ node.threshold = this.parseComparePoint();
771
+ node.end = node.threshold.value.end ?? token.end;
772
+ }
773
+ return node;
774
+ }
775
+
776
+ private parseReroll(target: ASTNode, token: Token): RerollNode {
777
+ this.rejectSuccessCountTarget(target, token);
778
+
779
+ // Groups have no reroll semantics — reject outright. Must come before
780
+ // `containsDicePool`, which recurses into `Group`.
781
+ this.rejectGroupTarget(target, token, 'reroll', 'INVALID_REROLL_TARGET');
782
+
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.
785
+ if (!containsDicePool(target)) {
786
+ throw new ParseError(
787
+ `Reroll modifier requires a dice pool target`,
788
+ 'INVALID_REROLL_TARGET',
789
+ token.position,
790
+ token,
791
+ );
792
+ }
793
+
794
+ // A reroll token must be followed by a comparison — bare `r` / `ro` is invalid.
795
+ if (!this.isComparePointAhead()) {
796
+ throw new ParseError(
797
+ `Expected comparison operator after '${token.value}'`,
798
+ 'EXPECTED_TOKEN',
799
+ token.position,
800
+ token,
801
+ );
802
+ }
803
+
804
+ const once = token.type === TokenType.REROLL_ONCE;
805
+ const condition = this.parseComparePoint();
806
+
807
+ return {
808
+ type: 'Reroll',
809
+ once,
810
+ condition,
811
+ target,
812
+ start: target.start ?? token.position,
813
+ end: condition.value.end ?? token.end,
814
+ };
815
+ }
816
+
817
+ private parseSort(target: ASTNode, token: Token): SortNode {
818
+ this.rejectSuccessCountTarget(target, token);
819
+ this.rejectVersusTarget(target, token);
820
+
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.
825
+ if (!deepContainsDicePool(target)) {
826
+ throw new ParseError(
827
+ `Sort modifier requires a dice pool target`,
828
+ 'INVALID_SORT_TARGET',
829
+ token.position,
830
+ token,
831
+ );
832
+ }
833
+
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']);
842
+ if (base.type === 'Group' && base.expressions.length >= 2) {
843
+ throw new ParseError(
844
+ `Sort modifier does not yet support multi-sub-roll groups`,
845
+ 'INVALID_SORT_TARGET',
846
+ token.position,
847
+ token,
848
+ );
849
+ }
850
+
851
+ const order: SortNode['order'] = token.type === TokenType.SORT_ASC ? 'ascending' : 'descending';
852
+
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.
856
+ return {
857
+ type: 'Sort',
858
+ order,
859
+ target,
860
+ start: target.start ?? token.position,
861
+ end: token.end,
862
+ };
863
+ }
864
+
865
+ private parseCritThreshold(target: ASTNode, token: Token): CritThresholdNode {
866
+ this.rejectSuccessCountTarget(target, token);
867
+ this.rejectVersusTarget(target, token);
868
+
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`.
875
+ this.rejectGroupTarget(
876
+ target,
877
+ token,
878
+ 'apply crit threshold to',
879
+ 'INVALID_CRIT_THRESHOLD_TARGET',
880
+ true,
881
+ );
882
+
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.
887
+ if (!containsDicePool(target)) {
888
+ throw new ParseError(
889
+ `Crit threshold modifier requires a dice pool target`,
890
+ 'INVALID_CRIT_THRESHOLD_TARGET',
891
+ token.position,
892
+ token,
893
+ );
894
+ }
895
+
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.
901
+ if (!this.isComparePointAhead() && containsFatePool(target)) {
902
+ throw new ParseError(
903
+ `Bare cs/cf cannot apply to Fate dice`,
904
+ 'INVALID_CRIT_THRESHOLD_TARGET',
905
+ token.position,
906
+ token,
907
+ );
908
+ }
909
+
910
+ const threshold: CritThreshold = this.isComparePointAhead()
911
+ ? this.parseComparePoint()
912
+ : 'default';
913
+ const end = threshold === 'default' ? token.end : (threshold.value.end ?? token.end);
914
+
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.
920
+ let chainTarget: ASTNode = target;
921
+ while (chainTarget.type === 'Grouped') {
922
+ chainTarget = chainTarget.expression;
923
+ }
924
+ 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;
932
+ }
933
+
934
+ return {
935
+ type: 'CritThreshold',
936
+ successThresholds: token.type === TokenType.CRIT_SUCCESS ? [threshold] : [],
937
+ failThresholds: token.type === TokenType.CRIT_FAIL ? [threshold] : [],
938
+ target,
939
+ start: target.start ?? token.position,
940
+ end,
941
+ };
942
+ }
943
+
944
+ private parseSuccessCount(target: ASTNode, token: Token): SuccessCountNode {
945
+ // Success counting is terminal: chaining (`>=5>=3`) has no semantics.
946
+ this.rejectSuccessCountTarget(target, token);
947
+
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.
951
+ if (!containsDicePool(target)) {
952
+ throw new ParseError(
953
+ `Success counting requires a dice pool target`,
954
+ 'INVALID_SUCCESS_COUNT_TARGET',
955
+ token.position,
956
+ token,
957
+ );
958
+ }
959
+
960
+ const operator = this.getCompareOp(token);
961
+ // ? Threshold binding: `BP.DICE_LEFT` — see `parseComparePoint` JSDoc.
962
+ const value = this.parseExpression(BP.DICE_LEFT);
963
+ this.rejectSuccessCountTarget(value, token);
964
+ this.rejectVersusTarget(value, token);
965
+ const node: SuccessCountNode = {
966
+ type: 'SuccessCount',
967
+ target,
968
+ threshold: { operator, value },
969
+ start: target.start ?? token.position,
970
+ end: value.end ?? token.end,
971
+ };
972
+
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
+ }
986
+
987
+ return node;
988
+ }
989
+
990
+ private parseVersus(left: ASTNode, token: Token): VersusNode {
991
+ this.rejectSuccessCountTarget(left, token);
992
+
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`.
997
+ let leftChain: ASTNode = left;
998
+ while (leftChain.type === 'Grouped') {
999
+ leftChain = leftChain.expression;
1000
+ }
1001
+ if (leftChain.type === 'Versus') {
1002
+ throw new ParseError('Cannot chain versus operators', 'NESTED_VERSUS', token.position, token);
1003
+ }
1004
+
1005
+ const dc = this.parseExpression(BP.VS_RIGHT);
1006
+ this.rejectSuccessCountTarget(dc, token);
1007
+
1008
+ return {
1009
+ type: 'Versus',
1010
+ roll: left,
1011
+ dc,
1012
+ start: left.start ?? token.position,
1013
+ end: dc.end ?? token.end,
1014
+ };
1015
+ }
1016
+
1017
+ // * Compare point utilities
1018
+
1019
+ /**
1020
+ * Checks whether the next token is a comparison operator.
1021
+ */
1022
+ isComparePointAhead(): boolean {
1023
+ const type = this.peek().type;
1024
+ return (
1025
+ type === TokenType.GREATER ||
1026
+ type === TokenType.GREATER_EQUAL ||
1027
+ type === TokenType.LESS ||
1028
+ type === TokenType.LESS_EQUAL ||
1029
+ type === TokenType.EQUAL
1030
+ );
1031
+ }
1032
+
1033
+ /**
1034
+ * Parses a comparison operator followed by a value expression.
1035
+ * Called by modifier parsers (explode, reroll, success counting).
1036
+ *
1037
+ * The threshold value is parsed at `BP.DICE_LEFT`, which binds tighter than
1038
+ * arithmetic. This keeps the comparison bound to the dice pool on the left
1039
+ * rather than letting arithmetic to the right be consumed into the
1040
+ * threshold. As a consequence, `1d6>=5+2` parses as `(1d6>=5)+2` with
1041
+ * threshold `5` — not `7`. Computed thresholds require parens:
1042
+ * `1d6>=(5+2)`. Same binding applies to `parseSuccessCount` below.
1043
+ *
1044
+ * @returns A ComparePoint with the operator and value AST node
1045
+ * @throws {ParseError} If the next token is not a comparison operator
1046
+ */
1047
+ parseComparePoint(): ComparePoint {
1048
+ const token = this.peek();
1049
+ const operator = this.getCompareOp(token);
1050
+
1051
+ this.advance();
1052
+
1053
+ const value = this.parseExpression(BP.DICE_LEFT);
1054
+ this.rejectSuccessCountTarget(value, token);
1055
+ this.rejectVersusTarget(value, token);
1056
+
1057
+ return { operator, value };
1058
+ }
1059
+
1060
+ private getCompareOp(token: Token): CompareOp {
1061
+ switch (token.type) {
1062
+ case TokenType.GREATER:
1063
+ return '>';
1064
+ case TokenType.GREATER_EQUAL:
1065
+ return '>=';
1066
+ case TokenType.LESS:
1067
+ return '<';
1068
+ case TokenType.LESS_EQUAL:
1069
+ return '<=';
1070
+ case TokenType.EQUAL:
1071
+ return '=';
1072
+ default:
1073
+ throw new ParseError(
1074
+ `Expected comparison operator but got '${token.value}'`,
1075
+ 'EXPECTED_TOKEN',
1076
+ token.position,
1077
+ token,
1078
+ );
1079
+ }
1080
+ }
1081
+
1082
+ // * Helpers
1083
+
1084
+ private getOperatorSymbol(token: Token): '+' | '-' | '*' | '/' | '%' | '**' {
1085
+ switch (token.type) {
1086
+ case TokenType.PLUS:
1087
+ return '+';
1088
+ case TokenType.MINUS:
1089
+ return '-';
1090
+ case TokenType.MULTIPLY:
1091
+ return '*';
1092
+ case TokenType.DIVIDE:
1093
+ return '/';
1094
+ case TokenType.MODULO:
1095
+ return '%';
1096
+ case TokenType.POWER:
1097
+ return '**';
1098
+ default:
1099
+ throw new ParseError(
1100
+ `Unknown operator '${token.value}'`,
1101
+ 'UNEXPECTED_TOKEN',
1102
+ token.position,
1103
+ token,
1104
+ );
1105
+ }
1106
+ }
1107
+
1108
+ private getLeftBp(token: Token): number {
1109
+ switch (token.type) {
1110
+ case TokenType.VS:
1111
+ return BP.VS_LEFT;
1112
+ case TokenType.PLUS:
1113
+ case TokenType.MINUS:
1114
+ return BP.ADD_LEFT;
1115
+ case TokenType.MULTIPLY:
1116
+ case TokenType.DIVIDE:
1117
+ case TokenType.MODULO:
1118
+ return BP.MUL_LEFT;
1119
+ case TokenType.POWER:
1120
+ return BP.POW_LEFT;
1121
+ case TokenType.DICE:
1122
+ case TokenType.DICE_PERCENT:
1123
+ case TokenType.DICE_FATE:
1124
+ return BP.DICE_LEFT;
1125
+ case TokenType.KEEP_HIGH:
1126
+ case TokenType.KEEP_LOW:
1127
+ case TokenType.DROP_HIGH:
1128
+ case TokenType.DROP_LOW:
1129
+ case TokenType.EXPLODE:
1130
+ case TokenType.EXPLODE_COMPOUND:
1131
+ case TokenType.EXPLODE_PENETRATING:
1132
+ case TokenType.REROLL:
1133
+ case TokenType.REROLL_ONCE:
1134
+ case TokenType.SORT_ASC:
1135
+ case TokenType.SORT_DESC:
1136
+ case TokenType.CRIT_SUCCESS:
1137
+ case TokenType.CRIT_FAIL:
1138
+ 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.
1145
+ case TokenType.GREATER:
1146
+ case TokenType.GREATER_EQUAL:
1147
+ case TokenType.LESS:
1148
+ case TokenType.LESS_EQUAL:
1149
+ case TokenType.EQUAL:
1150
+ return BP.COMPARE;
1151
+ case TokenType.RPAREN:
1152
+ case TokenType.EOF:
1153
+ // Punctuation and keywords that terminate expressions
1154
+ 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.
1159
+ case TokenType.LBRACE:
1160
+ case TokenType.RBRACE:
1161
+ return -1;
1162
+ default:
1163
+ return 0;
1164
+ }
1165
+ }
1166
+
1167
+ private getRightBp(token: Token): number {
1168
+ switch (token.type) {
1169
+ case TokenType.VS:
1170
+ return BP.VS_RIGHT;
1171
+ case TokenType.PLUS:
1172
+ case TokenType.MINUS:
1173
+ return BP.ADD_RIGHT;
1174
+ case TokenType.MULTIPLY:
1175
+ case TokenType.DIVIDE:
1176
+ case TokenType.MODULO:
1177
+ return BP.MUL_RIGHT;
1178
+ case TokenType.POWER:
1179
+ return BP.POW_RIGHT;
1180
+ case TokenType.DICE:
1181
+ return BP.DICE_RIGHT;
1182
+ default:
1183
+ return 0;
1184
+ }
1185
+ }
1186
+
1187
+ private peek(): Token {
1188
+ return (
1189
+ this.tokens[this.pos] ?? { type: TokenType.EOF, value: '', position: this.pos, end: this.pos }
1190
+ );
1191
+ }
1192
+
1193
+ private advance(): Token {
1194
+ const token = this.peek();
1195
+ this.pos++;
1196
+ return token;
1197
+ }
1198
+
1199
+ private expect(type: TokenType): Token {
1200
+ const token = this.peek();
1201
+ if (token.type !== type) {
1202
+ const expected = TOKEN_DISPLAY[type] ?? TokenType[type];
1203
+ const got = token.type === TokenType.EOF ? 'end of input' : `'${token.value}'`;
1204
+ throw new ParseError(
1205
+ `Expected ${expected} but got ${got}`,
1206
+ 'EXPECTED_TOKEN',
1207
+ token.position,
1208
+ token,
1209
+ );
1210
+ }
1211
+ return this.advance();
1212
+ }
1213
+
1214
+ private hasTokens(): boolean {
1215
+ return this.peek().type !== TokenType.EOF;
1216
+ }
1217
+ }
1218
+
1219
+ /**
1220
+ * Parse a dice notation string into an AST.
1221
+ *
1222
+ * @param notation - The dice notation to parse
1223
+ * @returns The root AST node
1224
+ * @throws {LexerError} If the input contains invalid characters
1225
+ * @throws {ParseError} If the input has invalid syntax
1226
+ *
1227
+ * @example
1228
+ * ```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 } }
1233
+ * ```
1234
+ */
1235
+ export function parse(notation: string): ASTNode {
1236
+ const tokens = lex(notation);
1237
+ const parser = new Parser(tokens);
1238
+ return parser.parse();
1239
+ }