roll-parser 3.0.0 → 3.2.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 (88) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/MIGRATION.md +293 -4
  3. package/README.md +246 -55
  4. package/dist/cli/format.d.ts.map +1 -1
  5. package/dist/cli/format.js +8 -7
  6. package/dist/cli/format.js.map +1 -1
  7. package/dist/evaluator/die.d.ts +2 -1
  8. package/dist/evaluator/die.d.ts.map +1 -1
  9. package/dist/evaluator/die.js.map +1 -1
  10. package/dist/evaluator/env.d.ts +57 -0
  11. package/dist/evaluator/env.d.ts.map +1 -1
  12. package/dist/evaluator/env.js.map +1 -1
  13. package/dist/evaluator/evaluator.d.ts.map +1 -1
  14. package/dist/evaluator/evaluator.js +104 -56
  15. package/dist/evaluator/evaluator.js.map +1 -1
  16. package/dist/evaluator/modifiers/crit-threshold.d.ts +43 -5
  17. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  18. package/dist/evaluator/modifiers/crit-threshold.js +24 -11
  19. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -1
  20. package/dist/evaluator/modifiers/die-bound.d.ts +5 -2
  21. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -1
  22. package/dist/evaluator/modifiers/die-bound.js +6 -3
  23. package/dist/evaluator/modifiers/die-bound.js.map +1 -1
  24. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  25. package/dist/evaluator/modifiers/explode.js +13 -5
  26. package/dist/evaluator/modifiers/explode.js.map +1 -1
  27. package/dist/evaluator/modifiers/flags.d.ts +17 -0
  28. package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
  29. package/dist/evaluator/modifiers/flags.js +4 -4
  30. package/dist/evaluator/modifiers/flags.js.map +1 -1
  31. package/dist/evaluator/modifiers/keep-drop.d.ts +5 -5
  32. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  33. package/dist/evaluator/modifiers/keep-drop.js +7 -7
  34. package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
  35. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  36. package/dist/evaluator/modifiers/reroll.js +16 -10
  37. package/dist/evaluator/modifiers/reroll.js.map +1 -1
  38. package/dist/evaluator/modifiers/sort.d.ts +1 -1
  39. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/sort.js +3 -3
  41. package/dist/evaluator/modifiers/sort.js.map +1 -1
  42. package/dist/evaluator/modifiers/success-count.d.ts +6 -2
  43. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  44. package/dist/evaluator/modifiers/success-count.js +9 -5
  45. package/dist/evaluator/modifiers/success-count.js.map +1 -1
  46. package/dist/notation.d.ts +13 -0
  47. package/dist/notation.d.ts.map +1 -0
  48. package/dist/notation.js +8 -0
  49. package/dist/notation.js.map +1 -0
  50. package/dist/parser/ast.d.ts +12 -7
  51. package/dist/parser/ast.d.ts.map +1 -1
  52. package/dist/parser/ast.js.map +1 -1
  53. package/dist/parser/guards.d.ts +38 -0
  54. package/dist/parser/guards.d.ts.map +1 -1
  55. package/dist/parser/guards.js +51 -0
  56. package/dist/parser/guards.js.map +1 -1
  57. package/dist/parser/parser.d.ts.map +1 -1
  58. package/dist/parser/parser.js +26 -2
  59. package/dist/parser/parser.js.map +1 -1
  60. package/dist/render.d.ts +95 -0
  61. package/dist/render.d.ts.map +1 -0
  62. package/dist/render.js +232 -0
  63. package/dist/render.js.map +1 -0
  64. package/dist/types.d.ts +64 -12
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/package.json +14 -4
  70. package/src/cli/format.ts +15 -23
  71. package/src/evaluator/die.ts +2 -1
  72. package/src/evaluator/env.ts +58 -0
  73. package/src/evaluator/evaluator.ts +192 -70
  74. package/src/evaluator/modifiers/crit-threshold.ts +81 -15
  75. package/src/evaluator/modifiers/die-bound.ts +13 -4
  76. package/src/evaluator/modifiers/explode.ts +21 -6
  77. package/src/evaluator/modifiers/flags.ts +21 -4
  78. package/src/evaluator/modifiers/keep-drop.ts +10 -8
  79. package/src/evaluator/modifiers/reroll.ts +20 -11
  80. package/src/evaluator/modifiers/sort.ts +9 -3
  81. package/src/evaluator/modifiers/success-count.ts +27 -5
  82. package/src/notation.ts +24 -0
  83. package/src/parser/ast.ts +12 -7
  84. package/src/parser/guards.ts +92 -1
  85. package/src/parser/parser.ts +56 -1
  86. package/src/render.ts +394 -0
  87. package/src/types.ts +64 -12
  88. package/src/version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.2.0] - 2026-08-09
