roll-parser 3.0.0-alpha.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +189 -0
  2. package/MIGRATION.md +147 -0
  3. package/README.md +985 -43
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +16 -4
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +17 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -14
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +26 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +58 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +62 -40
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +906 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.d.ts +1 -1
  37. package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
  38. package/dist/evaluator/modifiers/compare.js +15 -0
  39. package/dist/evaluator/modifiers/compare.js.map +1 -0
  40. package/dist/evaluator/modifiers/crit-threshold.d.ts +27 -0
  41. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  42. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  43. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  45. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  46. package/dist/evaluator/modifiers/die-bound.js +14 -0
  47. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  48. package/dist/evaluator/modifiers/explode.d.ts +18 -6
  49. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  50. package/dist/evaluator/modifiers/explode.js +103 -0
  51. package/dist/evaluator/modifiers/explode.js.map +1 -0
  52. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  53. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  54. package/dist/evaluator/modifiers/flags.js +18 -0
  55. package/dist/evaluator/modifiers/flags.js.map +1 -0
  56. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  57. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  58. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  59. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  60. package/dist/evaluator/modifiers/reroll.d.ts +14 -6
  61. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  62. package/dist/evaluator/modifiers/reroll.js +62 -0
  63. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  64. package/dist/evaluator/modifiers/sort.d.ts +27 -0
  65. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  66. package/dist/evaluator/modifiers/sort.js +13 -0
  67. package/dist/evaluator/modifiers/sort.js.map +1 -0
  68. package/dist/evaluator/modifiers/success-count.d.ts +2 -6
  69. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  70. package/dist/evaluator/modifiers/success-count.js +24 -0
  71. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  72. package/dist/index.d.ts +35 -13
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +12 -1723
  75. package/dist/index.js.map +1 -0
  76. package/dist/lexer/lexer.d.ts +68 -8
  77. package/dist/lexer/lexer.d.ts.map +1 -1
  78. package/dist/lexer/lexer.js +260 -0
  79. package/dist/lexer/lexer.js.map +1 -0
  80. package/dist/lexer/tokens.d.ts +52 -7
  81. package/dist/lexer/tokens.d.ts.map +1 -1
  82. package/dist/lexer/tokens.js +42 -0
  83. package/dist/lexer/tokens.js.map +1 -0
  84. package/dist/parser/ast.d.ts +419 -80
  85. package/dist/parser/ast.d.ts.map +1 -1
  86. package/dist/parser/ast.js +52 -0
  87. package/dist/parser/ast.js.map +1 -0
  88. package/dist/parser/guards.d.ts +106 -0
  89. package/dist/parser/guards.d.ts.map +1 -0
  90. package/dist/parser/guards.js +121 -0
  91. package/dist/parser/guards.js.map +1 -0
  92. package/dist/parser/parser.d.ts +162 -15
  93. package/dist/parser/parser.d.ts.map +1 -1
  94. package/dist/parser/parser.js +751 -0
  95. package/dist/parser/parser.js.map +1 -0
  96. package/dist/rng/mock.d.ts +74 -13
  97. package/dist/rng/mock.d.ts.map +1 -1
  98. package/dist/rng/mock.js +30 -0
  99. package/dist/rng/mock.js.map +1 -0
  100. package/dist/rng/seeded.d.ts +149 -10
  101. package/dist/rng/seeded.d.ts.map +1 -1
  102. package/dist/rng/seeded.js +138 -0
  103. package/dist/rng/seeded.js.map +1 -0
  104. package/dist/rng/types.d.ts +57 -0
  105. package/dist/rng/types.d.ts.map +1 -1
  106. package/dist/rng/types.js +2 -0
  107. package/dist/rng/types.js.map +1 -0
  108. package/dist/roll.d.ts +59 -25
  109. package/dist/roll.d.ts.map +1 -1
  110. package/dist/roll.js +8 -0
  111. package/dist/roll.js.map +1 -0
  112. package/dist/testing.d.ts +5 -4
  113. package/dist/testing.d.ts.map +1 -1
  114. package/dist/testing.js +2 -38
  115. package/dist/testing.js.map +1 -0
  116. package/dist/types.d.ts +427 -24
  117. package/dist/types.d.ts.map +1 -1
  118. package/dist/types.js +8 -0
  119. package/dist/types.js.map +1 -0
  120. package/dist/version.d.ts +2 -0
  121. package/dist/version.d.ts.map +1 -0
  122. package/dist/version.js +2 -0
  123. package/dist/version.js.map +1 -0
  124. package/package.json +93 -40
  125. package/src/cli/args.ts +66 -9
  126. package/src/cli/format.ts +30 -7
  127. package/src/cli/index.ts +27 -67
  128. package/src/cli/main.ts +129 -0
  129. package/src/errors.ts +488 -23
  130. package/src/evaluator/die.ts +50 -0
  131. package/src/evaluator/env.ts +73 -0
  132. package/src/evaluator/evaluator.ts +1296 -349
  133. package/src/evaluator/modifiers/compare.ts +1 -1
  134. package/src/evaluator/modifiers/crit-threshold.ts +56 -0
  135. package/src/evaluator/modifiers/die-bound.ts +39 -0
  136. package/src/evaluator/modifiers/explode.ts +82 -76
  137. package/src/evaluator/modifiers/flags.ts +61 -0
  138. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  139. package/src/evaluator/modifiers/reroll.ts +36 -64
  140. package/src/evaluator/modifiers/sort.ts +43 -0
  141. package/src/evaluator/modifiers/success-count.ts +6 -9
  142. package/src/index.ts +73 -34
  143. package/src/lexer/lexer.ts +201 -35
  144. package/src/lexer/tokens.ts +72 -7
  145. package/src/parser/ast.ts +453 -104
  146. package/src/parser/guards.ts +248 -0
  147. package/src/parser/parser.ts +835 -135
  148. package/src/rng/mock.ts +75 -14
  149. package/src/rng/seeded.ts +323 -58
  150. package/src/rng/types.ts +57 -0
  151. package/src/roll.ts +66 -41
  152. package/src/testing.ts +5 -9
  153. package/src/types.ts +416 -24
  154. package/src/version.ts +2 -0
  155. package/dist/cli.js +0 -1775
  156. package/dist/evaluator/index.d.ts +0 -8
  157. package/dist/evaluator/index.d.ts.map +0 -1
  158. package/dist/index.mjs +0 -1724
  159. package/dist/rng/index.d.ts +0 -8
  160. package/dist/rng/index.d.ts.map +0 -1
  161. package/dist/testing.mjs +0 -39
  162. package/src/evaluator/index.ts +0 -14
  163. package/src/rng/index.ts +0 -8
