roll-parser 3.0.0-alpha.0 → 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 (80) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +166 -30
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/format.d.ts +1 -1
  5. package/dist/cli/format.d.ts.map +1 -1
  6. package/dist/cli.js +1110 -277
  7. package/dist/cli.js.map +28 -0
  8. package/dist/errors.d.ts +7 -4
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/evaluator/evaluator.d.ts +63 -7
  11. package/dist/evaluator/evaluator.d.ts.map +1 -1
  12. package/dist/evaluator/index.d.ts +2 -2
  13. package/dist/evaluator/index.d.ts.map +1 -1
  14. package/dist/evaluator/modifiers/compare.d.ts +1 -1
  15. package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
  16. package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
  17. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  18. package/dist/evaluator/modifiers/explode.d.ts +8 -4
  19. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  20. package/dist/evaluator/modifiers/keep-drop.d.ts +1 -1
  21. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  22. package/dist/evaluator/modifiers/reroll.d.ts +4 -4
  23. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  24. package/dist/evaluator/modifiers/sort.d.ts +23 -0
  25. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  26. package/dist/evaluator/modifiers/success-count.d.ts +1 -1
  27. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  28. package/dist/index.d.ts +13 -13
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1112 -275
  31. package/dist/index.js.map +26 -0
  32. package/dist/lexer/lexer.d.ts +18 -3
  33. package/dist/lexer/lexer.d.ts.map +1 -1
  34. package/dist/lexer/tokens.d.ts +22 -2
  35. package/dist/lexer/tokens.d.ts.map +1 -1
  36. package/dist/parser/ast.d.ts +209 -24
  37. package/dist/parser/ast.d.ts.map +1 -1
  38. package/dist/parser/parser.d.ts +48 -5
  39. package/dist/parser/parser.d.ts.map +1 -1
  40. package/dist/rng/index.d.ts +2 -2
  41. package/dist/rng/index.d.ts.map +1 -1
  42. package/dist/rng/mock.d.ts +1 -1
  43. package/dist/rng/mock.d.ts.map +1 -1
  44. package/dist/rng/seeded.d.ts +8 -1
  45. package/dist/rng/seeded.d.ts.map +1 -1
  46. package/dist/roll.d.ts +6 -2
  47. package/dist/roll.d.ts.map +1 -1
  48. package/dist/testing.d.ts +1 -1
  49. package/dist/testing.d.ts.map +1 -1
  50. package/dist/testing.js +3 -0
  51. package/dist/testing.js.map +11 -0
  52. package/dist/types.d.ts +143 -2
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +22 -18
  55. package/src/cli/args.ts +2 -1
  56. package/src/cli/format.ts +8 -4
  57. package/src/cli/index.ts +22 -5
  58. package/src/errors.ts +15 -3
  59. package/src/evaluator/evaluator.ts +826 -111
  60. package/src/evaluator/index.ts +2 -2
  61. package/src/evaluator/modifiers/compare.ts +1 -1
  62. package/src/evaluator/modifiers/crit-threshold.ts +59 -0
  63. package/src/evaluator/modifiers/explode.ts +29 -25
  64. package/src/evaluator/modifiers/keep-drop.ts +1 -1
  65. package/src/evaluator/modifiers/reroll.ts +18 -25
  66. package/src/evaluator/modifiers/sort.ts +30 -0
  67. package/src/evaluator/modifiers/success-count.ts +2 -2
  68. package/src/index.ts +33 -15
  69. package/src/lexer/lexer.ts +101 -8
  70. package/src/lexer/tokens.ts +42 -2
  71. package/src/parser/ast.ts +397 -30
  72. package/src/parser/parser.ts +590 -67
  73. package/src/rng/index.ts +2 -2
  74. package/src/rng/mock.ts +1 -1
  75. package/src/rng/seeded.ts +31 -1
  76. package/src/roll.ts +14 -6
  77. package/src/testing.ts +1 -1
  78. package/src/types.ts +127 -2
  79. package/dist/index.mjs +0 -1724
  80. package/dist/testing.mjs +0 -39
@@ -4,26 +4,41 @@
4
4
  * @module parser/parser
5
5
  */
6
6
 
