roll-parser 3.0.0-beta.0 → 3.1.0

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