package/CHANGELOG.md ADDED
@@ -0,0 +1,189 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [3.0.0] - 2026-08-06
11
+
12
+ First stable release of the v3 rewrite. This section covers what changed since
13
+ `3.0.0-beta.0`; the two pre-release sections below carry the rest of the rewrite.
14
+ Upgrading from 2.x starts at [MIGRATION.md](MIGRATION.md) — no 2.x API carries
15
+ over.
16
+
17
+ ### Added
18
+
19
+ - 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))
20
+ - 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))
21
+ - `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))
22
+ - 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))
23
+ - `roll-parser/testing` exports the `RNG` and `MockRNGExhaustedError` types it was missing ([#129](https://github.com/edloidas/roll-parser/issues/129))
24
+ - 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))
25
+ - 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))
26
+ - 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
27
+ - `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))
28
+ - 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))
29
+ - `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))
30
+ - 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))
31
+ - 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))
32
+ - `SECURITY.md` with a private vulnerability disclosure route ([#155](https://github.com/edloidas/roll-parser/issues/155))
33
+ - 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))
34
+ - `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))
35
+ - 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))
36
+ - `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
37
+ - `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
38
+ - `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))
39
+ - 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))
40
+
41
+ ### Changed
42
+
43
+ - **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))
44
+ - **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))
45
+ - **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)):
46
+ - **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))
47
+ - **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))
48
+ - **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))
49
+ - **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))
50
+ - **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))
51
+ - 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))
52
+ - **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))
53
+ - **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))
54
+ - **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))
55
+ - **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))
56
+ - **BREAKING:** `engines.node` raised from `>=22.0.0` to `>=22.12.0`, the release where `require(esm)` became unflagged on the 22.x line
57
+ - 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))
58
+ - `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))
59
+ - 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))
60
+ - 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))
61
+ - `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))
62
+ - 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))
63
+ - 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))
64
+ - 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))
65
+ - 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
66
+ - 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))
67
+ - 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))
68
+ - `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))
69
+ - `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))
70
+ - 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))
71
+ - **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))
72
+ - 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))
73
+
74
+ ### Fixed
75
+
76
+ - **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
77
+
78
+ - 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))
79
+ - 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))
80
+ - 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))
81
+ - 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))
82
+ - 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))
83
+ - 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))
84
+ - `@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))
85
+ - 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))
86
+
87
+ ### Documentation
88
+
89
+ - 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))
90
+ - 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))
91
+ - 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))
92
+ - `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))
93
+ - 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))
94
+ - `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
95
+ - 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
96
+ - 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))
97
+ - 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`
98
+ - 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))
99
+ - 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))
100
+ - `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))
101
+ - 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))
102
+ - 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))
103
+ - 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))
104
+ - 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))
105
+ - 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))
106
+
107
+ ## [3.0.0-beta.0] - 2026-07-07
108
+
109
+ ### Added
110
+
111
+ - Rich structured `RollResult.parts` output — a 16-variant discriminated union mirroring the AST 1:1, always built during evaluation. Each part carries its sub-total, resolved thresholds/specs (`ModifierSpec`, `ResolvedComparePoint`, `ResolvedCritThreshold`), source span, and (for dice parts) the same `DieResult` objects as `RollResult.rolls`. Group parts under keep/drop carry `keptIndices`; versus parts carry `degree`; successCount parts carry `successes`/`failures` with `total === successes - failures`. JSON-serializable with `parts.total === result.total` guaranteed ([#84](https://github.com/edloidas/roll-parser/issues/84))
112
+ - Source spans: lexer tokens carry an exclusive `end` offset, and every parser-produced AST node carries `start`/`end` offsets (`NodeSpan`) into the notation. `EvaluatorError` is stamped with the span of the tightest failing sub-expression, and the CLI caret uses it — `2d6+1d0+3` points at `1d0` ([#120](https://github.com/edloidas/roll-parser/issues/120))
113
+ - Root exports for the previously missing `GroupNode`/`SortNode`/`CritThresholdNode` types and `isGroup`/`isSort`/`isCritThreshold` guards ([#120](https://github.com/edloidas/roll-parser/issues/120))
114
+ - Benchmark suite (`bun bench`, mitata) covering parse, evaluate, and end-to-end roll paths ([#119](https://github.com/edloidas/roll-parser/issues/119))
115
+ - Coverage thresholds enforced in CI (100% functions, ≥95% lines per file); coverage was previously disabled in `bunfig.toml` and unmeasurable even with `--coverage` ([#119](https://github.com/edloidas/roll-parser/issues/119))
116
+ - CLI prints the notation with a caret under the error position for lexer/parser errors ([#118](https://github.com/edloidas/roll-parser/issues/118))
117
+ - Lexer hints at modifier splits for merged identifiers: `4d6khs` now suggests `kh1s` ([#118](https://github.com/edloidas/roll-parser/issues/118))
118
+ - `"./package.json"` export and `CHANGELOG.md` in the published tarball ([#119](https://github.com/edloidas/roll-parser/issues/119))
119
+
120
+ ### Changed
121
+
122
+ - **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))
123
+ - **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))
124
+ - **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))
125
+ - **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))
126
+ - **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))
127
+
128
+ ### Fixed
129
+
130
+ - 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))
131
+ - `SeededRNG.nextInt` no longer silently truncates ranges above 2^32 — a 53-bit two-draw sampling path covers up to `Number.MAX_SAFE_INTEGER`, and wider ranges throw `RangeError` instead of degrading ([#118](https://github.com/edloidas/roll-parser/issues/118))
132
+ - Deeply nested input (e.g. 20,000 parens) throws a typed `MAX_DEPTH_EXCEEDED` `ParseError` instead of an uncaught `RangeError` stack overflow that broke the `isRollParserError` contract ([#118](https://github.com/edloidas/roll-parser/issues/118))
133
+ - PF2e nat-20/nat-1 upgrade now survives standard (`!`) and penetrating (`!p`) explosion under `vs` — explosion continuation dice no longer make `extractNatural` bail as ambiguous, matching the compound (`!!`) behavior ([#118](https://github.com/edloidas/roll-parser/issues/118))
134
+ - Entire `package.json` (scripts, devDependencies, author email) is no longer embedded in every bundle via the `VERSION` import ([#119](https://github.com/edloidas/roll-parser/issues/119))
135
+ - CLI verbose output no longer leaks markdown for dropped group sub-rolls — `{1d8, 1d10}kh1 -v` printed `{~~1d8[2]~~, ...}` because only single-number spans were rewritten ([#118](https://github.com/edloidas/roll-parser/issues/118))
136
+ - Lexer errors report full code points for astral characters (`🎲` instead of a lone surrogate `�`); parser `expect()` errors name symbols and say `end of input` instead of `Expected RPAREN but got ''` ([#118](https://github.com/edloidas/roll-parser/issues/118))
137
+ - Parser now rejects `SuccessCount` wrapped in meta-expression positions — modifier count, dice count/sides (infix and prefix), Fate/percentile dice count, SuccessCount threshold and bare `fN` value, and compare-point values used by Explode/Reroll. `mergeMetaRolls` also strips `success`/`failure` modifier tags on meta-forwarded dice as defense-in-depth ([#69](https://github.com/edloidas/roll-parser/issues/69))
138
+ - Parser now rejects `Versus` wrapped in the same meta-expression positions so a PF2e `vs` outcome cannot be silently dropped by `mergeMetaRolls`, and the `parseVersus` chain guard unwraps `Grouped` so `(1d20 vs 15) vs 10` throws `NESTED_VERSUS` at parse time instead of at eval ([#70](https://github.com/edloidas/roll-parser/issues/70))
139
+ - Single-sub-roll Group passthrough on `cs`/`cf` no longer smuggles a buried multi-sub Group past the parser. `{{1d20, 1d20}kh1}cs>18`, `{{1d6, 2d8}+0}cs>5`, `{abs({1d6, 2d8})}cs>5`, and `{floor({1d6, 2d8}/1)}cs>5` now reject with `INVALID_CRIT_THRESHOLD_TARGET` via a new `containsMultiSubGroup` deep-walk in `rejectGroupTarget` ([#109](https://github.com/edloidas/roll-parser/issues/109))
140
+ - 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))
141
+ - 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))
142
+
143
+ ### Documentation
144
+
145
+ Intentional behaviours documented for the first time, not changes — semantics are unchanged.
146
+
147
+ - 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))
148
+ - 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))
149
+
150
+ ## [3.0.0-alpha.0] - 2026-04-20
151
+
152
+ First alpha of the v3 rewrite. Stage 1 (core engine) is complete; Stage 2 (dice mechanics) is largely in place.
153
+
154
+ ### Added
155
+
156
+ Core engine (Stage 1):
157
+
158
+ - Lexer and token system ([#3](https://github.com/edloidas/roll-parser/issues/3))
159
+ - Pratt parser and AST types ([#4](https://github.com/edloidas/roll-parser/issues/4))
160
+ - Seedable RNG system with `SeededRNG` (xorshift128) and `MockRNG` helpers ([#5](https://github.com/edloidas/roll-parser/issues/5))
161
+ - AST evaluator with keep/drop modifiers (`kh`, `kl`, `dh`, `dl`) ([#6](https://github.com/edloidas/roll-parser/issues/6))
162
+ - Public `roll(notation, options)` API with `RollResult` / `DieResult` types ([#7](https://github.com/edloidas/roll-parser/issues/7))
163
+ - `roll-parser` CLI with `--help`, `--version`, `--verbose`, `--seed` flags; ESM + CJS dual build ([#8](https://github.com/edloidas/roll-parser/issues/8))
164
+
165
+ Dice mechanics (Stage 2):
166
+
167
+ - Percentile dice notation (`d%`) ([#35](https://github.com/edloidas/roll-parser/issues/35))
168
+ - Fate/Fudge dice (`dF`) ([#36](https://github.com/edloidas/roll-parser/issues/36))
169
+ - Math functions: `floor()`, `ceil()`, `round()`, `abs()`, `max()`, `min()` ([#37](https://github.com/edloidas/roll-parser/issues/37))
170
+ - Exploding dice — `!` (standard), `!!` (compound), `!p` (penetrating) — with optional compare points ([#38](https://github.com/edloidas/roll-parser/issues/38))
171
+ - Reroll mechanics (`r`, `ro`) with compare points ([#39](https://github.com/edloidas/roll-parser/issues/39))
172
+ - Success counting / dice pools with `>`, `>=`, `<`, `<=`, `=` operators and `fN` failure tagging ([#40](https://github.com/edloidas/roll-parser/issues/40))
173
+ - PF2e Degrees of Success via the `vs` keyword, with nat-20/1 upgrade/downgrade ([#41](https://github.com/edloidas/roll-parser/issues/41))
174
+
175
+ ### Changed
176
+
177
+ - **BREAKING:** complete rewrite from v2.x. Public API, semantics, and notation coverage are not compatible with the 2.x line. Pin to `roll-parser@2.3.2` for the legacy implementation.
178
+ - Public API surface trimmed: `Lexer`, `Parser`, `lex`, `TokenType`, `Token`, and mock RNG exports removed from the root entry. `createMockRng` moved to the `roll-parser/testing` subpath. Added typed `RollParserError` base class with `RollParserErrorCode` union and `isRollParserError()` type guard ([#24](https://github.com/edloidas/roll-parser/issues/24))
179
+
180
+ ### Fixed
181
+
182
+ - Modifier chaining now matches the Roll20 standard: chained modifiers are flattened and each applies independently to the full pool; drop sets are unioned ([#12](https://github.com/edloidas/roll-parser/issues/12))
183
+ - 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
+ - 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
+
186
+ [Unreleased]: https://github.com/edloidas/roll-parser/compare/v3.0.0...HEAD
187
+ [3.0.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0
188
+ [3.0.0-beta.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0-beta.0
189
+ [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,147 @@
1
+ # Migrating from v2 to v3
2
+
3
+ v3 is a full rewrite. Nothing in the 2.x API carries over — the entry points,
4
+ the result shape, the error behaviour, and parts of the notation all changed. To
5
+ stay on the old line, pin `roll-parser@2.3.2`; it is unmaintained but unaffected
6
+ by any of this.
7
+
8
+ **Packaging.** v2 was CommonJS with a `main` field. v3 is ESM-only and resolves
9
+ through `exports`, so Node.js ≥ 22.12 is required and TypeScript needs
10
+ `moduleResolution` of `bundler`, `node16`, or `nodenext`. CommonJS callers can
11
+ still `require('roll-parser')` on Node ≥ 22.12, where `require(esm)` is
12
+ unflagged.
13
+
14
+ **One entry point instead of thirteen.** v2 exposed a matrix of
15
+ `parse*`/`roll*`/`parseAndRoll*` functions plus `Roll`, `WodRoll`, and `Result`
16
+ classes. v3 has `roll(notation, options)` for the common path, and
17
+ `parse`/`evaluate` when you want the two halves separately.
18
+
19
+ | v2 | v3 |
20
+ |----|----|
21
+ | `parseAndRoll(n)`, `parseAndRollClassic(n)`, `parseAndRollWod(n)` | `roll(n)` |
22
+ | `parseAndRollSimple(n)` | removed — simple notation is gone, see below |
23
+ | `parse(n)` → `Roll`/`WodRoll` data object | `parse(n)` → AST (a different, richer shape) |
24
+ | `roll(obj)`, `rollClassic(obj)`, `rollWod(obj)` | `evaluate(parse(n), rng)` |
25
+ | `parseClassicRoll(n)`, `parseWodRoll(n)` | `parse(n)` — one grammar covers both, so there is no per-dialect parser |
26
+ | `parseSimpleRoll(n)` | removed — simple notation is gone, see below |
27
+ | `random(faces)` | `new SeededRNG().nextInt(1, faces)` |
28
+ | `convert(obj)`, `Roll`, `WodRoll`, `Result` | removed — there are no roll-description objects to build or convert |
29
+
30
+ **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:
34
+
35
+ <!-- readme-test: skip -->
36
+ ```typescript
37
+ // v2
38
+ const res = parseAndRoll('2d10+1'); // { notation: '2d10+1', value: 9, rolls: [2, 6] }
39
+
40
+ // v3 — mock RNG so the numbers below are the actual ones, not a lucky roll
41
+ const res = roll('2d10+1', { rng: createMockRng([2, 6]) });
42
+ res.total; // 9 — was `value`
43
+ res.expression; // '2d10 + 1' — was `notation` (normalized form)
44
+ res.notation; // '2d10+1' — the input string, verbatim
45
+ res.rolls.map((die) => die.result); // [2, 6] — `rolls` is now DieResult[]
46
+ ```
47
+
48
+ **Invalid input throws instead of returning `null`.** v2's `parse` and
49
+ `parseAndRoll` returned `null` for anything they could not read, so failures
50
+ were easy to miss. v3 throws a typed error with a stable `code` and a source
51
+ span:
52
+
53
+ <!-- readme-test: skip -->
54
+ ```typescript
55
+ // v2
56
+ if (parseAndRoll(input) == null) {
57
+ /* handle */
58
+ }
59
+
60
+ // v3
61
+ try {
62
+ roll(input);
63
+ } catch (error) {
64
+ if (isRollParserError(error)) error.code; // e.g. 'UNEXPECTED_IDENTIFIER'
65
+ else throw error;
66
+ }
67
+ ```
68
+
69
+ See [Error handling](README.md#error-handling) for the error classes and span
70
+ helpers; the full code list lives in the
71
+ [API reference](https://roll-parser.edloidas.io/docs/).
72
+
73
+ **Notation changes.** Classic notation (`2d10+1`, `d6`) parses unchanged. Two
74
+ v2 forms do not:
75
+
76
+ - **Simple notation is gone.** `2 10 -1` no longer means `2d10-1`. Whitespace is
77
+ now insignificant between tokens rather than a separator, so `2 10 -1` is a
78
+ parse error. Convert these to classic notation.
79
+ - **WoD thresholds are inclusive and explicit.** v2's `>N` meant "N or higher",
80
+ so v2 `4d10!>8f1` becomes v3 `4d10!=10>=8f1`. Two things changed: `>` is
81
+ strictly greater-than in v3 (use `>=` for v2's meaning), and an explosion
82
+ combined with a success count needs its own compare point — a bare `!`
83
+ followed by `>=` reads the threshold as the explode target and then fails on
84
+ the `f`.
85
+
86
+ **Pool totals are no longer clamped.** v2 returned `Math.max(successes -
87
+ failures, 0)`, hiding botches below zero. v3 reports the real arithmetic, and
88
+ splits the tally out:
89
+
90
+ ```typescript
91
+ const pool = roll('4d10>=8f1', { rng: createMockRng([1, 1, 1, 5]) });
92
+ pool.total; // -3 — v2 would have reported 0
93
+ pool.successes; // 0
94
+ pool.failures; // 3
95
+ ```
96
+
97
+ **The CLI takes one notation, not a list.** v2 read every positional argument as
98
+ a separate roll and printed one line each, falling back to `d20` when you passed
99
+ none. v3 joins the positional arguments into a single notation — so `roll-parser
100
+ 2d6 + 3` works — and treats a missing notation as a usage error (exit code 2)
101
+ rather than rolling a d20. A v2 invocation like `roll-parser 1d6 1d8` now fails
102
+ to parse; run the CLI twice, or roll `1d6+1d8` if you wanted the sum.
103
+
104
+ Everything else in v3 is new surface rather than a replacement — the `parts`
105
+ tree, injectable RNGs, source spans, safety limits, and the wider notation set
106
+ have no v2 equivalent to migrate from. The
107
+ [full list of breaking changes](CHANGELOG.md) is in the changelog.
108
+
109
+ ## Upgrading from a v3 pre-release
110
+
111
+ `3.0.0-alpha.0` and `3.0.0-beta.0` derived a single 32-bit number from the seed
112
+ — djb2 for strings, a `>>> 0` truncation for numbers — expanded it with
113
+ splitmix32, and ran an xorshift128 core. `3.0.0` replaces all of it: cyrb128
114
+ hashes the seed straight into the full 128-bit state, and xoshiro128\*\*
115
+ generates the stream. Every seeded sequence therefore changed — the same seed
116
+ and notation roll different dice. Nothing in the API moved, so this surfaces
117
+ only as tests asserting pinned faces, or as saved games that stored a seed.
118
+
119
+ The fix is the same one the reproducibility contract has always implied: a seed
120
+ is stable within a major version, not across one, so **persist the
121
+ `RollResult`** — it holds the totals, the per-die faces, and the rendered
122
+ breakdown — rather than re-deriving rolls from a stored seed. Tests that need
123
+ exact faces should use `createMockRng` from `roll-parser/testing`, which is
124
+ engine-independent.
125
+
126
+ When you need a live generator to survive a save and resume, snapshot its state
127
+ instead of its seed. `state()` returns a format version plus four unsigned
128
+ 32-bit words that the constructor takes back verbatim, with no re-hashing and no
129
+ warm-up:
130
+
131
+ ```typescript
132
+ import { SeededRNG, roll } from 'roll-parser';
133
+
134
+ const rng = new SeededRNG('campaign');
135
+ const save = JSON.stringify(rng.state());
136
+
137
+ const live = roll('1d20', { rng });
138
+ const resumed = roll('1d20', { rng: new SeededRNG(JSON.parse(save)) });
139
+ live.total === resumed.total; // true
140
+ ```
141
+
142
+ `RngState` carries the same major-version binding as a seed — good for a save
143
+ file within a major, not across one. The binding is enforced rather than
144
+ documented: a snapshot from another format version throws
145
+ `INCOMPATIBLE_RNG_STATE` instead of resuming under different semantics, so a
146
+ stale save fails at load rather than diverging quietly. Snapshots taken with
147
+ `3.0.0-beta.0`, which had no version word, are rejected the same way.