7
- import type { RollParserErrorCode } from '../errors';
8
- import { RollParserError } from '../errors';
9
- import { lex } from '../lexer/lexer';
10
- import { type Token, TokenType } from '../lexer/tokens';
11
- import type { CompareOp, ComparePoint } from '../types';
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
12
  import type {
13
13
  ASTNode,
14
14
  BinaryOpNode,
15
+ CritThreshold,
16
+ CritThresholdNode,
15
17
  DiceNode,
16
18
  ExplodeNode,
17
19
  FateDiceNode,
18
20
  FunctionCallNode,
21
+ GroupedNode,
22
+ GroupNode,
19
23
  LiteralNode,
20
24
  ModifierNode,
21
25
  RerollNode,
26
+ SortNode,
22
27
  SuccessCountNode,
23
28
  UnaryOpNode,
29
+ VariableNode,
24
30
  VersusNode,
25
- } from './ast';
26
- import { containsDice, isSuccessCount } from './ast';
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';
27
42
 
28
43
  /**
29
44
  * Error thrown when the parser encounters invalid syntax.
@@ -57,6 +72,10 @@ const BP = {
57
72
  // Versus (left-associative, lowest precedence — `1d20+10 vs 25+10` = `(1d20+10) vs (25+10)`)
58
73
  VS_LEFT: 2,
59
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,
60
79
  // Addition/subtraction (left-associative)
61
80
  ADD_LEFT: 10,
62
81
  ADD_RIGHT: 11,
@@ -75,6 +94,24 @@ const BP = {
75
94
  DICE_RIGHT: 41,
76
95
  } as const;
77
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
+
78
115
  /**
79
116
  * Arity table for math functions. `min` and `max` are inclusive.
80
117
  * `POSITIVE_INFINITY` means unbounded (variadic).
@@ -97,6 +134,7 @@ const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
97
134
  export class Parser {
98
135
  private readonly tokens: Token[];
99
136
  private pos = 0;
137
+ private depth = 0;
100
138
 
101
139
  constructor(tokens: Token[]) {
102
140
  this.tokens = tokens;
@@ -130,19 +168,33 @@ export class Parser {
130
168
  * Parse an expression with minimum binding power.
131
169
  */
132
170
  private parseExpression(minBp: number): ASTNode {
133
- let left = this.parseNud();
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
+ }
134
180
 
135
- while (this.hasTokens()) {
136
- const token = this.peek();
137
- const leftBp = this.getLeftBp(token);
181
+ try {
182
+ let left = this.parseNud();
138
183
 
139
- if (leftBp < minBp) break;
184
+ while (this.hasTokens()) {
185
+ const token = this.peek();
186
+ const leftBp = this.getLeftBp(token);
140
187
 
141
- this.advance();
142
- left = this.parseLed(left, token);
143
- }
188
+ if (leftBp < minBp) break;
144
189
 
145
- return left;
190
+ this.advance();
191
+ left = this.parseLed(left, token);
192
+ }
193
+
194
+ return left;
195
+ } finally {
196
+ this.depth -= 1;
197
+ }
146
198
  }
147
199
 
