roll-parser 2.3.2 → 3.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +175 -170
  3. package/dist/cli/args.d.ts +35 -0
  4. package/dist/cli/args.d.ts.map +1 -0
  5. package/dist/cli/format.d.ts +20 -0
  6. package/dist/cli/format.d.ts.map +1 -0
  7. package/dist/cli/index.d.ts +8 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli.js +2608 -0
  10. package/dist/cli.js.map +28 -0
  11. package/dist/errors.d.ts +42 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/evaluator/evaluator.d.ts +121 -0
  14. package/dist/evaluator/evaluator.d.ts.map +1 -0
  15. package/dist/evaluator/index.d.ts +8 -0
  16. package/dist/evaluator/index.d.ts.map +1 -0
  17. package/dist/evaluator/modifiers/compare.d.ts +15 -0
  18. package/dist/evaluator/modifiers/compare.d.ts.map +1 -0
  19. package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
  20. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  21. package/dist/evaluator/modifiers/explode.d.ts +48 -0
  22. package/dist/evaluator/modifiers/explode.d.ts.map +1 -0
  23. package/dist/evaluator/modifiers/keep-drop.d.ts +44 -0
  24. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -0
  25. package/dist/evaluator/modifiers/reroll.d.ts +33 -0
  26. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -0
  27. package/dist/evaluator/modifiers/sort.d.ts +23 -0
  28. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  29. package/dist/evaluator/modifiers/success-count.d.ts +28 -0
  30. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -0
  31. package/dist/index.d.ts +20 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +2560 -0
  34. package/dist/index.js.map +26 -0
  35. package/dist/lexer/lexer.d.ts +90 -0
  36. package/dist/lexer/lexer.d.ts.map +1 -0
  37. package/dist/lexer/tokens.d.ts +107 -0
  38. package/dist/lexer/tokens.d.ts.map +1 -0
  39. package/dist/parser/ast.d.ts +368 -0
  40. package/dist/parser/ast.d.ts.map +1 -0
  41. package/dist/parser/parser.d.ts +145 -0
  42. package/dist/parser/parser.d.ts.map +1 -0
  43. package/dist/rng/index.d.ts +8 -0
  44. package/dist/rng/index.d.ts.map +1 -0
  45. package/dist/rng/mock.d.ts +36 -0
  46. package/dist/rng/mock.d.ts.map +1 -0
  47. package/dist/rng/seeded.d.ts +40 -0
  48. package/dist/rng/seeded.d.ts.map +1 -0
  49. package/dist/rng/types.d.ts +24 -0
  50. package/dist/rng/types.d.ts.map +1 -0
  51. package/dist/roll.d.ts +51 -0
  52. package/dist/roll.d.ts.map +1 -0
  53. package/dist/testing.d.ts +11 -0
  54. package/dist/testing.d.ts.map +1 -0
  55. package/dist/testing.js +41 -0
  56. package/dist/testing.js.map +11 -0
  57. package/dist/types.d.ts +246 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/package.json +66 -43
  60. package/src/cli/args.ts +73 -0
  61. package/src/cli/format.ts +49 -0
  62. package/src/cli/index.ts +92 -0
  63. package/src/errors.ts +90 -0
  64. package/src/evaluator/evaluator.ts +1552 -0
  65. package/src/evaluator/index.ts +14 -0
  66. package/src/evaluator/modifiers/compare.ts +29 -0
  67. package/src/evaluator/modifiers/crit-threshold.ts +59 -0
  68. package/src/evaluator/modifiers/explode.ts +219 -0
  69. package/src/evaluator/modifiers/keep-drop.ts +151 -0
  70. package/src/evaluator/modifiers/reroll.ts +165 -0
  71. package/src/evaluator/modifiers/sort.ts +30 -0
  72. package/src/evaluator/modifiers/success-count.ts +59 -0
  73. package/src/index.ts +90 -0
  74. package/src/lexer/lexer.ts +374 -0
  75. package/src/lexer/tokens.ts +187 -0
  76. package/src/parser/ast.ts +628 -0
  77. package/src/parser/parser.ts +1239 -0
  78. package/src/rng/index.ts +8 -0
  79. package/src/rng/mock.ts +65 -0
  80. package/src/rng/seeded.ts +157 -0
  81. package/src/rng/types.ts +25 -0
  82. package/src/roll.ts +74 -0
  83. package/src/testing.ts +16 -0
  84. package/src/types.ts +237 -0
  85. package/bin/help.txt +0 -19
  86. package/bin/roll-parser.js +0 -23
  87. package/dist/roll-parser.js +0 -697
  88. package/dist/roll-parser.min.js +0 -1
  89. package/index.js +0 -24
  90. package/src/complex/parse.js +0 -37
  91. package/src/complex/parseAndRoll.js +0 -21
  92. package/src/complex/parseAndRollClassic.js +0 -20
  93. package/src/complex/parseAndRollSimple.js +0 -19
  94. package/src/complex/parseAndRollWod.js +0 -20
  95. package/src/complex/parseClassicRoll.js +0 -22
  96. package/src/complex/parseSimpleRoll.js +0 -21
  97. package/src/complex/parseWodRoll.js +0 -21
  98. package/src/converter.js +0 -45
  99. package/src/grammar.js +0 -14
  100. package/src/mapper.js +0 -50
  101. package/src/normalizer.js +0 -51
  102. package/src/object/Result.js +0 -23
  103. package/src/object/Roll.js +0 -26
  104. package/src/object/Type.js +0 -5
  105. package/src/object/WodRoll.js +0 -29
  106. package/src/parser.js +0 -43
  107. package/src/random.js +0 -22
  108. package/src/roller.js +0 -106
  109. package/src/stringifier.js +0 -33
