# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [3.4.0] - 2026-09-11

CLI release: `--json` becomes a complete record. A payload now names the seed
and version that produced it, so any roll — seeded or not — can be replayed from
its own output, and every diagnostic the CLI raises now arrives as one JSON line
instead of an `Error:` prefix and a caret diagram. The library, its public types
and its serialized shape are untouched; the `--json` success payload only gains
keys at the end, so existing field access is unaffected.

### Added

- `--json` output ends in `seed` and `version`, making a payload re-derivable from itself. The CLI mints its own seed with `crypto.randomUUID()` rather than letting `SeededRNG` auto-seed and discard the value, so the emitted seed is one that can be handed straight back to `--seed`; `version` rides along because the seed-to-dice mapping is only fixed within a major. Minting is unconditional, so the plain and verbose paths draw from the same source. Both keys are appended, so every existing key keeps its position ([#342](https://github.com/edloidas/roll-parser/issues/342))
- Every diagnostic raised after `--json` is parsed is now one JSON line on stderr, carrying the error's stable `code` and the `span` that `getErrorSpan` normalizes, plus the `notation` and `seed` that produced the failure. Machine consumers no longer have to regex the message and count caret spaces for a position the process already held. The record reports `span` rather than a flat position because the evaluator carries a start and an end. A failing unseeded roll is now reproducible too — the minted seed was declared inside the `try` and the `catch` never echoed it. Exit codes and empty-stdout-on-failure are unchanged ([#343](https://github.com/edloidas/roll-parser/issues/343))

### Changed

- `--json` now covers the two usage errors that escaped it. `Unknown option: <x>` and `Missing value for --seed` were raised at the offending token, before the flag was known, so they printed as plain text; `parseArgs` now records the first usage error and runs the loop to the end, so the flag survives alongside it. Detection stays positional: `--seed --json` binds `--json` as the seed value and `roll-parser -- --json` makes it notation, and neither emits JSON. The record is `{"error":{"message":...},"version":...}` — a usage error never reached the dice, so it carries no `code`, `span`, `notation` or `seed`. Exit code 2 is unchanged ([#351](https://github.com/edloidas/roll-parser/issues/351))
- A failed run whose notation was passed after `--` now ends with a hint saying the terminator makes everything after it part of the notation. It is gated on what the parser did rather than on `argv` containing a `--`: `--seed` takes any non-empty next argument, so `--seed --` binds that `--` as a seed value and no terminator ever runs. `parseArgs` carries `terminated` on its success arm to express that. The `--json` path is untouched — every diagnostic there stays one JSON line ([#344](https://github.com/edloidas/roll-parser/issues/344))

### Fixed

- `roll-parser --seed -- --help` prints the manual instead of exiting 2 with `Unknown option`. The scan that finds `--help`, `-h` and `--version` broke at the first literal `--`, but `readSeedValue` takes any non-empty next argument, so in `--seed --` that `--` is the seed value and never reaches the terminator branch of the main loop. The scan now consumes a `--seed` value the way the loop does, exempting an informational flag from the skip so `--seed --help` still resolves to help ([#364](https://github.com/edloidas/roll-parser/issues/364))

### Documentation

- `HELP_TEXT` and the README CLI section no longer teach `--` as the way to write negative notation. `roll-parser -1d6+3` works on its own — `-1d6`, `-d6`, `-dF`, `-(2d6)`, `-{2d6}` and `-@str` are all accepted as positionals — and teaching the terminator there walked users into its real semantics, where a trailing `--verbose` becomes part of the notation and the lexer rejects it as an identifier. The `--` option line now names what the terminator is actually for ([#344](https://github.com/edloidas/roll-parser/issues/344))
- The README `--json` sample no longer pins a `"version":"3.3.1"` literal that nothing checks, and the prose states that `version` is a bare semver with no `v` prefix ([#348](https://github.com/edloidas/roll-parser/issues/348))
- The README performance table is re-measured under Bun 1.4 as the per-record median of eight `bench:json` passes: `roll` runs 8-18% faster on the light expressions and flat on the heavy pools, while `lex` and `parse` land within 10% of the old figures in both directions. The measurement protocol records the non-idle session and its wider pass-to-pass spread ([#340](https://github.com/edloidas/roll-parser/issues/340))

## [3.3.1] - 2026-08-20

### Changed

- performance: the evaluator does less work per node and per die. `EvalContext`'s `expression` and `rendered` fragment arrays are single strings carried on `EvalResult`, so no branch can omit one; `rewriteFlags` is one loop instead of a closure, a filter array and a spread; a modifier's target context no longer renders the pool it discards, with every pool render routed through `renderedPool`; and `renderGroup` looks kept indices up in a `Set` rather than an array. No notation evaluates differently and no public type changed ([#336](https://github.com/edloidas/roll-parser/issues/336))

### Documentation

- `package.json` metadata is tuned for npm search: the `description` leads with queryable nouns and names the systems, `roller`, `die`, `notation`, `pathfinder` and the space-separated `dice notation` join the keywords, and seven keywords that ranked outside the top 100 for their own chip are gone. `README.md` gains a `min+brotli ≤13.5 kB` badge linking to the size breakdown ([#333](https://github.com/edloidas/roll-parser/issues/333))

## [3.3.0] - 2026-08-17

Error-message work throughout: nine parse and lex errors now name the rule they
enforce, or the token actually at fault, instead of reporting parser jargon.
Every error code is unchanged and no notation evaluates differently — nothing
that parsed before now throws, and nothing that threw before now parses. Code
that matches on `code` is unaffected; code that matches on `message` is not.

### Added

- An expression that ends where an operand was required now names what it still owed, instead of reporting a bare unexpected end. `1d` reports `Expected a side count after 'd'`, `1d20 vs` reports `Expected a difficulty class after 'vs'`, `2d6r<` reports `Expected a value after '<'`, and a truncated `2d6>=4f` names the `f` rather than the comparison behind it. The code stays `UNEXPECTED_END`, the position still points past the last token, and empty input still reports `Empty notation` at position 0 ([#327](https://github.com/edloidas/roll-parser/issues/327))

### Changed

- Demo site: the five failure tints derive from `--failure` through `color-mix` instead of hard-coded `rgba()` literals, so they follow the token in both themes rather than painting the pre-`#322` dark red under the light palette ([#324](https://github.com/edloidas/roll-parser/issues/324))

### Fixed

- The hint for an identifier that merged two modifiers no longer suggests notation that does not parse. It is gated on both halves being separable modifiers — `flor` and `kx` merely start with one, so every split of them still failed — and the separator is picked by whether the first half takes a count, so the count-less `s`, `sa`, `sd`, `cs` and `cf` split on a space instead of `1`. `4d6khs` now suggests `kh1s`; a round-trip test asserts every hint the lexer emits parses ([#326](https://github.com/edloidas/roll-parser/issues/326))
- `2d6constructor` reports `Unexpected identifier: 'constructor'` instead of lexing into a token typed with a function. The keyword and separator tables were plain objects, so `constructor` resolved to `Object`'s own; both now have a null prototype ([#326](https://github.com/edloidas/roll-parser/issues/326))
- `Cannot apply modifier after success counting` is replaced by `Success counting is terminal: it cannot be part of a larger expression`, which holds for all three shapes that reach the rejection — a trailing modifier, arithmetic (`5d6>=5+3`), and meta-operand reuse (`4d(1d6>=3)`). The wording matches the **Pools and checks** callout in `README.md`, which carries the worked rewrites ([#326](https://github.com/edloidas/roll-parser/issues/326))
- `UNEXPECTED_CHARACTER` appends the code point when the rejected character is not printable ASCII, so a pasted no-break space, zero-width space or BOM reads as `Unexpected character (U+00A0): ' '` rather than an empty pair of quotes. Printable ASCII stays unadorned ([#326](https://github.com/edloidas/roll-parser/issues/326))
- `Empty @ variable name` is replaced by `@ variable name must start with a letter or '_'`, which was the rule all along — one check rejects a missing name and a wrongly-started one alike, so `@1` and `@力` were being called empty ([#326](https://github.com/edloidas/roll-parser/issues/326))
- `()` reports `Empty parentheses` at the opener instead of `Unexpected token ')'` at the closer, the way `{}` already did — `parseGrouped` was missing the check `parseGroup` had ([#326](https://github.com/edloidas/roll-parser/issues/326))
- A count after a modifier that takes none — `2d6s1`, `2d6cs1`, `2d6cf1`, `2d6!0` — reports `This modifier takes no count` instead of `Unexpected infix token '1'`. The digit is not the surprise, the modifier it follows is, and "infix token" is parser jargon. The check resolves down the right spine of binary, unary and versus nodes, so `1+2d6s1` is caught too, and it stops at any closer, so `2d(2d6s)1` keeps the generic wording — the stray token there follows the group, not the modifier inside it. Two juxtaposed values (`2d6 3`) and a modifier that already took a comparison (`2d6cs>5 3`) also keep the generic wording, since those are different mistakes ([#326](https://github.com/edloidas/roll-parser/issues/326), [#327](https://github.com/edloidas/roll-parser/issues/327))
- `vs` with nothing on its left reports `Versus needs a roll on its left` instead of `Unexpected token 'vs'` ([#326](https://github.com/edloidas/roll-parser/issues/326))
- Demo site: the palette meets WCAG AA. `--text-dim` and `--failure` were retuned in the dark palette, and `--text-dim`, `--accent`, `--accent-soft`, `--success` and `--failure` in both light blocks, each held on its own hue and measured against `--surface-strong`; the light `--gold-gradient` stops shifted by the same lightness delta as `--accent`, lifting the hero total's leading stop from 2.81 to 3.47 ([#322](https://github.com/edloidas/roll-parser/issues/322))
- Demo site: `site/404.html` no longer carries a stale `--accent`. Its inline light palette kept the pre-`#322` `#a06b1f` while `style.css` moved on, leaving the `code` span and the link hover/focus colour at 3.88 against the page gradient; `#855512` lifts them to 5.42 ([#322](https://github.com/edloidas/roll-parser/issues/322))
- Demo site: `--border` meets SC 1.4.11's 3:1 non-text bar on every fill it lands on — 3.37 dark and 3.30 light at the tightest adjacency, up from 1.47 and 1.33 against `--bg`. `.widget:hover` moved from `--accent-soft` to `--accent`, since the soft tint now composites fainter than the resting border and hover read as a de-emphasis ([#323](https://github.com/edloidas/roll-parser/issues/323))

### Documentation

- The README bundle-size figures match the current emit: `≈13.2 kB` for the full library and `≈6.1 kB` for `{ parse }` ([#326](https://github.com/edloidas/roll-parser/issues/326), [#327](https://github.com/edloidas/roll-parser/issues/327))

## [3.2.2] - 2026-08-14

### Changed

- A failure threshold with no success count to attach to now says so instead of reporting a bare `Unexpected infix token 'f'`. The input that provokes it reads like World of Darkness notation — `3d6!>4f1`, `10d10!>=6f1` — but an explode takes the comparison that follows it as its own trigger, leaving the `f` clause with nothing to modify. The code stays `UNEXPECTED_TOKEN`, and the position still points at the `f` ([#320](https://github.com/edloidas/roll-parser/issues/320))

### Documentation

- `README.md` gains an "An explode claims the comparison that follows it" gotcha under Arithmetic and precedence, with both rewrites: `(3d6!)>4f1` to count the exploded pool, or `3d6!>4>=5f1` to keep an explode trigger and a success threshold ([#320](https://github.com/edloidas/roll-parser/issues/320))

## [3.2.1] - 2026-08-10

### Changed

- The three errors for operands that must be whole numbers — dice count, dice sides, and keep/drop count — now name the fix instead of only the value: `5d(20/3)` reports `Invalid dice sides: 6.666666666666667 (use 'floor', 'ceil', or 'round')`. Division still does not floor, so a fraction there stays an error rather than being rounded for you; the hint points at the escape hatch the notation already has. `1d0`, negative counts, and non-finite values keep their bare messages, since no rounding rescues them, and the value is printed exact — a fixed-width display rounded `1.00001` to `1`, naming a side count the evaluator accepts ([#318](https://github.com/edloidas/roll-parser/issues/318))

### Documentation

- `README.md`'s "Division does not floor" gotcha now covers the dice-operand consequence, with `5d(20/3)` and its `5d(round(20/3))` rewrite ([#318](https://github.com/edloidas/roll-parser/issues/318))

## [3.2.0] - 2026-08-09

Every entry below is a correctness fix, but several change what a working
expression returns and five group forms that used to evaluate now throw.
[MIGRATION.md](MIGRATION.md#upgrading-from-310-to-320) walks each one with a
rewrite. Seeds are untouched: the same seed and notation roll the same faces
they did in 3.1.0.

### Fixed

- Success counting a multi-sub-roll group now scores one success per sub-roll subtotal, the way keep/drop on the same node always did, so `{2d6, 2d6}>=10` finds the 11 it was missing. The dice stop being the units: `rendered` shows each sub-roll's own bracket, `rolls[].modifiers` comes back untagged, and `fT` thresholds weigh subtotals too. Targets whose arithmetic no per-die comparison ever reaches — `{3d20+5}>=21`, `{2d6*2}>=4`, `{2d6, 2d8}kh1>=4` — throw `INVALID_SUCCESS_COUNT_TARGET` instead of comparing faces against a number the sub-roll's math never produced ([#303](https://github.com/edloidas/roll-parser/issues/303))
- A second success count reaching the same pool through group braces (`{4d6>=5f1}<=2f6`) now re-scores it outright rather than appending to the inner pass's tags, so no die comes back both `'success'` and `'failure'` and `successes`/`failures` describe `total` again. The outermost count owns the tags; dropped dice come out untagged, while the DC side of a `vs` keeps tags from an inner count, since no pool pass may tally it. `total` never moved ([#297](https://github.com/edloidas/roll-parser/issues/297))
- Success counting a group that can never roll a die (`{3, 5, 7}>=4`, `{3, 5, 7}kh1>=4`) now throws `INVALID_SUCCESS_COUNT_TARGET` instead of returning the group's sum with `successes: 0`, which read the documented `successCount.total === successes - failures` invariant as `15 === 0`. A pool that is empty only at run time (`{3, 0d6}>=4`) totals 0 rather than the literal sum ([#304](https://github.com/edloidas/roll-parser/issues/304))
- Keep/drop on a single-sub-roll group whose total is not the sum of its kept faces now throws `INVALID_KEEP_DROP_TARGET` instead of silently discarding the rest of the arithmetic. `{2d6+3}kh2` lost the `+3`, and `{2d6-1d4}kh3` — which drops nothing — flipped the `1d4` from `-3` to `+3`; `{2d6*2}kh2` and `{abs(1d6-1d8)}kh1` are refused for the same reason `(2d6+3)kh2` already was. The check is structural, so identity terms (`{2d6+0}kh2`) go with them. Additive pools (`{4d6+2d8}kh3`) and multi-sub-roll groups, which select on subtotals, are untouched. A brace-wrapped success count now reports `INVALID_SUCCESS_COUNT_TARGET` at any nesting depth, matching what `(4d6>=5)kh1` always did ([#302](https://github.com/edloidas/roll-parser/issues/302))
- Penetrating explosion now records the face it rolled in `initialResult` on every die it appends, so the `initialResult ?? result` idiom `DieResult` documents recovers the natural face instead of the stored `raw - 1` — a natural 20 was indistinguishable from a 19. Bare `cs`/`cf` consequently judges a `!p` continuation by that face, so `1d6cf>5!p` and `1d6!pcf>5` agree where the README documented them as disagreeing. Separately, a `vs` roll whose explosion dice are clamped by `minN`/`maxN` (`1d20!min5 vs 30`) stops misreading the continuation as a second primary d20, which had been suppressing the natural-20/1 step ([#300](https://github.com/edloidas/roll-parser/issues/300))
- `cs`, `cf`, `s`, and `sd` adjacent to another modifier now keep a space in `expression` and `rendered`, so the normalized string re-parses. Those four codes end in a letter the lexer scans as an identifier, so maximal munch swallowed whatever followed: `1d20cs cf` normalized to `1d20cscf` and `4d6 s kh2` to `4d6s kh2`, neither of which round-trips through `parse`. `4dF` and `!p` end in letters but are their own tokens and stay flush ([#299](https://github.com/edloidas/roll-parser/issues/299))
- Demo site: the equation chip keeps explode and reroll dice. It read only `sort` out of the part tree, so any other pool-bearing modifier's dice went missing from the chip ([#313](https://github.com/edloidas/roll-parser/issues/313))

### Documentation

- `MIGRATION.md` gains a 3.1.0 → 3.2.0 section covering all five newly rejected forms with a rewrite for each, plus the values that moved: `initialResult` on `!p` dice, one `vs` degree, the `expression`/`rendered` spacing, and the totals and tags of nested and grouped success counts ([#300](https://github.com/edloidas/roll-parser/issues/300), [#293](https://github.com/edloidas/roll-parser/issues/293))
- README gains a `Reading crit and fumble` subsection showing the per-die loop over `result.rolls`, with `site/src/dice.ts` named as the working in-repo example. It documents the four traps such a loop hits — `'meta'` dice must be filtered, `'dc'` dice need a deliberate choice, `rendered` must never be spliced by bracket position, and the default crit rule fires on every pool's max face — and names `rendered`'s markdown as Discord-flavored, pointing at `renderBreakdown` for other dialects ([#293](https://github.com/edloidas/roll-parser/issues/293))
- README's `## Contents` is a grouped two-level index, and the seven overlapping notation-rejection bullets are folded into one table with a `Write instead` column. The nested-count tag-ownership rule moved to `RollResult`, where the fields it describes live ([#293](https://github.com/edloidas/roll-parser/issues/293))

## [3.1.0] - 2026-08-08

### Added

- `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))

### Changed

- 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))

  Reading `parts` is unaffected. Three things do change:

  - 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.
  - Deep-equality assertions against those parts need the new field.
  - `--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.

- 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))

### Fixed

- 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))
- `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))
- 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))
- 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))
- 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))
- 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))

### Documentation

- `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))
- `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))
- The README no longer implies success counting rejects every enclosing expression — it is terminal only against direct wrapping
- The README performance table gained a `4d6sd` row and links the published benchmark trend, and the Node badge reads from the published `engines` range

## [3.0.0] - 2026-08-06

First stable release of the v3 rewrite. This section covers what changed since
`3.0.0-beta.0`; the two pre-release sections below carry the rest of the rewrite.
Upgrading from 2.x starts at [MIGRATION.md](MIGRATION.md) — no 2.x API carries
over.

### Added

- 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))
- 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))
- `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))
- 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))
- `roll-parser/testing` exports the `RNG` and `MockRNGExhaustedError` types it was missing ([#129](https://github.com/edloidas/roll-parser/issues/129))
- 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))
- 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))
- 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
- `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))
- 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))
- `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))
- 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))
- 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))
- `SECURITY.md` with a private vulnerability disclosure route ([#155](https://github.com/edloidas/roll-parser/issues/155))
- 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))
- `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))
- 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))
- `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
- `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
- `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))
- 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))

### Changed

- **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))
- **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))
- **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)):
  - **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))
  - **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))
  - **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))
- **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))
- **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))
- 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))
- **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))
- **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))
- **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))
- **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))
- **BREAKING:** `engines.node` raised from `>=22.0.0` to `>=22.12.0`, the release where `require(esm)` became unflagged on the 22.x line
- 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))
- `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))
- 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))
- 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))
- `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))
- 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))
- 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))
- 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))
- 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
- 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))
- 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))
- `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))
- `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))
- 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))
- **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))
- 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))