148
200
  /**
@@ -157,23 +209,29 @@ export class Parser {
157
209
  return this.parseLiteral(token);
158
210
 
159
211
  case TokenType.MINUS:
160
- return this.parseUnaryMinus();
212
+ return this.parseUnaryMinus(token);
161
213
 
162
214
  case TokenType.DICE:
163
- return this.parsePrefixDice();
215
+ return this.parsePrefixDice(token);
164
216
 
165
217
  case TokenType.DICE_PERCENT:
166
- return this.parsePrefixDicePercent();
218
+ return this.parsePrefixDicePercent(token);
167
219
 
168
220
  case TokenType.DICE_FATE:
169
- return this.parsePrefixFateDice();
221
+ return this.parsePrefixFateDice(token);
170
222
 
171
223
  case TokenType.LPAREN:
172
- return this.parseGrouped();
224
+ return this.parseGrouped(token);
225
+
226
+ case TokenType.LBRACE:
227
+ return this.parseGroup(token);
173
228
 
174
229
  case TokenType.FUNCTION:
175
230
  return this.parseFunctionCall(token);
176
231
 
232
+ case TokenType.AT:
233
+ return this.parseVariable(token);
234
+
177
235
  case TokenType.EOF:
178
236
  throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', token.position);
179
237
 
@@ -194,13 +252,13 @@ export class Parser {
194
252
  private parseLed(left: ASTNode, token: Token): ASTNode {
195
253
  switch (token.type) {
196
254
  case TokenType.DICE:
197
- return this.parseInfixDice(left);
255
+ return this.parseInfixDice(left, token);
198
256
 
199
257
  case TokenType.DICE_PERCENT:
200
- return this.parseInfixDicePercent(left);
258
+ return this.parseInfixDicePercent(left, token);
201
259
 
202
260
  case TokenType.DICE_FATE:
203
- return this.parseInfixFateDice(left);
261
+ return this.parseInfixFateDice(left, token);
204
262
 
205
263
  case TokenType.PLUS:
206
264
  case TokenType.MINUS:
@@ -225,6 +283,14 @@ export class Parser {
225
283
  case TokenType.REROLL_ONCE:
226
284
  return this.parseReroll(left, token);
227
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
+
228
294
  case TokenType.GREATER:
229
295
  case TokenType.GREATER_EQUAL:
230
296
  case TokenType.LESS:
@@ -247,82 +313,152 @@ export class Parser {
247
313
 
248
314
  // * Node parsers
249
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
+
250
324
  private parseLiteral(token: Token): LiteralNode {
251
325
  return {
252
326
  type: 'Literal',
253
327
  value: Number.parseFloat(token.value),
328
+ start: token.position,
329
+ end: token.end,
254
330
  };
255
331
  }
256
332
 
257
- private parseUnaryMinus(): UnaryOpNode {
333
+ private parseUnaryMinus(token: Token): UnaryOpNode {
258
334
  const operand = this.parseExpression(BP.UNARY);
335
+ this.rejectSuccessCountTarget(operand, token);
259
336
  return {
260
337
  type: 'UnaryOp',
261
338
  operator: '-',
262
339
  operand,
340
+ start: token.position,
341
+ end: operand.end ?? token.end,
263
342
  };
264
343
  }
265
344
 
266
- private parsePrefixDice(): DiceNode {
345
+ private parsePrefixDice(token: Token): DiceNode {
267
346
  // d20 → Dice(1, 20)
268
347
  const sides = this.parseExpression(BP.DICE_RIGHT);
348
+ this.rejectSuccessCountTarget(sides, token);
349
+ this.rejectVersusTarget(sides, token);
269
350
  return {
270
351
  type: 'Dice',
271
- count: { type: 'Literal', value: 1 },
352
+ count: Parser.syntheticLiteral(1, token),
272
353
  sides,
354
+ start: token.position,
355
+ end: sides.end ?? token.end,
273
356
  };
274
357
  }
275
358
 
276
- private parseInfixDice(left: ASTNode): DiceNode {
359
+ private parseInfixDice(left: ASTNode, token: Token): DiceNode {
277
360
  // 4d6 → Dice(4, 6)
361
+ this.rejectSuccessCountTarget(left, token);
362
+ this.rejectVersusTarget(left, token);
363
+ this.rejectBareDiceChain(left, token);
278
364
  const sides = this.parseExpression(BP.DICE_RIGHT);
365
+ this.rejectSuccessCountTarget(sides, token);
366
+ this.rejectVersusTarget(sides, token);
279
367
  return {
280
368
  type: 'Dice',
281
369
  count: left,
282
370
  sides,
371
+ start: left.start ?? token.position,
372
+ end: sides.end ?? token.end,
283
373
  };
284
374
  }
285
375
 
286
- private parsePrefixDicePercent(): DiceNode {
376
+ private parsePrefixDicePercent(token: Token): DiceNode {
287
377
  // d% → Dice(1, 100)
288
378
  return {
289
379
  type: 'Dice',
290
- count: { type: 'Literal', value: 1 },
291
- sides: { type: 'Literal', value: 100 },
380
+ count: Parser.syntheticLiteral(1, token),
381
+ sides: Parser.syntheticLiteral(100, token),
382
+ start: token.position,
383
+ end: token.end,
292
384
  };
293
385
  }
294
386
 
295
- private parseInfixDicePercent(left: ASTNode): DiceNode {
387
+ private parseInfixDicePercent(left: ASTNode, token: Token): DiceNode {
296
388
  // 2d% → Dice(2, 100)
389
+ this.rejectSuccessCountTarget(left, token);
390
+ this.rejectVersusTarget(left, token);
391
+ this.rejectBareDiceChain(left, token);
297
392
  return {
298
393
  type: 'Dice',
299
394
  count: left,
300
- sides: { type: 'Literal', value: 100 },
395
+ sides: Parser.syntheticLiteral(100, token),
396
+ start: left.start ?? token.position,
397
+ end: token.end,
301
398
  };
302
399
  }
303
400
 
304
- private parsePrefixFateDice(): FateDiceNode {
401
+ private parsePrefixFateDice(token: Token): FateDiceNode {
305
402
  // dF → FateDice(1)
306
403
  return {
307
404
  type: 'FateDice',
308
- count: { type: 'Literal', value: 1 },
405
+ count: Parser.syntheticLiteral(1, token),
406
+ start: token.position,
407
+ end: token.end,
309
408
  };
310
409
  }
311
410
 
312
- private parseInfixFateDice(left: ASTNode): FateDiceNode {
411
+ private parseInfixFateDice(left: ASTNode, token: Token): FateDiceNode {
313
412
  // 4dF → FateDice(4). Unlike parseInfixDice, there is no sides sub-parse,
314
413
  // so modifiers (`kh`, `dl`, …) naturally bind at the outer Pratt loop
315
414
  // without BP competition against a right-operand.
415
+ this.rejectSuccessCountTarget(left, token);
416
+ this.rejectVersusTarget(left, token);
417
+ this.rejectBareDiceChain(left, token);
316
418
  return {
317
419
  type: 'FateDice',
318
420
  count: left,
421
+ start: left.start ?? token.position,
422
+ end: token.end,
319
423
  };
320
424
  }
321
425
 
322
- private parseGrouped(): ASTNode {
323
- const expr = this.parseExpression(0);
324
- this.expect(TokenType.RPAREN);
325
- return expr;
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 };
326
462
  }
327
463
 
328
464
  private parseFunctionCall(token: Token): FunctionCallNode {
@@ -333,14 +469,18 @@ export class Parser {
333
469
 
334
470
  const args: ASTNode[] = [];
335
471
  if (this.peek().type !== TokenType.RPAREN) {
336
- args.push(this.parseExpression(0));
472
+ const first = this.parseExpression(0);
473
+ this.rejectSuccessCountTarget(first, token);
474
+ args.push(first);
337
475
  while (this.peek().type === TokenType.COMMA) {
338
476
  this.advance();
339
- args.push(this.parseExpression(0));
477
+ const next = this.parseExpression(0);
478
+ this.rejectSuccessCountTarget(next, token);
479
+ args.push(next);
340
480
  }
341
481
  }
342
482
 
343
- this.expect(TokenType.RPAREN);
483
+ const close = this.expect(TokenType.RPAREN);
344
484
 
345
485
  const arity = FUNCTION_ARITY[token.value];
346
486
  if (arity === undefined) {
@@ -370,24 +510,69 @@ export class Parser {
370
510
  );
371
511
  }
372
512
 
373
- return { type: 'FunctionCall', name: token.value, args };
513
+ return {
514
+ type: 'FunctionCall',
515
+ name: token.value,
516
+ args,
517
+ start: token.position,
518
+ end: close.end,
519
+ };
374
520
  }
375
521
 
376
522
  private parseBinaryOp(left: ASTNode, token: Token): BinaryOpNode {
523
+ this.rejectSuccessCountTarget(left, token);
524
+
377
525
  const operator = this.getOperatorSymbol(token);
378
526
  const rightBp = this.getRightBp(token);
379
527
  const right = this.parseExpression(rightBp);
380
528
 
529
+ this.rejectSuccessCountTarget(right, token);
530
+
381
531
  return {
382
532
  type: 'BinaryOp',
383
533
  operator,
384
534
  left,
385
535
  right,
536
+ start: left.start ?? token.position,
537
+ end: right.end ?? token.end,
386
538
  };
387
539
  }
388
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
+
389
569
  private rejectSuccessCountTarget(target: ASTNode, token: Token): void {
390
- if (isSuccessCount(target)) {
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)) {
391
576
  throw new ParseError(
392
577
  `Cannot apply modifier after success counting`,
393
578
  'INVALID_SUCCESS_COUNT_TARGET',
@@ -397,8 +582,102 @@ export class Parser {
397
582
  }
398
583
  }
399
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
+
400
660
  private parseModifier(target: ASTNode, token: Token): ModifierNode {
401
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
+ }
402
681
 
403
682
  const modifier =
404
683
  token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
@@ -410,10 +689,15 @@ export class Parser {
410
689
 
411
690
  // Default to 1 when no explicit count follows the modifier (e.g., 4d6kh → 4d6kh1)
412
691
  const nextToken = this.peek().type;
413
- const count: ASTNode =
414
- nextToken === TokenType.NUMBER || nextToken === TokenType.LPAREN
415
- ? this.parseExpression(BP.DICE_LEFT)
416
- : { type: 'Literal', value: 1 };
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);
417
701
 
418
702
  return {
419
703
  type: 'Modifier',
@@ -421,12 +705,42 @@ export class Parser {
421
705
  selector,
422
706
  count,
423
707
  target,
708
+ start: target.start ?? token.position,
709
+ end: hasExplicitCount ? (count.end ?? token.end) : token.end,
424
710
  };
425
711
  }
426
712
 
427
713
  private parseExplode(target: ASTNode, token: Token): ExplodeNode {
428
714
  this.rejectSuccessCountTarget(target, token);
429
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
+
430
744
  // ? Reject nested explodes (e.g., `1d6!!!`) — a second explode token atop
431
745
  // an ExplodeNode has no meaningful semantics and is rejected per spec.
432
746
  if (target.type === 'Explode') {
@@ -445,9 +759,16 @@ export class Parser {
445
759
  ? 'compound'
446
760
  : 'penetrating';
447
761
 
448
- const node: ExplodeNode = { type: 'Explode', variant, target };
762
+ const node: ExplodeNode = {
763
+ type: 'Explode',
764
+ variant,
765
+ target,
766
+ start: target.start ?? token.position,
767
+ end: token.end,
768
+ };
449
769
  if (this.isComparePointAhead()) {
450
770
  node.threshold = this.parseComparePoint();
771
+ node.end = node.threshold.value.end ?? token.end;
451
772
  }
452
773
  return node;
453
774
  }
@@ -455,6 +776,21 @@ export class Parser {
455
776
  private parseReroll(target: ASTNode, token: Token): RerollNode {
456
777
  this.rejectSuccessCountTarget(target, token);
457
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
+
458
794
  // A reroll token must be followed by a comparison — bare `r` / `ro` is invalid.
459
795
  if (!this.isComparePointAhead()) {
460
796
  throw new ParseError(
@@ -468,18 +804,153 @@ export class Parser {
468
804
  const once = token.type === TokenType.REROLL_ONCE;
469
805
  const condition = this.parseComparePoint();
470
806
 
471
- return { type: 'Reroll', once, condition, target };
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
+ };
472
942
  }
473
943
 
474
944
  private parseSuccessCount(target: ASTNode, token: Token): SuccessCountNode {
475
945
  // Success counting is terminal: chaining (`>=5>=3`) has no semantics.
476
946
  this.rejectSuccessCountTarget(target, token);
477
947
 
478
- // Reject non-dice targets like `1>=3` or `(1+2)>=3`. Success counting
479
- // operates on a dice pool; a sum has no pool to count.
480
- if (!containsDice(target)) {
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)) {
481
952
  throw new ParseError(
482
- `Success counting requires a dice expression`,
953
+ `Success counting requires a dice pool target`,
483
954
  'INVALID_SUCCESS_COUNT_TARGET',
484
955
  token.position,
485
956
  token,
@@ -487,33 +958,60 @@ export class Parser {
487
958
  }
488
959
 
489
960
  const operator = this.getCompareOp(token);
961
+ // ? Threshold binding: `BP.DICE_LEFT` — see `parseComparePoint` JSDoc.
490
962
  const value = this.parseExpression(BP.DICE_LEFT);
963
+ this.rejectSuccessCountTarget(value, token);
964
+ this.rejectVersusTarget(value, token);
491
965
  const node: SuccessCountNode = {
492
966
  type: 'SuccessCount',
493
967
  target,
494
968
  threshold: { operator, value },
969
+ start: target.start ?? token.position,
970
+ end: value.end ?? token.end,
495
971
  };
496
972
 
497
973
  if (this.peek().type === TokenType.FAIL) {
498
974
  this.advance();
499
- const failValue = this.parseExpression(BP.DICE_LEFT);
500
- node.failThreshold = { operator: '=', value: failValue };
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;
501
985
  }
502
986
 
503
987
  return node;
504
988
  }
505
989
 
506
990
  private parseVersus(left: ASTNode, token: Token): VersusNode {
991
+ this.rejectSuccessCountTarget(left, token);
992
+
507
993
  // ? Chained `a vs b vs c` has no semantics — a degree is a scalar, not a
508
- // comparable. Parens (`a vs (b vs c)`) slip past this check and are
509
- // caught by the evaluator via `EvalEnv.insideVersus`.
510
- if (left.type === 'Versus') {
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') {
511
1002
  throw new ParseError('Cannot chain versus operators', 'NESTED_VERSUS', token.position, token);
512
1003
  }
513
1004
 
514
1005
  const dc = this.parseExpression(BP.VS_RIGHT);
1006
+ this.rejectSuccessCountTarget(dc, token);
515
1007
 
516
- return { type: 'Versus', roll: left, dc };
1008
+ return {
1009
+ type: 'Versus',
1010
+ roll: left,
1011
+ dc,
1012
+ start: left.start ?? token.position,
1013
+ end: dc.end ?? token.end,
1014
+ };
517
1015
  }
518
1016
 
519
1017
  // * Compare point utilities
@@ -536,6 +1034,13 @@ export class Parser {
536
1034
  * Parses a comparison operator followed by a value expression.
537
1035
  * Called by modifier parsers (explode, reroll, success counting).
538
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
+ *
539
1044
  * @returns A ComparePoint with the operator and value AST node
540
1045
  * @throws {ParseError} If the next token is not a comparison operator
541
1046
  */