@@ -0,0 +1,628 @@
1
+ /**
2
+ * AST node type definitions for the dice notation parser.
3
+ *
4
+ * @module parser/ast
5
+ */
6
+
7
+ import type { ComparePoint } from '../types.js';
8
+
9
+ /**
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
+ export type NodeSpan = {
18
+ start?: number;
19
+ end?: number;
20
+ };
21
+
22
+ /**
23
+ * Numeric literal node.
24
+ */
25
+ export type LiteralNode = NodeSpan & {
26
+ type: 'Literal';
27
+ value: number;
28
+ };
29
+
30
+ /**
31
+ * Dice roll node.
32
+ * Count and sides can be expressions to support computed dice like (1+1)d(3*2).
33
+ */
34
+ export type DiceNode = NodeSpan & {
35
+ type: 'Dice';
36
+ count: ASTNode;
37
+ sides: ASTNode;
38
+ };
39
+
40
+ /**
41
+ * Fate/Fudge dice node (`dF`).
42
+ * Each die produces a result in {-1, 0, +1}. No configurable sides.
43
+ */
44
+ export type FateDiceNode = NodeSpan & {
45
+ type: 'FateDice';
46
+ count: ASTNode;
47
+ };
48
+
49
+ /**
50
+ * Binary operation node.
51
+ */
52
+ export type BinaryOpNode = NodeSpan & {
53
+ type: 'BinaryOp';
54
+ operator: '+' | '-' | '*' | '/' | '%' | '**';
55
+ left: ASTNode;
56
+ right: ASTNode;
57
+ };
58
+
59
+ /**
60
+ * Unary operation node.
61
+ */
62
+ export type UnaryOpNode = NodeSpan & {
63
+ type: 'UnaryOp';
64
+ operator: '-';
65
+ operand: ASTNode;
66
+ };
67
+
68
+ /**
69
+ * Keep/drop modifier node.
70
+ * 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;
78
+ };
79
+
80
+ /**
81
+ * Exploding dice node (`!`, `!!`, `!p`, `!>Y`).
82
+ * Wraps a dice expression with a standard, compounding, or penetrating
83
+ * explosion. An absent `threshold` means "explode on the die's maximum face".
84
+ */
85
+ export type ExplodeNode = NodeSpan & {
86
+ type: 'Explode';
87
+ variant: 'standard' | 'compound' | 'penetrating';
88
+ threshold?: ComparePoint;
89
+ target: ASTNode;
90
+ };
91
+
92
+ /**
93
+ * Reroll node (`r<COND>`, `ro<COND>`).
94
+ * Re-rolls dice that match a comparison condition. `once: true` for `ro`
95
+ * keeps the second result regardless of match; `once: false` for `r`
96
+ * re-rolls recursively until the condition no longer matches.
97
+ */
98
+ export type RerollNode = NodeSpan & {
99
+ type: 'Reroll';
100
+ once: boolean;
101
+ condition: ComparePoint;
102
+ target: ASTNode;
103
+ };
104
+
105
+ /**
106
+ * Success counting node (`>=T`, `>T`, `<T`, `<=T`, `=T`, with optional `f=F`).
107
+ *
108
+ * Transforms a dice pool into a success count: each die meeting `threshold`
109
+ * adds +1, each die meeting `failThreshold` subtracts 1. Terminal — a
110
+ * `SuccessCountNode` may not be wrapped by any postfix modifier, binary
111
+ * operator, unary operator, versus operand, or function argument. The
112
+ * `failThreshold` accepts any `CompareOp`; bare `fN` defaults to `operator: '='`.
113
+ */
114
+ export type SuccessCountNode = NodeSpan & {
115
+ type: 'SuccessCount';
116
+ target: ASTNode;
117
+ threshold: ComparePoint;
118
+ failThreshold?: ComparePoint;
119
+ };
120
+
121
+ /**
122
+ * Versus node (`<roll> vs <dc>`) — PF2e Degrees of Success.
123
+ *
124
+ * Both sides are full expressions. The `roll` side is evaluated and compared
125
+ * against the `dc` side total, producing a `DegreeOfSuccess` with natural
126
+ * d20 upgrade/downgrade applied when exactly one kept d20 appears on the
127
+ * roll side. Lowest-precedence operator — chaining (`a vs b vs c`) is
128
+ * rejected at parse time; nesting via parens (`a vs (b vs c)`) is rejected
129
+ * at evaluation time.
130
+ */
131
+ export type VersusNode = NodeSpan & {
132
+ type: 'Versus';
133
+ roll: ASTNode;
134
+ dc: ASTNode;
135
+ };
136
+
137
+ /**
138
+ * Math function call node (`floor(expr)`, `max(a, b, ...)`, etc.).
139
+ *
140
+ * Supports the fixed-arity functions `floor`, `ceil`, `round`, `abs`, and the
141
+ * variadic functions `max`, `min` (minimum 2 args). Arity is validated at
142
+ * parse time against a static table; by the time the evaluator sees a
143
+ * `FunctionCallNode`, `args.length` is guaranteed to match the function.
144
+ */
145
+ export type FunctionCallNode = NodeSpan & {
146
+ type: 'FunctionCall';
147
+ name: string;
148
+ args: ASTNode[];
149
+ };
150
+
151
+ /**
152
+ * Parenthesized group node (`(<expr>)`).
153
+ *
154
+ * Preserves explicit grouping typed by the user so that
155
+ * `RollResult.expression` and `RollResult.rendered` round-trip through
156
+ * `parse` without losing precedence information. Semantically transparent:
157
+ * evaluation returns the inner expression's value unchanged.
158
+ */
159
+ export type GroupedNode = NodeSpan & {
160
+ type: 'Grouped';
161
+ expression: ASTNode;
162
+ };
163
+
164
+ /**
165
+ * Variable reference node (`@name` or `@{name with spaces}`).
166
+ *
167
+ * Resolves to a numeric value from the evaluator's `context` map at
168
+ * evaluation time. Names are case-sensitive (`@StrMod` ≠ `@strmod`) — the
169
+ * lexer preserves case in the `AT` token's `value`, distinct from other
170
+ * identifier tokens which lowercase. Leaf node — no LED, never wraps a
171
+ * sub-expression.
172
+ */
173
+ export type VariableNode = NodeSpan & {
174
+ type: 'Variable';
175
+ name: string;
176
+ };
177
+
178
+ /**
179
+ * Grouped-roll node (`{expr}`, `{expr1, expr2, ...}`).
180
+ *
181
+ * Distinct from `GroupedNode` (parenthesized wrapper) — a `GroupNode`
182
+ * collects one or more sub-expressions whose evaluation semantics change
183
+ * with the sub-roll count. `expressions.length === 1` is a passthrough
184
+ * (flat-pool when wrapped by keep/drop); `expressions.length >= 2` treats
185
+ * each sub-roll's subtotal as a compound die for keep/drop selection.
186
+ */
187
+ export type GroupNode = NodeSpan & {
188
+ type: 'Group';
189
+ expressions: ASTNode[];
190
+ };
191
+
192
+ /**
193
+ * Sort modifier node (`s`, `sa`, `sd`).
194
+ *
195
+ * Cosmetically reorders the dice produced by `target` in ascending or
196
+ * descending order. Purely visual — does not affect `total`,
197
+ * `successes`/`failures`, or any die-level flag (`kept`/`dropped`/
198
+ * `critical`/`fumble`). Dropped dice retain their `dropped` flag and
199
+ * appear in sorted position alongside kept dice.
200
+ */
201
+ export type SortNode = NodeSpan & {
202
+ type: 'Sort';
203
+ order: 'ascending' | 'descending';
204
+ target: ASTNode;
205
+ };
206
+
207
+ /**
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`.
211
+ */
212
+ export type CritThreshold = ComparePoint | 'default';
213
+
214
+ /**
215
+ * Critical threshold modifier node (`cs`, `cf`).
216
+ *
217
+ * Overrides the default `critical`/`fumble` flag logic for the dice
218
+ * produced by `target`. Bare `cs`/`cf` uses the `'default'` sentinel
219
+ * (max face / 1). Custom thresholds accept any ComparePoint. Chaining
220
+ * collapses into a single node — `1d20cs=20cs=1cf>18` has two success
221
+ * and one fail threshold. Display-only: does not change `total`,
222
+ * explosion triggers, or success counting. `cs` and `cf` are independent
223
+ * overrides — a side with no explicit thresholds keeps the default rule
224
+ * (the evaluator substitutes the `'default'` sentinel at apply time).
225
+ */
226
+ export type CritThresholdNode = NodeSpan & {
227
+ type: 'CritThreshold';
228
+ successThresholds: CritThreshold[];
229
+ failThresholds: CritThreshold[];
230
+ target: ASTNode;
231
+ };
232
+
233
+ /**
234
+ * Union type of all AST nodes.
235
+ */
236
+ export type ASTNode =
237
+ | LiteralNode
238
+ | DiceNode
239
+ | FateDiceNode
240
+ | BinaryOpNode
241
+ | UnaryOpNode
242
+ | ModifierNode
243
+ | ExplodeNode
244
+ | RerollNode
245
+ | SuccessCountNode
246
+ | VersusNode
247
+ | FunctionCallNode
248
+ | GroupedNode
249
+ | VariableNode
250
+ | GroupNode
251
+ | SortNode
252
+ | CritThresholdNode;
253
+
254
+ /**
255
+ * Type guard for LiteralNode.
256
+ */
257
+ export function isLiteral(node: ASTNode): node is LiteralNode {
258
+ return node.type === 'Literal';
259
+ }
260
+
261
+ /**
262
+ * Type guard for DiceNode.
263
+ */
264
+ export function isDice(node: ASTNode): node is DiceNode {
265
+ return node.type === 'Dice';
266
+ }
267
+
268
+ /**
269
+ * Type guard for FateDiceNode.
270
+ */
271
+ export function isFateDice(node: ASTNode): node is FateDiceNode {
272
+ return node.type === 'FateDice';
273
+ }
274
+
275
+ /**
276
+ * Type guard for BinaryOpNode.
277
+ */
278
+ export function isBinaryOp(node: ASTNode): node is BinaryOpNode {
279
+ return node.type === 'BinaryOp';
280
+ }
281
+
282
+ /**
283
+ * Type guard for UnaryOpNode.
284
+ */
285
+ export function isUnaryOp(node: ASTNode): node is UnaryOpNode {
286
+ return node.type === 'UnaryOp';
287
+ }
288
+
289
+ /**
290
+ * Type guard for ModifierNode.
291
+ */
292
+ export function isModifier(node: ASTNode): node is ModifierNode {
293
+ return node.type === 'Modifier';
294
+ }
295
+
296
+ /**
297
+ * Type guard for ExplodeNode.
298
+ */
299
+ export function isExplode(node: ASTNode): node is ExplodeNode {
300
+ return node.type === 'Explode';
301
+ }
302
+
303
+ /**
304
+ * Type guard for RerollNode.
305
+ */
306
+ export function isReroll(node: ASTNode): node is RerollNode {
307
+ return node.type === 'Reroll';
308
+ }
309
+
310
+ /**
311
+ * Type guard for SuccessCountNode.
312
+ */
313
+ export function isSuccessCount(node: ASTNode): node is SuccessCountNode {
314
+ return node.type === 'SuccessCount';
315
+ }
316
+
317
+ /**
318
+ * Type guard for VersusNode.
319
+ */
320
+ export function isVersus(node: ASTNode): node is VersusNode {
321
+ return node.type === 'Versus';
322
+ }
323
+
324
+ /**
325
+ * Type guard for FunctionCallNode.
326
+ */
327
+ export function isFunctionCall(node: ASTNode): node is FunctionCallNode {
328
+ return node.type === 'FunctionCall';
329
+ }
330
+
331
+ /**
332
+ * Type guard for GroupedNode.
333
+ */
334
+ export function isGrouped(node: ASTNode): node is GroupedNode {
335
+ return node.type === 'Grouped';
336
+ }
337
+
338
+ /**
339
+ * Type guard for VariableNode.
340
+ */
341
+ export function isVariable(node: ASTNode): node is VariableNode {
342
+ return node.type === 'Variable';
343
+ }
344
+
345
+ /**
346
+ * Type guard for GroupNode.
347
+ */
348
+ export function isGroup(node: ASTNode): node is GroupNode {
349
+ return node.type === 'Group';
350
+ }
351
+
352
+ /**
353
+ * Type guard for SortNode.
354
+ */
355
+ export function isSort(node: ASTNode): node is SortNode {
356
+ return node.type === 'Sort';
357
+ }
358
+
359
+ /**
360
+ * Type guard for CritThresholdNode.
361
+ */
362
+ export function isCritThreshold(node: ASTNode): node is CritThresholdNode {
363
+ return node.type === 'CritThreshold';
364
+ }
365
+
366
+ /**
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
+ }
628
+ }