roll-parser 3.0.0-beta.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +142 -9
  2. package/MIGRATION.md +219 -0
  3. package/README.md +1026 -154
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +15 -3
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +18 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -17
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +27 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +89 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +41 -75
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +914 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.js +15 -0
  37. package/dist/evaluator/modifiers/compare.js.map +1 -0
  38. package/dist/evaluator/modifiers/crit-threshold.d.ts +57 -5
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +42 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +29 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +17 -0
  45. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  46. package/dist/evaluator/modifiers/explode.d.ts +13 -5
  47. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  48. package/dist/evaluator/modifiers/explode.js +105 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +47 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +25 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +15 -29
  55. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  56. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  57. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  58. package/dist/evaluator/modifiers/reroll.d.ts +12 -4
  59. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  60. package/dist/evaluator/modifiers/reroll.js +68 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +5 -1
  63. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  64. package/dist/evaluator/modifiers/sort.js +13 -0
  65. package/dist/evaluator/modifiers/sort.js.map +1 -0
  66. package/dist/evaluator/modifiers/success-count.d.ts +6 -7
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +25 -0
  69. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  70. package/dist/index.d.ts +33 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +12 -2560
  73. package/dist/index.js.map +1 -26
  74. package/dist/lexer/lexer.d.ts +50 -5
  75. package/dist/lexer/lexer.d.ts.map +1 -1
  76. package/dist/lexer/lexer.js +260 -0
  77. package/dist/lexer/lexer.js.map +1 -0
  78. package/dist/lexer/tokens.d.ts +31 -6
  79. package/dist/lexer/tokens.d.ts.map +1 -1
  80. package/dist/lexer/tokens.js +42 -0
  81. package/dist/lexer/tokens.js.map +1 -0
  82. package/dist/parser/ast.d.ts +324 -165
  83. package/dist/parser/ast.d.ts.map +1 -1
  84. package/dist/parser/ast.js +52 -0
  85. package/dist/parser/ast.js.map +1 -0
  86. package/dist/parser/guards.d.ts +106 -0
  87. package/dist/parser/guards.d.ts.map +1 -0
  88. package/dist/parser/guards.js +121 -0
  89. package/dist/parser/guards.js.map +1 -0
  90. package/dist/parser/parser.d.ts +118 -14
  91. package/dist/parser/parser.d.ts.map +1 -1
  92. package/dist/parser/parser.js +751 -0
  93. package/dist/parser/parser.js.map +1 -0
  94. package/dist/render.d.ts +95 -0
  95. package/dist/render.d.ts.map +1 -0
  96. package/dist/render.js +227 -0
  97. package/dist/render.js.map +1 -0
  98. package/dist/rng/mock.d.ts +73 -12
  99. package/dist/rng/mock.d.ts.map +1 -1
  100. package/dist/rng/mock.js +30 -0
  101. package/dist/rng/mock.js.map +1 -0
  102. package/dist/rng/seeded.d.ts +141 -9
  103. package/dist/rng/seeded.d.ts.map +1 -1
  104. package/dist/rng/seeded.js +138 -0
  105. package/dist/rng/seeded.js.map +1 -0
  106. package/dist/rng/types.d.ts +57 -0
  107. package/dist/rng/types.d.ts.map +1 -1
  108. package/dist/rng/types.js +2 -0
  109. package/dist/rng/types.js.map +1 -0
  110. package/dist/roll.d.ts +58 -28
  111. package/dist/roll.d.ts.map +1 -1
  112. package/dist/roll.js +8 -0
  113. package/dist/roll.js.map +1 -0
  114. package/dist/testing.d.ts +5 -4
  115. package/dist/testing.d.ts.map +1 -1
  116. package/dist/testing.js +2 -41
  117. package/dist/testing.js.map +1 -11
  118. package/dist/types.d.ts +349 -47
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/types.js +8 -0
  121. package/dist/types.js.map +1 -0
  122. package/dist/version.d.ts +2 -0
  123. package/dist/version.d.ts.map +1 -0
  124. package/dist/version.js +2 -0
  125. package/dist/version.js.map +1 -0
  126. package/package.json +93 -34
  127. package/src/cli/args.ts +66 -10
  128. package/src/cli/format.ts +37 -26
  129. package/src/cli/index.ts +27 -84
  130. package/src/cli/main.ts +129 -0
  131. package/src/errors.ts +480 -27
  132. package/src/evaluator/die.ts +51 -0
  133. package/src/evaluator/env.ts +105 -0
  134. package/src/evaluator/evaluator.ts +693 -434
  135. package/src/evaluator/modifiers/crit-threshold.ts +96 -14
  136. package/src/evaluator/modifiers/die-bound.ts +48 -0
  137. package/src/evaluator/modifiers/explode.ts +70 -62
  138. package/src/evaluator/modifiers/flags.ts +78 -0
  139. package/src/evaluator/modifiers/keep-drop.ts +129 -127
  140. package/src/evaluator/modifiers/reroll.ts +44 -56
  141. package/src/evaluator/modifiers/sort.ts +21 -2
  142. package/src/evaluator/modifiers/success-count.ts +24 -12
  143. package/src/index.ts +56 -35
  144. package/src/lexer/lexer.ts +107 -34
  145. package/src/lexer/tokens.ts +31 -6
  146. package/src/parser/ast.ts +333 -346
  147. package/src/parser/guards.ts +248 -0
  148. package/src/parser/parser.ts +419 -242
  149. package/src/render.ts +392 -0
  150. package/src/rng/mock.ts +74 -13
  151. package/src/rng/seeded.ts +299 -64
  152. package/src/rng/types.ts +57 -0
  153. package/src/roll.ts +64 -47
  154. package/src/testing.ts +5 -9
  155. package/src/types.ts +353 -46
  156. package/src/version.ts +2 -0
  157. package/dist/cli.js +0 -2608
  158. package/dist/cli.js.map +0 -28
  159. package/dist/evaluator/index.d.ts +0 -8
  160. package/dist/evaluator/index.d.ts.map +0 -1
  161. package/dist/rng/index.d.ts +0 -8
  162. package/dist/rng/index.d.ts.map +0 -1
  163. package/src/evaluator/index.ts +0 -14
  164. package/src/rng/index.ts +0 -8
