roll-parser 3.0.0-beta.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +107 -9
  2. package/MIGRATION.md +147 -0
  3. package/README.md +960 -154
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +15 -3
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +17 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -17
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +26 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +58 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +41 -75
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +906 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.js +15 -0
  37. package/dist/evaluator/modifiers/compare.js.map +1 -0
  38. package/dist/evaluator/modifiers/crit-threshold.d.ts +0 -1
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +14 -0
  45. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  46. package/dist/evaluator/modifiers/explode.d.ts +13 -5
  47. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  48. package/dist/evaluator/modifiers/explode.js +103 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +18 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  55. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  56. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  57. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  58. package/dist/evaluator/modifiers/reroll.d.ts +12 -4
  59. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  60. package/dist/evaluator/modifiers/reroll.js +62 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +4 -0
  63. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  64. package/dist/evaluator/modifiers/sort.js +13 -0
  65. package/dist/evaluator/modifiers/sort.js.map +1 -0
  66. package/dist/evaluator/modifiers/success-count.d.ts +2 -6
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +24 -0
  69. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  70. package/dist/index.d.ts +33 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +12 -2560
  73. package/dist/index.js.map +1 -26
  74. package/dist/lexer/lexer.d.ts +50 -5
  75. package/dist/lexer/lexer.d.ts.map +1 -1
  76. package/dist/lexer/lexer.js +260 -0
  77. package/dist/lexer/lexer.js.map +1 -0
  78. package/dist/lexer/tokens.d.ts +31 -6
  79. package/dist/lexer/tokens.d.ts.map +1 -1
  80. package/dist/lexer/tokens.js +42 -0
  81. package/dist/lexer/tokens.js.map +1 -0
  82. package/dist/parser/ast.d.ts +314 -160
  83. package/dist/parser/ast.d.ts.map +1 -1
  84. package/dist/parser/ast.js +52 -0
  85. package/dist/parser/ast.js.map +1 -0
  86. package/dist/parser/guards.d.ts +106 -0
  87. package/dist/parser/guards.d.ts.map +1 -0
  88. package/dist/parser/guards.js +121 -0
  89. package/dist/parser/guards.js.map +1 -0
  90. package/dist/parser/parser.d.ts +118 -14
  91. package/dist/parser/parser.d.ts.map +1 -1
  92. package/dist/parser/parser.js +751 -0
  93. package/dist/parser/parser.js.map +1 -0
  94. package/dist/rng/mock.d.ts +73 -12
  95. package/dist/rng/mock.d.ts.map +1 -1
  96. package/dist/rng/mock.js +30 -0
  97. package/dist/rng/mock.js.map +1 -0
  98. package/dist/rng/seeded.d.ts +141 -9
  99. package/dist/rng/seeded.d.ts.map +1 -1
  100. package/dist/rng/seeded.js +138 -0
  101. package/dist/rng/seeded.js.map +1 -0
  102. package/dist/rng/types.d.ts +57 -0
  103. package/dist/rng/types.d.ts.map +1 -1
  104. package/dist/rng/types.js +2 -0
  105. package/dist/rng/types.js.map +1 -0
  106. package/dist/roll.d.ts +58 -28
  107. package/dist/roll.d.ts.map +1 -1
  108. package/dist/roll.js +8 -0
  109. package/dist/roll.js.map +1 -0
  110. package/dist/testing.d.ts +5 -4
  111. package/dist/testing.d.ts.map +1 -1
  112. package/dist/testing.js +2 -41
  113. package/dist/testing.js.map +1 -11
  114. package/dist/types.d.ts +306 -44
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/types.js +8 -0
  117. package/dist/types.js.map +1 -0
  118. package/dist/version.d.ts +2 -0
  119. package/dist/version.d.ts.map +1 -0
  120. package/dist/version.js +2 -0
  121. package/dist/version.js.map +1 -0
  122. package/package.json +83 -34
  123. package/src/cli/args.ts +66 -10
  124. package/src/cli/format.ts +22 -3
  125. package/src/cli/index.ts +27 -84
  126. package/src/cli/main.ts +129 -0
  127. package/src/errors.ts +480 -27
  128. package/src/evaluator/die.ts +50 -0
  129. package/src/evaluator/env.ts +73 -0
  130. package/src/evaluator/evaluator.ts +653 -421
  131. package/src/evaluator/modifiers/crit-threshold.ts +3 -6
  132. package/src/evaluator/modifiers/die-bound.ts +39 -0
  133. package/src/evaluator/modifiers/explode.ts +60 -58
  134. package/src/evaluator/modifiers/flags.ts +61 -0
  135. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  136. package/src/evaluator/modifiers/reroll.ts +28 -49
  137. package/src/evaluator/modifiers/sort.ts +14 -1
  138. package/src/evaluator/modifiers/success-count.ts +5 -8
  139. package/src/index.ts +56 -35
  140. package/src/lexer/lexer.ts +107 -34
  141. package/src/lexer/tokens.ts +31 -6
  142. package/src/parser/ast.ts +323 -341
  143. package/src/parser/guards.ts +248 -0
  144. package/src/parser/parser.ts +419 -242
  145. package/src/rng/mock.ts +74 -13
  146. package/src/rng/seeded.ts +299 -64
  147. package/src/rng/types.ts +57 -0
  148. package/src/roll.ts +64 -47
  149. package/src/testing.ts +5 -9
  150. package/src/types.ts +310 -43
  151. package/src/version.ts +2 -0
  152. package/dist/cli.js +0 -2608
  153. package/dist/cli.js.map +0 -28
  154. package/dist/evaluator/index.d.ts +0 -8
  155. package/dist/evaluator/index.d.ts.map +0 -1
  156. package/dist/rng/index.d.ts +0 -8
  157. package/dist/rng/index.d.ts.map +0 -1
  158. package/src/evaluator/index.ts +0 -14
  159. package/src/rng/index.ts +0 -8
