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