### Fixed

- **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

- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- `@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))
- 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))

### Documentation

- 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))
- 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))
- 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))
- `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))
- 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))
- `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
- 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
- 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))
- 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`
- 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))
- 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))
- `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))
- 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))
- 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))
- 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))
- 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))
- 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))

## [3.0.0-beta.0] - 2026-07-07

### Added

- 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))
- 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))
- Root exports for the previously missing `GroupNode`/`SortNode`/`CritThresholdNode` types and `isGroup`/`isSort`/`isCritThreshold` guards ([#120](https://github.com/edloidas/roll-parser/issues/120))
- Benchmark suite (`bun bench`, mitata) covering parse, evaluate, and end-to-end roll paths ([#119](https://github.com/edloidas/roll-parser/issues/119))
- 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))
- CLI prints the notation with a caret under the error position for lexer/parser errors ([#118](https://github.com/edloidas/roll-parser/issues/118))
- Lexer hints at modifier splits for merged identifiers: `4d6khs` now suggests `kh1s` ([#118](https://github.com/edloidas/roll-parser/issues/118))
- `"./package.json"` export and `CHANGELOG.md` in the published tarball ([#119](https://github.com/edloidas/roll-parser/issues/119))

### Changed

- **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))
- **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))
- **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))
- **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))
- **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))

### Fixed

- 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))
- `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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))