package/src/parser/ast.ts CHANGED
@@ -13,92 +13,179 @@ import type { ComparePoint } from '../types.js';
13
13
  * original notation string. The parser sets both on every node it produces;
14
14
  * they are typed optional so hand-constructed ASTs (tests, programmatic
15
15
  * consumers) remain valid without positions.
16
+ *
17
+ * `notation.slice(start, end)` recovers the source text of any parsed node,
18
+ * which is how the CLI underlines the failing sub-expression.
19
+ *
20
+ * @category AST
16
21
  */
17
22
  export type NodeSpan = {
18
- start?: number;
19
- end?: number;
23
+ readonly start?: number;
24
+ readonly end?: number;
20
25
  };
21
26
 
22
27
  /**
23
- * Numeric literal node.
28
+ * Numeric literal node — an integer or decimal written directly in the
29
+ * notation (`3`, `2.5`). Leaf node.
30
+ *
31
+ * Decimals are accepted and carried through arithmetic exactly as written;
32
+ * only dice counts and sides are required to resolve to integers.
33
+ *
34
+ * @category AST
24
35
  */
25
36
  export type LiteralNode = NodeSpan & {
26
- type: 'Literal';
27
- value: number;
37
+ readonly type: 'Literal';
38
+ readonly value: number;
28
39
  };
29
40
 
30
41
  /**
31
- * Dice roll node.
32
- * Count and sides can be expressions to support computed dice like (1+1)d(3*2).
42
+ * Dice roll node (`NdX`, `dX`, `d%`).
43
+ *
44
+ * `count` and `sides` are full sub-expressions, not numbers, which is what
45
+ * makes computed dice like `(1+1)d(3*2)` and `(1d4)d6` expressible. Both are
46
+ * evaluated first and must resolve to integers; `d%` desugars to a `sides`
47
+ * literal of `100`, and an omitted count to a `count` literal of `1`.
48
+ *
49
+ * Dice rolled by the `count`/`sides` sub-expressions are meta-expression dice
50
+ * — they land in `RollResult.rolls` tagged `'meta'` and belong to no pool.
51
+ *
52
+ * @category AST
33
53
  */
34
54
  export type DiceNode = NodeSpan & {
35
- type: 'Dice';
36
- count: ASTNode;
37
- sides: ASTNode;
55
+ readonly type: 'Dice';
56
+ readonly count: ASTNode;
57
+ readonly sides: ASTNode;
38
58
  };
39
59
 
40
60
  /**
41
61
  * Fate/Fudge dice node (`dF`).
42
62
  * Each die produces a result in {-1, 0, +1}. No configurable sides.
63
+ *
64
+ * Fate dice carry `sides: 0` as a sentinel in their `DieResult`, and are
65
+ * never `critical` or `fumble` — there is no maximum face to hit.
66
+ *
67
+ * @category AST
43
68
  */
44
69
  export type FateDiceNode = NodeSpan & {
45
- type: 'FateDice';
46
- count: ASTNode;
70
+ readonly type: 'FateDice';
71
+ readonly count: ASTNode;
47
72
  };
48
73
 
49
74
  /**
50
- * Binary operation node.
75
+ * Binary operation node — the arithmetic backbone of an expression.
76
+ *
77
+ * | Operator | Meaning | Precedence | Associativity |
78
+ * |----------|---------|-----------:|---------------|
79
+ * | `+` `-` | add, subtract | 10 | left |
80
+ * | `*` `/` `%` | multiply, divide, modulo | 20 | left |
81
+ * | `**` | power (also spelled `^`) | 30 | right |
82
+ *
83
+ * Dice bind tighter than all of them (40), and postfix modifiers sit at 35,
84
+ * so `2d6+3` is `(2d6)+3` and `4d6kh3*2` is `(4d6kh3)*2`. The `vs` operator
85
+ * binds loosest of all (2) and has its own {@link VersusNode}.
86
+ *
87
+ * `/` and `%` throw `DIVISION_BY_ZERO` / `MODULO_BY_ZERO` on a zero right
88
+ * side; any operator producing a non-finite total throws `NON_FINITE_RESULT`.
89
+ * Division is not rounded — wrap it in `floor()` if you need an integer.
90
+ *
91
+ * @category AST
51
92
  */
52
93
  export type BinaryOpNode = NodeSpan & {
53
- type: 'BinaryOp';
54
- operator: '+' | '-' | '*' | '/' | '%' | '**';
55
- left: ASTNode;
56
- right: ASTNode;
94
+ readonly type: 'BinaryOp';
95
+ readonly operator: '+' | '-' | '*' | '/' | '%' | '**';
96
+ readonly left: ASTNode;
97
+ readonly right: ASTNode;
57
98
  };
58
99
 
59
100
  /**
60
- * Unary operation node.
101
+ * Unary operation node — prefix negation, the only unary operator.
102
+ *
103
+ * Binding power 25 sits between multiplication and power, so `-1d4` is
104
+ * `-(1d4)` (negate the roll) rather than `(-1)d4`, while `-2**2` is `-(2**2)`.
105
+ *
106
+ * @category AST
61
107
  */
62
108
  export type UnaryOpNode = NodeSpan & {
63
- type: 'UnaryOp';
64
- operator: '-';
65
- operand: ASTNode;
109
+ readonly type: 'UnaryOp';
110
+ readonly operator: '-';
111
+ readonly operand: ASTNode;
66
112
  };
67
113
 
