roll-parser 3.0.0-alpha.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 (163) hide show
  1. package/CHANGELOG.md +189 -0
  2. package/MIGRATION.md +147 -0
  3. package/README.md +985 -43
  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 +16 -4
  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 -14
  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 +62 -40
  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.d.ts +1 -1
  37. package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
  38. package/dist/evaluator/modifiers/compare.js +15 -0
  39. package/dist/evaluator/modifiers/compare.js.map +1 -0
  40. package/dist/evaluator/modifiers/crit-threshold.d.ts +27 -0
  41. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  42. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  43. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  45. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  46. package/dist/evaluator/modifiers/die-bound.js +14 -0
  47. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  48. package/dist/evaluator/modifiers/explode.d.ts +18 -6
  49. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  50. package/dist/evaluator/modifiers/explode.js +103 -0
  51. package/dist/evaluator/modifiers/explode.js.map +1 -0
  52. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  53. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  54. package/dist/evaluator/modifiers/flags.js +18 -0
  55. package/dist/evaluator/modifiers/flags.js.map +1 -0
  56. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  57. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  58. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  59. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  60. package/dist/evaluator/modifiers/reroll.d.ts +14 -6
  61. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  62. package/dist/evaluator/modifiers/reroll.js +62 -0
  63. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  64. package/dist/evaluator/modifiers/sort.d.ts +27 -0
  65. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  66. package/dist/evaluator/modifiers/sort.js +13 -0
  67. package/dist/evaluator/modifiers/sort.js.map +1 -0
  68. package/dist/evaluator/modifiers/success-count.d.ts +2 -6
  69. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  70. package/dist/evaluator/modifiers/success-count.js +24 -0
  71. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  72. package/dist/index.d.ts +35 -13
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +12 -1723
  75. package/dist/index.js.map +1 -0
  76. package/dist/lexer/lexer.d.ts +68 -8
  77. package/dist/lexer/lexer.d.ts.map +1 -1
  78. package/dist/lexer/lexer.js +260 -0
  79. package/dist/lexer/lexer.js.map +1 -0
  80. package/dist/lexer/tokens.d.ts +52 -7
  81. package/dist/lexer/tokens.d.ts.map +1 -1
  82. package/dist/lexer/tokens.js +42 -0
  83. package/dist/lexer/tokens.js.map +1 -0
  84. package/dist/parser/ast.d.ts +419 -80
  85. package/dist/parser/ast.d.ts.map +1 -1
  86. package/dist/parser/ast.js +52 -0
  87. package/dist/parser/ast.js.map +1 -0
  88. package/dist/parser/guards.d.ts +106 -0
  89. package/dist/parser/guards.d.ts.map +1 -0
  90. package/dist/parser/guards.js +121 -0
  91. package/dist/parser/guards.js.map +1 -0
  92. package/dist/parser/parser.d.ts +162 -15
  93. package/dist/parser/parser.d.ts.map +1 -1
  94. package/dist/parser/parser.js +751 -0
  95. package/dist/parser/parser.js.map +1 -0
  96. package/dist/rng/mock.d.ts +74 -13
  97. package/dist/rng/mock.d.ts.map +1 -1
  98. package/dist/rng/mock.js +30 -0
  99. package/dist/rng/mock.js.map +1 -0
  100. package/dist/rng/seeded.d.ts +149 -10
  101. package/dist/rng/seeded.d.ts.map +1 -1
  102. package/dist/rng/seeded.js +138 -0
  103. package/dist/rng/seeded.js.map +1 -0
  104. package/dist/rng/types.d.ts +57 -0
  105. package/dist/rng/types.d.ts.map +1 -1
  106. package/dist/rng/types.js +2 -0
  107. package/dist/rng/types.js.map +1 -0
  108. package/dist/roll.d.ts +59 -25
  109. package/dist/roll.d.ts.map +1 -1
  110. package/dist/roll.js +8 -0
  111. package/dist/roll.js.map +1 -0
  112. package/dist/testing.d.ts +5 -4
  113. package/dist/testing.d.ts.map +1 -1
  114. package/dist/testing.js +2 -38
  115. package/dist/testing.js.map +1 -0
  116. package/dist/types.d.ts +427 -24
  117. package/dist/types.d.ts.map +1 -1
  118. package/dist/types.js +8 -0
  119. package/dist/types.js.map +1 -0
  120. package/dist/version.d.ts +2 -0
  121. package/dist/version.d.ts.map +1 -0
  122. package/dist/version.js +2 -0
  123. package/dist/version.js.map +1 -0
  124. package/package.json +93 -40
  125. package/src/cli/args.ts +66 -9
  126. package/src/cli/format.ts +30 -7
  127. package/src/cli/index.ts +27 -67
  128. package/src/cli/main.ts +129 -0
  129. package/src/errors.ts +488 -23
  130. package/src/evaluator/die.ts +50 -0
  131. package/src/evaluator/env.ts +73 -0
  132. package/src/evaluator/evaluator.ts +1296 -349
  133. package/src/evaluator/modifiers/compare.ts +1 -1
  134. package/src/evaluator/modifiers/crit-threshold.ts +56 -0
  135. package/src/evaluator/modifiers/die-bound.ts +39 -0
  136. package/src/evaluator/modifiers/explode.ts +82 -76
  137. package/src/evaluator/modifiers/flags.ts +61 -0
  138. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  139. package/src/evaluator/modifiers/reroll.ts +36 -64
  140. package/src/evaluator/modifiers/sort.ts +43 -0
  141. package/src/evaluator/modifiers/success-count.ts +6 -9
  142. package/src/index.ts +73 -34
  143. package/src/lexer/lexer.ts +201 -35
  144. package/src/lexer/tokens.ts +72 -7
  145. package/src/parser/ast.ts +453 -104
  146. package/src/parser/guards.ts +248 -0
  147. package/src/parser/parser.ts +835 -135
  148. package/src/rng/mock.ts +75 -14
  149. package/src/rng/seeded.ts +323 -58
  150. package/src/rng/types.ts +57 -0
  151. package/src/roll.ts +66 -41
  152. package/src/testing.ts +5 -9
  153. package/src/types.ts +416 -24
  154. package/src/version.ts +2 -0
  155. package/dist/cli.js +0 -1775
  156. package/dist/evaluator/index.d.ts +0 -8
  157. package/dist/evaluator/index.d.ts.map +0 -1
  158. package/dist/index.mjs +0 -1724
  159. package/dist/rng/index.d.ts +0 -8
  160. package/dist/rng/index.d.ts.map +0 -1
  161. package/dist/testing.mjs +0 -39
  162. package/src/evaluator/index.ts +0 -14
  163. package/src/rng/index.ts +0 -8
