roll-parser 3.1.0 → 3.2.1

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 (60) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/MIGRATION.md +225 -8
  3. package/README.md +188 -60
  4. package/dist/evaluator/env.d.ts +27 -1
  5. package/dist/evaluator/env.d.ts.map +1 -1
  6. package/dist/evaluator/env.js.map +1 -1
  7. package/dist/evaluator/evaluator.d.ts.map +1 -1
  8. package/dist/evaluator/evaluator.js +89 -43
  9. package/dist/evaluator/evaluator.js.map +1 -1
  10. package/dist/evaluator/modifiers/crit-threshold.d.ts +13 -28
  11. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  12. package/dist/evaluator/modifiers/crit-threshold.js +6 -12
  13. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -1
  14. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  15. package/dist/evaluator/modifiers/explode.js +7 -1
  16. package/dist/evaluator/modifiers/explode.js.map +1 -1
  17. package/dist/evaluator/modifiers/flags.d.ts +9 -2
  18. package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
  19. package/dist/evaluator/modifiers/flags.js +4 -11
  20. package/dist/evaluator/modifiers/flags.js.map +1 -1
  21. package/dist/evaluator/modifiers/keep-drop.d.ts +1 -3
  22. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  23. package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
  24. package/dist/evaluator/modifiers/success-count.d.ts +6 -5
  25. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  26. package/dist/evaluator/modifiers/success-count.js +9 -6
  27. package/dist/evaluator/modifiers/success-count.js.map +1 -1
  28. package/dist/notation.d.ts +13 -0
  29. package/dist/notation.d.ts.map +1 -0
  30. package/dist/notation.js +8 -0
  31. package/dist/notation.js.map +1 -0
  32. package/dist/parser/guards.d.ts +38 -0
  33. package/dist/parser/guards.d.ts.map +1 -1
  34. package/dist/parser/guards.js +51 -0
  35. package/dist/parser/guards.js.map +1 -1
  36. package/dist/parser/parser.d.ts.map +1 -1
  37. package/dist/parser/parser.js +26 -2
  38. package/dist/parser/parser.js.map +1 -1
  39. package/dist/render.d.ts.map +1 -1
  40. package/dist/render.js +18 -13
  41. package/dist/render.js.map +1 -1
  42. package/dist/types.d.ts +15 -3
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +5 -5
  47. package/src/evaluator/env.ts +27 -1
  48. package/src/evaluator/evaluator.ts +179 -56
  49. package/src/evaluator/modifiers/crit-threshold.ts +29 -48
  50. package/src/evaluator/modifiers/explode.ts +13 -4
  51. package/src/evaluator/modifiers/flags.ts +13 -13
  52. package/src/evaluator/modifiers/keep-drop.ts +1 -3
  53. package/src/evaluator/modifiers/sort.ts +1 -1
  54. package/src/evaluator/modifiers/success-count.ts +19 -12
  55. package/src/notation.ts +24 -0
  56. package/src/parser/guards.ts +92 -1
  57. package/src/parser/parser.ts +56 -1
  58. package/src/render.ts +24 -22
  59. package/src/types.ts +15 -3
  60. package/src/version.ts +1 -1
package/README.md CHANGED
@@ -68,8 +68,9 @@ roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) }).total; // 14, every run
68
68
  - **Safe on untrusted input.** Dice count, explosion depth, reroll depth, and
69
69
  parse depth are all bounded, and every failure is a typed error with a stable
70
70
  code and a source span.
71
- - **Small and fast.** ≈11.9 kB brotli for the whole library, ≈5.5 kB for just
72
- `parse`, ≈213 B for the testing entry point. Zero runtime dependencies, zero
71
+ - **Small and fast.** ≈12.8 kB brotli for the whole library, ≈5.7 kB for just
72
+ `parse`, ≈1.4 kB for `roll-parser/render`, ≈213 B for the testing entry
73
+ point. Zero runtime dependencies, zero
73
74
  `node:` imports. A `1d20` round trip takes about 0.5 µs.
74
75
  - **Tested.** 1,500+ tests behind CI-enforced coverage floors — 100% of
75
76
  functions, 98% of lines — including every code example in this README, which
@@ -84,22 +85,28 @@ roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) }).total; // 14, every run
84
85
 
85
86
  ## Contents
86
87
 