68
114
  /**
69
- * Keep/drop modifier node.
115
+ * Keep/drop modifier node (`khN`, `klN`, `dhN`, `dlN`, and the `kN`
116
+ * shorthand for `khN`).
117
+ *
70
118
  * Wraps a dice expression with keep highest/lowest or drop highest/lowest.
71
- */
72
- export type ModifierNode = NodeSpan & {
73
- type: 'Modifier';
74
- modifier: 'keep' | 'drop';
75
- selector: 'highest' | 'lowest';
76
- count: ASTNode;
77
- target: ASTNode;
119
+ * An omitted count defaults to 1, so `4d6kh` is `4d6kh1`. `count` is a full
120
+ * sub-expression, so `4d6kh(1d2)` is legal — and its dice are drawn *before*
121
+ * the pool.
122
+ *
123
+ * Chained modifiers (`4d6kh3dl1`) do not nest: the evaluator flattens the
124
+ * chain and applies each spec independently to the same pool, unioning the
125
+ * dropped sets, which is the Roll20 rule.
126
+ *
127
+ * @category AST
128
+ */
129
+ export type KeepDropNode = NodeSpan & {
130
+ readonly type: 'KeepDrop';
131
+ readonly kind: 'keep' | 'drop';
132
+ readonly selector: 'highest' | 'lowest';
133
+ readonly count: ASTNode;
134
+ readonly target: ASTNode;
78
135
  };
79
136
 
80
137
  /**
81
138
  * Exploding dice node (`!`, `!!`, `!p`, `!>Y`).
82
139
  * Wraps a dice expression with a standard, compounding, or penetrating
83
140
  * explosion. An absent `threshold` means "explode on the die's maximum face".
141
+ *
142
+ * Thresholds accept any comparator. In notation like `1d10!=10` (the
143
+ * Storyteller "10-again" rule) the `!` is the explode marker and `=10` the
144
+ * equality threshold — there is no `!=` comparator. Per-die explosion count
145
+ * is capped by `EvaluationOptions.maxExplodeIterations`.
146
+ *
147
+ * @category AST
84
148
  */
85
149
  export type ExplodeNode = NodeSpan & {
86
- type: 'Explode';
87
- variant: 'standard' | 'compound' | 'penetrating';
88
- threshold?: ComparePoint;
89
- target: ASTNode;
150
+ readonly type: 'Explode';
151
+ readonly variant: 'standard' | 'compound' | 'penetrating';
152
+ readonly threshold?: ComparePoint;
153
+ readonly target: ASTNode;
90
154
  };
91
155
 
92
156
  /**
93
157
  * Reroll node (`r<COND>`, `ro<COND>`).
94
158
  * Re-rolls dice that match a comparison condition. `once: true` for `ro`
95
159
  * keeps the second result regardless of match; `once: false` for `r`
96
- * re-rolls recursively until the condition no longer matches.
160
+ * re-rolls recursively until the condition no longer matches, bounded by
161
+ * `EvaluationOptions.maxRerollIterations`.
162
+ *
163
+ * @category AST
97
164
  */
98
165
  export type RerollNode = NodeSpan & {
99
- type: 'Reroll';
100
- once: boolean;
101
- condition: ComparePoint;
166
+ readonly type: 'Reroll';
167
+ readonly once: boolean;
168
+ readonly condition: ComparePoint;
169
+ readonly target: ASTNode;
170
+ };
171
+
172
+ /**
173
+ * Per-die clamp node (`minN`, `maxN`).
174
+ * `4d6min2` raises every die below 2 to 2; `4d6max5` lowers every die above
175
+ * 5 to 5. The clamp rewrites `DieResult.result` (preserving the raw face in
176
+ * `initialResult`) and re-sums the pool; `critical`/`fumble` keep reflecting
177
+ * the natural face. The bound is a full sub-expression (`4d6min(1d2)`),
178
+ * drawn *after* the pool like other threshold arguments.
179
+ *
180
+ * Chained bounds nest and apply left to right: `4d6min2max5` clamps into
181
+ * [2, 5].
182
+ *
183
+ * @category AST
184
+ */
185
+ export type DieBoundNode = NodeSpan & {
186
+ type: 'DieBound';
187
+ bound: 'min' | 'max';
188
+ value: ASTNode;
102
189
  target: ASTNode;
103
190
  };
104
191
 
@@ -110,12 +197,14 @@ export type RerollNode = NodeSpan & {
110
197
  * `SuccessCountNode` may not be wrapped by any postfix modifier, binary
111
198
  * operator, unary operator, versus operand, or function argument. The
112
199
  * `failThreshold` accepts any `CompareOp`; bare `fN` defaults to `operator: '='`.
200
+ *
201
+ * @category AST
113
202
  */
114
203
  export type SuccessCountNode = NodeSpan & {
115
- type: 'SuccessCount';
116
- target: ASTNode;
117
- threshold: ComparePoint;
118
- failThreshold?: ComparePoint;
204
+ readonly type: 'SuccessCount';
205
+ readonly target: ASTNode;
206
+ readonly threshold: ComparePoint;
207
+ readonly failThreshold?: ComparePoint;
119
208
  };
120
209
 
121
210
  /**
@@ -127,11 +216,13 @@ export type SuccessCountNode = NodeSpan & {
127
216
  * roll side. Lowest-precedence operator — chaining (`a vs b vs c`) is
128
217
  * rejected at parse time; nesting via parens (`a vs (b vs c)`) is rejected
129
218
  * at evaluation time.
219
+ *
220
+ * @category AST
130
221
  */
131
222
  export type VersusNode = NodeSpan & {
132
- type: 'Versus';
133
- roll: ASTNode;
134
- dc: ASTNode;
223
+ readonly type: 'Versus';
224
+ readonly roll: ASTNode;
225
+ readonly dc: ASTNode;
135
226
  };
136
227
 
137
228
  /**
@@ -141,11 +232,16 @@ export type VersusNode = NodeSpan & {
141
232
  * variadic functions `max`, `min` (minimum 2 args). Arity is validated at
142
233
  * parse time against a static table; by the time the evaluator sees a
143
234
  * `FunctionCallNode`, `args.length` is guaranteed to match the function.
235
+ *
236
+ * `max`/`min` are variadic with no upper bound, so `max(1d20, 1d20, 1d20)`
237
+ * is a valid three-way advantage roll.
238
+ *
239
+ * @category AST
144
240
  */
145
241
  export type FunctionCallNode = NodeSpan & {
146
- type: 'FunctionCall';
147
- name: string;
148
- args: ASTNode[];
242
+ readonly type: 'FunctionCall';
243
+ readonly name: string;
244
+ readonly args: readonly ASTNode[];
149
245
  };
150
246
 
151
247
  /**
@@ -155,10 +251,12 @@ export type FunctionCallNode = NodeSpan & {
155
251
  * `RollResult.expression` and `RollResult.rendered` round-trip through
156
252
  * `parse` without losing precedence information. Semantically transparent:
157
253
  * evaluation returns the inner expression's value unchanged.
254
+ *
255
+ * @category AST
158
256
  */
159
257
  export type GroupedNode = NodeSpan & {
160
- type: 'Grouped';
161
- expression: ASTNode;
258
+ readonly type: 'Grouped';
259
+ readonly expression: ASTNode;
162
260
  };
163
261
 
164
262
  /**
@@ -169,10 +267,15 @@ export type GroupedNode = NodeSpan & {
169
267
  * lexer preserves case in the `AT` token's `value`, distinct from other
170
268
  * identifier tokens which lowercase. Leaf node — no LED, never wraps a
171
269
  * sub-expression.
270
+ *
271
+ * A name missing from `context` throws `UNDEFINED_VARIABLE` unless
272
+ * `onMissingVariable: 'zero'` is set.
273
+ *
274
+ * @category AST
172
275
  */
173
276
  export type VariableNode = NodeSpan & {
174
- type: 'Variable';
175
- name: string;
277
+ readonly type: 'Variable';
278
+ readonly name: string;
176
279
  };
177
280
 
178
281
  /**
@@ -183,10 +286,12 @@ export type VariableNode = NodeSpan & {
183
286
  * with the sub-roll count. `expressions.length === 1` is a passthrough
184
287
  * (flat-pool when wrapped by keep/drop); `expressions.length >= 2` treats
185
288
  * each sub-roll's subtotal as a compound die for keep/drop selection.
289
+ *
290
+ * @category AST
186
291
  */
187
292
  export type GroupNode = NodeSpan & {
188
- type: 'Group';
189
- expressions: ASTNode[];
293
+ readonly type: 'Group';
294
+ readonly expressions: readonly ASTNode[];
190
295
  };
191
296
 
192
297
  /**
@@ -197,17 +302,23 @@ export type GroupNode = NodeSpan & {
197
302
  * `successes`/`failures`, or any die-level flag (`kept`/`dropped`/
198
303
  * `critical`/`fumble`). Dropped dice retain their `dropped` flag and
199
304
  * appear in sorted position alongside kept dice.
305
+ *
306
+ * `s` and `sa` both mean ascending; `sd` is descending.
307
+ *
308
+ * @category AST
200
309
  */
201
310
  export type SortNode = NodeSpan & {
202
- type: 'Sort';
203
- order: 'ascending' | 'descending';
204
- target: ASTNode;
311
+ readonly type: 'Sort';
312
+ readonly order: 'ascending' | 'descending';
313
+ readonly target: ASTNode;
205
314
  };
206
315
 
207
316
  /**
208
317
  * Sentinel for bare `cs` / `cf` without a ComparePoint. Resolved to
209
318
  * `result === sides` (for critical) or `result === 1` (for fumble) at
210
319
  * evaluation time, using each die's own `sides`.
320
+ *
321
+ * @category AST
211
322
  */
212
323
  export type CritThreshold = ComparePoint | 'default';
213
324
 
@@ -222,16 +333,58 @@ export type CritThreshold = ComparePoint | 'default';
222
333
  * explosion triggers, or success counting. `cs` and `cf` are independent
223
334
  * overrides — a side with no explicit thresholds keeps the default rule
224
335
  * (the evaluator substitutes the `'default'` sentinel at apply time).
336
+ *
337
+ * @category AST
225
338
  */
226
339
  export type CritThresholdNode = NodeSpan & {
227
- type: 'CritThreshold';
228
- successThresholds: CritThreshold[];
229
- failThresholds: CritThreshold[];
230
- target: ASTNode;
340
+ readonly type: 'CritThreshold';
341
+ readonly successThresholds: readonly CritThreshold[];
342
+ readonly failThresholds: readonly CritThreshold[];
343
+ readonly target: ASTNode;
231
344
  };
232
345
 
233
346
  /**
234
- * Union type of all AST nodes.
347
+ * Discriminated union of all 17 AST node types — what {@link parse} returns
348
+ * and what {@link evaluate} consumes.
349
+ *
350
+ * Narrow it either by switching on `node.type` (PascalCase discriminants, as
351
+ * opposed to the camelCase ones on {@link RollPart}) or with the exported
352
+ * type guards: {@link isLiteral}, {@link isDice}, {@link isFateDice},
353
+ * {@link isBinaryOp}, {@link isUnaryOp}, {@link isKeepDrop},
354
+ * {@link isExplode}, {@link isReroll}, {@link isDieBound},
355
+ * {@link isSuccessCount}, {@link isVersus}, {@link isFunctionCall},
356
+ * {@link isGrouped}, {@link isVariable}, {@link isGroup}, {@link isSort},
357
+ * {@link isCritThreshold}.
358
+ *
359
+ * Nodes are plain data with no methods, so they are structurally clonable and
360
+ * safe to cache. Every parser-produced node carries a {@link NodeSpan}.
361
+ *
362
+ * @example A guard-based walker — count the dice an expression can roll
363
+ * ```typescript
364
+ * import {
365
+ * type ASTNode, isBinaryOp, isDice, isFateDice, isGroup, isGrouped,
366
+ * isFunctionCall, isLiteral, isUnaryOp, isVersus, parse,
367
+ * } from 'roll-parser';
368
+ *
369
+ * function countPools(node: ASTNode): number {
370
+ * if (isDice(node) || isFateDice(node)) return 1;
371
+ * if (isLiteral(node)) return 0;
372
+ * if (isBinaryOp(node)) return countPools(node.left) + countPools(node.right);
373
+ * if (isUnaryOp(node)) return countPools(node.operand);
374
+ * if (isGrouped(node)) return countPools(node.expression);
375
+ * if (isVersus(node)) return countPools(node.roll) + countPools(node.dc);
376
+ * if (isGroup(node)) return node.expressions.reduce((n, e) => n + countPools(e), 0);
377
+ * if (isFunctionCall(node)) return node.args.reduce((n, a) => n + countPools(a), 0);
378
+ * // Every remaining variant is a postfix modifier wrapping `target`.
379
+ * return 'target' in node ? countPools(node.target) : 0;
380
+ * }
381
+ *
382
+ * countPools(parse('2d6+3')); // 1
383
+ * countPools(parse('{2d20kh1+5, 3d8!}kh1')); // 2
384
+ * countPools(parse('1+2')); // 0
385
+ * ```
386
+ *
387
+ * @category AST
235
388
  */
236
389
  export type ASTNode =
237
390
  | LiteralNode
@@ -239,9 +392,10 @@ export type ASTNode =
239
392
  | FateDiceNode
240
393
  | BinaryOpNode
241
394
  | UnaryOpNode
242
- | ModifierNode
395
+ | KeepDropNode
243
396
  | ExplodeNode
244
397
  | RerollNode
398
+ | DieBoundNode
245
399
  | SuccessCountNode
246
400
  | VersusNode
247
401
  | FunctionCallNode
@@ -252,377 +406,205 @@ export type ASTNode =
252
406
  | CritThresholdNode;
253
407
 
254
408
  /**
255
- * Type guard for LiteralNode.
409
+ * Narrows an {@link ASTNode} to a numeric literal (`3`, `2.5`).
410
+ *
411
+ * @param node - Any AST node
412
+ * @returns `true` when `node` is a {@link LiteralNode}
413
+ *
414
+ * @category AST
256
415
  */
257
416
  export function isLiteral(node: ASTNode): node is LiteralNode {
258
417
  return node.type === 'Literal';
259
418
  }
260
419
 
261
420
  /**
262
- * Type guard for DiceNode.
421
+ * Narrows an {@link ASTNode} to a dice pool (`2d6`, `d%`, `(1d4)d6`).
422
+ *
423
+ * @param node - Any AST node
424
+ * @returns `true` when `node` is a {@link DiceNode}
425
+ *
426
+ * @category AST
263
427
  */
264
428
  export function isDice(node: ASTNode): node is DiceNode {
265
429
  return node.type === 'Dice';
266
430
  }
267
431
 
268
432
  /**
269
- * Type guard for FateDiceNode.
433
+ * Narrows an {@link ASTNode} to a Fate/Fudge pool (`4dF`).
434
+ *
435
+ * @param node - Any AST node
436
+ * @returns `true` when `node` is a {@link FateDiceNode}
437
+ *
438
+ * @category AST
270
439
  */
271
440
  export function isFateDice(node: ASTNode): node is FateDiceNode {
272
441
  return node.type === 'FateDice';
273
442
  }
274
443
 
275
444
  /**
276
- * Type guard for BinaryOpNode.
445
+ * Narrows an {@link ASTNode} to an arithmetic operation (`+ - * / % **`).
446
+ *
447
+ * @param node - Any AST node
448
+ * @returns `true` when `node` is a {@link BinaryOpNode}
449
+ *
450
+ * @category AST
277
451
  */
278
452
  export function isBinaryOp(node: ASTNode): node is BinaryOpNode {
279
453
  return node.type === 'BinaryOp';
280
454
  }
281
455
 
282
456
  /**
283
- * Type guard for UnaryOpNode.
457
+ * Narrows an {@link ASTNode} to a prefix negation (`-1d4`).
458
+ *
459
+ * @param node - Any AST node
460
+ * @returns `true` when `node` is a {@link UnaryOpNode}
461
+ *
462
+ * @category AST
284
463
  */
285
464
  export function isUnaryOp(node: ASTNode): node is UnaryOpNode {
286
465
  return node.type === 'UnaryOp';
287
466
  }
288
467
 
289
468
  /**
290
- * Type guard for ModifierNode.
469
+ * Narrows an {@link ASTNode} to a keep/drop modifier (`kh`, `kl`, `dh`, `dl`).
470
+ *
471
+ * @param node - Any AST node
472
+ * @returns `true` when `node` is a {@link KeepDropNode}
473
+ *
474
+ * @category AST
291
475
  */
292
- export function isModifier(node: ASTNode): node is ModifierNode {
293
- return node.type === 'Modifier';
476
+ export function isKeepDrop(node: ASTNode): node is KeepDropNode {
477
+ return node.type === 'KeepDrop';
294
478
  }
295
479
 
296
480
  /**
297
- * Type guard for ExplodeNode.
481
+ * Narrows an {@link ASTNode} to an explosion (`!`, `!!`, `!p`).
482
+ *
483
+ * @param node - Any AST node
484
+ * @returns `true` when `node` is a {@link ExplodeNode}
485
+ *
486
+ * @category AST
298
487
  */
299
488
  export function isExplode(node: ASTNode): node is ExplodeNode {
300
489
  return node.type === 'Explode';
301
490
  }
302
491
 
303
492
  /**
304
- * Type guard for RerollNode.
493
+ * Narrows an {@link ASTNode} to a reroll (`r`, `ro`).
494
+ *
495
+ * @param node - Any AST node
496
+ * @returns `true` when `node` is a {@link RerollNode}
497
+ *
498
+ * @category AST
305
499
  */
306
500
  export function isReroll(node: ASTNode): node is RerollNode {
307
501
  return node.type === 'Reroll';
308
502
  }
309
503
 
310
504
  /**
311
- * Type guard for SuccessCountNode.
505
+ * Narrows an {@link ASTNode} to a success count (`>=6`, with optional `f1`).
506
+ *
507
+ * @param node - Any AST node
508
+ * @returns `true` when `node` is a {@link SuccessCountNode}
509
+ *
510
+ * @category AST
312
511
  */
313
512
  export function isSuccessCount(node: ASTNode): node is SuccessCountNode {
314
513
  return node.type === 'SuccessCount';
315
514
  }
316
515
 
317
516
  /**
318
- * Type guard for VersusNode.
517
+ * Narrows an {@link ASTNode} to a PF2e degree-of-success check (`vs`).
518
+ *
519
+ * @param node - Any AST node
520
+ * @returns `true` when `node` is a {@link VersusNode}
521
+ *
522
+ * @category AST
319
523
  */
320
524
  export function isVersus(node: ASTNode): node is VersusNode {
321
525
  return node.type === 'Versus';
322
526
  }
323
527
 
324
528
  /**
325
- * Type guard for FunctionCallNode.
529
+ * Narrows an {@link ASTNode} to a math function call (`floor`, `max`, …).
530
+ *
531
+ * @param node - Any AST node
532
+ * @returns `true` when `node` is a {@link FunctionCallNode}
533
+ *
534
+ * @category AST
326
535
  */
327
536
  export function isFunctionCall(node: ASTNode): node is FunctionCallNode {
328
537
  return node.type === 'FunctionCall';
329
538
  }
330
539
 
331
540
  /**
332
- * Type guard for GroupedNode.
541
+ * Narrows an {@link ASTNode} to a parenthesized group (`(1d6+2)`).
542
+ *
543
+ * @param node - Any AST node
544
+ * @returns `true` when `node` is a {@link GroupedNode}
545
+ *
546
+ * @category AST
333
547
  */
334
548
  export function isGrouped(node: ASTNode): node is GroupedNode {
335
549
  return node.type === 'Grouped';
336
550
  }
337
551
 
338
552
  /**
339
- * Type guard for VariableNode.
553
+ * Narrows an {@link ASTNode} to a variable reference (`@str`).
554
+ *
555
+ * @param node - Any AST node
556
+ * @returns `true` when `node` is a {@link VariableNode}
557
+ *
558
+ * @category AST
340
559
  */
341
560
  export function isVariable(node: ASTNode): node is VariableNode {
342
561
  return node.type === 'Variable';
343
562
  }
344
563
 
345
564
  /**
346
- * Type guard for GroupNode.
565
+ * Narrows an {@link ASTNode} to a braced grouped roll (`{1d8, 1d10}`).
566
+ *
567
+ * @param node - Any AST node
568
+ * @returns `true` when `node` is a {@link GroupNode}
569
+ *
570
+ * @category AST
347
571
  */
348
572
  export function isGroup(node: ASTNode): node is GroupNode {
349
573
  return node.type === 'Group';
350
574
  }
351
575
 
352
576
  /**
353
- * Type guard for SortNode.
577
+ * Narrows an {@link ASTNode} to a sort modifier (`s`, `sa`, `sd`).
578
+ *
579
+ * @param node - Any AST node
580
+ * @returns `true` when `node` is a {@link SortNode}
581
+ *
582
+ * @category AST
354
583
  */
355
584
  export function isSort(node: ASTNode): node is SortNode {
356
585
  return node.type === 'Sort';
357
586
  }
358
587
 
359
588
  /**
360
- * Type guard for CritThresholdNode.
589
+ * Narrows an {@link ASTNode} to a crit-threshold override (`cs`, `cf`).
590
+ *
591
+ * @param node - Any AST node
592
+ * @returns `true` when `node` is a {@link CritThresholdNode}
593
+ *
594
+ * @category AST
361
595
  */
362
596
  export function isCritThreshold(node: ASTNode): node is CritThresholdNode {
363
597
  return node.type === 'CritThreshold';
364
598
  }
365
599
 
366
600
  /**
367
- * Wrapper kinds that `unwrapTransparent` can peel.
368
- *
369
- * "Transparent" is relative to the question being asked. `Modifier`/`Sort`/
370
- * `CritThreshold` are transparent for "what is the underlying operand?" when
371
- * deciding whether to reject a `Group` target — they preserve `containsDicePool`'s
372
- * answer for whatever they wrap. They are NOT transparent for "is this a
373
- * `SuccessCount`?" or "is this a `Versus`?", because the parsers that build
374
- * those wrappers already reject `SuccessCount`/`Versus` operands upstream.
375
- */
376
- export type TransparentWrapperKind = 'Grouped' | 'Modifier' | 'Sort' | 'CritThreshold';
377
-
378
- /**
379
- * Walks `node` while its `.type` is in `kinds`, returning the first descendant
380
- * that is not one of the listed wrappers. Each caller picks the subset that
381
- * matches its rejection semantics — see `TransparentWrapperKind` for guidance.
382
- *
383
- * Used by parser reject helpers to look past wrappers when deciding whether
384
- * an operand violates a rule (e.g., `Group` cannot be the target of `cs`/`cf`,
385
- * even when wrapped in `Modifier` like `{1d6}kh1cs>5`).
386
- */
387
- export function unwrapTransparent(
388
- node: ASTNode,
389
- kinds: readonly TransparentWrapperKind[],
390
- ): ASTNode {
391
- let current = node;
392
- while (true) {
393
- switch (current.type) {
394
- case 'Grouped':
395
- if (!kinds.includes('Grouped')) return current;
396
- current = current.expression;
397
- break;
398
- case 'Modifier':
399
- if (!kinds.includes('Modifier')) return current;
400
- current = current.target;
401
- break;
402
- case 'Sort':
403
- if (!kinds.includes('Sort')) return current;
404
- current = current.target;
405
- break;
406
- case 'CritThreshold':
407
- if (!kinds.includes('CritThreshold')) return current;
408
- current = current.target;
409
- break;
410
- default:
411
- return current;
412
- }
413
- }
414
- }
415
-
416
- /**
417
- * Returns `true` only when `node`'s direct result is a dice pool —
418
- * `Dice`, `FateDice`, or a chained pool modifier (`Modifier` / `Explode` /
419
- * `Reroll`). Does NOT recurse through arithmetic wrappers (`BinaryOp`,
420
- * `UnaryOp`, `FunctionCall`), so `(1d6+5)` and `floor(1d6/2)` are rejected.
421
- *
422
- * Used by the parser to reject postfix pool-modifier targets (kh/kl/dh/dl,
423
- * !/!!/!p, r/ro) that wrap a non-pool expression. Operating on the inner
424
- * dice pool would silently drop the surrounding arithmetic.
425
- */
426
- export function containsDicePool(node: ASTNode): boolean {
427
- switch (node.type) {
428
- case 'Dice':
429
- case 'FateDice':
430
- case 'Modifier':
431
- case 'Explode':
432
- case 'Reroll':
433
- return true;
434
- case 'Sort':
435
- case 'CritThreshold':
436
- return containsDicePool(node.target);
437
- case 'Grouped':
438
- return containsDicePool(node.expression);
439
- case 'Group':
440
- // ? Multi-sub-roll groups (`{a, b, c}kh1`) always accept: keep/drop
441
- // operates on subtotals, which are "compound dice" by definition —
442
- // even a literal-only `{3, 5, 7}kh1` is valid. Single-sub-roll
443
- // groups are the user's explicit opt-in to flat-pool semantics, so
444
- // we deep-walk through arithmetic that a raw `(1d6+5)kh1` would
445
- // reject. This is the `{}` escape hatch per Stage 3 spec.
446
- return node.expressions.length >= 2 || node.expressions.some(deepContainsDicePool);
447
- default:
448
- return false;
449
- }
450
- }
451
-
452
- /**
453
- * Deeper variant of `containsDicePool` that recurses through arithmetic and
454
- * function wrappers. Used from the `Group` case above (ordinary parenthesized
455
- * arithmetic `(1d6+5)kh1` must still reject, so the shallow `containsDicePool`
456
- * handles those directly) and from the Sort parser guard (sort accepts
457
- * `(1d6+2d8)s` per Stage 3 spec).
458
- */
459
- export function deepContainsDicePool(node: ASTNode): boolean {
460
- switch (node.type) {
461
- case 'Dice':
462
- case 'FateDice':
463
- return true;
464
- case 'BinaryOp':
465
- return deepContainsDicePool(node.left) || deepContainsDicePool(node.right);
466
- case 'UnaryOp':
467
- return deepContainsDicePool(node.operand);
468
- case 'Modifier':
469
- case 'Explode':
470
- case 'Reroll':
471
- case 'SuccessCount':
472
- case 'Sort':
473
- case 'CritThreshold':
474
- return deepContainsDicePool(node.target);
475
- case 'Versus':
476
- return deepContainsDicePool(node.roll) || deepContainsDicePool(node.dc);
477
- case 'FunctionCall':
478
- return node.args.some(deepContainsDicePool);
479
- case 'Grouped':
480
- return deepContainsDicePool(node.expression);
481
- case 'Group':
482
- return node.expressions.length >= 2 || node.expressions.some(deepContainsDicePool);
483
- default:
484
- return false;
485
- }
486
- }
487
-
488
- /**
489
- * Returns `true` if the pool this node resolves to is (or wraps) a `FateDice`
490
- * pool. Walks through chained pool modifiers (`Modifier` / `Explode` /
491
- * `Reroll`) but not arithmetic wrappers — callers should run
492
- * `containsDicePool` first to reject those.
493
- *
494
- * Used by the parser to reject `!`, `!!`, `!p` applied to Fate pools
495
- * (`4dF!`, `(4dF)kh2!`, etc.). Fate explosion semantics are undefined, so
496
- * parse-time rejection is preferred over a silent evaluator no-op.
497
- *
498
- * Inside a `Group`, recursion uses `deepContainsFatePool` to mirror
499
- * `containsDicePool`'s deep walk through the same case — otherwise
500
- * `{4dF+1d6}cf` slips past the bare-Fate guard and the default fumble
501
- * check (`result === 1`) flips `+1` faces into fumbles.
502
- */
503
- export function containsFatePool(node: ASTNode): boolean {
504
- switch (node.type) {
505
- case 'FateDice':
506
- return true;
507
- case 'Modifier':
508
- case 'Explode':
509
- case 'Reroll':
510
- case 'Sort':
511
- case 'CritThreshold':
512
- return containsFatePool(node.target);
513
- case 'Grouped':
514
- return containsFatePool(node.expression);
515
- case 'Group':
516
- return node.expressions.some(deepContainsFatePool);
517
- default:
518
- return false;
519
- }
520
- }
521
-
522
- /**
523
- * Deeper variant of `containsFatePool` that recurses through arithmetic and
524
- * function wrappers. Mirrors `deepContainsDicePool`. Used from
525
- * `containsFatePool`'s `Group` case so single-sub-roll groups containing
526
- * arithmetic-wrapped Fate (`{4dF+1d6}cf`) still trip the bare-Fate guard.
527
- *
528
- * Outside a `Group`, ordinary parenthesized arithmetic (`(4dF+1d6)cf`) is
529
- * already rejected upstream by shallow `containsDicePool`, so this helper
530
- * intentionally stays Group-internal.
531
- */
532
- export function deepContainsFatePool(node: ASTNode): boolean {
533
- switch (node.type) {
534
- case 'FateDice':
535
- return true;
536
- case 'BinaryOp':
537
- return deepContainsFatePool(node.left) || deepContainsFatePool(node.right);
538
- case 'UnaryOp':
539
- return deepContainsFatePool(node.operand);
540
- case 'Modifier':
541
- case 'Explode':
542
- case 'Reroll':
543
- case 'SuccessCount':
544
- case 'Sort':
545
- case 'CritThreshold':
546
- return deepContainsFatePool(node.target);
547
- case 'Versus':
548
- return deepContainsFatePool(node.roll) || deepContainsFatePool(node.dc);
549
- case 'FunctionCall':
550
- return node.args.some(deepContainsFatePool);
551
- case 'Grouped':
552
- return deepContainsFatePool(node.expression);
553
- case 'Group':
554
- return node.expressions.some(deepContainsFatePool);
555
- default:
556
- return false;
557
- }
558
- }
559
-
560
- /**
561
- * Deep-walks a node to find any descendant `Group` with two or more
562
- * sub-expressions. Used by `rejectGroupTarget`'s single-sub-roll
563
- * passthrough so a multi-sub Group buried under arithmetic
564
- * (`{{1d6,2d8}+0}cs>5`), function calls (`{abs({1d6,2d8})}cs>5`), or any
565
- * other non-transparent wrapper still rejects with the same error code.
566
- *
567
- * Without this walk, the unwrap inside `rejectGroupTarget` only peels
568
- * `Grouped`/`Modifier`/`Sort`/`CritThreshold` — a multi-sub Group cloaked
569
- * in a `BinaryOp`/`UnaryOp`/`FunctionCall` revives issue #97.
570
- */
571
- export function containsMultiSubGroup(node: ASTNode): boolean {
572
- switch (node.type) {
573
- case 'Group':
574
- return node.expressions.length >= 2 || node.expressions.some(containsMultiSubGroup);
575
- case 'BinaryOp':
576
- return containsMultiSubGroup(node.left) || containsMultiSubGroup(node.right);
577
- case 'UnaryOp':
578
- return containsMultiSubGroup(node.operand);
579
- case 'Modifier':
580
- case 'Explode':
581
- case 'Reroll':
582
- case 'SuccessCount':
583
- case 'Sort':
584
- case 'CritThreshold':
585
- return containsMultiSubGroup(node.target);
586
- case 'Versus':
587
- return containsMultiSubGroup(node.roll) || containsMultiSubGroup(node.dc);
588
- case 'FunctionCall':
589
- return node.args.some(containsMultiSubGroup);
590
- case 'Grouped':
591
- return containsMultiSubGroup(node.expression);
592
- default:
593
- return false;
594
- }
595
- }
596
-
597
- /**
598
- * Deep-walks a node to find any descendant `Versus`. Used by
599
- * `rejectVersusTarget`'s single-sub-roll Group passthrough so a buried
600
- * Versus (`{1+(1d20 vs 15)}cs>18`, `{abs(1d20 vs 15)}cs>18`,
601
- * `{-(1d20 vs 15)}kh1`) still rejects with `NESTED_VERSUS` instead of
602
- * silently dropping `versusMetadata` at the modifier consumer site.
603
- */
604
- export function containsVersus(node: ASTNode): boolean {
605
- switch (node.type) {
606
- case 'Versus':
607
- return true;
608
- case 'BinaryOp':
609
- return containsVersus(node.left) || containsVersus(node.right);
610
- case 'UnaryOp':
611
- return containsVersus(node.operand);
612
- case 'Modifier':
613
- case 'Explode':
614
- case 'Reroll':
615
- case 'SuccessCount':
616
- case 'Sort':
617
- case 'CritThreshold':
618
- return containsVersus(node.target);
619
- case 'FunctionCall':
620
- return node.args.some(containsVersus);
621
- case 'Grouped':
622
- return containsVersus(node.expression);
623
- case 'Group':
624
- return node.expressions.some(containsVersus);
625
- default:
626
- return false;
627
- }
601
+ * Narrows an {@link ASTNode} to a per-die clamp (`min2`, `max5`).
602
+ *
603
+ * @param node - Any AST node
604
+ * @returns `true` when `node` is a {@link DieBoundNode}
605
+ *
606
+ * @category AST
607
+ */
608
+ export function isDieBound(node: ASTNode): node is DieBoundNode {
609
+ return node.type === 'DieBound';
628
610
  }