package/CHANGELOG.md CHANGED
@@ -7,15 +7,138 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [3.0.0-beta.0] - 2026-07-07
10
+ ## [3.1.0] - 2026-08-08
11
+
12
+ ### Added
13
+
14
+ - `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))
11
15
 
12
16
  ### Changed
13
17
 
14
- - **BREAKING:** package is now ESM-only. The `require` export condition shipped a file that was actually ESM (`bun build --target node` emits ESM), so it only ever worked via Node ≥22 `require(esm)` which continues to work. Bundles are built with `--target node` (no `// @bun` pragma) and include linked sourcemaps; type declarations now resolve under `moduleResolution: node16/nodenext`, validated by `@arethetypeswrong/cli` + `publint` in CI ([#119](https://github.com/edloidas/roll-parser/issues/119))
15
- - **BREAKING:** `cs`/`cf` are now independent overrides (Roll20 semantics). `1d20cf<3` keeps the default nat-20 crit instead of clearing it; `1d20cs>=19` keeps the default nat-1 fumble. The previous replace semantics required chaining `cscf<3` to preserve defaults ([#118](https://github.com/edloidas/roll-parser/issues/118))
16
- - **BREAKING:** bare `NdXdY` dice chains (e.g. `4d6d1`) are rejected at parse time with `AMBIGUOUS_DICE_CHAIN` instead of silently parsing as `(4d6)d1` — roll 4d6, use the result as a count of d1 dice — which is almost never intended. Use `4d6dl1` to drop dice or `(4d6)d1` for nested dice ([#118](https://github.com/edloidas/roll-parser/issues/118))
17
- - **BREAKING:** non-finite totals (`Infinity`/`NaN`, e.g. `2**1024`) now throw `NON_FINITE_RESULT` instead of returning a non-finite `total` ([#118](https://github.com/edloidas/roll-parser/issues/118))
18
- - **BREAKING:** `1d1` no longer reports `fumble: true`a d1 always rolls 1, so it is neither critical nor fumble, mirroring the existing `critical` guard ([#118](https://github.com/edloidas/roll-parser/issues/118))
18
+ - 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))
19
+
20
+ Reading `parts` is unaffected. Three things do change:
21
+
22
+ - 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.
23
+ - Deep-equality assertions against those parts need the new field.
24
+ - `--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.
25
+
26
+ - 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))
27
+
28
+ ### Fixed
29
+
30
+ - 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))
31
+ - `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))
32
+ - 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))
33
+ - 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))
34
+ - 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))
35
+ - 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))
36
+
37
+ ### Documentation
38
+
39
+ - `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))
40
+ - `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))
41
+ - The README no longer implies success counting rejects every enclosing expression — it is terminal only against direct wrapping
42
+ - The README performance table gained a `4d6sd` row and links the published benchmark trend, and the Node badge reads from the published `engines` range
43
+
44
+ ## [3.0.0] - 2026-08-06
45
+
46
+ First stable release of the v3 rewrite. This section covers what changed since
47
+ `3.0.0-beta.0`; the two pre-release sections below carry the rest of the rewrite.
48
+ Upgrading from 2.x starts at [MIGRATION.md](MIGRATION.md) — no 2.x API carries
49
+ over.
50
+
51
+ ### Added
52
+
53
+ - CLI `--json` flag that emits the complete `RollResult`, including the structured `parts` tree, as compact JSON on stdout. `DegreeOfSuccess` serializes as its numeric value; diagnostics stay plain text on stderr, so scripts can branch on the exit code before parsing ([#136](https://github.com/edloidas/roll-parser/issues/136))
54
+ - CLI `--` terminator so every following argument is treated as notation, `--seed=<value>` alongside `--seed <value>`, `--help`/`--version` precedence over usage errors, and exit codes 0/1/2 documented in `--help` ([#136](https://github.com/edloidas/roll-parser/issues/136))
55
+ - `getErrorSpan(error)`, which normalizes the lexer/parser `position` and the evaluator `start`/`end` into one `{ start, end? }` span ([#128](https://github.com/edloidas/roll-parser/issues/128))
56
+ - Root exports for `Token`, `TokenType`, `CritThreshold`, `lex`, `MAX_PARSE_DEPTH`, and the new `EvaluationOptions` type shared by `RollOptions` and `EvaluateOptions` — `Token` and `CritThreshold` already leaked through `ParseError.token` and `CritThresholdNode` ([#129](https://github.com/edloidas/roll-parser/issues/129))
57
+ - `roll-parser/testing` exports the `RNG` and `MockRNGExhaustedError` types it was missing ([#129](https://github.com/edloidas/roll-parser/issues/129))
58
+ - Bundle-size budgets enforced in CI via `size-limit`, per named import: 12.5 kB for the full library, 6 kB for `{ parse }`, 12 kB for `{ roll }`, 250 B for `{ createMockRng }` ([#127](https://github.com/edloidas/roll-parser/issues/127), [#255](https://github.com/edloidas/roll-parser/issues/255))
59
+ - Staged benchmark suite — one shared case table driving separate `bench:lex`, `bench:parse`, `bench:evaluate`, and `bench:roll` stages, a pool-scaling tripwire, and p50 trend tracking in CI ([#131](https://github.com/edloidas/roll-parser/issues/131))
60
+ - Interactive demo site at [roll-parser.edloidas.io](https://roll-parser.edloidas.io/) — a playground, a notation guide where every example rolls in place, and the hosted TypeDoc API reference
61
+ - `unpkg` and `jsdelivr` manifest fields pointing at `dist/index.js`, so the ESM build loads straight from a CDN without a bundler ([#125](https://github.com/edloidas/roll-parser/issues/125))
62
+ - Release integrity gates in CI: `node-smoke` installs the actual `npm pack` tarball into a scratch project and exercises both entry points plus the `roll-parser` bin across Node 22.12.0, 22.x, 24.x, and `lts/*`; a new `browser-smoke` job bundles both entries with esbuild `--platform=browser`, hard-failing on any Node builtin, then executes the bundle. Every action is SHA-pinned, the Bun version has a single `.bun-version` pin, and Dependabot groups action bumps into one weekly PR ([#126](https://github.com/edloidas/roll-parser/issues/126), [#149](https://github.com/edloidas/roll-parser/issues/149))
63
+ - `deno-smoke` and `workers-smoke` install the packed tarball and assert a pinned `createMockRng` total on Deno and in `workerd`, turning the Deno and Cloudflare Workers support the README claims into something CI earns rather than something inferred from the absence of `node:` imports. `browser-run` additionally executes the browser bundle in real headless Chromium ([#160](https://github.com/edloidas/roll-parser/issues/160), [#222](https://github.com/edloidas/roll-parser/issues/222))
64
+ - Environment-neutrality is now enforced rather than assumed: Biome `noNodejsModules` on library sources (exempting `src/cli/`), a `types: []` typecheck pass, and a CI assertion that no shipped library file carries a `node:`, `bun:`, or `require` specifier ([#126](https://github.com/edloidas/roll-parser/issues/126))
65
+ - Root exports for `ROLL_PARSER_ERROR_CODES` — the readonly tuple behind `RollParserErrorCode`, usable for runtime validation — and `RollPartBase`, the shared `total`/`start`/`end` contract of every `RollPart` variant. Both now render in the API reference instead of being suppressed ([#155](https://github.com/edloidas/roll-parser/issues/155))
66
+ - `SECURITY.md` with a private vulnerability disclosure route ([#155](https://github.com/edloidas/roll-parser/issues/155))
67
+ - Committed golden `SeededRNG` sequences pin the seed → dice mapping the README promises stable within a version, and malformed-input fuzz properties assert every rejection is a typed `RollParserError` ([#155](https://github.com/edloidas/roll-parser/issues/155))
68
+ - `SeededRNG.state()` exports a format version plus the engine's four words as an `RngState` tuple, and the constructor accepts one back (`string | number | RngState`). A restore copies the words verbatim — no re-hashing, no warm-up draws — so the resumed stream continues exactly where the snapshot was taken. This closes the "replay an unseeded roll" gap: snapshot before rolling and an auto-seeded roll becomes reproducible after the fact. The injectable `RNG` interface is unchanged — state is a `SeededRNG` capability, not part of the contract ([#203](https://github.com/edloidas/roll-parser/issues/203))
69
+ - A cyrb128 conformance suite pins the seed → state mapping against a transcribed reference, so changing the word derivation fails a test instead of silently rewriting every seeded sequence ([#210](https://github.com/edloidas/roll-parser/issues/210))
70
+ - `minN` / `maxN` per-die clamps (`4d6min2`, `4d6max5`) as a new `DieBound` AST node. A clamped die keeps its natural face in `initialResult` and gains a `'min'` / `'max'` tag, so criticals and fumbles still read the face that was rolled. Bounds chain (`4d6min2max5`), accept meta-expressions (`4d6min(1d2)`, drawn after the pool like other thresholds), and reject non-pool targets with the new `INVALID_DIE_BOUND_TARGET` code
71
+ - `sqrt(x)` and `pow(a, b)` join the function registry; the variadic `min(a, b, …)` / `max(a, b, …)` calls are unchanged, since the clamp form is postfix
72
+ - `isNotationError(error)` plus the `NOTATION_ERROR_CODES` tuple and `NotationErrorCode` union it narrows to — the 28 codes of the 34 that the *input* is answerable for. It pairs with `isRollParserError` to split "the notation was bad, show the user a caret" from "the library or the caller has a bug, rethrow". The six excluded are the invariants and the caller-side mistakes: `UNKNOWN_OPERATOR`, `UNKNOWN_NODE_TYPE`, `UNKNOWN_FUNCTION`, `INVALID_VARIABLE_VALUE`, `INCOMPATIBLE_RNG_STATE`, and `INVALID_EVALUATION_LIMIT` — none reachable from notation a user can type. A `Record<RollParserErrorCode, boolean>` map gates the classification, so a new code cannot silently default into either bucket ([#231](https://github.com/edloidas/roll-parser/issues/231))
73
+ - The `ts-compat` CI matrix typechecks a consumer fixture against the packed tarball on TypeScript 5.0, 5.4, 5.9, 6.x, and 7.x under both `moduleResolution: bundler` and `nodenext`, asserting resolved types rather than a zero exit — `NotAny` guards, a narrowing `switch` over `RollPart`, and `@ts-expect-error` lines all fail if the imports degrade to `any`. `ts-compat-floor` runs the same fixture on 4.9 and requires the failure to carry TS2792 and TS2344, so the documented ≥5.0 floor is proven at both ends instead of assumed ([#221](https://github.com/edloidas/roll-parser/issues/221))
74
+
75
+ ### Changed
76
+
77
+ - **BREAKING:** a `vs` comparison hidden behind a wrapper in a meta-expression position is rejected at parse time instead of being consumed as a plain number. The guard unwrapped only parentheses and special-cased a single-sub-roll group, so `4d6kh(floor(1d20 vs 15))`, `(floor(1d20 vs 15))d6`, `1d(floor(1d20 vs 15)+5)`, and `4d6min(floor(1d20 vs 15))` all parsed, then dropped the resolved `degree`/`natural` at the consumer with no error — the same silent loss the bare form already raised `NESTED_VERSUS` for. The walk is now deep at every meta-operand site: dice count and sides, keep/drop count, explode and reroll compare points, die bounds, crit thresholds, and success-count thresholds. `evaluate()` runs the same check, so a hand-built AST — the only way the bare form was still reachable — raises `NESTED_VERSUS` rather than discarding the metadata. Modifier *target* positions stay shallow: `{1d20 vs 15, 1d6}kh1` is legal, and the degree propagates from kept sub-rolls. Breaking only for notation that returned a degree-less result ([#267](https://github.com/edloidas/roll-parser/issues/267))
78
+ - **BREAKING:** an invalid `maxDice`, `maxExplodeIterations`, or `maxRerollIterations` now throws the new `INVALID_EVALUATION_LIMIT` code instead of silently falling back to the library default. The old behaviour failed *open*: `maxDice: Number(untrustedInput)` on unparseable input handed the caller the permissive 10,000 default with no signal, and `maxDice: 0.5` floored to `0` and rejected every roll. Accepted values are safe integers — `maxDice >= 1`, iteration limits `>= 0`; strings are not coerced. Omitting an option, or passing `undefined` / `null`, still takes its default, so the no-options path is unchanged ([#216](https://github.com/edloidas/roll-parser/issues/216))
79
+ - **BREAKING:** every seeded sequence changed. Three RNG fixes land together in this major — the same seed and notation that produced one set of dice in 3.0.0-beta.0 produce a different set now. Persist the `RollResult`, not the seed ([#200](https://github.com/edloidas/roll-parser/issues/200)):
80
+ - **Seeding.** djb2 plus splitmix32 state expansion is replaced by cyrb128, which hashes the seed into all four state words at once. The previous scheme collided on 52% of two-character string seeds (`'ab'` and `'bA'` rolled identical dice) and capped stream diversity at 2^32 despite a 128-bit state. Numeric seeds are stringified before hashing, so all 53 exact bits reach it instead of being truncated by `seed >>> 0` — which had aliased `0` with `2**40`, and `-1` with `2**53 - 1`. `WARMUP_DRAWS` drops from 20 to 8, since the state now arrives from a single 128-bit hash ([#201](https://github.com/edloidas/roll-parser/issues/201))
81
+ - **Engine.** the xorshift128 core in `nextUint32()` is replaced by xoshiro128\*\* 1.1, verified against the C reference, keeping the four-word state shape. `nextInt` and the 53-bit wide-range path are untouched ([#202](https://github.com/edloidas/roll-parser/issues/202))
82
+ - **Auto-seed.** an unseeded `SeededRNG` now derives its seed from a `${Date.now()}-${Math.random()}-${Math.random()}` entropy string, spreading roughly 100 bits over the 128-bit state instead of 32. This widens collision resistance between concurrently created instances; it is not a claim of unpredictability, which stays bounded by the host `Math.random()` ([#204](https://github.com/edloidas/roll-parser/issues/204))
83
+ - **BREAKING:** `RngState` is now `readonly [version, s0, s1, s2, s3]` — a leading format version ahead of the four state words, where 3.0.0-beta.0 had the four words alone. Restoring a snapshot whose version does not match the running engine throws a `RollParserError` with the new `INCOMPATIBLE_RNG_STATE` code instead of resuming under different semantics; unversioned beta snapshots are rejected by the same check. The version is bumped by any change to the engine or the word layout, which is what makes the stability promise below enforceable rather than merely documented ([#215](https://github.com/edloidas/roll-parser/issues/215))
84
+ - **BREAKING:** the seed → dice mapping is now promised stable for the lifetime of a major version, replacing the "may change in any release, including a patch" wording that shipped in the README. A genuine distribution bug — bias, faulty rejection sampling — may still change the mapping, but only in a minor release and only with a `BREAKING` changelog note, never silently in a patch. Four sources previously disagreed on this; README, `MIGRATION.md`, and the `RngState`/`SeededRNG` TSDoc now state one policy ([#215](https://github.com/edloidas/roll-parser/issues/215))
85
+ - performance: seeded rolling is 2–16% faster despite the engine swap. State words are stored as signed int32 rather than normalized with `>>> 0` on write — identical bit patterns and output sequence, but a word above 2^31 leaves V8's Smi field representation and roughly doubles per-draw cost, which the old xorshift core was paying ([#202](https://github.com/edloidas/roll-parser/issues/202))
86
+ - **BREAKING:** error messages no longer embed the source position. `LexerError` and `ParseError` report it through `position`, `EvaluatorError` through `start`/`end`, and all three uniformly through `getErrorSpan` ([#128](https://github.com/edloidas/roll-parser/issues/128))
87
+ - **BREAKING:** every property of every `ASTNode` variant and of `Token` is now `readonly`, and the arrays reachable from a node — `FunctionCallNode.args`, `GroupNode.expressions`, `CritThresholdNode.successThresholds`/`failThresholds` — are `readonly T[]`. The docs already described the AST and token stream as immutable inputs, but nothing stopped a consumer from mutating a parsed node in place; the compiler now does. Type-only and source-compatible for the documented usage: `readonly` properties still accept object literals, so hand-built ASTs keep compiling, and `readonly` erases at runtime so the byte budgets are unmoved. Breaking only for code that mutates a parsed AST or token after the fact ([#217](https://github.com/edloidas/roll-parser/issues/217))
88
+ - **BREAKING:** every public name that said "modifier" but meant keep/drop only now says keep/drop. `ModifierNode` → `KeepDropNode`, `isModifier` → `isKeepDrop`, `ModifierSpec` → `KeepDropSpec`, the `ASTNode` discriminant `'Modifier'` → `'KeepDrop'` (so `EvaluatorError.nodeType` reports the new string), the `RollPart` discriminant `'modifier'` → `'keepDrop'` (visible in `--json` output), the error codes `INVALID_MODIFIER_COUNT`/`INVALID_MODIFIER_TARGET` → `INVALID_KEEP_DROP_COUNT`/`INVALID_KEEP_DROP_TARGET`, and the node's own `modifier: 'keep' | 'drop'` field → `kind`, which is what `KeepDropSpec` already called the same value. Everywhere else in the library "modifier" covers explode, reroll, sort, success-count, and crit — the parser's own messages read "Explode modifier requires …" — so `isModifier(parse('1d6!'))` returning `false` read as a bug at the call site, and `case 'modifier':` sat beside `'explode'`/`'reroll'`/`'sort'` as if it were their peer. No behaviour changed; the guard narrows the same node, the part carries the same fields. Breaking for code that switches on either discriminant, matches those error codes, or hand-builds a keep/drop node. No deprecated aliases: these names have no stable predecessor, and an alias pair is permanent surface against a 12 kB budget ([#219](https://github.com/edloidas/roll-parser/issues/219))
89
+ - **BREAKING:** `RollResult` is `Readonly` at the top level, and the `KeepDropSpec`, `ComparePoint`, `ResolvedComparePoint`, and `NodeSpan` fields are readonly. The `rolls` array and the `parts` tree stay mutable so consumers can annotate their own views ([#129](https://github.com/edloidas/roll-parser/issues/129))
90
+ - **BREAKING:** `engines.node` raised from `>=22.0.0` to `>=22.12.0`, the release where `require(esm)` became unflagged on the 22.x line
91
+ - npm `keywords` aligned with the terms people actually search, so the package surfaces for dice-notation and TTRPG queries rather than only its own name ([#198](https://github.com/edloidas/roll-parser/issues/198))
92
+ - `dist/` is now a per-file `tsc` emit instead of a bundle — JS, `.d.ts`, and both sourcemap kinds come from a single compiler pass, so stack traces and go-to-definition land on real source files. The bin moved to `dist/cli/index.js`; the `exports` map is unchanged, and deep imports into `dist/` were never part of the public surface ([#125](https://github.com/edloidas/roll-parser/issues/125))
93
+ - The node10-era `main` and `types` manifest fields are gone — `exports` is the single source of truth, which requires a TypeScript `moduleResolution` of `node16`, `nodenext`, or `bundler` ([#125](https://github.com/edloidas/roll-parser/issues/125))
94
+ - The exported `VERSION` constant now comes from a generated `src/version.ts` rather than a `package.json` JSON import, so no manifest metadata can reach the published output ([#125](https://github.com/edloidas/roll-parser/issues/125))
95
+ - `EvaluatorError` moved to `errors.ts` and is re-exported from the evaluator, breaking an ESM value cycle. Every error constructor now threads `ErrorOptions`, so `cause` chains work ([#128](https://github.com/edloidas/roll-parser/issues/128))
96
+ - Dice `sides` above `Number.MAX_SAFE_INTEGER` raise a typed `INVALID_DICE_SIDES` instead of a bare `RangeError` escaping from `SeededRNG.nextInt`, closing the last hole in the `isRollParserError` contract ([#128](https://github.com/edloidas/roll-parser/issues/128))
97
+ - performance: evaluator hot path is 1.4–1.9x faster — `100d6` 23.9 µs to 12.8 µs, `1000d6` 224 µs to 148 µs p50 — by giving literal meta-operands a fast path, stamping `'kept'` at die construction instead of cloning a pass over the pool, and dropping redundant walks ([#132](https://github.com/edloidas/roll-parser/issues/132))
98
+ - performance: lexer is 1.5–2.6x faster from charCode dispatch and slice capture, one string per token instead of one per character ([#133](https://github.com/edloidas/roll-parser/issues/133))
99
+ - performance: rendering a plain pool no longer walks per-die modifier tags — dice tagged exactly `['kept']` take a fast path in `renderDice`, cutting `1000d6` from 52.7 µs to 23.1 µs, `100d6` from 5.78 µs to 2.62 µs, and `4d6kh3` from 1.39 µs to 1.11 µs
100
+ - performance: parser guards traverse directly instead of scanning arrays — `unwrapTransparent` split into `unwrapGrouped`/`unwrapAllTransparent`, dropping a per-call kinds array and its `includes` scans, and `someDescendant` became a recursive switch rather than a `childNodes` generator allocating per step ([#163](https://github.com/edloidas/roll-parser/issues/163))
101
+ - performance: `kh1`/`kl1`/`dh1`/`dl1` — the overwhelmingly common keep/drop case — take a linear `markSingleExtreme` scan instead of allocating a per-die wrapper and running a comparator sort. First-occurrence tie-break and mask-union semantics are preserved exactly ([#164](https://github.com/edloidas/roll-parser/issues/164))
102
+ - `sideEffects` is narrowed to the CLI entry rather than a blanket `false`. The library entries are genuinely side-effect-free; `src/cli/index.ts` calls `main()` at module scope, so declaring the whole package pure invited a bundler to drop it ([#213](https://github.com/edloidas/roll-parser/issues/213))
103
+ - `EvaluatorError.stampSpan` is gone from the public surface, replaced by a module-scoped function assigned from a `static` block — the only scope the private `#start`/`#end` fields are reachable from. As a method on an exported class, any consumer could overwrite a caught error's span, and sealing it after 3.0.0 ships would have required a major. It cost 110 B on the `{ parse }` budget: the `static` block makes the class declaration impure, so `EvaluatorError` no longer tree-shakes out of a parse-only import ([#232](https://github.com/edloidas/roll-parser/issues/232))
104
+ - The emitted JS carries no comments. The declaration pass keeps TSDoc intact, so editor hovers and the `.d.ts.map` jump into the shipped `src/` are unchanged — only the runtime bytes shrink ([#165](https://github.com/edloidas/roll-parser/issues/165))
105
+ - **BREAKING:** `isRollParserError` matches on a brand carried by `RollParserError.prototype` instead of duck-typing a known `code`. The old fallback accepted any `Error` whose `code` collided with one of ours — a third-party `DIVISION_BY_ZERO` was reported to the end user as a dice-notation typo and never rethrown — while the realm crossings it was written for could not reach it, since it sat behind the `value instanceof Error` test that a realm crossing is precisely what breaks. The brand is a registered symbol, and the global symbol registry is shared by every realm in an agent, so it resolves from an iframe, a `vm` context, and a duplicate copy of the library in `node_modules`, including one from a newer minor carrying a code this build has never heard of — the brand is proof of origin, so the code is no longer re-validated against a closed list. Three things that used to pass now do not: a non-roll-parser error whose `code` collides with ours, a library error thrown by a copy older than the brand, and a copy of an error that preserved its own properties but not its prototype — `Object.assign(new Error(msg), rollError)`, or an RPC layer that revives own enumerable fields. `name` and `code` are own properties and the brand is not, by design, so any copy mechanism that walks own properties loses it ([#230](https://github.com/edloidas/roll-parser/issues/230))
106
+ - Release and CI trust boundaries tightened: npm publishing is split into an unprivileged validate-and-pack job and a minimal OIDC job that publishes the pre-built tarball with no checkout or dependency install; benchmark trend publishing moved to a default-branch-only job so branch runs hold no write token; CI validates fork pull requests; Wrangler is pinned exactly during credentialed site deploys; `release:dry` gains a `bun audit` gate ([#155](https://github.com/edloidas/roll-parser/issues/155))
107
+
108
+ ### Fixed
109
+
110
+ - **Security:** long operator chains no longer escape the typed-error contract. `MAX_PARSE_DEPTH` counted one level per recursive parser frame, but a left-associative chain (`1+1+1+…`) is consumed by a loop inside a single frame — so AST depth grew unbounded, the guard never fired, and the recursive evaluator overflowed the stack with a bare `RangeError` that `isRollParserError` rejects. Chain growth now counts toward the same bound. Roughly 25 kB of input was enough on Bun and about 2,000 terms on Node, which any caller passing untrusted notation to `roll` could be handed. The fuzz properties capped generated input at 64 characters and could never reach it; a long-chain property now covers the range
111
+
112
+ - Failure thresholds keep their comparator in `expression` and `rendered` — `10d10>=6f<3` rendered as `10d10>=6f3`, which re-parsed to a different expression. Bare `fN` stays the canonical form for `=` ([#124](https://github.com/edloidas/roll-parser/issues/124))
113
+ - The DC side of a `vs` comparison is no longer treated as part of the roll-side pool. `evalVersus` merges both sides into one `rolls` array so both render, but nothing distinguished them, so every modifier that walks its target's dice reached across: `{1d20 vs 2d10, 1d4}>=5` counted the DC faces as successes (3 instead of 1), and on hand-built ASTs `minN` clamped a `d10` DC die to a face a d10 cannot roll and summed both sides into the total (60 instead of 20), while `explode`, `reroll`, and keep/drop each summed or selected across the DC. DC dice now carry a new `'dc'` `DieModifier` tag, and summing, selection, clamping, rerolling, exploding, crit tagging, and success tallying all skip them — including the whole-roll tally behind `RollResult.successes`, which a success-count inside the DC sub-expression could otherwise feed. `sd`/`s` hold DC dice in their positions instead of sorting them into the roll-side pool. They still render — `1d20[3] vs 2d10[5, 6]` is unchanged — and `{1d20 vs 15}>=10`, which was already correct, still reports one success ([#262](https://github.com/edloidas/roll-parser/issues/262))
114
+ - A `minN`/`maxN` clamp over a `vs` target no longer reports a `degree` resolved against the pre-clamp total. `evalVersus` computes the degree once, and clamping re-sums, so `DieBound{min: 20}` over `1d20 vs 15` rolling 3 returned `total: 20` beside `degree: CriticalFailure` — a verdict for a total of 3 against DC 15. A clamped versus now reports no `degree` or `natural`, matching `explode`, `reroll`, keep/drop, and success-count, which mutate the total the same way. Reachable only through `evaluate()` on a hand-built AST; `(1d20 vs 15)min20` is rejected at parse time ([#260](https://github.com/edloidas/roll-parser/issues/260))
115
+ - CLI caret column counts code points rather than UTF-16 units, so notation containing astral characters (`🎲`) no longer shifts the caret right ([#128](https://github.com/edloidas/roll-parser/issues/128))
116
+ - The `sort` `RollPart` variant carries the `rolls: DieResult[]` it was missing — `evalSort` already computed the sorted array and then discarded it, so the one part whose entire purpose is ordering was the one part that could not report its order. The references are shared rather than copied, so flags set by a later modifier (`4d6s dl1`) stay visible through the part ([#242](https://github.com/edloidas/roll-parser/issues/242))
117
+ - Passing a non-string `notation` raises the new `INVALID_NOTATION_TYPE` code instead of failing somewhere downstream with a shape-dependent message. The guard sits in `lex` alone, which `parse` and `roll` both funnel through ([#229](https://github.com/edloidas/roll-parser/issues/229))
118
+ - `@internal` members no longer leak into the published `.d.ts`. `stripInternal` was set only for the TypeDoc site, so `EvaluatorError.stampSpan` and its kin shipped in the declarations while being hidden from the documentation — the two views of the surface disagreed. Both map kinds and the `.d.ts.map` link into the shipped `src/` are unaffected ([#220](https://github.com/edloidas/roll-parser/issues/220))
119
+ - Benchmark p50s no longer swing with mitata's sampling-mode heuristic, which committed a case to single-call mode from three cold calls against a 65,536 ns cutoff. All four stages now prime the bench body so every case is batch-sampled, and the sampling mode is recorded in `bench:json` as a tripwire. The README's 1000-die figure is restated — the old single-call mode inflated it ([#143](https://github.com/edloidas/roll-parser/issues/143))
120
+
121
+ ### Documentation
122
+
123
+ - The `isRollParserError` realm claim now describes what is delivered. The README and TSDoc promised the fallback matched errors that crossed a "worker, iframe, vm context" boundary; none of the three worked. Worker is documented as the one boundary that cannot work — `postMessage` and `structuredClone` rebuild an `Error` from `message` and `stack` alone, discarding `code`, `name`, and the prototype — with a recipe for posting `error.code` yourself instead ([#230](https://github.com/edloidas/roll-parser/issues/230))
124
+ - README rebuilt around real use cases: runnable examples above the fold, badges and site links, a complete notation reference including eleven previously undocumented forms, recipes by game system, the `RollResult`/`parts` tour, the MockRNG draw-order rules, the error-code contract, safety limits, a re-measured performance table with its protocol, and reviewed known limitations. Samples are hand-mirrored into the test suite, which asserts their claimed outputs ([#137](https://github.com/edloidas/roll-parser/issues/137))
125
+ - JSDoc polished across the whole public surface: `@category` tags on every exported symbol wired into TypeDoc, verified `@example` blocks on the high-traffic entry points, and documented fields on every error class, `SeededRNG` method, and `DieModifier` member ([#138](https://github.com/edloidas/roll-parser/issues/138))
126
+ - `CONTRIBUTING.md` covering the Bun-only toolchain and why, the pre-commit hook, Conventional Commits, the `scripts/docs` TypeScript pin, why `files` ships `src/`, and the release flow ([#139](https://github.com/edloidas/roll-parser/issues/139))
127
+ - README section on loading the package from a CDN without a bundler, and the minimum TypeScript `moduleResolution` the `exports`-only manifest requires ([#125](https://github.com/edloidas/roll-parser/issues/125))
128
+ - `MIGRATION.md`, a v2→v3 upgrade guide shipped in the published tarball: the packaging change, a mapping table from the thirteen 2.x entry points to `roll`/`parse`/`evaluate`, the `value`→`total` and `number[]`→`DieResult[]` result-shape moves, throwing instead of returning `null`, the two notation forms that do not carry over (simple notation, and WoD `>N` now meaning `>=N`), and pool totals no longer clamping at zero
129
+ - README install instructions lead with `npm install roll-parser`; the `yarn` fence is gone, `npx roll-parser` stays for one-off CLI use. The **Related projects** section was removed
130
+ - README restructured for readability: the runnable examples and the "why" list moved above the fold, a table of contents was added, and the error-code section now states the contract and links the API reference rather than reprinting every code ([#198](https://github.com/edloidas/roll-parser/issues/198))
131
+ - New **Versioning** section states what semver covers: unions (`RollParserErrorCode`, `RollPart`, `ASTNode`, `TokenType`, `DieModifier`) gain members in minor releases so consumers should keep a `default` arm; the seed → dice mapping and `RngState` are stable within a major, with a documented minor-release exception for genuine distribution bugs; and the mutable-by-design parts of `RollResult` are called out. This replaces the previous claim that exhaustive switches need no `default`, which contradicted the policy already documented on `RollParserErrorCode`
132
+ - Three previously undocumented limitations added to the README: division does not floor (`7/2` totals `3.5`), the power operator has no overflow guard (`2**999` totals `5.357…e+300`, since only non-finite totals throw), and integer literals above `Number.MAX_SAFE_INTEGER` lose precision ([#153](https://github.com/edloidas/roll-parser/issues/153))
133
+ - README Randomness section documents the full three-stage RNG pipeline — cyrb128 seeding, the xoshiro128\*\* core, and the rejection sampling in `nextInt` that stops a plain `% sides` from favouring low faces — plus replay and save/resume recipes built on `state()`. The former "fork by state" recipe is replaced by seed derivation: restoring a snapshot replays the parent stream at an offset, so the documented forks were one stream rather than independent substreams ([#205](https://github.com/edloidas/roll-parser/issues/205))
134
+ - `MIGRATION.md` gains a section for v3 pre-release upgraders, covering the changed seeded sequences and the `state()` save/resume recipe ([#205](https://github.com/edloidas/roll-parser/issues/205))
135
+ - The supported TypeScript floor is stated as ≥5.0 in the README Install and Versioning sections, where it was previously only implied by the `moduleResolution` requirement ([#221](https://github.com/edloidas/roll-parser/issues/221))
136
+ - Four README claims corrected or softened: the tree-shaking note now matches the narrowed `sideEffects` manifest, the impossible "distinct strings stay distinct" cyrb128 claim became collision resistance in both the README and the `SeededRNG` TSDoc (the true 53-exact-bit claim is kept), and the same overclaim was softened where it recurred in the per-entity substream section ([#218](https://github.com/edloidas/roll-parser/issues/218))
137
+ - The absolute "every failure extends `RollParserError`" claim is corrected, with a new **Errors outside the hierarchy** section covering `MockRNGExhaustedError` and the mock's `RangeError`. `mock.ts` documents why both stay un-branded: an exhausted mock is a fixture bug, so `isRollParserError` rejecting it makes a consumer's rethrow line fail the test rather than answer "bad dice" ([#232](https://github.com/edloidas/roll-parser/issues/232))
138
+ - The performance table was re-baselined on an idle machine, with `lex` and `parse` switched to nanoseconds now that both are sub-microsecond, and the protocol tightened to the median of three passes agreeing within 5% — a two-pass comparison cannot tell which of the two is the outlier ([#224](https://github.com/edloidas/roll-parser/issues/224))
139
+ - The notation guide covers the base forms it had been skipping, so every form the lexer accepts appears somewhere in the reference ([#243](https://github.com/edloidas/roll-parser/issues/243))
140
+
141
+ ## [3.0.0-beta.0] - 2026-07-07
19
142
 
20
143
  ### Added
21
144
 
@@ -28,6 +151,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
28
151
  - Lexer hints at modifier splits for merged identifiers: `4d6khs` now suggests `kh1s` ([#118](https://github.com/edloidas/roll-parser/issues/118))
29
152
  - `"./package.json"` export and `CHANGELOG.md` in the published tarball ([#119](https://github.com/edloidas/roll-parser/issues/119))
30
153
 
154
+ ### Changed
155
+
156
+ - **BREAKING:** package is now ESM-only. The `require` export condition shipped a file that was actually ESM (`bun build --target node` emits ESM), so it only ever worked via Node ≥22 `require(esm)` — which continues to work. Bundles are built with `--target node` (no `// @bun` pragma) and include linked sourcemaps; type declarations now resolve under `moduleResolution: node16/nodenext`, validated by `@arethetypeswrong/cli` + `publint` in CI ([#119](https://github.com/edloidas/roll-parser/issues/119))
157
+ - **BREAKING:** `cs`/`cf` are now independent overrides (Roll20 semantics). `1d20cf<3` keeps the default nat-20 crit instead of clearing it; `1d20cs>=19` keeps the default nat-1 fumble. The previous replace semantics required chaining `cscf<3` to preserve defaults ([#118](https://github.com/edloidas/roll-parser/issues/118))
158
+ - **BREAKING:** bare `NdXdY` dice chains (e.g. `4d6d1`) are rejected at parse time with `AMBIGUOUS_DICE_CHAIN` instead of silently parsing as `(4d6)d1` — roll 4d6, use the result as a count of d1 dice — which is almost never intended. Use `4d6dl1` to drop dice or `(4d6)d1` for nested dice ([#118](https://github.com/edloidas/roll-parser/issues/118))
159
+ - **BREAKING:** non-finite totals (`Infinity`/`NaN`, e.g. `2**1024`) now throw `NON_FINITE_RESULT` instead of returning a non-finite `total` ([#118](https://github.com/edloidas/roll-parser/issues/118))
160
+ - **BREAKING:** `1d1` no longer reports `fumble: true` — a d1 always rolls 1, so it is neither critical nor fumble, mirroring the existing `critical` guard ([#118](https://github.com/edloidas/roll-parser/issues/118))
161
+
31
162
  ### Fixed
32
163
 
33
164
  - Dropped group sub-rolls no longer leak `success`/`failure` tags into `RollResult.successes`/`failures` — `{2d6>=4, 2d6>=4}kh1` counted successes from the dropped sub-roll, and the rendered output showed `**bold**` markers inside `~~strikethrough~~` ([#118](https://github.com/edloidas/roll-parser/issues/118))
@@ -43,9 +174,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
43
174
  - Single-sub-roll Group passthrough no longer flips Fate `+1` faces to fumble. `{4dF+1d6}cf`, `({4dF+1d6})cf`, and `{abs(4dF)}cf` now reject via a new `deepContainsFatePool` mirror of `deepContainsDicePool` used inside `containsFatePool`'s Group case ([#109](https://github.com/edloidas/roll-parser/issues/109))
44
175
  - Single-sub-roll Group passthrough no longer smuggles `Versus` past the meta-expression rejection, so `{1d20 vs 15}cs>18`, `{1d20 vs 15}s`, `{1d20 vs 15}kh1`, `{1+(1d20 vs 15)}cs>18`, `{abs(1d20 vs 15)}cs>18`, and `4d6>={1d20 vs 15}` now throw `NESTED_VERSUS`. `parseModifier` also gained the `rejectVersusTarget` call it was missing — closing a pre-existing inconsistency where `(1d20 vs 15)kh1` rejected with a different error code while the brace form silently dropped `degree`/`natural` ([#109](https://github.com/edloidas/roll-parser/issues/109))
45
176
 
46
- ### Notes
177
+ ### Documentation
47
178
 
48
- These are intentional behaviours documented for the first time, not changes — semantics are unchanged.
179
+ Intentional behaviours documented for the first time, not changes — semantics are unchanged.
49
180
 
50
181
  - Sort flattens additive pools: `(2d6+1d8)s` renders as one combined sorted list rather than per-pool brackets. Same applies to wrapped pools like `floor(4d6)s`. Totals are preserved ([#96](https://github.com/edloidas/roll-parser/issues/96))
51
182
  - Outer parens drop from `result.expression` when chained `cs`/`cf` thresholds collapse: `(1d20cs>19)cs=1` renders as `1d20cs>19cs=1`. Re-parses to the same AST; only the textual form differs ([#96](https://github.com/edloidas/roll-parser/issues/96))
@@ -86,6 +217,8 @@ Dice mechanics (Stage 2):
86
217
  - 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))
87
218
  - 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))
88
219
 
89
- [Unreleased]: https://github.com/edloidas/roll-parser/compare/v3.0.0-beta.0...HEAD
220
+ [Unreleased]: https://github.com/edloidas/roll-parser/compare/v3.1.0...HEAD
221
+ [3.1.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.1.0
222
+ [3.0.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0
90
223
  [3.0.0-beta.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0-beta.0
91
224
  [3.0.0-alpha.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0-alpha.0
package/MIGRATION.md ADDED
@@ -0,0 +1,219 @@
1
+ # Migration notes
2
+
3
+ Newest first. [Upgrading from 3.0.0 to 3.1.0](#upgrading-from-300-to-310) ·
4
+ [Migrating from v2 to v3](#migrating-from-v2-to-v3) ·
5
+ [Upgrading from a v3 pre-release](#upgrading-from-a-v3-pre-release)
6
+
7
+ ## Upgrading from 3.0.0 to 3.1.0
8
+
9
+ Nothing was removed and no behaviour changed: `total`, `expression`, `rendered`,
10
+ `rolls`, and `degree` are byte-identical to 3.0.0 for every expression. The
11
+ whole of this section is about one added field.
12
+
13
+ **`RollPart` gained `rolls` on three variants.** The `explode`, `reroll`, and
14
+ `successCount` members now carry `rolls: DieResult[]` — the pool the modifier
15
+ produced — joining `sort`, which always had it. They needed it: standard and
16
+ penetrating explosions and both reroll forms *append* dice, and those dice
17
+ appeared nowhere in the part tree, so a part could not describe its own output.
18
+
19
+ <!-- readme-test: skip -->
20
+ ```typescript
21
+ // 3.0.0 — the explosion die is missing from the tree
22
+ roll('2d6!').parts.target.rolls.length; // 2, but `rendered` shows three dice
23
+
24
+ // 3.1.0 — the modifier carries the pool it actually rendered
25
+ roll('2d6!').parts.rolls.length; // 3
26
+ ```
27
+
28
+ Per the [versioning policy](README.md#versioning), adding a field to a returned
29
+ object is a minor. Reading `parts` is unaffected and needs no change. Three
30
+ things can still bite:
31
+
32
+ - **Constructing one of those parts stops compiling** — `TS2322: Property
33
+ 'rolls' is missing in type … but required`. This is only reachable in test
34
+ fixtures and mocks, since no library function accepts a `RollPart`. Add the
35
+ pool, or widen the annotation.
36
+ - **Deep-equality assertions against those parts fail.** Add `rolls` to the
37
+ expected object, or assert on the fields you care about instead of the whole
38
+ part.
39
+ - **`--json` and `JSON.stringify(result)` payloads grow** for expressions using
40
+ explode, reroll, or success counting, because the pool is serialized on the
41
+ modifier as well as on its target — roughly +40% to +96% depending on pool
42
+ size. Expressions without those modifiers are unchanged. Budget for it if you
43
+ log, cache, or ship results over a wire.
44
+
45
+ **The CLI renders nested dropped sub-rolls correctly.** `--verbose` on a dropped
46
+ group sub-roll nested inside another one used to emit mismatched delimiters —
47
+ `{{(1d6[1]), 1d8[4]}, ({)1d10[1](, 1d12[3]})}`. It now emits
48
+ `{{(1d6[1]), 1d8[4]}, ({(1d10[1]), 1d12[3]})}`. Anything parsing that output
49
+ should be reading `--json` instead.
50
+
51
+ **New, optional: `roll-parser/render`.** A subpath export whose
52
+ `renderBreakdown(result, marks?)` rebuilds the breakdown from `result.parts`
53
+ with markers you choose, so you no longer have to regex the markdown out of
54
+ `rendered`. With no marks its output is byte-identical to `rendered`, which
55
+ makes it a drop-in starting point:
56
+
57
+ ```typescript
58
+ import { roll } from 'roll-parser';
59
+ import { renderBreakdown } from 'roll-parser/render';
60
+ import { createMockRng } from 'roll-parser/testing';
61
+
62
+ const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });
63
+
64
+ renderBreakdown(result) === result.rendered; // true
65
+ renderBreakdown(result, { dropped: (_die, text) => `(${text})` });
66
+ // '4d6[3, 6, (2), 5] = 14'
67
+ ```
68
+
69
+ See [Custom markers](README.md#custom-markers) for the six slots and the order
70
+ they compose in. Nothing forces you to adopt it — `rendered` is unchanged and
71
+ is not deprecated.
72
+
73
+ ## Migrating from v2 to v3
74
+
75
+ v3 is a full rewrite. Nothing in the 2.x API carries over — the entry points,
76
+ the result shape, the error behaviour, and parts of the notation all changed. To
77
+ stay on the old line, pin `roll-parser@2.3.2`; it is unmaintained but unaffected
78
+ by any of this.
79
+
80
+ **Packaging.** v2 was CommonJS with a `main` field. v3 is ESM-only and resolves
81
+ through `exports`, so Node.js ≥ 22.12 is required and TypeScript needs
82
+ `moduleResolution` of `bundler`, `node16`, or `nodenext`. CommonJS callers can
83
+ still `require('roll-parser')` on Node ≥ 22.12, where `require(esm)` is
84
+ unflagged.
85
+
86
+ **One entry point instead of thirteen.** v2 exposed a matrix of
87
+ `parse*`/`roll*`/`parseAndRoll*` functions plus `Roll`, `WodRoll`, and `Result`
88
+ classes. v3 has `roll(notation, options)` for the common path, and
89
+ `parse`/`evaluate` when you want the two halves separately.
90
+
91
+ | v2 | v3 |
92
+ |----|----|
93
+ | `parseAndRoll(n)`, `parseAndRollClassic(n)`, `parseAndRollWod(n)` | `roll(n)` |
94
+ | `parseAndRollSimple(n)` | removed — simple notation is gone, see below |
95
+ | `parse(n)` → `Roll`/`WodRoll` data object | `parse(n)` → AST (a different, richer shape) |
96
+ | `roll(obj)`, `rollClassic(obj)`, `rollWod(obj)` | `evaluate(parse(n), rng)` |
97
+ | `parseClassicRoll(n)`, `parseWodRoll(n)` | `parse(n)` — one grammar covers both, so there is no per-dialect parser |
98
+ | `parseSimpleRoll(n)` | removed — simple notation is gone, see below |
99
+ | `random(faces)` | `new SeededRNG().nextInt(1, faces)` |
100
+ | `convert(obj)`, `Roll`, `WodRoll`, `Result` | removed — there are no roll-description objects to build or convert |
101
+
102
+ **Result fields moved.** v2 returned `Result { notation, value, rolls }` where
103
+ `rolls` was a plain `number[]`. The v3 snippets below draw from
104
+ `createMockRng` (exported by `roll-parser/testing`) so their numbers are exact
105
+ rather than whatever the dice happened to do:
106
+
107
+ <!-- readme-test: skip -->
108
+ ```typescript
109
+ // v2
110
+ const res = parseAndRoll('2d10+1'); // { notation: '2d10+1', value: 9, rolls: [2, 6] }
111
+
112
+ // v3 — mock RNG so the numbers below are the actual ones, not a lucky roll
113
+ const res = roll('2d10+1', { rng: createMockRng([2, 6]) });
114
+ res.total; // 9 — was `value`
115
+ res.expression; // '2d10 + 1' — was `notation` (normalized form)
116
+ res.notation; // '2d10+1' — the input string, verbatim
117
+ res.rolls.map((die) => die.result); // [2, 6] — `rolls` is now DieResult[]
118
+ ```
119
+
120
+ **Invalid input throws instead of returning `null`.** v2's `parse` and
121
+ `parseAndRoll` returned `null` for anything they could not read, so failures
122
+ were easy to miss. v3 throws a typed error with a stable `code` and a source
123
+ span:
124
+
125
+ <!-- readme-test: skip -->
126
+ ```typescript
127
+ // v2
128
+ if (parseAndRoll(input) == null) {
129
+ /* handle */
130
+ }
131
+
132
+ // v3
133
+ try {
134
+ roll(input);
135
+ } catch (error) {
136
+ if (isRollParserError(error)) error.code; // e.g. 'UNEXPECTED_IDENTIFIER'
137
+ else throw error;
138
+ }
139
+ ```
140
+
141
+ See [Error handling](README.md#error-handling) for the error classes and span
142
+ helpers; the full code list lives in the
143
+ [API reference](https://roll-parser.edloidas.io/docs/).
144
+
145
+ **Notation changes.** Classic notation (`2d10+1`, `d6`) parses unchanged. Two
146
+ v2 forms do not:
147
+
148
+ - **Simple notation is gone.** `2 10 -1` no longer means `2d10-1`. Whitespace is
149
+ now insignificant between tokens rather than a separator, so `2 10 -1` is a
150
+ parse error. Convert these to classic notation.
151
+ - **WoD thresholds are inclusive and explicit.** v2's `>N` meant "N or higher",
152
+ so v2 `4d10!>8f1` becomes v3 `4d10!=10>=8f1`. Two things changed: `>` is
153
+ strictly greater-than in v3 (use `>=` for v2's meaning), and an explosion
154
+ combined with a success count needs its own compare point — a bare `!`
155
+ followed by `>=` reads the threshold as the explode target and then fails on
156
+ the `f`.
157
+
158
+ **Pool totals are no longer clamped.** v2 returned `Math.max(successes -
159
+ failures, 0)`, hiding botches below zero. v3 reports the real arithmetic, and
160
+ splits the tally out:
161
+
162
+ ```typescript
163
+ const pool = roll('4d10>=8f1', { rng: createMockRng([1, 1, 1, 5]) });
164
+ pool.total; // -3 — v2 would have reported 0
165
+ pool.successes; // 0
166
+ pool.failures; // 3
167
+ ```
168
+
169
+ **The CLI takes one notation, not a list.** v2 read every positional argument as
170
+ a separate roll and printed one line each, falling back to `d20` when you passed
171
+ none. v3 joins the positional arguments into a single notation — so `roll-parser
172
+ 2d6 + 3` works — and treats a missing notation as a usage error (exit code 2)
173
+ rather than rolling a d20. A v2 invocation like `roll-parser 1d6 1d8` now fails
174
+ to parse; run the CLI twice, or roll `1d6+1d8` if you wanted the sum.
175
+
176
+ Everything else in v3 is new surface rather than a replacement — the `parts`
177
+ tree, injectable RNGs, source spans, safety limits, and the wider notation set
178
+ have no v2 equivalent to migrate from. The
179
+ [full list of breaking changes](CHANGELOG.md) is in the changelog.
180
+
181
+ ## Upgrading from a v3 pre-release
182
+
183
+ `3.0.0-alpha.0` and `3.0.0-beta.0` derived a single 32-bit number from the seed
184
+ — djb2 for strings, a `>>> 0` truncation for numbers — expanded it with
185
+ splitmix32, and ran an xorshift128 core. `3.0.0` replaces all of it: cyrb128
186
+ hashes the seed straight into the full 128-bit state, and xoshiro128\*\*
187
+ generates the stream. Every seeded sequence therefore changed — the same seed
188
+ and notation roll different dice. Nothing in the API moved, so this surfaces
189
+ only as tests asserting pinned faces, or as saved games that stored a seed.
190
+
191
+ The fix is the same one the reproducibility contract has always implied: a seed
192
+ is stable within a major version, not across one, so **persist the
193
+ `RollResult`** — it holds the totals, the per-die faces, and the rendered
194
+ breakdown — rather than re-deriving rolls from a stored seed. Tests that need
195
+ exact faces should use `createMockRng` from `roll-parser/testing`, which is
196
+ engine-independent.
197
+
198
+ When you need a live generator to survive a save and resume, snapshot its state
199
+ instead of its seed. `state()` returns a format version plus four unsigned
200
+ 32-bit words that the constructor takes back verbatim, with no re-hashing and no
201
+ warm-up:
202
+
203
+ ```typescript
204
+ import { SeededRNG, roll } from 'roll-parser';
205
+
206
+ const rng = new SeededRNG('campaign');
207
+ const save = JSON.stringify(rng.state());
208
+
209
+ const live = roll('1d20', { rng });
210
+ const resumed = roll('1d20', { rng: new SeededRNG(JSON.parse(save)) });
211
+ live.total === resumed.total; // true
212
+ ```
213
+
214
+ `RngState` carries the same major-version binding as a seed — good for a save
215
+ file within a major, not across one. The binding is enforced rather than
216
+ documented: a snapshot from another format version throws
217
+ `INCOMPATIBLE_RNG_STATE` instead of resuming under different semantics, so a
218
+ stale save fails at load rather than diverging quietly. Snapshots taken with
219
+ `3.0.0-beta.0`, which had no version word, are rejected the same way.