roll-parser 2.3.2 → 3.0.0-alpha.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 (101) hide show
  1. package/README.md +47 -178
  2. package/dist/cli/args.d.ts +35 -0
  3. package/dist/cli/args.d.ts.map +1 -0
  4. package/dist/cli/format.d.ts +20 -0
  5. package/dist/cli/format.d.ts.map +1 -0
  6. package/dist/cli/index.d.ts +8 -0
  7. package/dist/cli/index.d.ts.map +1 -0
  8. package/dist/cli.js +1775 -0
  9. package/dist/errors.d.ts +39 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/evaluator/evaluator.d.ts +65 -0
  12. package/dist/evaluator/evaluator.d.ts.map +1 -0
  13. package/dist/evaluator/index.d.ts +8 -0
  14. package/dist/evaluator/index.d.ts.map +1 -0
  15. package/dist/evaluator/modifiers/compare.d.ts +15 -0
  16. package/dist/evaluator/modifiers/compare.d.ts.map +1 -0
  17. package/dist/evaluator/modifiers/explode.d.ts +44 -0
  18. package/dist/evaluator/modifiers/explode.d.ts.map +1 -0
  19. package/dist/evaluator/modifiers/keep-drop.d.ts +44 -0
  20. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -0
  21. package/dist/evaluator/modifiers/reroll.d.ts +33 -0
  22. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -0
  23. package/dist/evaluator/modifiers/success-count.d.ts +28 -0
  24. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -0
  25. package/dist/index.d.ts +20 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +1723 -0
  28. package/dist/index.mjs +1724 -0
  29. package/dist/lexer/lexer.d.ts +75 -0
  30. package/dist/lexer/lexer.d.ts.map +1 -0
  31. package/dist/lexer/tokens.d.ts +87 -0
  32. package/dist/lexer/tokens.d.ts.map +1 -0
  33. package/dist/parser/ast.d.ts +183 -0
  34. package/dist/parser/ast.d.ts.map +1 -0
  35. package/dist/parser/parser.d.ts +102 -0
  36. package/dist/parser/parser.d.ts.map +1 -0
  37. package/dist/rng/index.d.ts +8 -0
  38. package/dist/rng/index.d.ts.map +1 -0
  39. package/dist/rng/mock.d.ts +36 -0
  40. package/dist/rng/mock.d.ts.map +1 -0
  41. package/dist/rng/seeded.d.ts +33 -0
  42. package/dist/rng/seeded.d.ts.map +1 -0
  43. package/dist/rng/types.d.ts +24 -0
  44. package/dist/rng/types.d.ts.map +1 -0
  45. package/dist/roll.d.ts +47 -0
  46. package/dist/roll.d.ts.map +1 -0
  47. package/dist/testing.d.ts +11 -0
  48. package/dist/testing.d.ts.map +1 -0
  49. package/dist/testing.js +38 -0
  50. package/dist/testing.mjs +39 -0
  51. package/dist/types.d.ts +105 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/package.json +62 -43
  54. package/src/cli/args.ts +72 -0
  55. package/src/cli/format.ts +45 -0
  56. package/src/cli/index.ts +75 -0
  57. package/src/errors.ts +78 -0
  58. package/src/evaluator/evaluator.ts +837 -0
  59. package/src/evaluator/index.ts +14 -0
  60. package/src/evaluator/modifiers/compare.ts +29 -0
  61. package/src/evaluator/modifiers/explode.ts +215 -0
  62. package/src/evaluator/modifiers/keep-drop.ts +151 -0
  63. package/src/evaluator/modifiers/reroll.ts +172 -0
  64. package/src/evaluator/modifiers/success-count.ts +59 -0
  65. package/src/index.ts +72 -0
  66. package/src/lexer/lexer.ts +281 -0
  67. package/src/lexer/tokens.ts +147 -0
  68. package/src/parser/ast.ts +261 -0
  69. package/src/parser/parser.ts +716 -0
  70. package/src/rng/index.ts +8 -0
  71. package/src/rng/mock.ts +65 -0
  72. package/src/rng/seeded.ts +127 -0
  73. package/src/rng/types.ts +25 -0
  74. package/src/roll.ts +66 -0
  75. package/src/testing.ts +16 -0
  76. package/src/types.ts +112 -0
  77. package/bin/help.txt +0 -19
  78. package/bin/roll-parser.js +0 -23
  79. package/dist/roll-parser.js +0 -697
  80. package/dist/roll-parser.min.js +0 -1
  81. package/index.js +0 -24
  82. package/src/complex/parse.js +0 -37
  83. package/src/complex/parseAndRoll.js +0 -21
  84. package/src/complex/parseAndRollClassic.js +0 -20
  85. package/src/complex/parseAndRollSimple.js +0 -19
  86. package/src/complex/parseAndRollWod.js +0 -20
  87. package/src/complex/parseClassicRoll.js +0 -22
  88. package/src/complex/parseSimpleRoll.js +0 -21
  89. package/src/complex/parseWodRoll.js +0 -21
  90. package/src/converter.js +0 -45
  91. package/src/grammar.js +0 -14
  92. package/src/mapper.js +0 -50
  93. package/src/normalizer.js +0 -51
  94. package/src/object/Result.js +0 -23
  95. package/src/object/Roll.js +0 -26
  96. package/src/object/Type.js +0 -5
  97. package/src/object/WodRoll.js +0 -29
  98. package/src/parser.js +0 -43
  99. package/src/random.js +0 -22
  100. package/src/roller.js +0 -106
  101. package/src/stringifier.js +0 -33