package/src/parser/ast.ts CHANGED
@@ -4,88 +4,188 @@
4
4
  * @module parser/ast
5
5
  */
6
6
 
7
- import type { ComparePoint } from '../types';
7
+ import type { ComparePoint } from '../types.js';
8
8
 
9
9
  /**
10
- * Numeric literal node.
10
+ * Source span carried by every AST node.
11
+ *
12
+ * `start` is inclusive, `end` exclusive, both in UTF-16 code units into the
13
+ * original notation string. The parser sets both on every node it produces;
14
+ * they are typed optional so hand-constructed ASTs (tests, programmatic
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
11
21
  */
12
- export type LiteralNode = {
13
- type: 'Literal';
14
- value: number;
22
+ export type NodeSpan = {
23
+ readonly start?: number;
24
+ readonly end?: number;
15
25
  };
16
26
 
17
27
  /**
18
- * Dice roll node.
19
- * Count and sides can be expressions to support computed dice like (1+1)d(3*2).
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
20
35
  */
21
- export type DiceNode = {
22
- type: 'Dice';
23
- count: ASTNode;
24
- sides: ASTNode;
36
+ export type LiteralNode = NodeSpan & {
37
+ readonly type: 'Literal';
38
+ readonly value: number;
39
+ };
40
+
41
+ /**
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
53
+ */
54
+ export type DiceNode = NodeSpan & {
55
+ readonly type: 'Dice';
56
+ readonly count: ASTNode;
57
+ readonly sides: ASTNode;
25
58
  };
26
59
 
27
60
  /**
28
61
  * Fate/Fudge dice node (`dF`).
29
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
30
68
  */
31
- export type FateDiceNode = {
32
- type: 'FateDice';
33
- count: ASTNode;
69
+ export type FateDiceNode = NodeSpan & {
70
+ readonly type: 'FateDice';
71
+ readonly count: ASTNode;
34
72
  };
35
73
 
36
74
  /**
37
- * 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
38
92
  */
39
- export type BinaryOpNode = {
40
- type: 'BinaryOp';
41
- operator: '+' | '-' | '*' | '/' | '%' | '**';
42
- left: ASTNode;
43
- right: ASTNode;
93
+ export type BinaryOpNode = NodeSpan & {
94
+ readonly type: 'BinaryOp';
95
+ readonly operator: '+' | '-' | '*' | '/' | '%' | '**';
96
+ readonly left: ASTNode;
97
+ readonly right: ASTNode;
44
98
  };
45
99
 
46
100
  /**
47
- * 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
48
107
  */
49
- export type UnaryOpNode = {
50
- type: 'UnaryOp';
51
- operator: '-';
52
- operand: ASTNode;
108
+ export type UnaryOpNode = NodeSpan & {
109
+ readonly type: 'UnaryOp';
110
+ readonly operator: '-';
111
+ readonly operand: ASTNode;
53
112
  };
54
113
 
55
114
  /**
56
- * Keep/drop modifier node.
115
+ * Keep/drop modifier node (`khN`, `klN`, `dhN`, `dlN`, and the `kN`
116
+ * shorthand for `khN`).
117
+ *
57
118
  * Wraps a dice expression with keep highest/lowest or drop highest/lowest.
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
58
128
  */
59
- export type ModifierNode = {
60
- type: 'Modifier';
61
- modifier: 'keep' | 'drop';
62
- selector: 'highest' | 'lowest';
63
- count: ASTNode;
64
- target: ASTNode;
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;
65
135
  };
66
136
 
67
137
  /**
68
138
  * Exploding dice node (`!`, `!!`, `!p`, `!>Y`).
69
139
  * Wraps a dice expression with a standard, compounding, or penetrating
70
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
71
148
  */
72
- export type ExplodeNode = {
73
- type: 'Explode';
74
- variant: 'standard' | 'compound' | 'penetrating';
75
- threshold?: ComparePoint;
76
- target: ASTNode;
149
+ export type ExplodeNode = NodeSpan & {
150
+ readonly type: 'Explode';
151
+ readonly variant: 'standard' | 'compound' | 'penetrating';
152
+ readonly threshold?: ComparePoint;
153
+ readonly target: ASTNode;
77
154
  };
78
155
 
79
156
  /**
80
157
  * Reroll node (`r<COND>`, `ro<COND>`).
81
158
  * Re-rolls dice that match a comparison condition. `once: true` for `ro`
82
159
  * keeps the second result regardless of match; `once: false` for `r`
83
- * 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
164
+ */
165
+ export type RerollNode = NodeSpan & {
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
84
184
  */
85
- export type RerollNode = {
86
- type: 'Reroll';
87
- once: boolean;
88
- condition: ComparePoint;
185
+ export type DieBoundNode = NodeSpan & {
186
+ type: 'DieBound';
187
+ bound: 'min' | 'max';
188
+ value: ASTNode;
89
189
  target: ASTNode;
90
190
  };
91
191
 
@@ -93,15 +193,18 @@ export type RerollNode = {
93
193
  * Success counting node (`>=T`, `>T`, `<T`, `<=T`, `=T`, with optional `f=F`).
94
194
  *
95
195
  * Transforms a dice pool into a success count: each die meeting `threshold`
96
- * adds +1, each die meeting `failThreshold` subtracts 1. Terminal — no further
97
- * postfix modifiers may wrap a `SuccessCountNode`. The `failThreshold`
98
- * operator is always `=` (fail on an exact value).
196
+ * adds +1, each die meeting `failThreshold` subtracts 1. Terminal — a
197
+ * `SuccessCountNode` may not be wrapped by any postfix modifier, binary
198
+ * operator, unary operator, versus operand, or function argument. The
199
+ * `failThreshold` accepts any `CompareOp`; bare `fN` defaults to `operator: '='`.
200
+ *
201
+ * @category AST
99
202
  */
100
- export type SuccessCountNode = {
101
- type: 'SuccessCount';
102
- target: ASTNode;
103
- threshold: ComparePoint;
104
- failThreshold?: ComparePoint;
203
+ export type SuccessCountNode = NodeSpan & {
204
+ readonly type: 'SuccessCount';
205
+ readonly target: ASTNode;
206
+ readonly threshold: ComparePoint;
207
+ readonly failThreshold?: ComparePoint;
105
208
  };
106
209
 
107
210
  /**
@@ -113,11 +216,13 @@ export type SuccessCountNode = {
113
216
  * roll side. Lowest-precedence operator — chaining (`a vs b vs c`) is
114
217
  * rejected at parse time; nesting via parens (`a vs (b vs c)`) is rejected
115
218
  * at evaluation time.
219
+ *
220
+ * @category AST
116
221
  */
117
- export type VersusNode = {
118
- type: 'Versus';
119
- roll: ASTNode;
120
- dc: ASTNode;
222
+ export type VersusNode = NodeSpan & {
223
+ readonly type: 'Versus';
224
+ readonly roll: ASTNode;
225
+ readonly dc: ASTNode;
121
226
  };
122
227
 
123
228
  /**
@@ -127,15 +232,159 @@ export type VersusNode = {
127
232
  * variadic functions `max`, `min` (minimum 2 args). Arity is validated at
128
233
  * parse time against a static table; by the time the evaluator sees a
129
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
240
+ */
241
+ export type FunctionCallNode = NodeSpan & {
242
+ readonly type: 'FunctionCall';
243
+ readonly name: string;
244
+ readonly args: readonly ASTNode[];
245
+ };
246
+
247
+ /**
248
+ * Parenthesized group node (`(<expr>)`).
249
+ *
250
+ * Preserves explicit grouping typed by the user so that
251
+ * `RollResult.expression` and `RollResult.rendered` round-trip through
252
+ * `parse` without losing precedence information. Semantically transparent:
253
+ * evaluation returns the inner expression's value unchanged.
254
+ *
255
+ * @category AST
256
+ */
257
+ export type GroupedNode = NodeSpan & {
258
+ readonly type: 'Grouped';
259
+ readonly expression: ASTNode;
260
+ };
261
+
262
+ /**
263
+ * Variable reference node (`@name` or `@{name with spaces}`).
264
+ *
265
+ * Resolves to a numeric value from the evaluator's `context` map at
266
+ * evaluation time. Names are case-sensitive (`@StrMod` ≠ `@strmod`) — the
267
+ * lexer preserves case in the `AT` token's `value`, distinct from other
268
+ * identifier tokens which lowercase. Leaf node — no LED, never wraps a
269
+ * sub-expression.
270
+ *
271
+ * A name missing from `context` throws `UNDEFINED_VARIABLE` unless
272
+ * `onMissingVariable: 'zero'` is set.
273
+ *
274
+ * @category AST
275
+ */
276
+ export type VariableNode = NodeSpan & {
277
+ readonly type: 'Variable';
278
+ readonly name: string;
279
+ };
280
+
281
+ /**
282
+ * Grouped-roll node (`{expr}`, `{expr1, expr2, ...}`).
283
+ *
284
+ * Distinct from `GroupedNode` (parenthesized wrapper) — a `GroupNode`
285
+ * collects one or more sub-expressions whose evaluation semantics change
286
+ * with the sub-roll count. `expressions.length === 1` is a passthrough
287
+ * (flat-pool when wrapped by keep/drop); `expressions.length >= 2` treats
288
+ * each sub-roll's subtotal as a compound die for keep/drop selection.
289
+ *
290
+ * @category AST
291
+ */
292
+ export type GroupNode = NodeSpan & {
293
+ readonly type: 'Group';
294
+ readonly expressions: readonly ASTNode[];
295
+ };
296
+
297
+ /**
298
+ * Sort modifier node (`s`, `sa`, `sd`).
299
+ *
300
+ * Cosmetically reorders the dice produced by `target` in ascending or
301
+ * descending order. Purely visual — does not affect `total`,
302
+ * `successes`/`failures`, or any die-level flag (`kept`/`dropped`/
303
+ * `critical`/`fumble`). Dropped dice retain their `dropped` flag and
304
+ * appear in sorted position alongside kept dice.
305
+ *
306
+ * `s` and `sa` both mean ascending; `sd` is descending.
307
+ *
308
+ * @category AST
309
+ */
310
+ export type SortNode = NodeSpan & {
311
+ readonly type: 'Sort';
312
+ readonly order: 'ascending' | 'descending';
313
+ readonly target: ASTNode;
314
+ };
315
+
316
+ /**
317
+ * Sentinel for bare `cs` / `cf` without a ComparePoint. Resolved to
318
+ * `result === sides` (for critical) or `result === 1` (for fumble) at
319
+ * evaluation time, using each die's own `sides`.
320
+ *
321
+ * @category AST
130
322
  */
131
- export type FunctionCallNode = {
132
- type: 'FunctionCall';
133
- name: string;
134
- args: ASTNode[];
323
+ export type CritThreshold = ComparePoint | 'default';
324
+
325
+ /**
326
+ * Critical threshold modifier node (`cs`, `cf`).
327
+ *
328
+ * Overrides the default `critical`/`fumble` flag logic for the dice
329
+ * produced by `target`. Bare `cs`/`cf` uses the `'default'` sentinel
330
+ * (max face / 1). Custom thresholds accept any ComparePoint. Chaining
331
+ * collapses into a single node — `1d20cs=20cs=1cf>18` has two success
332
+ * and one fail threshold. Display-only: does not change `total`,
333
+ * explosion triggers, or success counting. `cs` and `cf` are independent
334
+ * overrides — a side with no explicit thresholds keeps the default rule
335
+ * (the evaluator substitutes the `'default'` sentinel at apply time).
336
+ *
337
+ * @category AST
338
+ */
339
+ export type CritThresholdNode = NodeSpan & {
340
+ readonly type: 'CritThreshold';
341
+ readonly successThresholds: readonly CritThreshold[];
342
+ readonly failThresholds: readonly CritThreshold[];
343
+ readonly target: ASTNode;
135
344
  };
136
345
 
137
346
  /**
138
- * 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
139
388
  */
140
389
  export type ASTNode =
141
390
  | LiteralNode
@@ -143,119 +392,219 @@ export type ASTNode =
143
392
  | FateDiceNode
144
393
  | BinaryOpNode
145
394
  | UnaryOpNode
146
- | ModifierNode
395
+ | KeepDropNode
147
396
  | ExplodeNode
148
397
  | RerollNode
398
+ | DieBoundNode
149
399
  | SuccessCountNode
150
400
  | VersusNode
151
- | FunctionCallNode;
401
+ | FunctionCallNode
402
+ | GroupedNode
403
+ | VariableNode
404
+ | GroupNode
405
+ | SortNode
406
+ | CritThresholdNode;
152
407
 
153
408
  /**
154
- * 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
155
415
  */
156
416
  export function isLiteral(node: ASTNode): node is LiteralNode {
157
417
  return node.type === 'Literal';
158
418
  }
159
419
 
160
420
  /**
161
- * 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
162
427
  */
163
428
  export function isDice(node: ASTNode): node is DiceNode {
164
429
  return node.type === 'Dice';
165
430
  }
166
431
 
167
432
  /**
168
- * 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
169
439
  */
170
440
  export function isFateDice(node: ASTNode): node is FateDiceNode {
171
441
  return node.type === 'FateDice';
172
442
  }
173
443
 
174
444
  /**
175
- * 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
176
451
  */
177
452
  export function isBinaryOp(node: ASTNode): node is BinaryOpNode {
178
453
  return node.type === 'BinaryOp';
179
454
  }
180
455
 
181
456
  /**
182
- * 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
183
463
  */
184
464
  export function isUnaryOp(node: ASTNode): node is UnaryOpNode {
185
465
  return node.type === 'UnaryOp';
186
466
  }
187
467
 
188
468
  /**
189
- * 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
190
475
  */
191
- export function isModifier(node: ASTNode): node is ModifierNode {
192
- return node.type === 'Modifier';
476
+ export function isKeepDrop(node: ASTNode): node is KeepDropNode {
477
+ return node.type === 'KeepDrop';
193
478
  }
194
479
 
195
480
  /**
196
- * 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
197
487
  */
198
488
  export function isExplode(node: ASTNode): node is ExplodeNode {
199
489
  return node.type === 'Explode';
200
490
  }
201
491
 
202
492
  /**
203
- * 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
204
499
  */
205
500
  export function isReroll(node: ASTNode): node is RerollNode {
206
501
  return node.type === 'Reroll';
207
502
  }
208
503
 
209
504
  /**
210
- * 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
211
511
  */
212
512
  export function isSuccessCount(node: ASTNode): node is SuccessCountNode {
213
513
  return node.type === 'SuccessCount';
214
514
  }
215
515
 
216
516
  /**
217
- * 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
218
523
  */
219
524
  export function isVersus(node: ASTNode): node is VersusNode {
220
525
  return node.type === 'Versus';
221
526
  }
222
527
 
223
528
  /**
224
- * 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
225
535
  */
226
536
  export function isFunctionCall(node: ASTNode): node is FunctionCallNode {
227
537
  return node.type === 'FunctionCall';
228
538
  }
229
539
 
230
540
  /**
231
- * Returns `true` if the AST contains a `Dice` or `FateDice` node reachable
232
- * through structural composition (BinaryOp, UnaryOp, keep/drop, explode,
233
- * reroll, success-count wrappers). Meta-expressions dice count/sides,
234
- * modifier counts, and ComparePoint values are treated as leaves and
235
- * never recursed into.
236
- *
237
- * Used by the parser to reject success-counting targets that don't actually
238
- * roll any dice (e.g. `1>=3`, `(1+2)>=3`).
239
- */
240
- export function containsDice(node: ASTNode): boolean {
241
- switch (node.type) {
242
- case 'Dice':
243
- case 'FateDice':
244
- return true;
245
- case 'Literal':
246
- return false;
247
- case 'BinaryOp':
248
- return containsDice(node.left) || containsDice(node.right);
249
- case 'UnaryOp':
250
- return containsDice(node.operand);
251
- case 'Modifier':
252
- case 'Explode':
253
- case 'Reroll':
254
- case 'SuccessCount':
255
- return containsDice(node.target);
256
- case 'Versus':
257
- return containsDice(node.roll) || containsDice(node.dc);
258
- case 'FunctionCall':
259
- return node.args.some(containsDice);
260
- }
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
547
+ */
548
+ export function isGrouped(node: ASTNode): node is GroupedNode {
549
+ return node.type === 'Grouped';
550
+ }
551
+
552
+ /**
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
559
+ */
560
+ export function isVariable(node: ASTNode): node is VariableNode {
561
+ return node.type === 'Variable';
562
+ }
563
+
564
+ /**
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
571
+ */
572
+ export function isGroup(node: ASTNode): node is GroupNode {
573
+ return node.type === 'Group';
574
+ }
575
+
576
+ /**
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
583
+ */
584
+ export function isSort(node: ASTNode): node is SortNode {
585
+ return node.type === 'Sort';
586
+ }
587
+
588
+ /**
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
595
+ */
596
+ export function isCritThreshold(node: ASTNode): node is CritThresholdNode {
597
+ return node.type === 'CritThreshold';
598
+ }
599
+
600
+ /**
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';
261
610
  }