@@ -546,6 +1051,8 @@ export class Parser {
546
1051
  this.advance();
547
1052
 
548
1053
  const value = this.parseExpression(BP.DICE_LEFT);
1054
+ this.rejectSuccessCountTarget(value, token);
1055
+ this.rejectVersusTarget(value, token);
549
1056
 
550
1057
  return { operator, value };
551
1058
  }
@@ -624,20 +1131,33 @@ export class Parser {
624
1131
  case TokenType.EXPLODE_PENETRATING:
625
1132
  case TokenType.REROLL:
626
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;
627
1139
  // Comparison operators act as LED-dispatched success-count modifiers
628
- // at the Pratt level. Inside `parseComparePoint` (called manually by
629
- // explode/reroll) they are consumed directly and this BP is bypassed.
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.
630
1145
  case TokenType.GREATER:
631
1146
  case TokenType.GREATER_EQUAL:
632
1147
  case TokenType.LESS:
633
1148
  case TokenType.LESS_EQUAL:
634
1149
  case TokenType.EQUAL:
635
- return BP.MODIFIER;
1150
+ return BP.COMPARE;
636
1151
  case TokenType.RPAREN:
637
1152
  case TokenType.EOF:
638
1153
  // Punctuation and keywords that terminate expressions
639
1154
  case TokenType.COMMA:
640
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:
641
1161
  return -1;
642
1162
  default:
643
1163
  return 0;
@@ -665,7 +1185,9 @@ export class Parser {
665
1185
  }
666
1186
 
667
1187
  private peek(): Token {
668
- return this.tokens[this.pos] ?? { type: TokenType.EOF, value: '', position: this.pos };
1188
+ return (
1189
+ this.tokens[this.pos] ?? { type: TokenType.EOF, value: '', position: this.pos, end: this.pos }
1190
+ );
669
1191
  }
670
1192
 
671
1193
  private advance(): Token {
@@ -677,9 +1199,10 @@ export class Parser {
677
1199
  private expect(type: TokenType): Token {
678
1200
  const token = this.peek();
679
1201
  if (token.type !== type) {
680
- const expected = TokenType[type];
1202
+ const expected = TOKEN_DISPLAY[type] ?? TokenType[type];
1203
+ const got = token.type === TokenType.EOF ? 'end of input' : `'${token.value}'`;
681
1204
  throw new ParseError(
682
- `Expected ${expected} but got '${token.value}'`,
1205
+ `Expected ${expected} but got ${got}`,
683
1206
  'EXPECTED_TOKEN',
684
1207
  token.position,
685
1208
  token,