87
- - [Install](#install)
88
- - [Notation reference](#notation-reference)
88
+ **Start here**
89
+
90
+ - [Install](#install) — [CDN](#cdn--browser-without-a-bundler) · [Upgrading](#upgrading)
91
+ - [Notation reference](#notation-reference) — [Dice](#dice) · [Arithmetic](#arithmetic-and-functions) · [Modifiers](#modifiers) · [Pools and checks](#pools-and-checks)
89
92
  - [Recipes by game system](#recipes-by-game-system)
90
- - [Working with results](#working-with-results)
91
- - [Randomness](#randomness)
92
- - [Options](#options)
93
- - [Error handling](#error-handling)
94
- - [Using the parser directly](#using-the-parser-directly)
95
- - [TypeScript](#typescript)
96
- - [CLI](#cli)
97
- - [Performance](#performance)
98
- - [Known limitations](#known-limitations)
99
- - [Versioning](#versioning)
100
- - [Upgrading](#upgrading)
101
- - [Contributing](#contributing)
102
- - [License](#license)
93
+
94
+ **Using results**
95
+
96
+ - [Working with results](#working-with-results) — [RollResult](#rollresult) · [The parts tree](#the-parts-tree)
97
+ - [Rendering](#rendering) [Custom markers](#custom-markers) · [Reading crit and fumble](#reading-crit-and-fumble)
98
+
99
+ **Configuring a roll**
100
+
101
+ - [Options](#options) — [Safety limits](#safety-limits)
102
+ - [Randomness](#randomness) — [Seeded rolls](#seeded-rolls) · [Replay](#replay-and-saveresume) · [Custom RNGs](#custom-rngs) · [Testing](#testing)
103
+
104
+ **Reference**
105
+
106
+ - [Error handling](#error-handling) — [Notation errors](#notation-errors) · [Error classes](#error-classes) · [Error codes](#error-codes)
107
+ - [Using the parser directly](#using-the-parser-directly) · [TypeScript](#typescript)
108
+ - [CLI](#cli) · [Performance](#performance) · [Known limitations](#known-limitations)
109
+ - [Versioning](#versioning) · [Contributing](#contributing) · [License](#license)
103
110
 
104
111
  ## Install
105
112
 
@@ -145,9 +152,9 @@ as one request:
145
152
 
146
153
  ### Upgrading
147
154
 
148
- [MIGRATION.md](MIGRATION.md) covers every upgrade path, newest first 3.0.0 to
149
- 3.1.0, v2 to v3, and the v3 pre-releases. v3 is a complete rewrite and the 2.x
150
- API is not compatible; to stay on the old line, pin `roll-parser@2.3.2`.
155
+ [MIGRATION.md](MIGRATION.md) covers every upgrade path, newest first. v3 is a
156
+ complete rewrite and the 2.x API is not compatible; to stay on the old line,
157
+ pin `roll-parser@2.3.2`.
151
158
 
152
159
  ## Notation reference
153
160
 
@@ -157,8 +164,9 @@ wherever the two overlap: `kh`/`kl`/`dh`/`dl`, `!`/`!!`/`!p`, `r`/`ro`, success
157
164
  and failure counting, `cs`/`cf`, grouped rolls, and `floor`/`ceil`/`round`/`abs`
158
165
  all use Roll20's spelling and semantics. It is not a strict superset — sorting
159
166
  (`s`), PF2e `vs` checks, `max`/`min`, and computed dice counts and sides are
160
- additions, and two Roll20 forms are deliberately rejected (`4d6d1`, arithmetic
161
- after a success count) see [Known limitations](#known-limitations).
167
+ additions, and three Roll20 forms are deliberately rejected (`4d6d1`, arithmetic
168
+ after a success count, and a counted single-sub-roll group folding its own math
169
+ into each die, `{3d20+5}>=21`) — see [Known limitations](#known-limitations).
162
170
 
163
171
  Notation is case-insensitive and whitespace-tolerant — `2 D 20 KH 1` and
164
172
  `2d20kh1` are the same expression, newlines included. The one exception is
@@ -220,15 +228,17 @@ Chained keep/drop modifiers do **not** nest. `4d6kh3dl1` flattens into two
220
228
  specs applied independently to the same pool, with the dropped sets unioned —
221
229
  the Roll20 rule.
222
230
 
223
- `cs` / `cf` covers the whole pool, including dice that `!`, `!p`, `r`, and `ro`
224
- add after it — `1d6cs<2!` and `1d6!cs<2` flag the same dice. Two cases stay
225
- order-sensitive. Modifiers that rewrite a die's value rather than add one (`!!`,
226
- `minN`, `maxN`) are judged on whatever that value is when the threshold runs:
227
- with `[6, 6, 3]`, `1d6cs>10!!` reports no critical while `1d6!!cs>10` reports
228
- one, judging the compounded 15. And because `!p`'s added dice store one less
229
- than they rolled, the side you *don't* override moves with position —
230
- `1d6cf>5!p` keeps the critical that plain `1d6!p` gives the added 6, where
231
- `1d6!pcf>5` does not.
231
+ `cs` / `cf` covers the whole pool, dice that `!`, `!p`, `r`, and `ro` add
232
+ included, and position does not matter — `1d6cs<2!` and `1d6!cs<2` flag the
233
+ same dice. The side you leave alone keeps the default rule, reading each die's
234
+ natural face, so writing `cf>5` does not disturb `critical`: `1d6cf>5!p` and
235
+ `1d6!pcf>5` both agree with plain `1d6!p` on the appended die.
236
+
237
+ One case stays order-sensitive: an explicit threshold reads a die's current
238
+ value, so modifiers that rewrite it rather than add a die (`!!`, `minN`,
239
+ `maxN`) are judged on whatever that value is when the threshold runs. With
240
+ `[6, 6, 3]`, `1d6cs>10!!` reports no critical while `1d6!!cs>10` reports one,
241
+ judging the compounded 15.
232
242
 
233
243
  ### Pools and checks
234
244
 
@@ -238,13 +248,19 @@ than they rolled, the side you *don't* override moves with position —
238
248
  | `fT` / `f<cmp>T` | Subtract dice meeting the failure threshold — `10d10>=6f1`, `10d10>=6f<=2` |
239
249
  | `<roll> vs <dc>` | PF2e degree of success, with nat-20/nat-1 upgrade and downgrade |
240
250
  | `{a, b}khN` | Grouped roll: each sub-roll's subtotal competes as one compound die |
241
- | `{a+b}khN` | Single-sub-roll group: keep/drop selects across the flattened pool |
251
+ | `{a+b}khN` | Single-sub-roll group: keep/drop selects across the flattened pool — `+`-joined pools only, a literal or any math throws |
252
+ | `{a, b}<cmp>T` | Grouped roll: each sub-roll's subtotal is counted, not its dice — `{2d6, 2d6}>=10` |
253
+ | `{a+b}<cmp>T` | Single-sub-roll group: counts across the flattened pool — `+`-joined pools only, a literal or any math throws |
242
254
 
243
255
  > [!IMPORTANT]
244
256
  > Success counting is **terminal** — no modifier or arithmetic applies to it
245
257
  > directly, so `10d10>=6 + 2` is a parse error. Put the arithmetic inside the
246
258
  > threshold, `10d10>=(4+2)`, or wrap the count in group braces to operate on its
247
259
  > result: `{10d10>=6}+2`.
260
+ >
261
+ > Braces also let a second count re-score the same pool — `{4d6>=5}<=2f5`. The
262
+ > outermost count owns the result; see
263
+ > [RollResult](#rollresult) for what that means for the fields you read.
248
264
 
249
265
  > [!WARNING]
250
266
  > A bare `d` after a dice expression is rejected: `4d6d1` throws
@@ -293,7 +309,7 @@ pool.total; // 5 — successes minus failures
293
309
  | `notation` | `string` | Exactly what you passed in |
294
310
  | `expression` | `string` | Normalized form; meta-expressions appear as their resolved values |
295
311
  | `rendered` | `string` | Markdown breakdown with per-die markers |
296
- | `rolls` | `DieResult[]` | Every die in order: `sides`, `result`, `modifiers`, `critical`, `fumble` |
312
+ | `rolls` | `DieResult[]` | Every die in order, meta-expression dice included: `sides`, `result`, `modifiers`, `critical`, `fumble` |
297
313
  | `parts` | `RollPart` | Typed evaluation tree mirroring the AST 1:1 |
298
314
  | `successes` / `failures` | `number?` | Present only when success counting was used |
299
315
  | `degree` / `natural` | `DegreeOfSuccess?` / `number?` | Present only for a top-level `vs` |
@@ -324,6 +340,12 @@ JSON.stringify(roll('3d6', { rng: createMockRng([4, 2, 6]) }));
324
340
  > `rolls[]` and the `rolls[]` inside `parts` hold the *same* `DieResult`
325
341
  > objects — no deep clone, so annotating a die is visible through both views.
326
342
 
343
+ When counts nest through braces (`{4d6>=5}<=2f5`), the outermost one owns the
344
+ result: every die is judged against its thresholds alone, so `successes`,
345
+ `failures`, `rolls[].modifiers`, and `rendered` all agree with it, and dice it
346
+ does not count come out untagged. The DC side of a `vs` is the exception — it
347
+ sits outside every pool pass and keeps whatever tagged it.
348
+
327
349
  ### The parts tree
328
350
 
329
351
  `result.parts` is a 16-variant discriminated union mirroring the AST one-to-one.
@@ -379,7 +401,9 @@ dice land in `result.rolls` tagged `'meta'` so an audit log can still show them.
379
401
 
380
402
  `result.rendered` uses markdown markers, so a Discord bot or chat log can print
381
403
  it as-is: `~~n~~` for a die dropped by keep/drop or group selection, `**n**` for
382
- a success, `__n__` for a failure.
404
+ a success, `__n__` for a failure. The dialect is Discord's, not a universal one
405
+ — Telegram MarkdownV2 rejects `**bold**` and spells strike `~n~` — so expect a
406
+ transform, or emit your own dialect with [`renderBreakdown`](#custom-markers).
383
407
 
384
408
  ```typescript
385
409
  roll('4d6kh3', { seed: 'demo' }).rendered; // '4d6[1, 6, ~~1~~, 3] = 10'
@@ -424,6 +448,79 @@ exactly one of `dropped`, `success`, or `failure` — a dropped die is never
424
448
  also shown as a success. With no marks the output is byte-identical to
425
449
  `result.rendered`; a property test pins that.
426
450
 
451
+ #### Reading crit and fumble
452
+
453
+ `rendered` carries no marker for a crit or a fumble — those live on the dice.
454
+ `die.critical` and `die.fumble` already reflect any `cs` / `cf` override, so a
455
+ UI that builds its own elements reads the booleans rather than the string.
456
+
457
+ ```typescript
458
+ const result = roll('4d20cs>=19', { rng: createMockRng([20, 19, 7, 1]) });
459
+
460
+ const marked = result.rolls
461
+ .filter((die) => !die.modifiers.includes('meta'))
462
+ .map((die) => {
463
+ let text = String(die.result);
464
+ if (die.critical) text = `<b>${text}</b>`;
465
+ if (die.fumble) text = `<i>${text}</i>`;
466
+ return text;
467
+ });
468
+
469
+ marked.join(', '); // '<b>20</b>, <b>19</b>, 7, <i>1</i>'
470
+ ```
471
+
472
+ The 19 is critical only because `cs>=19` said so — the booleans are where the
473
+ override lands. Compose the two marks rather than branching on one: overlapping
474
+ thresholds set both, and `1d6cs>=3cf<=4` rolling a 3 does exactly that.
475
+
476
+ The [playground](https://roll-parser.edloidas.io/) does exactly this — its
477
+ [source](https://github.com/edloidas/roll-parser/blob/master/site/src/dice.ts)
478
+ turns the same booleans into CSS classes, a tooltip, and a legend.
479
+
480
+ That loop is right for a plain pool. Four decisions it cannot dodge:
481
+
482
+ - **`'meta'` dice are not yours to show.** They resolve counts, sides, and
483
+ modifier arguments, and they carry ordinary crit flags — in `4d6kh(1d2)` the
484
+ `1d2` rolling a 2 hits its max face and arrives `critical: true`. The filter
485
+ above is not optional.
486
+ - **`'dc'` dice are a judgment call.** They do render — `1d20 vs 2d10` shows
487
+ `1d20[5] vs 2d10[10, 10]` — but they sit outside the roll-side pool while
488
+ still taking the default crit rule, so that `[10, 10]` arrives as two crits
489
+ nobody rolled for. Drop them and a per-die zip has no dice left for a
490
+ two-number bracket; keep them and the DC side lights up on its own faces.
491
+ [`renderBreakdown`](#custom-markers) does not decide this for you — it marks
492
+ every flagged die — but its callbacks receive the die, so gate on the tag.
493
+ For a hand-rolled walk, `result.parts` keeps the two sides apart: the
494
+ `versus` part carries `roll` and `dc` as separate subtrees.
495
+ - **Never splice `rendered` by bracket position.** `{2d6, 2d8}kh1` strikes the
496
+ dropped sub-roll as a whole, `{2d6[6, 6], ~~2d8[1, 1]~~}`, yet its inner dice
497
+ each carry `'dropped'` too — marking every `'dropped'` die into that string
498
+ yields `~~2d8[~~1~~, ~~1~~]~~`. Rebuild with
499
+ [`renderBreakdown`](#custom-markers) instead of patching the baked string.
500
+ - **Density is your call.** The default rule flags the max face of any pool,
501
+ not just d20s — `3d6` rolling a 6 is `critical: true` — so marking it
502
+ unconditionally makes ordinary pools glitter. Gate on `die.sides === 20`, or
503
+ on whatever your table's rule is. `d1` and Fate dice are the exceptions:
504
+ having no exceptional face, they stay `false` unless `cs` / `cf` says so.
505
+
506
+ The claims above, in code:
507
+
508
+ ```typescript
509
+ import { renderBreakdown } from 'roll-parser/render';
510
+
511
+ roll('1d6cs>=3cf<=4', { rng: createMockRng([3]) }).rolls[0]?.fumble; // true
512
+ roll('4d6kh(1d2)', { rng: createMockRng([2, 3, 6, 2, 5]) }).rolls[0]?.critical; // true
513
+ roll('1d20 vs 2d10', { rng: createMockRng([5, 10, 10]) }).rolls[2]?.critical; // true
514
+ roll('3dF', { rng: createMockRng([1, 0, -1]) }).rolls[0]?.critical; // false
515
+ roll('{2d6, 2d8}kh1', { rng: createMockRng([6, 6, 1, 1]) }).rendered;
516
+ // '{2d6[6, 6], ~~2d8[1, 1]~~} = 12'
517
+
518
+ // Gating a mark on the `'dc'` tag keeps the DC side unmarked
519
+ renderBreakdown(roll('1d20 vs 2d10', { rng: createMockRng([5, 10, 10]) }), {
520
+ critical: (die, text) => (die.modifiers.includes('dc') ? text : `<b>${text}</b>`),
521
+ }); // '1d20[5] vs 2d10[10, 10] = Critical Failure'
522
+ ```
523
+
427
524
  ## Randomness
428
525
 
429
526
  Every die is drawn through the `RNG` interface — no roll path bypasses the RNG
@@ -967,16 +1064,12 @@ Values are **p50**, from
967
1064
  two significant digits: another machine shifts every row, and a busy one
968
1065
  inflates the heavy cases most.
969
1066
 
970
- The `10d10>=6f1` `roll` figure and the `4d6sd` row come from a later
971
- five-pass measurement on the same machine, after the per-die `'dc'` tag checks
972
- that had entered the keep/drop, success-count, and sort paths were hoisted out
973
- of those loops. That session was not idle: `evaluate / 10d10>=6f1` and
974
- `evaluate / 10d10sd` each read bimodally across passes with roughly 10% between
975
- the two modes, so those two are quoted as "recovered to the pre-exclusion
976
- range" rather than to a point value. The rows this hoist does not touch
977
- (`1d20`, `2d6+3`, `4d6kh3`, `100d6`) re-measured within noise of the figures
978
- above and are left as first measured, since the earlier idle session is the
979
- better data for them.
1067
+ The `4d6sd` row and the `10d10>=6f1` `roll` figure come from a later five-pass
1068
+ measurement on the same machine, after the per-die `'dc'` tag checks were
1069
+ hoisted out of the keep/drop, success-count, and sort loops. That session was
1070
+ not idle and those two read bimodally with roughly 10% between the modes, so
1071
+ take them as a recovered range rather than point values. Every other row is as
1072
+ first measured.
980
1073
 
981
1074
  p50 rather than mean, because the mean here is effectively a GC-pause
982
1075
  histogram and swings ±40% between processes. Every bench body is JIT-primed
@@ -996,34 +1089,69 @@ any commit that regresses a case past 1.75x. Bundle size is gated in CI by
996
1089
 
997
1090
  ## Known limitations
998
1091
 
999
- - **Keep/drop does not echo into the render prefix.** `4d6kh3` renders as
1000
- `4d6[1, 6, ~~1~~, 3] = 10`, while every other modifier family does echo
1001
- (`8d6![…]`, `4d6sd[…]`, `10d10>=6f1[…]`). The dropped die is still marked;
1002
- only the `kh3` is missing, and `result.expression` has it.
1003
- - **`4d6d1` is a parse error, not "drop 1".** The bare `d` is the dice
1004
- operator, and reading `4d6d1` as "roll 4d6, then roll that many d1" is a
1005
- silent trap, so it throws `AMBIGUOUS_DICE_CHAIN`.
1006
- - **Threshold comparisons bind tight.** `1d6!>=5+2` parses as `(1d6!>=5)+2`;
1007
- parenthesize for a computed threshold, `1d6!>=(5+2)`. Success counts bind the
1008
- same way but are terminal, so `1d6>=5+2` errors outright — write
1009
- `1d6>=(5+2)`, or `{1d6>=5}+2` to add to the count itself.
1092
+ ### Notation the parser rejects
1093
+
1094
+ | Form | Throws | Write instead |
1095
+ |------|--------|---------------|
1096
+ | `{2d6+3}kh2`, `{2d6-1d4}kh3`, `{2d6*2}kh2`, `{2d6+0}kh2` | `INVALID_KEEP_DROP_TARGET` | `{2d6}kh2 + 3`, or one sub-roll per term: `{2d6+3, 1d8}kh1` |
1097
+ | `{3d20+5}>=21`, `{2d6-1d4}>=3`, `{2d6*2}>=4`, `{floor(2d6/2)}>=2` | `INVALID_SUCCESS_COUNT_TARGET` | fold the scalar into the threshold: `3d20>=16` |
1098
+ | `{3, 5, 7}>=4` | `INVALID_SUCCESS_COUNT_TARGET` | give the group a pool: `3d6>=4` |
1099
+ | `{{2d6, 2d8}}>=4`, `({2d6, 2d8})>=4`, `{2d6, 2d8}kh1>=4` | `INVALID_SUCCESS_COUNT_TARGET` | count the group directly: `{2d6, 2d8}>=4` |
1100
+ | `{4d6>=5}kh1` | `INVALID_SUCCESS_COUNT_TARGET` | the count is already terminal — select first, `{4d6kh1}>=5` |
1101
+ | `{2d6, 1d8}s` | `INVALID_SORT_TARGET` | flatten to one sub-roll: `{2d6+1d8}s` |
1102
+ | `4d6d1` | `AMBIGUOUS_DICE_CHAIN` | `4d6dl1` to drop a die, `(4d6)d1` for nested dice |
1103
+
1104
+ The first two rows share one cause: a single-sub-roll group compares dice one
1105
+ face at a time, so a literal, a subtracted or scaled pool, or a function wrapper
1106
+ never reaches the comparison. The check is structural rather than arithmetic, so
1107
+ an identity term (`{2d6+0}kh2`) is refused with the rest. Adding pools
1108
+ (`{4d6+2d8}kh3`, `{2d6+1d8}>=5`) is the form these braces exist for and always
1109
+ works.
1110
+
1111
+ A counted group needs at least one die somewhere in it — one real pool is
1112
+ enough, and the other sub-rolls may be literals: `{3, 1d6}>=4` scores both
1113
+ subtotals, the literal `3` included. A pool that is merely empty at run time
1114
+ (`0d6>=4`) still parses and totals 0. Subtotals exist only while the count holds
1115
+ the group directly, which is why the fourth row throws rather than falling back
1116
+ to loose faces; the DC side of a `vs` is exempt (`{1d20 vs {2d6, 2d8}}>=5`
1117
+ counts the d20), since no pool pass tallies a DC.
1118
+
1119
+ Sorting a multi-sub-roll group is refused rather than shipped wrong: spec-correct
1120
+ sorting there is hierarchical — dice within each sub-roll, then sub-rolls by
1121
+ total — and the evaluator only flat-sorts.
1122
+
1123
+ ### Rendering and `expression`
1124
+
1125
+ - **Keep/drop does not echo into the render prefix** — see
1126
+ [Rendering](#rendering). The dropped die is still marked; only the `kh3` is
1127
+ missing, and `result.expression` has it.
1010
1128
  - **`result.expression` substitutes meta-expressions with their resolved
1011
1129
  values,** so it does not round-trip through `parse` when they are present:
1012
1130
  `roll('(1d4)d6').expression` is `'4d6'` when the `1d4` rolled 4, and
1013
1131
  `roll('1d6!>(1d2+3)').expression` is `'1d6!>5'`.
1132
+ - **Adjacent bare modifiers keep a space.** `cs`, `cf`, `s`, and `sd` without a
1133
+ threshold or count end an identifier the lexer would extend into whatever
1134
+ follows, so `expression` and `rendered` separate them the way the input had
1135
+ to: `roll('1d20cs cf').expression` is `'1d20cs cf'`, and `4d6 s kh2` comes
1136
+ back as `'4d6s kh2'`. Everything else stays flush — `4d6cs>4cf<2`.
1014
1137
  - **Sort flattens additive pools.** `(2d6+1d8)s` renders as one combined sorted
1015
1138
  list, `(2d6 + 1d8)s[2, 3, 6] = 11`, rather than `2d6[2, 6] + 1d8[3]`. Totals
1016
1139
  are unaffected; only the breakdown loses pool boundaries.
1017
1140
  - **Outer parentheses drop when crit thresholds collapse.** `(1d20cs>19)cs=1`
1018
1141
  reports `expression: '1d20cs>19cs=1'` because chained `cs`/`cf` fold into one
1019
1142
  node. It re-parses to the same AST; only the text differs.
1020
- - **Sorting a multi-sub-roll group is rejected.** `{2d6, 1d8}s` throws
1021
- `INVALID_SORT_TARGET`. Spec-correct sorting there is hierarchical — dice
1022
- within each sub-roll, then sub-rolls by total — and the evaluator only
1023
- flat-sorts, so the syntax is refused rather than shipped wrong. Single
1024
- sub-roll groups (`{2d6+1d8}s`) still work as the flat-pool escape hatch.
1143
+
1144
+ ### Arithmetic and precedence
1145
+
1146
+ - **Threshold comparisons bind tight.** `1d6!>=5+2` parses as `(1d6!>=5)+2`;
1147
+ parenthesize for a computed threshold, `1d6!>=(5+2)`. Success counts bind the
1148
+ same way but are terminal, so `1d6>=5+2` errors outright — write
1149
+ `1d6>=(5+2)`, or `{1d6>=5}+2` to add to the count itself.
1025
1150
  - **Division does not floor.** `7/2` totals `3.5`, not `3` — arithmetic is plain
1026
1151
  IEEE-754 throughout. Wrap it when you need an integer: `floor(7/2)` totals `3`.
1152
+ Operands that must be whole numbers — dice count, dice sides, keep/drop count —
1153
+ reject a fraction instead of rounding it for you: `5d(20/3)` throws
1154
+ `INVALID_DICE_SIDES`, `5d(round(20/3))` rolls `5d7`.
1027
1155
  - **The power operator has no overflow guard.** `2**999` totals `5.357…e+300`.
1028
1156
  Only a non-finite result throws `NON_FINITE_RESULT`, so finite-but-enormous
1029
1157
  totals pass through unflagged.
@@ -33,8 +33,20 @@ export type EvalEnv = {
33
33
  * Set to `true` by `evalSuccessCount`. Propagates through the shared env
34
34
  * so `evaluate()` can include `successes`/`failures` fields even when no
35
35
  * die was tagged (impossible threshold).
36
+ *
37
+ * ! Set *after* the target is evaluated, not before. `evalSuccessCount` reads
38
+ * ! the pre-set value to learn whether an inner count already tagged its pool
39
+ * ! — moving the assignment back to the top makes it read its own write.
36
40
  */
37
41
  hasSuccessCount: boolean;
42
+ /**
43
+ * What a multi-sub-roll group count (`{2d6, 2d6}>=10`) scored. Its units are
44
+ * subtotals, which exist only as throwaway synthetic dice, so these verdicts
45
+ * reach the top-level tally here rather than as tags on a die. Every path
46
+ * that releases `TALLY_FLAGS` releases these too.
47
+ */
48
+ subtotalSuccesses: number;
49
+ subtotalFailures: number;
38
50
  /**
39
51
  * `true` while the evaluator is inside a `VersusNode`'s roll or DC
40
52
  * sub-evaluation. `evalVersus` rejects nesting via this flag — catches
@@ -46,7 +58,7 @@ export type EvalEnv = {
46
58
  * `true` once a `vs` has tagged its DC dice `'dc'`. Lets every pool
47
59
  * operation skip its per-die `isVersusDc` check outright — the tag exists in
48
60
  * a small minority of notation, and the call inside the loop cost 11-38% on
49
- * keep/drop, success, and sort notation that can never carry one (#281).
61
+ * keep/drop, success, and sort notation that can never carry one.
50
62
  *
51
63
  * ! Monotonic: set by `evalVersus`, never cleared. `insideVersus` is not a
52
64
  * ! model for it — that flag resets in a `finally`, and it is false exactly
@@ -63,6 +75,20 @@ export type EvalEnv = {
63
75
  * so the shape stays stable for notation that has none.
64
76
  */
65
77
  critRules: WeakMap<DieResult, CritRule> | undefined;
78
+ /**
79
+ * Dice minted as explosion continuations by `!` / `!p`, keyed on the
80
+ * `DieResult` object itself. Read by `extractNatural`, which counts them out
81
+ * of the versus primaries — the `'exploded'` tag cannot say it alone, since
82
+ * a compound explode stamps it on the original die it accumulated into.
83
+ *
84
+ * Out of band for the same reason as {@link EvalEnv.critRules}: `DieResult`
85
+ * is public and serialized.
86
+ *
87
+ * ! Only populated while {@link EvalEnv.insideVersus} — nothing outside a
88
+ * ! `vs` reads it, and the `add` per appended die is not free. Any new
89
+ * ! reader must be on a versus path too.
90
+ */
91
+ explosionDice: WeakSet<DieResult> | undefined;
66
92
  /**
67
93
  * User-supplied variable map for `@name` / `@{name}` references. Always
68
94
  * defined — `evaluate()` defaults to an empty object so lookups can be
@@ -1 +1 @@
1
- {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../src/evaluator/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEpE;;;;GAIG;AACH,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,OAAO,EAAE,SAAS,qBAAqB,EAAE,CAAC;IACnD,QAAQ,CAAC,IAAI,EAAE,SAAS,qBAAqB,EAAE,CAAC;CACjD,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,eAAe,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,eAAe,EAAE,OAAO,CAAC;IACzB;;;;;OAKG;IACH,YAAY,EAAE,OAAO,CAAC;IACtB;;;;;;;;;OASG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,QAAQ,CAAC,GAAG,SAAS,CAAC;IACpD;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD;;;OAGG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,GAAG,MAAM,CAAC;CAC9C,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,IAAI,CASvF;AAED,mDAAmD;AACnD,wBAAgB,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,IAAI,CAEvE"}
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../src/evaluator/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEpE;;;;GAIG;AACH,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,OAAO,EAAE,SAAS,qBAAqB,EAAE,CAAC;IACnD,QAAQ,CAAC,IAAI,EAAE,SAAS,qBAAqB,EAAE,CAAC;CACjD,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,eAAe,EAAE,MAAM,CAAC;IACxB;;;;;;;;OAQG;IACH,eAAe,EAAE,OAAO,CAAC;IACzB;;;;;OAKG;IACH,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,YAAY,EAAE,OAAO,CAAC;IACtB;;;;;;;;;OASG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,QAAQ,CAAC,GAAG,SAAS,CAAC;IACpD;;;;;;;;;;;;OAYG;IACH,aAAa,EAAE,OAAO,CAAC,SAAS,CAAC,GAAG,SAAS,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD;;;OAGG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,GAAG,MAAM,CAAC;CAC9C,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,IAAI,CASvF;AAED,mDAAmD;AACnD,wBAAgB,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,IAAI,CAEvE"}
@@ -1 +1 @@
1
- {"version":3,"file":"env.js","sourceRoot":"","sources":["../../src/evaluator/env.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAgF9C,MAAM,UAAU,UAAU,CAAC,GAAY,EAAE,KAAa,EAAE,QAAyB;IAC/E,IAAI,GAAG,CAAC,eAAe,GAAG,KAAK,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC;QAC9C,MAAM,IAAI,cAAc,CACtB,oBAAoB,GAAG,CAAC,eAAe,GAAG,KAAK,qBAAqB,GAAG,CAAC,OAAO,EAAE,EACjF,qBAAqB,EACrB,QAAQ,CACT,CAAC;IACJ,CAAC;IACD,GAAG,CAAC,eAAe,IAAI,KAAK,CAAC;AAC/B,CAAC;AAGD,MAAM,UAAU,SAAS,CAAC,GAAY,EAAE,QAAyB;IAC/D,UAAU,CAAC,GAAG,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC;AAC/B,CAAC"}
1
+ {"version":3,"file":"env.js","sourceRoot":"","sources":["../../src/evaluator/env.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AA0G9C,MAAM,UAAU,UAAU,CAAC,GAAY,EAAE,KAAa,EAAE,QAAyB;IAC/E,IAAI,GAAG,CAAC,eAAe,GAAG,KAAK,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC;QAC9C,MAAM,IAAI,cAAc,CACtB,oBAAoB,GAAG,CAAC,eAAe,GAAG,KAAK,qBAAqB,GAAG,CAAC,OAAO,EAAE,EACjF,qBAAqB,EACrB,QAAQ,CACT,CAAC;IACJ,CAAC;IACD,GAAG,CAAC,eAAe,IAAI,KAAK,CAAC;AAC/B,CAAC;AAGD,MAAM,UAAU,SAAS,CAAC,GAAY,EAAE,QAAyB;IAC/D,UAAU,CAAC,GAAG,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC;AAC/B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluator/evaluator.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAiB,cAAc,EAAuC,MAAM,cAAc,CAAC;AAClG,OAAO,KAAK,EACV,OAAO,EAmBR,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,KAAK,EAIV,SAAS,EACT,eAAe,EAKf,UAAU,EACX,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAK9C,OAAO,EAKL,8BAA8B,EAC/B,MAAM,wBAAwB,CAAC;AAShC,OAAO,EAGL,6BAA6B,EAC9B,MAAM,uBAAuB,CAAC;AAM/B,OAAO,EAAE,cAAc,EAAE,CAAC;AAM1B;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,QAAS,CAAC;AAmCvC,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,CAAC;AAMzE;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB;;;;;OAKG;IACH,cAAc,CAAC,EAAE;QACf,MAAM,EAAE,eAAe,CAAC;QACxB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;QAC5B,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;CACH,CAAC;AAogDF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,GAAE,eAAoB,GAAG,UAAU,CA8D1F"}
1
+ {"version":3,"file":"evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluator/evaluator.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAiB,cAAc,EAAuC,MAAM,cAAc,CAAC;AAElG,OAAO,KAAK,EACV,OAAO,EAmBR,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,KAAK,EAIV,SAAS,EACT,eAAe,EAKf,UAAU,EACX,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAK9C,OAAO,EAKL,8BAA8B,EAC/B,MAAM,wBAAwB,CAAC;AAWhC,OAAO,EAGL,6BAA6B,EAC9B,MAAM,uBAAuB,CAAC;AAM/B,OAAO,EAAE,cAAc,EAAE,CAAC;AAM1B;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,QAAS,CAAC;AAmCvC,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,CAAC;AAMzE;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB;;;;;OAKG;IACH,cAAc,CAAC,EAAE;QACf,MAAM,EAAE,eAAe,CAAC;QACxB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;QAC5B,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;CACH,CAAC;AAsnDF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,GAAE,eAAoB,GAAG,UAAU,CAiE1F"}