@@ -0,0 +1,716 @@
1
+ /**
2
+ * Pratt parser for dice notation.
3
+ *
4
+ * @module parser/parser
5
+ */
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';
12
+ import type {
13
+ ASTNode,
14
+ BinaryOpNode,
15
+ DiceNode,
16
+ ExplodeNode,
17
+ FateDiceNode,
18
+ FunctionCallNode,
19
+ LiteralNode,
20
+ ModifierNode,
21
+ RerollNode,
22
+ SuccessCountNode,
23
+ UnaryOpNode,
24
+ VersusNode,
25
+ } from './ast';
26
+ import { containsDice, isSuccessCount } from './ast';
27
+
28
+ /**
29
+ * Error thrown when the parser encounters invalid syntax.
30
+ */
31
+ export class ParseError extends RollParserError {
32
+ readonly position: number;
33
+ readonly token: Token | undefined;
34
+
35
+ constructor(message: string, code: RollParserErrorCode, position: number, token?: Token) {
36
+ super(`${message} at position ${position}`, code);
37
+ this.name = 'ParseError';
38
+ this.position = position;
39
+ this.token = token ?? undefined;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Binding power constants for operators.
45
+ * Higher values bind tighter. Right < Left for right-associativity.
46
+ *
47
+ * Precedence order (lowest to highest):
48
+ * - Versus (`vs`): 2-3 (lowest — full expressions on both sides)
49
+ * - Addition/subtraction: 10
50
+ * - Multiplication/division/modulo: 20
51
+ * - Unary minus: 25 (binds to complete dice expr: -1d4 = -(1d4))
52
+ * - Power: 30-31
53
+ * - Modifiers (postfix): 35 (must be < DICE_RIGHT to bind to complete dice expr)
54
+ * - Dice: 40-41
55
+ */
56
+ const BP = {
57
+ // Versus (left-associative, lowest precedence — `1d20+10 vs 25+10` = `(1d20+10) vs (25+10)`)
58
+ VS_LEFT: 2,
59
+ VS_RIGHT: 3,
60
+ // Addition/subtraction (left-associative)
61
+ ADD_LEFT: 10,
62
+ ADD_RIGHT: 11,
63
+ // Multiplication/division/modulo (left-associative)
64
+ MUL_LEFT: 20,
65
+ MUL_RIGHT: 21,
66
+ // Unary minus: between mul and power so -1d4 = -(1d4) not (-1)d4
67
+ UNARY: 25,
68
+ // Power (right-associative: left > right)
69
+ POW_LEFT: 31,
70
+ POW_RIGHT: 30,
71
+ // Postfix modifiers: must be < DICE_RIGHT so they bind to complete dice expr
72
+ MODIFIER: 35,
73
+ // Dice operator (highest math precedence)
74
+ DICE_LEFT: 40,
75
+ DICE_RIGHT: 41,
76
+ } as const;
77
+
78
+ /**
79
+ * Arity table for math functions. `min` and `max` are inclusive.
80
+ * `POSITIVE_INFINITY` means unbounded (variadic).
81
+ */
82
+ const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
83
+ floor: { min: 1, max: 1 },
84
+ ceil: { min: 1, max: 1 },
85
+ round: { min: 1, max: 1 },
86
+ abs: { min: 1, max: 1 },
87
+ max: { min: 2, max: Number.POSITIVE_INFINITY },
88
+ min: { min: 2, max: Number.POSITIVE_INFINITY },
89
+ };
90
+
91
+ /**
92
+ * Pratt parser for dice notation.
93
+ *
94
+ * Uses binding power (precedence) to handle operator associativity and
95
+ * precedence without left recursion issues.
96
+ */
97
+ export class Parser {
98
+ private readonly tokens: Token[];
99
+ private pos = 0;
100
+
101
+ constructor(tokens: Token[]) {
102
+ this.tokens = tokens;
103
+ }
104
+
105
+ /**
106
+ * Parse the token stream into an AST.
107
+ */
108
+ parse(): ASTNode {
109
+ if (this.peek().type === TokenType.EOF) {
110
+ throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', this.peek().position);
111
+ }
112
+
113
+ const ast = this.parseExpression(0);
114
+
115
+ // Ensure we consumed all tokens
116
+ if (this.peek().type !== TokenType.EOF) {
117
+ const token = this.peek();
118
+ throw new ParseError(
119
+ `Unexpected token '${token.value}'`,
120
+ 'UNEXPECTED_TOKEN',
121
+ token.position,
122
+ token,
123
+ );
124
+ }
125
+
126
+ return ast;
127
+ }
128
+
129
+ /**
130
+ * Parse an expression with minimum binding power.
131
+ */
132
+ private parseExpression(minBp: number): ASTNode {
133
+ let left = this.parseNud();
134
+
135
+ while (this.hasTokens()) {
136
+ const token = this.peek();
137
+ const leftBp = this.getLeftBp(token);
138
+
139
+ if (leftBp < minBp) break;
140
+
141
+ this.advance();
142
+ left = this.parseLed(left, token);
143
+ }
144
+
145
+ return left;
146
+ }
147
+
148
+ /**
149
+ * NUD - Null Denotation.
150
+ * Handles tokens that appear at the start of an expression (prefix position).
151
+ */
152
+ private parseNud(): ASTNode {
153
+ const token = this.advance();
154
+
155
+ switch (token.type) {
156
+ case TokenType.NUMBER:
157
+ return this.parseLiteral(token);
158
+
159
+ case TokenType.MINUS:
160
+ return this.parseUnaryMinus();
161
+
162
+ case TokenType.DICE:
163
+ return this.parsePrefixDice();
164
+
165
+ case TokenType.DICE_PERCENT:
166
+ return this.parsePrefixDicePercent();
167
+
168
+ case TokenType.DICE_FATE:
169
+ return this.parsePrefixFateDice();
170
+
171
+ case TokenType.LPAREN:
172
+ return this.parseGrouped();
173
+
174
+ case TokenType.FUNCTION:
175
+ return this.parseFunctionCall(token);
176
+
177
+ case TokenType.EOF:
178
+ throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', token.position);
179
+
180
+ default:
181
+ throw new ParseError(
182
+ `Unexpected token '${token.value}'`,
183
+ 'UNEXPECTED_TOKEN',
184
+ token.position,
185
+ token,
186
+ );
187
+ }
188
+ }
189
+
190
+ /**
191
+ * LED - Left Denotation.
192
+ * Handles tokens that appear between expressions (infix/postfix position).
193
+ */
194
+ private parseLed(left: ASTNode, token: Token): ASTNode {
195
+ switch (token.type) {
196
+ case TokenType.DICE:
197
+ return this.parseInfixDice(left);
198
+
199
+ case TokenType.DICE_PERCENT:
200
+ return this.parseInfixDicePercent(left);
201
+
202
+ case TokenType.DICE_FATE:
203
+ return this.parseInfixFateDice(left);
204
+
205
+ case TokenType.PLUS:
206
+ case TokenType.MINUS:
207
+ case TokenType.MULTIPLY:
208
+ case TokenType.DIVIDE:
209
+ case TokenType.MODULO:
210
+ case TokenType.POWER:
211
+ return this.parseBinaryOp(left, token);
212
+
213
+ case TokenType.KEEP_HIGH:
214
+ case TokenType.KEEP_LOW:
215
+ case TokenType.DROP_HIGH:
216
+ case TokenType.DROP_LOW:
217
+ return this.parseModifier(left, token);
218
+
219
+ case TokenType.EXPLODE:
220
+ case TokenType.EXPLODE_COMPOUND:
221
+ case TokenType.EXPLODE_PENETRATING:
222
+ return this.parseExplode(left, token);
223
+
224
+ case TokenType.REROLL:
225
+ case TokenType.REROLL_ONCE:
226
+ return this.parseReroll(left, token);
227
+
228
+ case TokenType.GREATER:
229
+ case TokenType.GREATER_EQUAL:
230
+ case TokenType.LESS:
231
+ case TokenType.LESS_EQUAL:
232
+ case TokenType.EQUAL:
233
+ return this.parseSuccessCount(left, token);
234
+
235
+ case TokenType.VS:
236
+ return this.parseVersus(left, token);
237
+
238
+ default:
239
+ throw new ParseError(
240
+ `Unexpected infix token '${token.value}'`,
241
+ 'UNEXPECTED_TOKEN',
242
+ token.position,
243
+ token,
244
+ );
245
+ }
246
+ }
247
+
248
+ // * Node parsers
249
+
250
+ private parseLiteral(token: Token): LiteralNode {
251
+ return {
252
+ type: 'Literal',
253
+ value: Number.parseFloat(token.value),
254
+ };
255
+ }
256
+
257
+ private parseUnaryMinus(): UnaryOpNode {
258
+ const operand = this.parseExpression(BP.UNARY);
259
+ return {
260
+ type: 'UnaryOp',
261
+ operator: '-',
262
+ operand,
263
+ };
264
+ }
265
+
266
+ private parsePrefixDice(): DiceNode {
267
+ // d20 → Dice(1, 20)
268
+ const sides = this.parseExpression(BP.DICE_RIGHT);
269
+ return {
270
+ type: 'Dice',
271
+ count: { type: 'Literal', value: 1 },
272
+ sides,
273
+ };
274
+ }
275
+
276
+ private parseInfixDice(left: ASTNode): DiceNode {
277
+ // 4d6 → Dice(4, 6)
278
+ const sides = this.parseExpression(BP.DICE_RIGHT);
279
+ return {
280
+ type: 'Dice',
281
+ count: left,
282
+ sides,
283
+ };
284
+ }
285
+
286
+ private parsePrefixDicePercent(): DiceNode {
287
+ // d% → Dice(1, 100)
288
+ return {
289
+ type: 'Dice',
290
+ count: { type: 'Literal', value: 1 },
291
+ sides: { type: 'Literal', value: 100 },
292
+ };
293
+ }
294
+
295
+ private parseInfixDicePercent(left: ASTNode): DiceNode {
296
+ // 2d% → Dice(2, 100)
297
+ return {
298
+ type: 'Dice',
299
+ count: left,
300
+ sides: { type: 'Literal', value: 100 },
301
+ };
302
+ }
303
+
304
+ private parsePrefixFateDice(): FateDiceNode {
305
+ // dF → FateDice(1)
306
+ return {
307
+ type: 'FateDice',
308
+ count: { type: 'Literal', value: 1 },
309
+ };
310
+ }
311
+
312
+ private parseInfixFateDice(left: ASTNode): FateDiceNode {
313
+ // 4dF → FateDice(4). Unlike parseInfixDice, there is no sides sub-parse,
314
+ // so modifiers (`kh`, `dl`, …) naturally bind at the outer Pratt loop
315
+ // without BP competition against a right-operand.
316
+ return {
317
+ type: 'FateDice',
318
+ count: left,
319
+ };
320
+ }
321
+
322
+ private parseGrouped(): ASTNode {
323
+ const expr = this.parseExpression(0);
324
+ this.expect(TokenType.RPAREN);
325
+ return expr;
326
+ }
327
+
328
+ private parseFunctionCall(token: Token): FunctionCallNode {
329
+ // `FUNCTION` has BP = -1 so callers stop here; `COMMA` and `RPAREN` also
330
+ // terminate inner `parseExpression(0)` calls, so argument boundaries are
331
+ // natural.
332
+ this.expect(TokenType.LPAREN);
333
+
334
+ const args: ASTNode[] = [];
335
+ if (this.peek().type !== TokenType.RPAREN) {
336
+ args.push(this.parseExpression(0));
337
+ while (this.peek().type === TokenType.COMMA) {
338
+ this.advance();
339
+ args.push(this.parseExpression(0));
340
+ }
341
+ }
342
+
343
+ this.expect(TokenType.RPAREN);
344
+
345
+ const arity = FUNCTION_ARITY[token.value];
346
+ if (arity === undefined) {
347
+ // ? Unreachable in practice: lexer only emits FUNCTION for registered
348
+ // names. Kept defensive to keep parser/evaluator error-code contract
349
+ // symmetrical.
350
+ throw new ParseError(
351
+ `Unknown function '${token.value}'`,
352
+ 'UNKNOWN_FUNCTION',
353
+ token.position,
354
+ token,
355
+ );
356
+ }
357
+
358
+ if (args.length < arity.min || args.length > arity.max) {
359
+ const expected =
360
+ arity.max === Number.POSITIVE_INFINITY
361
+ ? `at least ${arity.min}`
362
+ : arity.min === arity.max
363
+ ? `${arity.min}`
364
+ : `${arity.min}–${arity.max}`;
365
+ throw new ParseError(
366
+ `Function '${token.value}' expects ${expected} argument${arity.min === 1 && arity.max === 1 ? '' : 's'}, got ${args.length}`,
367
+ 'INVALID_FUNCTION_ARITY',
368
+ token.position,
369
+ token,
370
+ );
371
+ }
372
+
373
+ return { type: 'FunctionCall', name: token.value, args };
374
+ }
375
+
376
+ private parseBinaryOp(left: ASTNode, token: Token): BinaryOpNode {
377
+ const operator = this.getOperatorSymbol(token);
378
+ const rightBp = this.getRightBp(token);
379
+ const right = this.parseExpression(rightBp);
380
+
381
+ return {
382
+ type: 'BinaryOp',
383
+ operator,
384
+ left,
385
+ right,
386
+ };
387
+ }
388
+
389
+ private rejectSuccessCountTarget(target: ASTNode, token: Token): void {
390
+ if (isSuccessCount(target)) {
391
+ throw new ParseError(
392
+ `Cannot apply modifier after success counting`,
393
+ 'INVALID_SUCCESS_COUNT_TARGET',
394
+ token.position,
395
+ token,
396
+ );
397
+ }
398
+ }
399
+
400
+ private parseModifier(target: ASTNode, token: Token): ModifierNode {
401
+ this.rejectSuccessCountTarget(target, token);
402
+
403
+ const modifier =
404
+ token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
405
+
406
+ const selector =
407
+ token.type === TokenType.KEEP_HIGH || token.type === TokenType.DROP_HIGH
408
+ ? 'highest'
409
+ : 'lowest';
410
+
411
+ // Default to 1 when no explicit count follows the modifier (e.g., 4d6kh → 4d6kh1)
412
+ 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 };
417
+
418
+ return {
419
+ type: 'Modifier',
420
+ modifier,
421
+ selector,
422
+ count,
423
+ target,
424
+ };
425
+ }
426
+
427
+ private parseExplode(target: ASTNode, token: Token): ExplodeNode {
428
+ this.rejectSuccessCountTarget(target, token);
429
+
430
+ // ? Reject nested explodes (e.g., `1d6!!!`) — a second explode token atop
431
+ // an ExplodeNode has no meaningful semantics and is rejected per spec.
432
+ if (target.type === 'Explode') {
433
+ throw new ParseError(
434
+ `Cannot chain explode modifiers`,
435
+ 'INVALID_EXPLODE_TARGET',
436
+ token.position,
437
+ token,
438
+ );
439
+ }
440
+
441
+ const variant: ExplodeNode['variant'] =
442
+ token.type === TokenType.EXPLODE
443
+ ? 'standard'
444
+ : token.type === TokenType.EXPLODE_COMPOUND
445
+ ? 'compound'
446
+ : 'penetrating';
447
+
448
+ const node: ExplodeNode = { type: 'Explode', variant, target };
449
+ if (this.isComparePointAhead()) {
450
+ node.threshold = this.parseComparePoint();
451
+ }
452
+ return node;
453
+ }
454
+
455
+ private parseReroll(target: ASTNode, token: Token): RerollNode {
456
+ this.rejectSuccessCountTarget(target, token);
457
+
458
+ // A reroll token must be followed by a comparison — bare `r` / `ro` is invalid.
459
+ if (!this.isComparePointAhead()) {
460
+ throw new ParseError(
461
+ `Expected comparison operator after '${token.value}'`,
462
+ 'EXPECTED_TOKEN',
463
+ token.position,
464
+ token,
465
+ );
466
+ }
467
+
468
+ const once = token.type === TokenType.REROLL_ONCE;
469
+ const condition = this.parseComparePoint();
470
+
471
+ return { type: 'Reroll', once, condition, target };
472
+ }
473
+
474
+ private parseSuccessCount(target: ASTNode, token: Token): SuccessCountNode {
475
+ // Success counting is terminal: chaining (`>=5>=3`) has no semantics.
476
+ this.rejectSuccessCountTarget(target, token);
477
+
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)) {
481
+ throw new ParseError(
482
+ `Success counting requires a dice expression`,
483
+ 'INVALID_SUCCESS_COUNT_TARGET',
484
+ token.position,
485
+ token,
486
+ );
487
+ }
488
+
489
+ const operator = this.getCompareOp(token);
490
+ const value = this.parseExpression(BP.DICE_LEFT);
491
+ const node: SuccessCountNode = {
492
+ type: 'SuccessCount',
493
+ target,
494
+ threshold: { operator, value },
495
+ };
496
+
497
+ if (this.peek().type === TokenType.FAIL) {
498
+ this.advance();
499
+ const failValue = this.parseExpression(BP.DICE_LEFT);
500
+ node.failThreshold = { operator: '=', value: failValue };
501
+ }
502
+
503
+ return node;
504
+ }
505
+
506
+ private parseVersus(left: ASTNode, token: Token): VersusNode {
507
+ // ? 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') {
511
+ throw new ParseError('Cannot chain versus operators', 'NESTED_VERSUS', token.position, token);
512
+ }
513
+
514
+ const dc = this.parseExpression(BP.VS_RIGHT);
515
+
516
+ return { type: 'Versus', roll: left, dc };
517
+ }
518
+
519
+ // * Compare point utilities
520
+
521
+ /**
522
+ * Checks whether the next token is a comparison operator.
523
+ */
524
+ isComparePointAhead(): boolean {
525
+ const type = this.peek().type;
526
+ return (
527
+ type === TokenType.GREATER ||
528
+ type === TokenType.GREATER_EQUAL ||
529
+ type === TokenType.LESS ||
530
+ type === TokenType.LESS_EQUAL ||
531
+ type === TokenType.EQUAL
532
+ );
533
+ }
534
+
535
+ /**
536
+ * Parses a comparison operator followed by a value expression.
537
+ * Called by modifier parsers (explode, reroll, success counting).
538
+ *
539
+ * @returns A ComparePoint with the operator and value AST node
540
+ * @throws {ParseError} If the next token is not a comparison operator
541
+ */
542
+ parseComparePoint(): ComparePoint {
543
+ const token = this.peek();
544
+ const operator = this.getCompareOp(token);
545
+
546
+ this.advance();
547
+
548
+ const value = this.parseExpression(BP.DICE_LEFT);
549
+
550
+ return { operator, value };
551
+ }
552
+
553
+ private getCompareOp(token: Token): CompareOp {
554
+ switch (token.type) {
555
+ case TokenType.GREATER:
556
+ return '>';
557
+ case TokenType.GREATER_EQUAL:
558
+ return '>=';
559
+ case TokenType.LESS:
560
+ return '<';
561
+ case TokenType.LESS_EQUAL:
562
+ return '<=';
563
+ case TokenType.EQUAL:
564
+ return '=';
565
+ default:
566
+ throw new ParseError(
567
+ `Expected comparison operator but got '${token.value}'`,
568
+ 'EXPECTED_TOKEN',
569
+ token.position,
570
+ token,
571
+ );
572
+ }
573
+ }
574
+
575
+ // * Helpers
576
+
577
+ private getOperatorSymbol(token: Token): '+' | '-' | '*' | '/' | '%' | '**' {
578
+ switch (token.type) {
579
+ case TokenType.PLUS:
580
+ return '+';
581
+ case TokenType.MINUS:
582
+ return '-';
583
+ case TokenType.MULTIPLY:
584
+ return '*';
585
+ case TokenType.DIVIDE:
586
+ return '/';
587
+ case TokenType.MODULO:
588
+ return '%';
589
+ case TokenType.POWER:
590
+ return '**';
591
+ default:
592
+ throw new ParseError(
593
+ `Unknown operator '${token.value}'`,
594
+ 'UNEXPECTED_TOKEN',
595
+ token.position,
596
+ token,
597
+ );
598
+ }
599
+ }
600
+
601
+ private getLeftBp(token: Token): number {
602
+ switch (token.type) {
603
+ case TokenType.VS:
604
+ return BP.VS_LEFT;
605
+ case TokenType.PLUS:
606
+ case TokenType.MINUS:
607
+ return BP.ADD_LEFT;
608
+ case TokenType.MULTIPLY:
609
+ case TokenType.DIVIDE:
610
+ case TokenType.MODULO:
611
+ return BP.MUL_LEFT;
612
+ case TokenType.POWER:
613
+ return BP.POW_LEFT;
614
+ case TokenType.DICE:
615
+ case TokenType.DICE_PERCENT:
616
+ case TokenType.DICE_FATE:
617
+ return BP.DICE_LEFT;
618
+ case TokenType.KEEP_HIGH:
619
+ case TokenType.KEEP_LOW:
620
+ case TokenType.DROP_HIGH:
621
+ case TokenType.DROP_LOW:
622
+ case TokenType.EXPLODE:
623
+ case TokenType.EXPLODE_COMPOUND:
624
+ case TokenType.EXPLODE_PENETRATING:
625
+ case TokenType.REROLL:
626
+ case TokenType.REROLL_ONCE:
627
+ // 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.
630
+ case TokenType.GREATER:
631
+ case TokenType.GREATER_EQUAL:
632
+ case TokenType.LESS:
633
+ case TokenType.LESS_EQUAL:
634
+ case TokenType.EQUAL:
635
+ return BP.MODIFIER;
636
+ case TokenType.RPAREN:
637
+ case TokenType.EOF:
638
+ // Punctuation and keywords that terminate expressions
639
+ case TokenType.COMMA:
640
+ case TokenType.FUNCTION:
641
+ return -1;
642
+ default:
643
+ return 0;
644
+ }
645
+ }
646
+
647
+ private getRightBp(token: Token): number {
648
+ switch (token.type) {
649
+ case TokenType.VS:
650
+ return BP.VS_RIGHT;
651
+ case TokenType.PLUS:
652
+ case TokenType.MINUS:
653
+ return BP.ADD_RIGHT;
654
+ case TokenType.MULTIPLY:
655
+ case TokenType.DIVIDE:
656
+ case TokenType.MODULO:
657
+ return BP.MUL_RIGHT;
658
+ case TokenType.POWER:
659
+ return BP.POW_RIGHT;
660
+ case TokenType.DICE:
661
+ return BP.DICE_RIGHT;
662
+ default:
663
+ return 0;
664
+ }
665
+ }
666
+
667
+ private peek(): Token {
668
+ return this.tokens[this.pos] ?? { type: TokenType.EOF, value: '', position: this.pos };
669
+ }
670
+
671
+ private advance(): Token {
672
+ const token = this.peek();
673
+ this.pos++;
674
+ return token;
675
+ }
676
+
677
+ private expect(type: TokenType): Token {
678
+ const token = this.peek();
679
+ if (token.type !== type) {
680
+ const expected = TokenType[type];
681
+ throw new ParseError(
682
+ `Expected ${expected} but got '${token.value}'`,
683
+ 'EXPECTED_TOKEN',
684
+ token.position,
685
+ token,
686
+ );
687
+ }
688
+ return this.advance();
689
+ }
690
+
691
+ private hasTokens(): boolean {
692
+ return this.peek().type !== TokenType.EOF;
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Parse a dice notation string into an AST.
698
+ *
699
+ * @param notation - The dice notation to parse
700
+ * @returns The root AST node
701
+ * @throws {LexerError} If the input contains invalid characters
702
+ * @throws {ParseError} If the input has invalid syntax
703
+ *
704
+ * @example
705
+ * ```typescript
706
+ * const ast = parse('2d6+3');
707
+ * // { type: 'BinaryOp', operator: '+',
708
+ * // left: { type: 'Dice', count: 2, sides: 6 },
709
+ * // right: { type: 'Literal', value: 3 } }
710
+ * ```
711
+ */
712
+ export function parse(notation: string): ASTNode {
713
+ const tokens = lex(notation);
714
+ const parser = new Parser(tokens);
715
+ return parser.parse();
716
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * RNG module - Seedable random number generation.
3
+ *
4
+ * @module rng
5
+ */
6
+
7
+ export type { RNG } from './types';
8
+ export { SeededRNG } from './seeded';