### Documentation

Intentional behaviours documented for the first time, not changes — semantics are unchanged.

- 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))
- 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))

## [3.0.0-alpha.0] - 2026-04-20

First alpha of the v3 rewrite. Stage 1 (core engine) is complete; Stage 2 (dice mechanics) is largely in place.

### Added

Core engine (Stage 1):

- Lexer and token system ([#3](https://github.com/edloidas/roll-parser/issues/3))
- Pratt parser and AST types ([#4](https://github.com/edloidas/roll-parser/issues/4))
- Seedable RNG system with `SeededRNG` (xorshift128) and `MockRNG` helpers ([#5](https://github.com/edloidas/roll-parser/issues/5))
- AST evaluator with keep/drop modifiers (`kh`, `kl`, `dh`, `dl`) ([#6](https://github.com/edloidas/roll-parser/issues/6))
- Public `roll(notation, options)` API with `RollResult` / `DieResult` types ([#7](https://github.com/edloidas/roll-parser/issues/7))
- `roll-parser` CLI with `--help`, `--version`, `--verbose`, `--seed` flags; ESM + CJS dual build ([#8](https://github.com/edloidas/roll-parser/issues/8))

Dice mechanics (Stage 2):

- Percentile dice notation (`d%`) ([#35](https://github.com/edloidas/roll-parser/issues/35))
- Fate/Fudge dice (`dF`) ([#36](https://github.com/edloidas/roll-parser/issues/36))
- Math functions: `floor()`, `ceil()`, `round()`, `abs()`, `max()`, `min()` ([#37](https://github.com/edloidas/roll-parser/issues/37))
- Exploding dice — `!` (standard), `!!` (compound), `!p` (penetrating) — with optional compare points ([#38](https://github.com/edloidas/roll-parser/issues/38))
- Reroll mechanics (`r`, `ro`) with compare points ([#39](https://github.com/edloidas/roll-parser/issues/39))
- Success counting / dice pools with `>`, `>=`, `<`, `<=`, `=` operators and `fN` failure tagging ([#40](https://github.com/edloidas/roll-parser/issues/40))
- PF2e Degrees of Success via the `vs` keyword, with nat-20/1 upgrade/downgrade ([#41](https://github.com/edloidas/roll-parser/issues/41))

### Changed

- **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.
- 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))

### Fixed

- 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))
- 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))
- 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))

[Unreleased]: https://github.com/edloidas/roll-parser/compare/v3.4.0...HEAD
[3.4.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.4.0
[3.3.1]: https://github.com/edloidas/roll-parser/releases/tag/v3.3.1
[3.3.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.3.0
[3.2.2]: https://github.com/edloidas/roll-parser/releases/tag/v3.2.2
[3.2.1]: https://github.com/edloidas/roll-parser/releases/tag/v3.2.1
[3.2.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.2.0
[3.1.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.1.0
[3.0.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0
[3.0.0-beta.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0-beta.0
[3.0.0-alpha.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0-alpha.0