11
+
12
+ Every entry below is a correctness fix, but several change what a working
13
+ expression returns and five group forms that used to evaluate now throw.
14
+ [MIGRATION.md](MIGRATION.md#upgrading-from-310-to-320) walks each one with a
15
+ rewrite. Seeds are untouched: the same seed and notation roll the same faces
16
+ they did in 3.1.0.
17
+
18
+ ### Fixed
19
+
20
+ - Success counting a multi-sub-roll group now scores one success per sub-roll subtotal, the way keep/drop on the same node always did, so `{2d6, 2d6}>=10` finds the 11 it was missing. The dice stop being the units: `rendered` shows each sub-roll's own bracket, `rolls[].modifiers` comes back untagged, and `fT` thresholds weigh subtotals too. Targets whose arithmetic no per-die comparison ever reaches — `{3d20+5}>=21`, `{2d6*2}>=4`, `{2d6, 2d8}kh1>=4` — throw `INVALID_SUCCESS_COUNT_TARGET` instead of comparing faces against a number the sub-roll's math never produced ([#303](https://github.com/edloidas/roll-parser/issues/303))
21
+ - A second success count reaching the same pool through group braces (`{4d6>=5f1}<=2f6`) now re-scores it outright rather than appending to the inner pass's tags, so no die comes back both `'success'` and `'failure'` and `successes`/`failures` describe `total` again. The outermost count owns the tags; dropped dice come out untagged, while the DC side of a `vs` keeps tags from an inner count, since no pool pass may tally it. `total` never moved ([#297](https://github.com/edloidas/roll-parser/issues/297))
22
+ - Success counting a group that can never roll a die (`{3, 5, 7}>=4`, `{3, 5, 7}kh1>=4`) now throws `INVALID_SUCCESS_COUNT_TARGET` instead of returning the group's sum with `successes: 0`, which read the documented `successCount.total === successes - failures` invariant as `15 === 0`. A pool that is empty only at run time (`{3, 0d6}>=4`) totals 0 rather than the literal sum ([#304](https://github.com/edloidas/roll-parser/issues/304))
23
+ - Keep/drop on a single-sub-roll group whose total is not the sum of its kept faces now throws `INVALID_KEEP_DROP_TARGET` instead of silently discarding the rest of the arithmetic. `{2d6+3}kh2` lost the `+3`, and `{2d6-1d4}kh3` — which drops nothing — flipped the `1d4` from `-3` to `+3`; `{2d6*2}kh2` and `{abs(1d6-1d8)}kh1` are refused for the same reason `(2d6+3)kh2` already was. The check is structural, so identity terms (`{2d6+0}kh2`) go with them. Additive pools (`{4d6+2d8}kh3`) and multi-sub-roll groups, which select on subtotals, are untouched. A brace-wrapped success count now reports `INVALID_SUCCESS_COUNT_TARGET` at any nesting depth, matching what `(4d6>=5)kh1` always did ([#302](https://github.com/edloidas/roll-parser/issues/302))
24
+ - Penetrating explosion now records the face it rolled in `initialResult` on every die it appends, so the `initialResult ?? result` idiom `DieResult` documents recovers the natural face instead of the stored `raw - 1` — a natural 20 was indistinguishable from a 19. Bare `cs`/`cf` consequently judges a `!p` continuation by that face, so `1d6cf>5!p` and `1d6!pcf>5` agree where the README documented them as disagreeing. Separately, a `vs` roll whose explosion dice are clamped by `minN`/`maxN` (`1d20!min5 vs 30`) stops misreading the continuation as a second primary d20, which had been suppressing the natural-20/1 step ([#300](https://github.com/edloidas/roll-parser/issues/300))
25
+ - `cs`, `cf`, `s`, and `sd` adjacent to another modifier now keep a space in `expression` and `rendered`, so the normalized string re-parses. Those four codes end in a letter the lexer scans as an identifier, so maximal munch swallowed whatever followed: `1d20cs cf` normalized to `1d20cscf` and `4d6 s kh2` to `4d6s kh2`, neither of which round-trips through `parse`. `4dF` and `!p` end in letters but are their own tokens and stay flush ([#299](https://github.com/edloidas/roll-parser/issues/299))
26
+ - Demo site: the equation chip keeps explode and reroll dice. It read only `sort` out of the part tree, so any other pool-bearing modifier's dice went missing from the chip ([#313](https://github.com/edloidas/roll-parser/issues/313))
27
+
28
+ ### Documentation
29
+
30
+ - `MIGRATION.md` gains a 3.1.0 → 3.2.0 section covering all five newly rejected forms with a rewrite for each, plus the values that moved: `initialResult` on `!p` dice, one `vs` degree, the `expression`/`rendered` spacing, and the totals and tags of nested and grouped success counts ([#300](https://github.com/edloidas/roll-parser/issues/300), [#293](https://github.com/edloidas/roll-parser/issues/293))
31
+ - README gains a `Reading crit and fumble` subsection showing the per-die loop over `result.rolls`, with `site/src/dice.ts` named as the working in-repo example. It documents the four traps such a loop hits — `'meta'` dice must be filtered, `'dc'` dice need a deliberate choice, `rendered` must never be spliced by bracket position, and the default crit rule fires on every pool's max face — and names `rendered`'s markdown as Discord-flavored, pointing at `renderBreakdown` for other dialects ([#293](https://github.com/edloidas/roll-parser/issues/293))
32
+ - README's `## Contents` is a grouped two-level index, and the seven overlapping notation-rejection bullets are folded into one table with a `Write instead` column. The nested-count tag-ownership rule moved to `RollResult`, where the fields it describes live ([#293](https://github.com/edloidas/roll-parser/issues/293))
33
+
34
+ ## [3.1.0] - 2026-08-08
35
+
36
+ ### Added
37
+
38
+ - `roll-parser/render`, a subpath export holding `renderBreakdown(result, marks?)`, `DieMarks`, and `MARKDOWN_MARKS`. It rebuilds the `RollResult.rendered` breakdown from `RollResult.parts` with markers the caller chooses, so consumers targeting anything other than Discord markdown no longer regex-parse the baked string. Six slots — `dropped`, `success`, `failure`, `critical`, `fumble`, and `droppedGroup` — compose in a fixed order; `critical`/`fumble` read the `DieResult` booleans, which `rendered` has no marker for. With no `marks` the output is byte-identical to `rendered`, pinned by a property test. Budgeted separately at 2 kB, leaving the `index.js` and `{ roll }` budgets to the library ([#292](https://github.com/edloidas/roll-parser/issues/292))
39
+
40
+ ### Changed
41
+
42
+ - The `explode`, `reroll`, and `successCount` members of `RollPart` now carry `rolls: DieResult[]`, the pool the modifier produced — mirroring what `sort` already did. Standard and penetrating explosions and both reroll forms append dice that appear nowhere under `target`, so the part tree could not previously describe its own output ([#292](https://github.com/edloidas/roll-parser/issues/292))
43
+
44
+ Reading `parts` is unaffected. Three things do change:
45
+
46
+ - Code that **constructs** one of those three parts stops compiling — `TS2322: Property 'rolls' is missing in type … but required`. Add the pool, or widen the annotation.
47
+ - Deep-equality assertions against those parts need the new field.
48
+ - `--json` and `JSON.stringify(result)` payloads grow for expressions using explode, reroll, or success counting, since the pool is serialized on the modifier as well as its target — roughly +40% to +96% depending on pool size. Expressions without those modifiers are unchanged.
49
+
50
+ - performance: the per-die `isVersusDc` check is now skipped entirely for notation that cannot carry a `'dc'` tag, `sortDice` scans before allocating instead of running an unconditional `filter`, and `countSuccesses` filters DC dice up front rather than guarding inside the loop. Sort notation had no benchmark coverage, which is why the regression shipped unseen; `4d6sd`, `10d10sd`, `100d6sa`, and `10d10sd>=6f1` now have bench cases ([#281](https://github.com/edloidas/roll-parser/issues/281))
51
+
52
+ ### Fixed
53
+
54
+ - Bare `cs`/`cf` now judge the natural face (`initialResult ?? result`) rather than a value a preceding modifier rewrote, matching what the documentation always claimed. Compound explode also stopped clobbering the raw face recorded by an earlier `minN`/`maxN` clamp, which made `1d6min6!!cs` report a false critical. Explicit thresholds deliberately keep reading `result`, so the two kinds can disagree on one die ([#288](https://github.com/edloidas/roll-parser/issues/288))
55
+ - `cs`/`cf` thresholds now apply to dice minted by explode and reroll, which previously fell back to the built-in default rule. Each resolved rule is recorded per die and inherited by appended and replacement dice, so a further explode or reroll inherits it in turn. Overriding one side no longer moves the other: `1d6cf>5!p` keeps the `critical` that plain `1d6!p` gives the appended die ([#289](https://github.com/edloidas/roll-parser/issues/289))
56
+ - Chained same-kind bounds (`1d6min2min3`) no longer push duplicate `'min'`/`'max'` modifiers onto a die, and `initialResult` still comes from the first bound that moved it. The same duplication is fixed for `'success'`/`'failure'` on a group counted after its members (`{4d6>=5}>=1`) and for `'meta'` on nested meta operands (`((1d2)d4)d6`) ([#290](https://github.com/edloidas/roll-parser/issues/290))
57
+ - CLI `--verbose` no longer mangles a dropped group sub-roll nested inside another dropped group sub-roll. `{{1d6, 1d8}kh1, {1d10, 1d12}kh1}kh1` rendered as `({)1d10[1](, 1d12[3]})`; the marker rewrite was a regex over the flat string and stopped at the first inner `~~`. It now reads the structure and emits `({(1d10[1]), 1d12[3]})` ([#292](https://github.com/edloidas/roll-parser/issues/292))
58
+ - Demo site: fractional totals are rounded to four decimals — falling back to exponential where rounding would show a non-zero total as `0` — with the exact value on a `title` attribute, instead of overflowing the result card ([#285](https://github.com/edloidas/roll-parser/issues/285))
59
+ - Demo site: the total count-up is gated on `Number.isSafeInteger`, so an astronomical total lands without animating rather than interpolating through to `0` ([#286](https://github.com/edloidas/roll-parser/issues/286))
60
+
61
+ ### Documentation
62
+
63
+ - `MIGRATION.md` gains a 3.0.0 → 3.1.0 section covering the `RollPart` field, the three ways it can surface, and the new render subpath. The file is now ordered newest-first with an index, and `README.md` points at it for every upgrade path rather than only for 2.x ([#292](https://github.com/edloidas/roll-parser/issues/292))
64
+ - `DieResult.critical` and `DieResult.fumble` now scope the max-face/1 default to `sides > 1`, so neither claims to fire on `d1` or Fate dice, and the `FateDiceNode` / `createFateDieResult` TSDoc no longer states the flags are never set on Fate dice — an explicit `4dFcs>0` or `4dFcf=-1` does set them ([#291](https://github.com/edloidas/roll-parser/issues/291))
65
+ - The README no longer implies success counting rejects every enclosing expression — it is terminal only against direct wrapping
66
+ - The README performance table gained a `4d6sd` row and links the published benchmark trend, and the Node badge reads from the published `engines` range
67
+
10
68
  ## [3.0.0] - 2026-08-06
11
69
 
12
70
  First stable release of the v3 rewrite. This section covers what changed since
@@ -183,7 +241,9 @@ Dice mechanics (Stage 2):
183
241
  - Dice count safety limit via `maxDice` option (default 10,000), enforced across the whole expression to prevent DoS via additive groups like `5000d6+5000d6` ([#19](https://github.com/edloidas/roll-parser/issues/19))
184
242
  - Parser and evaluator correctness: duplicate `kept` modifier entries, implicit modifier count defaulting to 1 (`4d6kh` → `4d6kh1`), `critical` flag suppression when `sides === 1`, negative `--seed` CLI values ([#21](https://github.com/edloidas/roll-parser/issues/21))
185
243
 
186
- [Unreleased]: https://github.com/edloidas/roll-parser/compare/v3.0.0...HEAD
244
+ [Unreleased]: https://github.com/edloidas/roll-parser/compare/v3.2.0...HEAD
245
+ [3.2.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.2.0
246
+ [3.1.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.1.0
187
247
  [3.0.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0
188
248
  [3.0.0-beta.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0-beta.0
189
249
  [3.0.0-alpha.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0-alpha.0
package/MIGRATION.md CHANGED
@@ -1,4 +1,295 @@
1
- # Migrating from v2 to v3
1
+ # Migration notes
2
+
3
+ Newest first.
4
+
5
+ - [Upgrading from 3.1.0 to 3.2.0](#upgrading-from-310-to-320)
6
+ - [Upgrading from 3.0.0 to 3.1.0](#upgrading-from-300-to-310)
7
+ - [Migrating from v2 to v3](#migrating-from-v2-to-v3)
8
+ - [Upgrading from a v3 pre-release](#upgrading-from-a-v3-pre-release)
9
+
10
+ ## Upgrading from 3.1.0 to 3.2.0
11
+
12
+ Nothing was removed from the API, no type changed, and the seed → dice mapping
13
+ is untouched — the same seed and notation roll the same faces they did in
14
+ 3.1.0. What moves falls in two buckets. Several group forms that used to
15
+ evaluate now throw a parse error — `{3, 5, 7}>=4`, `{2d6+3}kh2`, `{2d6*2}kh2`,
16
+ `{3d20+5}>=21`, and `{2d6, 2d8}kh1>=4` — each listed below with a rewrite. The
17
+ rest are values that moved: `initialResult` on `!p` dice, one `vs` degree, the
18
+ `expression` and `rendered` strings, and the totals and tags of nested and
19
+ grouped success counts.
20
+
21
+ **`!p` continuation dice now carry `initialResult`.** A penetrating explosion
22
+ stores `raw - 1` on each die it appends, and used to record the face it
23
+ replaced nowhere, so the `initialResult ?? result` idiom `DieResult` documents
24
+ returned the decremented value and a natural 20 was indistinguishable from a
25
+ 19.
26
+
27
+ ```typescript
28
+ import { roll } from 'roll-parser';
29
+ import { createMockRng } from 'roll-parser/testing';
30
+
31
+ const die = roll('1d20!p', { rng: createMockRng([20, 20, 5]) }).rolls[1];
32
+
33
+ die.result; // 19
34
+ die.initialResult; // 20 — was `undefined`
35
+ ```
36
+
37
+ Standard `!` is unchanged: its appended dice store what they rolled, so they
38
+ still carry no `initialResult`. Two things can bite:
39
+
40
+ - **Deep-equality assertions against a `!p` pool fail.** The appended dice gain
41
+ a field. Assert on what you care about, or add `initialResult` to the
42
+ expected die.
43
+ - **`--json` and `JSON.stringify(result)` payloads grow** by one number per
44
+ appended `!p` die. Expressions without `!p` are unchanged.
45
+
46
+ **Bare `cs`/`cf` judges a `!p` die by the face it rolled.** The side you do not
47
+ override reads the natural face, which now exists on these dice — so a
48
+ continuation that rolled its maximum is critical even though it displays one
49
+ less, matching the flags plain `1d6!p` already set.
50
+
51
+ ```typescript
52
+ const result = roll('1d6!pcf>5', { rng: createMockRng([6, 6, 3]) });
53
+
54
+ result.rendered; // '1d6!pcf>5[6, 5, 2] = 13'
55
+ result.rolls[1].critical; // true — was `false`, judged by the stored 5
56
+ ```
57
+
58
+ If you assert `critical` or `fumble` on `!p` continuation dice, re-derive the
59
+ expected flag from `initialResult ?? result`. Order stops mattering here:
60
+ `1d6cf>5!p` and `1d6!pcf>5` now agree, where 3.1.0's README documented them as
61
+ disagreeing, and [Modifiers](README.md#modifiers) is rewritten to match. An
62
+ explicit threshold is still a predicate over a die's current `result`, so `!!`,
63
+ `minN`, and `maxN` remain order-sensitive.
64
+
65
+ **Adjacent bare modifiers keep a space in `expression` and `rendered`.** `cs`,
66
+ `cf`, `s`, and `sd` with no threshold or count end in a letter the lexer scans
67
+ as an identifier, so the normalized form used to re-lex as one token and no
68
+ longer round-tripped through `parse`.
69
+
70
+ ```typescript
71
+ roll('1d20cs cf', { rng: createMockRng([20]) }).expression; // '1d20cs cf' — was '1d20cscf'
72
+ roll('4d6 s kh2', { rng: createMockRng([1, 2, 3, 4]) }).rendered;
73
+ // '4d6s[~~1~~, ~~2~~, 3, 4] = 7' — the prefix was '4d6skh2'
74
+ ```
75
+
76
+ Only those four codes are affected; `4dF` and `!p` end in letters but are their
77
+ own tokens, so they stay flush. Snapshot assertions over `expression` or
78
+ `rendered` for an affected expression need re-recording.
79
+
80
+ **One `vs` case stops discarding its natural face.** Only `vs` expressions
81
+ whose roll side both appends explosion dice and clamps them — `!` or `!!`
82
+ combined with `minN`/`maxN` — are affected: a clamped continuation used to be
83
+ counted as a second primary d20, and two primaries suppress the natural 20 /
84
+ natural 1 step. Without the clamp (`1d20! vs 30`) the continuation never looked
85
+ like a primary, and those degrees are unchanged.
86
+
87
+ ```typescript
88
+ import { DegreeOfSuccess } from 'roll-parser';
89
+
90
+ const result = roll('1d20!min5 vs 30', { rng: createMockRng([20, 2, 12]) });
91
+
92
+ result.natural; // 20 — was `undefined`
93
+ result.degree === DegreeOfSuccess.Success; // true — was `Failure`
94
+ ```
95
+
96
+ If you pinned `natural` or `degree` on such a roll, re-record it.
97
+
98
+ **Success counting a dice-less group is now a parse error.** `{3, 5, 7}>=4`
99
+ used to return the group's sum with `successes: 0`, so the
100
+ `successCount.total === successes - failures` invariant the README offers read
101
+ `15 === 0`. There are no dice to tally, and whether a group's units should be
102
+ its subtotals is undecided, so the form is refused instead.
103
+
104
+ ```typescript
105
+ roll('{3, 5, 7}>=4'); // throws 'INVALID_SUCCESS_COUNT_TARGET' — was 15
106
+ roll('{3, 5, 7}kh1>=4'); // throws 'INVALID_SUCCESS_COUNT_TARGET' — was 7
107
+ roll('{3, 0d6}>=4').total; // 0 — was 3
108
+ ```
109
+
110
+ Groups holding at least one pool are untouched — `{3, 1d6}>=4` counts the `1d6`
111
+ exactly as it did. `0d6>=4` also still parses and totals 0; only targets that
112
+ can never roll a die are rejected. There is no in-notation rewrite for a
113
+ literal-only group: replace the literals with the pool they stood in for
114
+ (`3d6>=4`), or drop the count and keep the group's sum (`{3, 5, 7}`).
115
+
116
+ **A second success count re-scores the pool instead of adding to it.** Group
117
+ braces let one count wrap another (`{4d6>=5f1}<=2f6`), which the parse-time
118
+ reject on a direct `4d6>=5>=1` never reached. The inner pass used to leave its
119
+ tags behind, so a die could come back both `'success'` and `'failure'` and the
120
+ top-level counts stopped describing the total. The outermost count now owns the
121
+ tags outright.
122
+
123
+ ```typescript
124
+ const result = roll('{4d6>=5f1}<=2f6', { rng: createMockRng([6, 5, 2, 1]) });
125
+
126
+ result.total; // 1
127
+ result.successes; // 2 — was 4
128
+ result.failures; // 1 — was 0
129
+ result.rendered; // '{4d6>=5f1}<=2f6[__6__, 5, **2**, **1**] = 1'
130
+ ```
131
+
132
+ `total` never moved — it always came from the outermost count, so a test
133
+ asserting only `total` needs no change. What changed is `successes`,
134
+ `failures`, `rolls[].modifiers`, and `rendered`, which now agree with it, so
135
+ `successCount.total === successes - failures` holds again. A lone
136
+ count is untouched, and so is a nested pair whose thresholds agree
137
+ (`{4d6>=5}>=5`). Dropped dice come out untagged; only the DC side of a `vs`
138
+ keeps tags from an inner count, since no pool pass may tally it.
139
+
140
+ **Keep/drop on a single-sub-roll group now takes added dice terms only.** The
141
+ flat-pool form totals the faces it kept, so any term in the sub-roll that is not
142
+ an added die face was discarded from the total with no error and no marker.
143
+ `{2d6+3}kh2` lost the `+3`, and `{2d6-1d4}kh3` — which drops nothing — flipped
144
+ the `1d4` from `-3` to `+3`. The parser already refused `(2d6+3)kh2` for exactly
145
+ this reason; the brace form now does too.
146
+
147
+ ```typescript
148
+ roll('{2d6+3}kh2', { rng: createMockRng([4, 5]) }); // throws 'INVALID_KEEP_DROP_TARGET' — was 9
149
+ roll('{2d6-1d4}kh3', { rng: createMockRng([4, 5, 3]) }); // throws 'INVALID_KEEP_DROP_TARGET' — was 12
150
+ roll('{2d6*2}kh2', { rng: createMockRng([4, 5]) }); // throws 'INVALID_KEEP_DROP_TARGET'
151
+ roll('{2d6+0}kh2', { rng: createMockRng([4, 5]) }); // throws 'INVALID_KEEP_DROP_TARGET' — identity terms too
152
+ roll('{4d6+2d8}kh3', { rng: createMockRng([6, 6, 6, 1, 8, 1]) }).total; // 20 — unchanged
153
+ ```
154
+
155
+ Also newly rejected, same cause: a function-wrapped pool (`{abs(1d6-1d8)}kh1`),
156
+ and a success count, which returned the face sum rather than the tally.
157
+ `{4d6>=5}kh1` throws `INVALID_SUCCESS_COUNT_TARGET`, not the keep/drop code — a
158
+ single-sub-roll group used to hide the count from the reject that `(4d6>=5)kh1`
159
+ has always hit, and both spellings now report the same thing.
160
+
161
+ The check is structural rather than arithmetic, so identity terms are refused
162
+ with the rest even though their total was already right: `{2d6+0}kh2`,
163
+ `{2d6*1}kh2`, and `{2d6+1d8-0}kh3` all throw.
164
+
165
+ Additive pools are the form this syntax exists for and are untouched —
166
+ `{4d6+2d8}kh3`, `{2d6kh1+1d8}kh2`, `{{1d6, 1d8}+2d6}kh2`. Multi-sub-roll groups
167
+ never had the bug, since keep/drop compares subtotals there: `{2d6+3, 1d8}kh1`
168
+ still works. To keep a rejected expression, move the arithmetic outside the
169
+ selection (`{2d6}kh2+3`) or give each term its own sub-roll.
170
+
171
+ **Success counting a multi-sub-roll group now scores subtotals, not dice.**
172
+ `{2d6, 2d6}>=10` used to compare all four faces against 10 and find nothing,
173
+ while `{2d6, 2d6}kh1` on the same node saw two compound dice worth 11 and 3.
174
+ Roll20 counts one success per sub-roll total, and that is what the count does
175
+ now.
176
+
177
+ ```typescript
178
+ const result = roll('{2d6, 2d6}>=10', { rng: createMockRng([6, 5, 2, 1]) });
179
+
180
+ result.total; // 1 — was 0
181
+ result.successes; // 1 — was 0
182
+ result.rendered; // '{2d6[6, 5], 2d6[2, 1]}>=10 = 1'
183
+ ```
184
+
185
+ Four things move with it:
186
+
187
+ - **`rendered`** shows each sub-roll's own bracket instead of one flat pool —
188
+ the dice are no longer the units.
189
+ - **`rolls[].modifiers`** comes back untagged: no `'success'` or `'failure'` on
190
+ any die, for the same reason. Read `successes` / `failures` instead.
191
+ - **`fT` thresholds** score subtotals too, so `{4d6+2d8, 3d20+3,
192
+ 5d10+1}>=40f<=10` weighs three subtotals rather than 21 faces.
193
+ - **A literal sub-roll** is a subtotal like any other, so it is scored rather
194
+ than skipped.
195
+
196
+ ```typescript
197
+ const literal = roll('{3, 1d6}>=4f<=3', { rng: createMockRng([6]) });
198
+
199
+ literal.successes; // 1
200
+ literal.failures; // 1 — was 0
201
+ literal.total; // 0 — was 1
202
+ ```
203
+
204
+ Single-sub-roll groups (`{4d6}>=5`, `{2d6+1d8}>=5`) are the flat-pool form and
205
+ are unchanged.
206
+
207
+ **Two group spellings of a success count are now parse errors.** Both used to
208
+ reach the flat counter with the subtotals already gone.
209
+
210
+ ```typescript
211
+ roll('{3d20+5}>=21'); // throws 'INVALID_SUCCESS_COUNT_TARGET' — was a count of bare faces
212
+ roll('{2d6, 2d8}kh1>=4'); // throws 'INVALID_SUCCESS_COUNT_TARGET' — was a count of loose dice
213
+ roll('({2d6, 2d8})>=4'); // throws 'INVALID_SUCCESS_COUNT_TARGET'
214
+ ```
215
+
216
+ - **`{3d20+5}>=21`** hits the single-sub-roll rule keep/drop already has: that
217
+ form compares one face at a time, so a scalar term never reaches the
218
+ comparison. A scaled or function-wrapped pool goes with it — `{2d6*2}>=4` and
219
+ `{floor(2d6/2)}>=2` throw too, exactly as `{2d6*2}kh2` does. To keep it, fold
220
+ the scalar into the threshold: `3d20>=16` counts the same dice.
221
+ - **`{2d6, 2d8}kh1>=4`** covers every way a multi-sub-roll group can arrive
222
+ somewhere other than as the count's direct target — `{{2d6, 2d8}}>=4` and
223
+ `({2d6, 2d8})>=4` throw the same code. To keep it, count the group directly:
224
+ `{2d6, 2d8}>=4`.
225
+
226
+ ## Upgrading from 3.0.0 to 3.1.0
227
+
228
+ Nothing was removed and no behaviour changed: `total`, `expression`, `rendered`,
229
+ `rolls`, and `degree` are byte-identical to 3.0.0 for every expression. The
230
+ whole of this section is about one added field.
231
+
232
+ **`RollPart` gained `rolls` on three variants.** The `explode`, `reroll`, and
233
+ `successCount` members now carry `rolls: DieResult[]` — the pool the modifier
234
+ produced — joining `sort`, which always had it. They needed it: standard and
235
+ penetrating explosions and both reroll forms *append* dice, and those dice
236
+ appeared nowhere in the part tree, so a part could not describe its own output.
237
+
238
+ <!-- readme-test: skip -->
239
+ ```typescript
240
+ // 3.0.0 — the explosion die is missing from the tree
241
+ roll('2d6!').parts.target.rolls.length; // 2, but `rendered` shows three dice
242
+
243
+ // 3.1.0 — the modifier carries the pool it actually rendered
244
+ roll('2d6!').parts.rolls.length; // 3
245
+ ```
246
+
247
+ Per the [versioning policy](README.md#versioning), adding a field to a returned
248
+ object is a minor. Reading `parts` is unaffected and needs no change. Three
249
+ things can still bite:
250
+
251
+ - **Constructing one of those parts stops compiling** — `TS2322: Property
252
+ 'rolls' is missing in type … but required`. This is only reachable in test
253
+ fixtures and mocks, since no library function accepts a `RollPart`. Add the
254
+ pool, or widen the annotation.
255
+ - **Deep-equality assertions against those parts fail.** Add `rolls` to the
256
+ expected object, or assert on the fields you care about instead of the whole
257
+ part.
258
+ - **`--json` and `JSON.stringify(result)` payloads grow** for expressions using
259
+ explode, reroll, or success counting, because the pool is serialized on the
260
+ modifier as well as on its target — roughly +40% to +96% depending on pool
261
+ size. Expressions without those modifiers are unchanged. Budget for it if you
262
+ log, cache, or ship results over a wire.
263
+
264
+ **The CLI renders nested dropped sub-rolls correctly.** `--verbose` on a dropped
265
+ group sub-roll nested inside another one used to emit mismatched delimiters —
266
+ `{{(1d6[1]), 1d8[4]}, ({)1d10[1](, 1d12[3]})}`. It now emits
267
+ `{{(1d6[1]), 1d8[4]}, ({(1d10[1]), 1d12[3]})}`. Anything parsing that output
268
+ should be reading `--json` instead.
269
+
270
+ **New, optional: `renderBreakdown` from `roll-parser/render`.** A subpath
271
+ export whose `renderBreakdown(result, marks?)` rebuilds the breakdown from `result.parts`
272
+ with markers you choose, so you no longer have to regex the markdown out of
273
+ `rendered`. With no marks its output is byte-identical to `rendered`, which
274
+ makes it a drop-in starting point:
275
+
276
+ ```typescript
277
+ import { roll } from 'roll-parser';
278
+ import { renderBreakdown } from 'roll-parser/render';
279
+ import { createMockRng } from 'roll-parser/testing';
280
+
281
+ const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });
282
+
283
+ renderBreakdown(result) === result.rendered; // true
284
+ renderBreakdown(result, { dropped: (_die, text) => `(${text})` });
285
+ // '4d6[3, 6, (2), 5] = 14'
286
+ ```
287
+
288
+ See [Custom markers](README.md#custom-markers) for the six slots and the order
289
+ they compose in. Nothing forces you to adopt it — `rendered` is unchanged and
290
+ is not deprecated.
291
+
292
+ ## Migrating from v2 to v3
2
293
 
3
294
  v3 is a full rewrite. Nothing in the 2.x API carries over — the entry points,
4
295
  the result shape, the error behaviour, and parts of the notation all changed. To
@@ -28,9 +319,7 @@ classes. v3 has `roll(notation, options)` for the common path, and
28
319
  | `convert(obj)`, `Roll`, `WodRoll`, `Result` | removed — there are no roll-description objects to build or convert |
29
320
 
30
321
  **Result fields moved.** v2 returned `Result { notation, value, rolls }` where
31
- `rolls` was a plain `number[]`. The v3 snippets below draw from
32
- `createMockRng` (exported by `roll-parser/testing`) so their numbers are exact
33
- rather than whatever the dice happened to do:
322
+ `rolls` was a plain `number[]`.
34
323
 
35
324
  <!-- readme-test: skip -->
36
325
  ```typescript