roll-parser 3.0.0-beta.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +107 -9
  2. package/MIGRATION.md +147 -0
  3. package/README.md +960 -154
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +15 -3
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +17 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -17
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +26 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +58 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +41 -75
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +906 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.js +15 -0
  37. package/dist/evaluator/modifiers/compare.js.map +1 -0
  38. package/dist/evaluator/modifiers/crit-threshold.d.ts +0 -1
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +14 -0
  45. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  46. package/dist/evaluator/modifiers/explode.d.ts +13 -5
  47. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  48. package/dist/evaluator/modifiers/explode.js +103 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +18 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  55. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  56. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  57. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  58. package/dist/evaluator/modifiers/reroll.d.ts +12 -4
  59. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  60. package/dist/evaluator/modifiers/reroll.js +62 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +4 -0
  63. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  64. package/dist/evaluator/modifiers/sort.js +13 -0
  65. package/dist/evaluator/modifiers/sort.js.map +1 -0
  66. package/dist/evaluator/modifiers/success-count.d.ts +2 -6
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +24 -0
  69. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  70. package/dist/index.d.ts +33 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +12 -2560
  73. package/dist/index.js.map +1 -26
  74. package/dist/lexer/lexer.d.ts +50 -5
  75. package/dist/lexer/lexer.d.ts.map +1 -1
  76. package/dist/lexer/lexer.js +260 -0
  77. package/dist/lexer/lexer.js.map +1 -0
  78. package/dist/lexer/tokens.d.ts +31 -6
  79. package/dist/lexer/tokens.d.ts.map +1 -1
  80. package/dist/lexer/tokens.js +42 -0
  81. package/dist/lexer/tokens.js.map +1 -0
  82. package/dist/parser/ast.d.ts +314 -160
  83. package/dist/parser/ast.d.ts.map +1 -1
  84. package/dist/parser/ast.js +52 -0
  85. package/dist/parser/ast.js.map +1 -0
  86. package/dist/parser/guards.d.ts +106 -0
  87. package/dist/parser/guards.d.ts.map +1 -0
  88. package/dist/parser/guards.js +121 -0
  89. package/dist/parser/guards.js.map +1 -0
  90. package/dist/parser/parser.d.ts +118 -14
  91. package/dist/parser/parser.d.ts.map +1 -1
  92. package/dist/parser/parser.js +751 -0
  93. package/dist/parser/parser.js.map +1 -0
  94. package/dist/rng/mock.d.ts +73 -12
  95. package/dist/rng/mock.d.ts.map +1 -1
  96. package/dist/rng/mock.js +30 -0
  97. package/dist/rng/mock.js.map +1 -0
  98. package/dist/rng/seeded.d.ts +141 -9
  99. package/dist/rng/seeded.d.ts.map +1 -1
  100. package/dist/rng/seeded.js +138 -0
  101. package/dist/rng/seeded.js.map +1 -0
  102. package/dist/rng/types.d.ts +57 -0
  103. package/dist/rng/types.d.ts.map +1 -1
  104. package/dist/rng/types.js +2 -0
  105. package/dist/rng/types.js.map +1 -0
  106. package/dist/roll.d.ts +58 -28
  107. package/dist/roll.d.ts.map +1 -1
  108. package/dist/roll.js +8 -0
  109. package/dist/roll.js.map +1 -0
  110. package/dist/testing.d.ts +5 -4
  111. package/dist/testing.d.ts.map +1 -1
  112. package/dist/testing.js +2 -41
  113. package/dist/testing.js.map +1 -11
  114. package/dist/types.d.ts +306 -44
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/types.js +8 -0
  117. package/dist/types.js.map +1 -0
  118. package/dist/version.d.ts +2 -0
  119. package/dist/version.d.ts.map +1 -0
  120. package/dist/version.js +2 -0
  121. package/dist/version.js.map +1 -0
  122. package/package.json +83 -34
  123. package/src/cli/args.ts +66 -10
  124. package/src/cli/format.ts +22 -3
  125. package/src/cli/index.ts +27 -84
  126. package/src/cli/main.ts +129 -0
  127. package/src/errors.ts +480 -27
  128. package/src/evaluator/die.ts +50 -0
  129. package/src/evaluator/env.ts +73 -0
  130. package/src/evaluator/evaluator.ts +653 -421
  131. package/src/evaluator/modifiers/crit-threshold.ts +3 -6
  132. package/src/evaluator/modifiers/die-bound.ts +39 -0
  133. package/src/evaluator/modifiers/explode.ts +60 -58
  134. package/src/evaluator/modifiers/flags.ts +61 -0
  135. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  136. package/src/evaluator/modifiers/reroll.ts +28 -49
  137. package/src/evaluator/modifiers/sort.ts +14 -1
  138. package/src/evaluator/modifiers/success-count.ts +5 -8
  139. package/src/index.ts +56 -35
  140. package/src/lexer/lexer.ts +107 -34
  141. package/src/lexer/tokens.ts +31 -6
  142. package/src/parser/ast.ts +323 -341
  143. package/src/parser/guards.ts +248 -0
  144. package/src/parser/parser.ts +419 -242
  145. package/src/rng/mock.ts +74 -13
  146. package/src/rng/seeded.ts +299 -64
  147. package/src/rng/types.ts +57 -0
  148. package/src/roll.ts +64 -47
  149. package/src/testing.ts +5 -9
  150. package/src/types.ts +310 -43
  151. package/src/version.ts +2 -0
  152. package/dist/cli.js +0 -2608
  153. package/dist/cli.js.map +0 -28
  154. package/dist/evaluator/index.d.ts +0 -8
  155. package/dist/evaluator/index.d.ts.map +0 -1
  156. package/dist/rng/index.d.ts +0 -8
  157. package/dist/rng/index.d.ts.map +0 -1
  158. package/src/evaluator/index.ts +0 -14
  159. package/src/rng/index.ts +0 -8
package/README.md CHANGED
@@ -1,215 +1,1021 @@
1
+ <p align="center">
2
+ <a href="https://roll-parser.edloidas.io/"><img src="https://raw.githubusercontent.com/edloidas/roll-parser/master/.github/logo.svg" width="92" alt="roll-parser logo"></a>
3
+ </p>
4
+
1
5
  <h1 align="center">Roll Parser</h1>
2
6
 
3
7
  <p align="center">
4
- High-performance dice notation parser for tabletop RPGs.<br>
5
- TypeScript-first, Bun-optimized, Pratt parser architecture.
8
+ Dice notation for tabletop RPGs, rolled into structured results.
6
9
  </p>
7
10
 
8
11
  <p align="center">
9
- <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-6-blue.svg" alt="TypeScript"></a>
10
- <a href="https://bun.sh/"><img src="https://img.shields.io/badge/Bun-1.3+-black.svg" alt="Bun"></a>
11
- <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-339933.svg" alt="Node.js"></a>
12
+ <a href="https://www.npmjs.com/package/roll-parser"><img src="https://img.shields.io/npm/v/roll-parser?color=cb3837" alt="npm version"></a>
13
+ <a href="https://github.com/edloidas/roll-parser/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/edloidas/roll-parser/ci.yml?branch=master&label=CI" alt="CI status"></a>
14
+ <a href="https://github.com/edloidas/roll-parser/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/roll-parser?color=blue" alt="MIT license"></a>
15
+ <!-- TODO: switch to img.shields.io/node/v/roll-parser once 3.0.0 is npm `latest` — until then it reads 2.x engines -->
16
+ <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/node-%3E%3D22.12-5fa04e" alt="Node.js >= 22.12"></a>
12
17
  </p>
13
18
 
14
- ## Status
15
-
16
- > **v3 Alpha** — Stages 1–3 (core engine, system compatibility, advanced
17
- > features incl. rich structured `parts` output) are implemented with 1,000+
18
- > tests, 100% function coverage, and a property-based test suite.
19
- >
20
- > For production use, install [v2.3.2](https://www.npmjs.com/package/roll-parser/v/2.3.2).
21
-
22
- ## Features
23
-
24
- - **Basic dice**: `2d6`, `d20`, `4d6+4`, percentile `d%`, Fate/Fudge `4dF`
25
- - **Full arithmetic**: `+`, `-`, `*`, `/`, `%`, `**` (also `^`), parentheses
26
- - **Computed dice**: `(1+1)d(3*2)`, `(1d4)d6`
27
- - **Keep/Drop**: `4d6kh3`, `2d20kl1`, `4d6dl1`, shorthand `4d6k3`
28
- - **Exploding dice**: standard `1d6!`, compound `1d6!!`, penetrating `1d6!p`, with compare points `1d6!>=5`
29
- - **Rerolls**: recursive `2d6r<2`, once `2d6ro<3`
30
- - **Success counting**: `10d10>=6`, with failure tagging `10d10>=6f1`
31
- - **PF2e degrees of success**: `1d20+10 vs 25` with nat-20/nat-1 upgrade/downgrade
32
- - **Math functions**: `floor()`, `ceil()`, `round()`, `abs()`, `max()`, `min()`
33
- - **Variables**: `1d20+@str`, `1d20+@{sneak attack}`
34
- - **Grouped rolls**: `{1d8, 1d10}kh1`, `{4d10+5d6}kh2`
35
- - **Sorting**: `4d6s`, `4d6sd` (display-only)
36
- - **Crit thresholds**: `1d20cs>=19`, `1d20cf<3` (display-only, independent overrides)
37
- - **Structured `parts` output**: every result carries a typed evaluation tree mirroring the AST — per-sub-expression totals, dice, resolved thresholds — for chat-log/character-sheet rendering without re-parsing
38
- - **Source spans**: every AST node and `RollPart` carries `start`/`end` offsets into the notation; evaluator errors point at the exact failing sub-expression
39
- - **Seedable RNG** (xorshift128) for reproducible rolls, mock RNG for tests
40
- - **Typed errors** with stable `code`s and input positions
41
- - **Safety limits**: `maxDice`, `maxExplodeIterations`, `maxRerollIterations`, parse depth cap
42
-
43
- ## Installation
19
+ <p align="center">
20
+ <a href="https://roll-parser.edloidas.io/"><strong>Playground</strong></a> ·
21
+ <a href="https://roll-parser.edloidas.io/reference"><strong>Notation Guide</strong></a> ·
22
+ <a href="https://roll-parser.edloidas.io/docs/"><strong>API Reference</strong></a>
23
+ </p>
24
+
25
+ Roll dice:
26
+
27
+ ```typescript
28
+ import { roll } from 'roll-parser';
29
+
30
+ const result = roll('4d6kh3');
31
+ result.total; // e.g. 14
32
+ result.rendered; // e.g. '4d6[3, 6, ~~2~~, 5] = 14'
33
+ ```
34
+
35
+ Read the breakdown instead of re-parsing the string:
36
+
37
+ ```typescript
38
+ const result = roll('4d6kh3 + 2');
39
+
40
+ result.parts.type; // 'binaryOp'
41
+ result.parts.total === result.total; // true every node of the tree carries its sub-total
42
+ result.rolls.filter((die) => !die.modifiers.includes('dropped')); // the kept dice
43
+ ```
44
+
45
+ Pin the dice in your tests:
46
+
47
+ ```typescript
48
+ import { createMockRng } from 'roll-parser/testing';
49
+
50
+ roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) }).total; // 14, every run
51
+ ```
52
+
53
+ ## Why roll-parser
54
+
55
+ - **Complete notation.** Keep/drop, three flavours of exploding dice, rerolls,
56
+ min/max clamps, success pools, crit thresholds, sorting, grouped rolls, PF2e
57
+ degrees of success, math functions, variables, computed dice — enough for D&D 5e,
58
+ Pathfinder, World of Darkness, Shadowrun, Fate, Savage Worlds, and Call of
59
+ Cthulhu without escape hatches. Spelled the way
60
+ [Roll20](https://help.roll20.net/hc/en-us/articles/360037773133-Dice-Reference)
61
+ spells it wherever the two overlap, so the notation your table already uses
62
+ keeps working.
63
+ - **Deterministic.** Every die goes through an injectable `RNG`. Seed a roll to
64
+ reproduce it, or script the sequence and assert exact totals.
65
+ - **Structured.** Results are not strings. Each roll returns a typed tree
66
+ mirroring the expression one-to-one, with per-node sub-totals, resolved
67
+ thresholds, and source spans — enough to render a character sheet or a chat
68
+ log without re-parsing anything.
69
+ - **Safe on untrusted input.** Dice count, explosion depth, reroll depth, and
70
+ parse depth are all bounded, and every failure is a typed error with a stable
71
+ code and a source span.
72
+ - **Small and fast.** ≈11.9 kB brotli for the whole library, ≈5.5 kB for just
73
+ `parse`, ≈213 B for the testing entry point. Zero runtime dependencies, zero
74
+ `node:` imports. A `1d20` round trip takes about 0.5 µs.
75
+ - **Tested.** 1,500+ tests behind CI-enforced coverage floors — 100% of
76
+ functions, 98% of lines — including every code example in this README, which
77
+ must produce the values its comments claim.
78
+ - **Measured, not asserted.** A
79
+ [comparison suite](https://github.com/edloidas/roll-parser/tree/master/bench/competitors)
80
+ runs 23 canonical notations through every dice-notation library published on
81
+ npm and validates each cell over 5,000 rolls against its closed-form mean —
82
+ support means *statistically correct*, not merely parsed. roll-parser is the
83
+ only one that gets all 23 right. Run `bun run bench:competitors` and check
84
+ the numbers yourself.
85
+
86
+ ## Contents
87
+
88
+ - [Install](#install)
89
+ - [Notation reference](#notation-reference)
90
+ - [Recipes by game system](#recipes-by-game-system)
91
+ - [Working with results](#working-with-results)
92
+ - [Randomness](#randomness)
93
+ - [Options](#options)
94
+ - [Error handling](#error-handling)
95
+ - [Using the parser directly](#using-the-parser-directly)
96
+ - [TypeScript](#typescript)
97
+ - [CLI](#cli)
98
+ - [Performance](#performance)
99
+ - [Known limitations](#known-limitations)
100
+ - [Versioning](#versioning)
101
+ - [Upgrading from 2.x](#upgrading-from-2x)
102
+ - [Contributing](#contributing)
103
+ - [License](#license)
104
+
105
+ ## Install
106
+
107
+ <!-- readme-test: skip -->
44
108
 
45
109
  ```bash
46
- bun add roll-parser
47
110
  npm install roll-parser
48
111
  ```
49
112
 
50
- The package is **ESM-only**. On Node.js ≥ 22 (required), CommonJS consumers
51
- can still `require('roll-parser')` via `require(esm)`.
113
+ > [!IMPORTANT]
114
+ > **ESM-only.** Node.js ≥ 22.12 is required — the floor at which
115
+ > `require(esm)` is unflagged — so CommonJS consumers can still
116
+ > `require('roll-parser')` even though the published files are ESM.
117
+
118
+ For TypeScript consumers, `moduleResolution` must be `bundler`, `node16`, or
119
+ `nodenext` — the package resolves through `exports` only, so the legacy `node10`
120
+ resolution does not find it. **TypeScript ≥ 5.0** is the supported floor — the
121
+ first release with `moduleResolution: bundler`. CI typechecks a consumer fixture
122
+ against the packed tarball on 5.0, 5.4, 5.9, 6.x, and 7.x, and asserts the types
123
+ really resolve rather than degrading to `any`. `node16`/`nodenext` setups do
124
+ resolve on 4.9, but that combination is untested and outside the promise.
125
+
126
+ Bundlers tree-shake the library cleanly: the library entries are side-effect-free
127
+ and touch no `process` or filesystem globals. Only the CLI entry is marked as
128
+ having effects, since it calls `main()` at module scope.
129
+
130
+ ### CDN / browser without a bundler
131
+
132
+ The published files are environment-neutral ES modules, so they load directly
133
+ in the browser. Use a bundling CDN endpoint — it serves the whole module graph
134
+ as one request:
135
+
136
+ ```html
137
+ <script type="module">
138
+ import { roll } from 'https://cdn.jsdelivr.net/npm/roll-parser/+esm';
139
+
140
+ console.log(roll('4d6kh3').total);
141
+ </script>
142
+ ```
143
+
144
+ `https://esm.sh/roll-parser` works the same way. Raw file URLs
145
+ (`unpkg.com/roll-parser`) also work but fetch each module separately.
146
+
147
+ ### Upgrading from 2.x
148
+
149
+ v3 is a complete rewrite and the API is not compatible — see
150
+ [MIGRATION.md](MIGRATION.md). To stay on the old line, pin `roll-parser@2.3.2`.
151
+
152
+ ## Notation reference
153
+
154
+ The notation tracks
155
+ [Roll20's Dice Reference](https://help.roll20.net/hc/en-us/articles/360037773133-Dice-Reference)
156
+ wherever the two overlap: `kh`/`kl`/`dh`/`dl`, `!`/`!!`/`!p`, `r`/`ro`, success
157
+ and failure counting, `cs`/`cf`, grouped rolls, and `floor`/`ceil`/`round`/`abs`
158
+ all use Roll20's spelling and semantics. It is not a strict superset — sorting
159
+ (`s`), PF2e `vs` checks, `max`/`min`, and computed dice counts and sides are
160
+ additions, and two Roll20 forms are deliberately rejected (`4d6d1`, arithmetic
161
+ after a success count) — see [Known limitations](#known-limitations).
162
+
163
+ Notation is case-insensitive and whitespace-tolerant — `2 D 20 KH 1` and
164
+ `2d20kh1` are the same expression, newlines included. The one exception is
165
+ variable names: `@StrMod` and `@strmod` are different variables.
166
+
167
+ ### Dice
168
+
169
+ | Notation | Meaning |
170
+ |----------|---------|
171
+ | `NdX` | Roll `N` dice with `X` sides — `2d6` |
172
+ | `dX` | Count defaults to 1 — `d20` is `1d20` |
173
+ | `Nd%` | Percentile dice; `d%` normalizes to `1d100` |
174
+ | `NdF` | Fate/Fudge dice, each −1, 0, or +1 — `4dF` |
175
+ | `(expr)dX` | Computed count — `(1d4)d6` rolls 1d4, then that many d6 |
176
+ | `Nd(expr)` | Computed sides — `(1+1)d(3*2)` is `2d6` |
177
+
178
+ ### Arithmetic and functions
179
+
180
+ | Notation | Meaning |
181
+ |----------|---------|
182
+ | `+` `-` `*` `/` `%` | Add, subtract, multiply, divide, modulo |
183
+ | `**` or `^` | Power, right-associative |
184
+ | `-expr` | Unary minus, binding to the whole dice expression: `-1d4` is `-(1d4)` |
185
+ | `( )` | Explicit grouping — `(1d6+2)*3` |
186
+ | `2.5` | Decimal literals, in arithmetic only — never as a dice count or side count |
187
+ | `floor(x)` `ceil(x)` `round(x)` `abs(x)` `sqrt(x)` | One argument each |
188
+ | `pow(a, b)` | Exactly two arguments — same as `a ** b` |
189
+ | `max(a, b, …)` `min(a, b, …)` | Variadic, two arguments minimum — `max(1d20, 1d20, 1d20)` |
190
+ | `@name` `@{any name}` | Variable from the `context` option |
191
+
192
+ ### Modifiers
193
+
194
+ Postfix modifiers attach to a dice pool. Counts are optional and default to
195
+ **1** — `4d6kh` means `4d6kh1`.
196
+
197
+ | Notation | Meaning |
198
+ |----------|---------|
199
+ | `khN` / `kN` | Keep the highest `N` — `4d6kh3` |
200
+ | `klN` | Keep the lowest `N` — `2d20kl1` (disadvantage) |
201
+ | `dhN` / `dlN` | Drop the highest / lowest `N` — `4d6dl1` |
202
+ | `!` | Explode: a max roll adds another die |
203
+ | `!!` | Compound explode: extra dice fold into the original die's value |
204
+ | `!p` | Penetrating explode: each extra die takes a −1 penalty |
205
+ | `!<cmp>` | Explode on a threshold instead of the max face — `1d6!>=5`, `5d10!=10` |
206
+ | `r<cmp>` | Reroll recursively while the condition holds — `2d6r<2` |
207
+ | `ro<cmp>` | Reroll once, keeping the second result — `2d6ro<3` |
208
+ | `minN` / `maxN` | Clamp each die's value — `4d6min2` lifts 1s to 2, `4d6max5` caps 6s at 5 |
209
+ | `s` / `sa` / `sd` | Sort ascending / ascending / descending — display only |
210
+ | `cs` / `cs<cmp>` | Override the crit threshold — bare `cs` means "max face" |
211
+ | `cf` / `cf<cmp>` | Override the fumble threshold — bare `cf` means "1" |
212
+
213
+ `<cmp>` is a comparator (`>`, `>=`, `<`, `<=`, `=`) plus a value, which may
214
+ itself be an expression: `1d6!>(1d2+3)`. In `5d10!=10` the `!` is the explode
215
+ operator and `=10` is its threshold — it reads "explode on a 10", not "explode
216
+ on not-ten". Explode and reroll always need an explicit comparator: `2d6r1` is
217
+ a syntax error, `2d6r=1` is not.
218
+
219
+ Chained keep/drop modifiers do **not** nest. `4d6kh3dl1` flattens into two
220
+ specs applied independently to the same pool, with the dropped sets unioned —
221
+ the Roll20 rule.
222
+
223
+ ### Pools and checks
224
+
225
+ | Notation | Meaning |
226
+ |----------|---------|
227
+ | `<cmp>T` | Count dice meeting the threshold as successes — `10d10>=6` |
228
+ | `fT` / `f<cmp>T` | Subtract dice meeting the failure threshold — `10d10>=6f1`, `10d10>=6f<=2` |
229
+ | `<roll> vs <dc>` | PF2e degree of success, with nat-20/nat-1 upgrade and downgrade |
230
+ | `{a, b}khN` | Grouped roll: each sub-roll's subtotal competes as one compound die |
231
+ | `{a+b}khN` | Single-sub-roll group: keep/drop selects across the flattened pool |
232
+
233
+ > [!IMPORTANT]
234
+ > Success counting is **terminal** — nothing may wrap it, so `10d10>=6 + 2` is
235
+ > a parse error. Put the arithmetic inside the threshold: `10d10>=(4+2)`.
236
+
237
+ > [!WARNING]
238
+ > A bare `d` after a dice expression is rejected: `4d6d1` throws
239
+ > `AMBIGUOUS_DICE_CHAIN`. Write `4d6dl1` to drop a die, or `(4d6)d1` if you
240
+ > really meant nested dice.
241
+
242
+ ## Recipes by game system
243
+
244
+ Every notation below links to a live roll in the
245
+ [playground](https://roll-parser.edloidas.io/).
246
+
247
+ | System | Notation | What it does |
248
+ |--------|----------|--------------|
249
+ | D&D 5e | [`4d6kh3`](https://roll-parser.edloidas.io/?d=4d6kh3) | Roll an ability score |
250
+ | D&D 5e | [`2d20kh1+7`](https://roll-parser.edloidas.io/?d=2d20kh1%2B7) | Attack with advantage |
251
+ | D&D 5e | [`2d6ro<3+4`](https://roll-parser.edloidas.io/?d=2d6ro%3C3%2B4) | Great Weapon Fighting — reroll 1s and 2s once |
252
+ | D&D 5e | [`8d6`](https://roll-parser.edloidas.io/?d=8d6) | Fireball damage |
253
+ | Pathfinder 2e | [`1d20+12 vs 20`](https://roll-parser.edloidas.io/?d=1d20%2B12%20vs%2020) | Check against a DC, with degrees of success |
254
+ | World of Darkness | [`7d10>=6f1`](https://roll-parser.edloidas.io/?d=7d10%3E%3D6f1) | Successes with a botch threshold |
255
+ | World of Darkness | [`5d10!=10>=8`](https://roll-parser.edloidas.io/?d=5d10!%3D10%3E%3D8) | 10-again, successes on 8+ |
256
+ | Shadowrun | [`12d6>=5`](https://roll-parser.edloidas.io/?d=12d6%3E%3D5) | Count hits on 5 or 6 |
257
+ | Fate | [`4dF+2`](https://roll-parser.edloidas.io/?d=4dF%2B2) | Four Fudge dice plus a skill |
258
+ | Savage Worlds | [`{1d8!, 1d6!}kh1`](https://roll-parser.edloidas.io/?d=%7B1d8!%2C%201d6!%7Dkh1) | Trait die vs. exploding wild die |
259
+ | Call of Cthulhu | [`d%`](https://roll-parser.edloidas.io/?d=d%25) | Percentile roll |
260
+
261
+ ```typescript
262
+ // Pathfinder 2e — the natural d20 face drives the degree upgrade
263
+ const check = roll('1d20+7 vs 15', { rng: createMockRng([12]) });
264
+ check.degree; // DegreeOfSuccess.Success (2)
265
+ check.natural; // 12
266
+ check.rendered; // '1d20[12] + 7 vs 15 = Success'
267
+
268
+ // World of Darkness — successes and failures are tallied separately
269
+ const pool = roll('10d10>=6f1', { seed: 'demo' });
270
+ pool.total; // 5 — successes minus failures
271
+ [pool.successes, pool.failures]; // [7, 2]
272
+ ```
273
+
274
+ ## Working with results
275
+
276
+ ### RollResult
277
+
278
+ | Field | Type | Notes |
279
+ |-------|------|-------|
280
+ | `total` | `number` | Final total. Always finite — overflow throws instead |
281
+ | `notation` | `string` | Exactly what you passed in |
282
+ | `expression` | `string` | Normalized form; meta-expressions appear as their resolved values |
283
+ | `rendered` | `string` | Markdown breakdown with per-die markers |
284
+ | `rolls` | `DieResult[]` | Every die in order: `sides`, `result`, `modifiers`, `critical`, `fumble` |
285
+ | `parts` | `RollPart` | Typed evaluation tree mirroring the AST 1:1 |
286
+ | `successes` / `failures` | `number?` | Present only when success counting was used |
287
+ | `degree` / `natural` | `DegreeOfSuccess?` / `number?` | Present only for a top-level `vs` |
288
+
289
+ `Readonly` at the top level and fully JSON-serializable — this is exactly what
290
+ the CLI's `--json` flag prints.
291
+
292
+ ```typescript
293
+ JSON.stringify(roll('3d6', { rng: createMockRng([4, 2, 6]) }));
294
+ ```
295
+
296
+ ```json
297
+ {
298
+ "total": 12,
299
+ "notation": "3d6",
300
+ "expression": "3d6",
301
+ "rendered": "3d6[4, 2, 6] = 12",
302
+ "rolls": [
303
+ { "sides": 6, "result": 4, "modifiers": ["kept"], "critical": false, "fumble": false },
304
+ { "sides": 6, "result": 2, "modifiers": ["kept"], "critical": false, "fumble": false },
305
+ { "sides": 6, "result": 6, "modifiers": ["kept"], "critical": true, "fumble": false }
306
+ ],
307
+ "parts": { "type": "dice", "count": 3, "sides": 6, "rolls": ["…"], "total": 12, "start": 0, "end": 3 }
308
+ }
309
+ ```
310
+
311
+ > [!NOTE]
312
+ > `rolls[]` and the `rolls[]` inside `parts` hold the *same* `DieResult`
313
+ > objects — no deep clone, so annotating a die is visible through both views.
314
+
315
+ ### The parts tree
316
+
317
+ `result.parts` is a 16-variant discriminated union mirroring the AST one-to-one.
318
+ Each part carries its sub-total, its resolved thresholds, and the `start`/`end`
319
+ offsets of the notation it came from.
320
+
321
+ ```typescript
322
+ import type { RollPart } from 'roll-parser';
323
+
324
+ function describe(part: RollPart): string {
325
+ switch (part.type) {
326
+ case 'literal':
327
+ return String(part.value);
328
+ case 'dice':
329
+ return `${part.count}d${part.sides}[${part.rolls.map((d) => d.result).join(', ')}]`;
330
+ case 'binaryOp':
331
+ return `${describe(part.left)} ${part.operator} ${describe(part.right)}`;
332
+ case 'keepDrop':
333
+ return `${describe(part.target)} [${part.specs.length} keep/drop]`;
334
+ default: // fateDice, variable, grouped, unaryOp, explode, reroll,
335
+ return part.type; // successCount, versus, functionCall, group, sort, critThreshold
336
+ }
337
+ }
338
+
339
+ describe(roll('4d6kh3 + 2', { rng: createMockRng([3, 6, 2, 5]) }).parts);
340
+ // '4d6[3, 6, 2, 5] [1 keep/drop] + 2'
341
+ ```
342
+
343
+ Invariants worth relying on: `result.parts.total === result.total`,
344
+ `successCount.total === successes - failures`, and every part's `rolls[]`
345
+ sharing references with `result.rolls`. A `sort` part's `rolls[]` is its
346
+ target's pool in sorted order — chained sorts (`4d6s sd`) nest, and the
347
+ outermost one holds the order `rendered` shows.
348
+
349
+ Meta-expressions do **not** appear as nested parts. `(1d4)d6`, `4d6kh(1d2)`, and
350
+ `1d6!>(1d2+3)` surface only their resolved numbers in the owning part; their
351
+ dice land in `result.rolls` tagged `'meta'` so an audit log can still show them.
352
+
353
+ ### Rendering
52
354
 
53
- ## Usage
355
+ `result.rendered` uses markdown markers, so a Discord bot or chat log can print
356
+ it as-is: `~~n~~` for a die dropped by keep/drop or group selection, `**n**` for
357
+ a success, `__n__` for a failure.
358
+
359
+ ```typescript
360
+ roll('4d6kh3', { seed: 'demo' }).rendered; // '4d6[1, 6, ~~1~~, 3] = 10'
361
+ roll('4d6sd', { seed: 'demo' }).rendered; // '4d6sd[6, 3, 1, 1] = 11'
362
+
363
+ // Source spans map any sub-total back onto the characters the user typed
364
+ const { start, end } = roll('4d6kh3 + 2', { seed: 'demo' }).parts; // 0, 10
365
+ ```
366
+
367
+ The render prefix echoes explode, reroll, min/max, sort, crit, and
368
+ success-count modifiers, but **not** keep/drop — `4d6kh3` renders as `4d6[…]`
369
+ while `8d6!` renders as `8d6![…]`. Read `result.expression` when you need the
370
+ modifier back.
371
+
372
+ ## Randomness
373
+
374
+ Every die is drawn through the `RNG` interface — no roll path bypasses the RNG
375
+ you chose. `Math.random()` appears in exactly one place: seeding a `SeededRNG`
376
+ when you do not supply a seed — the auto-seed hashes `Date.now()` together with
377
+ two `Math.random()` draws, giving roughly 100 bits of width rather than the 32 an
378
+ XOR of clock and one draw would cap it at. That width is what keeps two
379
+ auto-seeded generators from landing on the same stream; it is not a claim about
380
+ unpredictability, which stays bounded by however the host engine seeds
381
+ `Math.random()`. Pass a seed or your own `RNG` and it is never reached.
382
+
383
+ ```typescript
384
+ type RNG = {
385
+ next(): number; // float in [0, 1)
386
+ nextInt(min: number, max: number): number; // integer in [min, max]
387
+ };
388
+ ```
389
+
390
+ ### Seeded rolls
391
+
392
+ `roll(notation)` builds a fresh `SeededRNG` (xoshiro128\*\*, period 2^128 − 1) per
393
+ call. Pass `seed` to make it reproducible, or `rng` to supply your own instance
394
+ — `rng` wins when both are given.
395
+
396
+ Behind a seed sit three stages. cyrb128 hashes the seed into all four state
397
+ words, so the full 128 bits are seeded rather than a single word; xoshiro128\*\*
398
+ generates the stream; and `nextInt` maps each draw onto the die's faces by
399
+ rejection sampling, discarding the values that would make a plain `% sides`
400
+ favour the low faces. Seeds are stringified before hashing — numeric seeds keep
401
+ all 53 exact bits instead of truncating to 32, unrelated strings are
402
+ overwhelmingly unlikely to land on the same state, and `42` and `'42'` name the
403
+ same stream.
404
+
405
+ ```typescript
406
+ import { SeededRNG, roll } from 'roll-parser';
407
+
408
+ roll('2d6+3', { seed: 'demo' }).total; // 10, every time
409
+
410
+ // An injected instance keeps advancing; `{ seed }` restarts the stream
411
+ const rng = new SeededRNG('demo');
412
+ roll('1d20', { rng }).total; // 1
413
+ roll('1d20', { rng }).total; // 20
414
+ ```
415
+
416
+ The same seed and the same notation produce the same dice for the lifetime of a
417
+ major version, with one narrow exception for distribution bugs — see
418
+ [Versioning](#versioning) for the exact promise. Across majors they do not: the
419
+ mapping changed between 3.0.0-beta.0 and 3.0.0. The generator is also not
420
+ cryptographically secure. If a roll has to survive an upgrade, persist the
421
+ `RollResult`, not the seed.
422
+
423
+ ### Replay and save/resume
424
+
425
+ `SeededRNG` can hand out its internal state as an `RngState` — a format version
426
+ followed by four unsigned 32-bit words — and take one back through its
427
+ constructor. Restoring copies the words verbatim, with no re-hashing and no
428
+ warm-up draws, so the resumed stream continues exactly where the snapshot was
429
+ taken. That holds for snapshots `state()` produced — the type is the contract,
430
+ and within the current version a hand-built tuple is coerced to 32 bits per
431
+ word rather than rejected.
432
+
433
+ That answers the question an auto-seeded roll otherwise cannot. `roll('1d20')`
434
+ mints a seed and discards it; snapshot the state first and the roll is
435
+ reproducible after the fact:
436
+
437
+ ```typescript
438
+ import { SeededRNG, roll } from 'roll-parser';
439
+
440
+ const rng = new SeededRNG(); // auto-seeded, seed discarded
441
+ const snapshot = rng.state();
442
+
443
+ const first = roll('1d20', { rng });
444
+ const replay = roll('1d20', { rng: new SeededRNG(snapshot) });
445
+ first.total === replay.total; // true
446
+ ```
447
+
448
+ The same snapshot is what a mid-session save writes: it is a plain tuple, so
449
+ `JSON.stringify` round-trips it, and loading it resumes the campaign's dice
450
+ rather than restarting them.
451
+
452
+ > [!WARNING]
453
+ > `state()` is for replay and save/resume, **not** for forking substreams. A
454
+ > restored generator resumes the parent's stream verbatim, so two children taken
455
+ > a few draws apart replay the same sequence at an offset — their rolls predict
456
+ > each other exactly. xoshiro has no jump function here to separate them.
457
+
458
+ To give each entity its own stream, derive a seed per entity instead. cyrb128
459
+ sends unrelated strings to states that are overwhelmingly unlikely to coincide,
460
+ which is what makes this work:
461
+
462
+ ```typescript
463
+ import { SeededRNG } from 'roll-parser';
464
+
465
+ const goblin = new SeededRNG('world:goblin');
466
+ const orc = new SeededRNG('world:orc');
467
+
468
+ goblin.nextInt(1, 20); // 9
469
+ orc.nextInt(1, 20); // 1
470
+ ```
471
+
472
+ `RngState` carries the same version binding as a seed: the words are opaque, and
473
+ a release that changes the engine behind them bumps the leading version. A
474
+ snapshot from a different version is rejected with an `INCOMPATIBLE_RNG_STATE`
475
+ error rather than resumed under the wrong semantics, so a save file that
476
+ outlives the promise fails loudly:
477
+
478
+ ```typescript
479
+ import { SeededRNG } from 'roll-parser';
480
+
481
+ const foreign = [0, 1, 2, 3, 4] as const; // a version this build does not speak
482
+
483
+ new SeededRNG(foreign); // throws 'INCOMPATIBLE_RNG_STATE'
484
+ ```
485
+
486
+ ### Custom RNGs
487
+
488
+ Anything structurally matching `RNG` works, so a crypto-backed or table-driven
489
+ generator drops straight in:
490
+
491
+ ```typescript
492
+ import { roll, type RNG } from 'roll-parser';
493
+
494
+ const cryptoRng: RNG = {
495
+ next: () => crypto.getRandomValues(new Uint32Array(1))[0]! / 2 ** 32,
496
+ nextInt: (min, max) => min + Math.floor(cryptoRng.next() * (max - min + 1)),
497
+ };
498
+
499
+ roll('4d6kh3', { rng: cryptoRng });
500
+ ```
501
+
502
+ ### Testing
503
+
504
+ `roll-parser/testing` is a ≈215 B entry point holding the mock RNG.
505
+
506
+ ```typescript
507
+ import { createMockRng, MockRNGExhaustedError } from 'roll-parser/testing';
508
+
509
+ roll('3d6', { rng: createMockRng([4, 2, 6]) }).total; // 12
510
+
511
+ try {
512
+ roll('4d6', { rng: createMockRng([1, 2, 3]) });
513
+ } catch (error) {
514
+ error instanceof MockRNGExhaustedError; // true
515
+ (error as MockRNGExhaustedError).consumed; // 3
516
+ }
517
+ ```
518
+
519
+ The mock is deliberately strict so that a miscounted sequence fails loudly
520
+ instead of silently passing: it never wraps around, and `nextInt` throws a
521
+ `RangeError` when a scripted value falls outside the requested range —
522
+ `createMockRng([7])` cannot satisfy a `d6`.
523
+
524
+ **Draw order.** Values are consumed left to right, one `nextInt` per die. Two
525
+ rules cover meta-expressions:
526
+
527
+ 1. **Keep/drop counts are drawn before the pool** — the evaluator resolves the
528
+ modifier chain first, then rolls the dice it selects from. Applies to `kh`,
529
+ `kl`, `dh`, `dl`.
530
+ 2. **Threshold expressions are drawn after the pool** — explode, reroll,
531
+ min/max, crit-threshold, and success-count modifiers post-process a pool
532
+ that already exists, so their thresholds (and clamp bounds like
533
+ `4d6min(1d2)`) resolve later.
534
+
535
+ `4d6kh(1d2)` with `[1, 5, 3, 4, 6]` — the keep count draws first:
536
+
537
+ | Draw | Consumed by |
538
+ |------|-------------|
539
+ | 1 | `1d2`, the keep count → `1` |
540
+ | 2–5 | `4d6` pool → `5, 3, 4, 6` |
541
+ | | total `6` — the highest die |
542
+
543
+ `4d6cs>(1d2)` with `[5, 3, 4, 6, 1]` — the threshold draws last:
544
+
545
+ | Draw | Consumed by |
546
+ |------|-------------|
547
+ | 1–4 | `4d6` pool → `5, 3, 4, 6` |
548
+ | 5 | `1d2`, the crit threshold → `1` |
549
+ | | total `18` — all four dice crit against `>1` |
550
+
551
+ ## Options
552
+
553
+ Everything `roll()` accepts, in one place. `evaluate()` takes the same
554
+ options minus `rng`/`seed` (it receives the RNG directly) plus `notation`.
555
+
556
+ | Option | Default | Effect |
557
+ |--------|---------|--------|
558
+ | `rng` | fresh `SeededRNG` | Randomness source. Wins over `seed` when both are given |
559
+ | `seed` | random | Seeds the per-call `SeededRNG`. Ignored when `rng` is set |
560
+ | `context` | `{}` | Values for `@name` / `@{name}` variable references |
561
+ | `onMissingVariable` | `'throw'` | A variable absent from `context` throws `UNDEFINED_VARIABLE`; `'zero'` substitutes `0` instead |
562
+ | `maxDice` | `10_000` | [Safety limit](#safety-limits): total dice per expression, integer `>= 1` |
563
+ | `maxExplodeIterations` | `1_000` | [Safety limit](#safety-limits): explosions per die, integer `>= 0` |
564
+ | `maxRerollIterations` | `1_000` | [Safety limit](#safety-limits): recursive rerolls per die, integer `>= 0` |
54
565
 
55
566
  ```typescript
56
567
  import { roll } from 'roll-parser';
57
568
 
58
- const result = roll('4d6kh3');
59
- result.total; // e.g. 14
60
- result.notation; // '4d6kh3'
61
- result.rendered; // '4d6kh3[3, 6, ~~2~~, 5] = 14'
62
- result.rolls; // per-die results with kept/dropped/critical flags
569
+ roll('1d20+@prof', { context: { prof: 3 }, seed: 'demo' }).total; // 4
570
+ roll('1d20+@prof', { onMissingVariable: 'zero', seed: 'demo' }).total; // 1
571
+ ```
572
+
573
+ ### Safety limits
574
+
575
+ Untrusted notation is bounded by default, and every limit can be lowered per
576
+ call. `maxDice` counts the total dice across the whole expression —
577
+ explosions, rerolls, and meta-expressions included — so `6000d6+6000d6`
578
+ breaches the default. `maxExplodeIterations` and `maxRerollIterations` apply
579
+ per die.
580
+
581
+ ```typescript
582
+ roll('99999d6', { maxDice: 100 }); // throws, code 'DICE_LIMIT_EXCEEDED'
63
583
  ```
64
584
 
65
- ### Options
585
+ The limits themselves are validated, and they fail closed. Omit one — or pass
586
+ `undefined` or `null`, which a partial config or a JSON body produces — and it
587
+ takes its default; supply anything else that is not a safe integer in range and
588
+ the call throws `INVALID_EVALUATION_LIMIT` before rolling a single die. There is
589
+ no coercion: `maxDice: Number(untrusted)` rejects bad input rather than quietly
590
+ reverting to the permissive default.
591
+
592
+ | Supplied `maxDice` | Result |
593
+ |--------------------|--------|
594
+ | omitted, `undefined`, `null` | `10_000` — the default |
595
+ | `100` | `100` |
596
+ | `'100'` | throws `INVALID_EVALUATION_LIMIT` |
597
+ | `NaN`, `Infinity`, `-Infinity` | throws `INVALID_EVALUATION_LIMIT` |
598
+ | `0`, `-1` | throws `INVALID_EVALUATION_LIMIT` |
599
+ | `0.5`, `2.9` | throws `INVALID_EVALUATION_LIMIT` |
600
+
601
+ `maxExplodeIterations` and `maxRerollIterations` follow the same rules, except
602
+ that `0` is valid — it disables the modifier's recursion rather than rejecting
603
+ every roll.
66
604
 
67
605
  ```typescript
68
- import { roll, SeededRNG } from 'roll-parser';
606
+ roll('1d6', { maxDice: 0 }); // throws, code 'INVALID_EVALUATION_LIMIT'
607
+ ```
69
608
 
70
- roll('4d6', { seed: 'character-1' }); // reproducible rolls
71
- roll('4d6', { rng: new SeededRNG(42) }); // custom RNG instance (takes precedence)
609
+ One limit is not configurable: expression nesting is capped at
610
+ `MAX_PARSE_DEPTH` (128). Deeply nested input 20,000 parentheses, say
611
+ throws a typed `MAX_DEPTH_EXCEEDED` instead of blowing the stack, which keeps
612
+ `isRollParserError` a complete filter for adversarial input.
613
+
614
+ ## Error handling
615
+
616
+ Every failure raised by lexing, parsing, evaluation, or options validation
617
+ extends `RollParserError` and carries a stable `code`. Two failures do not, and
618
+ both need an injected mock to occur — see [Errors outside the
619
+ hierarchy](#errors-outside-the-hierarchy). Use
620
+ `isRollParserError` as the outer filter — anything it rejects came from
621
+ somewhere else, so rethrow it. What it does *not* tell you is whose fault the
622
+ failure was: it answers `true` for bad notation, for a bad options object, and
623
+ for a broken invariant in here alike, so it is the wrong test to hang a
624
+ user-facing message on. [`isNotationError`](#notation-errors) is that test.
625
+ Library errors carry a brand on their prototype, so unlike
626
+ `instanceof` the filter still matches an error thrown in an iframe or a `vm`
627
+ context, or by a second copy of the library in `node_modules` at 3.0.0 or newer.
628
+ It is a brand and not a `code` sniff, so a foreign error whose own `code` happens
629
+ to collide with one of ours is rejected rather than reported to your user as a
630
+ bad roll. Holding the brand is proof of origin, so the `code` is trusted rather
631
+ than checked against this build's list — an error from a newer minor passes
632
+ carrying a code this version has never heard of.
72
633
 
73
- roll('1d20+@str', {
74
- context: { str: 4 }, // variable values
75
- onMissingVariable: 'zero', // or 'throw' (default)
76
- });
634
+ ```typescript
635
+ import { isRollParserError, roll } from 'roll-parser';
77
636
 
78
- roll('100d6!', {
79
- maxDice: 1_000, // total dice cap, default 10,000
80
- maxExplodeIterations: 100, // per-die explosion cap, default 1,000
81
- maxRerollIterations: 100, // per-die reroll cap, default 1,000
82
- });
637
+ try {
638
+ roll(userInput);
639
+ } catch (error) {
640
+ if (!isRollParserError(error)) throw error;
641
+ console.error(error.code, error.message);
642
+ }
83
643
  ```
84
644
 
85
- ### Results
645
+ That filter is complete for untrusted input, including input that is not a
646
+ string at all. `notation` is typed `string`, but the APIs that produce it hand
647
+ you `string | null` — an absent slash-command option, a missing JSON field — so
648
+ a non-string is checked at the boundary and reported as `INVALID_NOTATION_TYPE`
649
+ rather than escaping as a bare `TypeError`:
86
650
 
87
- | Field | Type | Notes |
88
- |-------|------|-------|
89
- | `total` | `number` | Final computed total (always finite — overflow throws) |
90
- | `notation` | `string` | Original input |
91
- | `expression` | `string` | Normalized form; meta-expressions render as their resolved values |
92
- | `rendered` | `string` | Markdown breakdown: `~~n~~` dropped, `**n**` success, `__n__` failure |
93
- | `rolls` | `DieResult[]` | Every die: `sides`, `result`, `modifiers`, `critical`, `fumble` |
94
- | `parts` | `RollPart` | Typed evaluation tree mirroring the AST 1:1 (see below) |
95
- | `successes` / `failures` | `number?` | Present when success counting was used |
96
- | `degree` / `natural` | `DegreeOfSuccess?` / `number?` | Present for top-level `vs` expressions |
651
+ ```typescript
652
+ roll(null as unknown as string); // throws, code 'INVALID_NOTATION_TYPE'
653
+ ```
97
654
 
98
- ### Structured breakdown (`parts`)
655
+ One boundary the filter cannot cross is a worker. `postMessage` and
656
+ `structuredClone` rebuild an `Error` from `message` and `stack` alone — `code`,
657
+ `name`, and the prototype are all discarded — so a roll-parser error that
658
+ arrives from a worker is no longer recognizable as one. Roll inside the worker
659
+ and post the parts you need:
99
660
 
100
- `result.parts` is a discriminated union (16 variants — `dice`, `binaryOp`,
101
- `modifier`, `versus`, …) where each part holds its own sub-total, resolved
102
- thresholds/specs, and — for dice parts — the same `DieResult` objects as
103
- `result.rolls`. JSON-serializable; `parts.total === result.total` always.
661
+ <!-- readme-test: skip -->
104
662
 
105
663
  ```typescript
106
- const r = roll('4d6kh3 + 2');
107
- // r.parts = { type: 'binaryOp', operator: '+', total: 16,
108
- // left: { type: 'modifier', specs: [{ kind: 'keep', selector: 'highest', count: 3 }],
109
- // target: { type: 'dice', count: 4, sides: 6, rolls: [...], ... }, total: 14 },
110
- // right: { type: 'literal', value: 2, total: 2 } }
664
+ try {
665
+ post({ ok: true, total: roll(notation).total });
666
+ } catch (error) {
667
+ if (!isRollParserError(error)) throw error;
668
+ post({ ok: false, code: error.code, message: error.message });
669
+ }
111
670
  ```
112
671
 
113
- Meta-expressions (`(1d4)d6` counts, computed thresholds) appear as resolved
114
- numbers in the owning part; their dice live in `result.rolls` tagged `'meta'`.
672
+ ### Errors outside the hierarchy
673
+
674
+ Two failures can surface from `roll()` without extending `RollParserError` or
675
+ carrying a `code`, and both come from `roll-parser/testing`:
676
+
677
+ | Thrown | When |
678
+ |--------|------|
679
+ | `MockRNGExhaustedError` | a `createMockRng` sequence runs out of values mid-roll |
680
+ | `RangeError` | a scripted value falls outside the `[min, max]` a die asked for |
681
+
682
+ Neither is reachable unless you passed `{ rng: createMockRng(...) }` yourself, so
683
+ the filter stays complete for untrusted notation — this is a miscounted test
684
+ fixture, not a runtime failure mode, and it is deliberately left un-branded so it
685
+ cannot be mistaken for one. `isRollParserError` returns `false` for both, which
686
+ means the `if (!isRollParserError(error)) throw error` line above already does
687
+ the right thing: the exhausted mock propagates to your test runner and fails the
688
+ test, rather than being swallowed by a `catch` written for bad dice notation.
689
+
690
+ Nothing else in the library documents `instanceof` as the check — for these two
691
+ it is the only option, and `MockRNGExhaustedError` exposes `consumed` to tell you
692
+ how many draws the sequence supplied before it ran dry. See
693
+ [Testing](#testing) for the worked example.
694
+
695
+ ### Notation errors
696
+
697
+ `isRollParserError` establishes origin; `isNotationError` splits what is left
698
+ into "tell the user" and "report a bug". It is `true` only for the codes the
699
+ *input* is answerable for, which is all of them but six:
115
700
 
116
- ### Error handling
701
+ | Excluded code | Why it is not the user's fault |
702
+ |---------------|--------------------------------|
703
+ | `INVALID_EVALUATION_LIMIT` | a bad `maxDice` / `maxExplodeIterations` / `maxRerollIterations` |
704
+ | `INVALID_VARIABLE_VALUE` | a non-finite entry in the `context` you supplied |
705
+ | `INCOMPATIBLE_RNG_STATE` | an RNG snapshot from another version |
706
+ | `UNKNOWN_NODE_TYPE`, `UNKNOWN_OPERATOR`, `UNKNOWN_FUNCTION` | a hand-built AST, or a bug in here |
117
707
 
118
- All errors extend `RollParserError` with a typed `code`
119
- (e.g. `DICE_LIMIT_EXCEEDED`, `DIVISION_BY_ZERO`, `AMBIGUOUS_DICE_CHAIN`);
120
- lexer and parser errors also carry the input `position`.
708
+ So a bot that treats every `isRollParserError` as bad notation answers "check
709
+ your dice" to `maxDice: '100'` — its own bug — and to a broken library
710
+ invariant. Branch on both:
121
711
 
122
712
  ```typescript
123
- import { isRollParserError } from 'roll-parser';
713
+ import { isNotationError, isRollParserError, roll } from 'roll-parser';
124
714
 
125
715
  try {
126
716
  roll(userInput);
127
717
  } catch (error) {
128
- if (isRollParserError(error)) console.error(error.code, error.message);
718
+ if (!isRollParserError(error)) throw error;
719
+ if (isNotationError(error)) console.log(`Bad notation: ${error.message}`);
720
+ else console.error('a bug, not a typo:', error.code);
129
721
  }
130
722
  ```
131
723
 
132
- ### Testing your integrations
724
+ The subset is exported as `NOTATION_ERROR_CODES`, with `NotationErrorCode` as its
725
+ union — `isNotationError` narrows `code` to that union, so a message catalog
726
+ keyed by it stays exhaustive without covering the six.
727
+
728
+ Two boundaries are worth knowing. `DIVISION_BY_ZERO`, `MODULO_BY_ZERO`, and
729
+ `NON_FINITE_RESULT` are included because notation alone reaches them (`1d6/0`,
730
+ `10**400`), but a `context` variable reaches them too — a `true` is not proof the
731
+ notation was at fault. And unlike `isRollParserError`, which trusts the brand and
732
+ never re-reads the code, `isNotationError` has to check the code against the list
733
+ this build carries: an error from a *newer* copy of the library, carrying a
734
+ notation code added after your build, reads as `false` and lands in the bug
735
+ branch. That is the safe direction, but keep versions aligned when the
736
+ distinction drives more than a message.
737
+
738
+ ### Error classes
739
+
740
+ | Class | Stage | Extra fields |
741
+ |-------|-------|--------------|
742
+ | `RollParserError` | base | `code` |
743
+ | `LexerError` | lexing | `position`, `character` |
744
+ | `ParseError` | parsing | `position`, `token` |
745
+ | `EvaluatorError` | evaluation | `nodeType`, `start`, `end` |
746
+
747
+ Error messages never embed the source position. Read it from the class fields,
748
+ or read it uniformly through `getErrorSpan`.
749
+
750
+ ### Error codes
751
+
752
+ `RollParserErrorCode` is a union of 34 codes today — match on the code, not the
753
+ message, and give the `switch` a `default` arm: new codes arrive in minor
754
+ releases (never patches), so an exhaustive switch would turn a minor upgrade
755
+ into a silent fall-through. See [Versioning](#versioning) for the full policy.
756
+ The ones you will meet first: `EXPECTED_TOKEN` and
757
+ `UNEXPECTED_TOKEN` for malformed notation, `AMBIGUOUS_DICE_CHAIN` for `4d6d1`,
758
+ `UNDEFINED_VARIABLE` for a `@name` missing from `context`, and
759
+ `DICE_LIMIT_EXCEEDED` when a [safety limit](#safety-limits) trips. The full
760
+ list lives in the [API reference](https://roll-parser.edloidas.io/docs/).
761
+
762
+ ### Source spans
763
+
764
+ `getErrorSpan` normalizes the lexer/parser `position` and the evaluator
765
+ `start`/`end` into one shape — enough to underline the failure:
133
766
 
134
767
  ```typescript
135
- import { createMockRng } from 'roll-parser/testing';
768
+ const span = getErrorSpan(error); // { start } or { start, end }, or undefined
769
+ if (span != null) {
770
+ const width = (span.end ?? span.start + 1) - span.start;
771
+ console.log(notation);
772
+ console.log(' '.repeat(span.start) + '^'.repeat(width));
773
+ }
774
+
775
+ // 2d6+& 2d6+1d0+3
776
+ // ^ ^^^
777
+ ```
778
+
779
+ ## Using the parser directly
780
+
781
+ `roll` is `evaluate(parse(notation), rng)`. Split it to validate without
782
+ rolling, or to roll the same notation many times.
136
783
 
137
- const result = roll('3d6', { rng: createMockRng([4, 2, 6]) });
138
- result.total; // 12 throws if the roll consumes more values than provided
784
+ ```typescript
785
+ import { evaluate, lex, parse, SeededRNG } from 'roll-parser';
786
+
787
+ parse(userInput); // validate, consuming no randomness
788
+
789
+ // Parse once, roll many — skips lexing and parsing after the first roll
790
+ const ast = parse('4d6kh3');
791
+ const rng = new SeededRNG('demo');
792
+ const scores = Array.from({ length: 6 }, () => evaluate(ast, rng).total);
793
+
794
+ lex('2d20+5'); // [NUMBER, DICE, NUMBER, PLUS, NUMBER, EOF] — for editor integrations
139
795
  ```
140
796
 
141
- ### CLI
797
+ `ASTNode` is a discriminated union of 16 node types with a type guard for each,
798
+ so a walker narrows without casts:
799
+
800
+ ```typescript
801
+ import { type ASTNode, isBinaryOp, isDice, isLiteral, parse } from 'roll-parser';
802
+
803
+ function countPools(node: ASTNode): number {
804
+ if (isDice(node)) return 1;
805
+ if (isLiteral(node)) return 0;
806
+ if (isBinaryOp(node)) return countPools(node.left) + countPools(node.right);
807
+ return 'target' in node ? countPools(node.target) : 0;
808
+ }
809
+
810
+ countPools(parse('2d6+3')); // 1
811
+ ```
812
+
813
+ `DiceNode.count` and `DiceNode.sides` are full sub-expressions rather than
814
+ numbers — that is what makes `(1d4)d6` expressible.
815
+
816
+ ## TypeScript
817
+
818
+ Every public symbol is typed and documented in the generated
819
+ [API reference](https://roll-parser.edloidas.io/docs/). The
820
+ types you name in practice are few — `RollResult` and `RollPart` for reading
821
+ results, `RollOptions` and `RNG` for configuring a roll, `ASTNode` for walking
822
+ the syntax tree, `RollParserErrorCode` for handling failures — and the rest
823
+ (`Token` from `lex`, `ErrorSpan` from `getErrorSpan`, and the like) arrives
824
+ through inference from the functions that return it.
825
+
826
+ `RollPart` and `ASTNode` are both discriminated unions, so a `switch` over the
827
+ discriminant narrows each arm to the right variant. Give it a `default` arm
828
+ anyway — new notation means new variants, and those arrive in minor releases
829
+ (see [Versioning](#versioning)). `RollPart` discriminants are camelCase
830
+ (`'binaryOp'`), `ASTNode` discriminants PascalCase (`'BinaryOp'`), which keeps
831
+ the two trees distinguishable at a glance.
832
+
833
+ Type resolution across module settings ([see Install](#install)) is verified
834
+ in CI by `@arethetypeswrong/cli` and `publint`.
835
+
836
+ ## CLI
837
+
838
+ <!-- readme-test: skip -->
142
839
 
143
840
  ```bash
144
- roll-parser 2d6+3
145
- roll-parser 4d6kh3 --verbose --seed test
146
- roll-parser --help
841
+ npx roll-parser 2d6+3
147
842
  ```
148
843
 
149
- ## Notation reference
844
+ ```
845
+ Usage: roll-parser [options] [--] <notation>
846
+
847
+ Options:
848
+ -h, --help Show this help message
849
+ --version Show version number
850
+ -v, --verbose Show detailed roll breakdown
851
+ --json Print the whole result as compact JSON (wins over --verbose)
852
+ --seed <value> Use seed for reproducible rolls
853
+ -- Treat every following argument as notation
854
+ ```
150
855
 
151
- | Notation | Meaning |
152
- |----------|---------|
153
- | `NdX`, `dX`, `Nd%`, `NdF` | Roll N X-sided / percentile / Fate dice |
154
- | `khN` `klN` `dhN` `dlN` (`kN`) | Keep/drop highest/lowest N (default 1) |
155
- | `!` `!!` `!p` | Explode: standard / compound / penetrating (optional `>=T` etc.) |
156
- | `r<T`, `ro<T` | Reroll recursively / once while condition matches |
157
- | `>=T`, `>T`, `=T`, `<T`, `<=T` | Count successes against threshold (terminal) |
158
- | `fT`, `f<T` | Failure threshold after success counting |
159
- | `vs DC` | PF2e degree of success against a DC |
160
- | `s`, `sd` | Sort dice ascending/descending (display-only) |
161
- | `csT`, `cfT` | Override crit/fumble display thresholds independently |
162
- | `@name`, `@{any name}` | Variable from `context` |
163
- | `{a, b}khN` | Grouped rolls; keep/drop by sub-roll subtotal |
164
- | `floor() ceil() round() abs() max() min()` | Math functions |
856
+ ```bash
857
+ $ roll-parser 2d6+3 --seed demo
858
+ 10
859
+
860
+ $ roll-parser 4d6kh3 --verbose --seed demo
861
+ 4d6[1, 6, (1), 3] = 10
862
+
863
+ $ roll-parser "1d20+7 vs 15" --json --seed demo
864
+ {"total":8,"notation":"1d20+7 vs 15","expression":"1d20 + 7 vs 15","rendered":"1d20[1] + 7 vs 15 = Critical Failure",...}
865
+
866
+ $ roll-parser "2d6+1d0+3"
867
+ Error: Invalid dice sides: 0
868
+ 2d6+1d0+3
869
+ ^
870
+
871
+ $ roll-parser --seed demo -- -1d6+3
872
+ 2
873
+ ```
874
+
875
+ Verbose mode rewrites the markdown markers for plain terminals: `~~n~~` becomes
876
+ `(n)`, `**n**` becomes `[n]`, `__n__` becomes `{n}`. `--seed` takes both
877
+ `--seed value` and `--seed=value`, and accepts any non-empty value including a
878
+ dash-prefixed one. `--help` and `--version` win over any usage error that
879
+ precedes them. Errors go to stderr; only the result goes to stdout.
880
+
881
+ | Exit code | Meaning |
882
+ |----------:|---------|
883
+ | `0` | Success |
884
+ | `1` | Roll or parse error |
885
+ | `2` | Usage error — unknown option, missing notation |
165
886
 
166
887
  ## Performance
167
888
 
168
- Run `bun bench` for the mitata suite (parse, evaluate, and end-to-end paths).
169
- Indicative numbers on a modern x86-64 container (Bun 1.3):
170
-
171
- | Operation | Time |
172
- |-----------|------|
173
- | `parse('2d6+3')` | ~0.4 µs |
174
- | `roll('1d20+5')` | ~2.5 µs (~400k rolls/s) |
175
- | `roll('4d6kh3')` | ~5 µs |
176
- | `evaluate(parse('100d6'))` | ~17 µs |
177
-
178
- Numbers include building the structured `parts` tree (always on).
179
-
180
- ## Known Limitations
181
-
182
- - **`4d6d1` is a parse error, not "drop 1".** The bare `d` token is the dice
183
- operator, and `4d6d1` reading as "roll 4d6, use the result as a count of
184
- d1 dice" is a silent trap — so chaining `d` directly onto a dice expression
185
- throws `AMBIGUOUS_DICE_CHAIN`. Use `4d6dl1` to drop dice, or `(4d6)d1` for
186
- nested dice.
187
- - **Threshold comparisons bind tight.** In explode and reroll thresholds,
188
- `1d6!>=5+2` parses as `(1d6!>=5)+2` the comparison binds to the dice
189
- pool, not the arithmetic after it. For a computed threshold, parenthesize:
190
- `1d6!>=(5+2)`. Success-count thresholds bind the same way, but wrapping the
191
- resulting `SuccessCountNode` is a parse error (terminal by design), so
192
- `1d6>=5+2` throws parenthesize: `1d6>=(5+2)`.
193
- - **Meta-expressions in `result.expression` are substituted with their
194
- resolved values.** Dice counts, sides, modifier counts, and threshold
195
- expressions are rendered as the integer they evaluated to — so
196
- `result.expression` does not round-trip through `parse` when
197
- meta-expressions are present.
198
-
199
- ```typescript
200
- roll('(1d4)d6').expression; // '2d6' (if 1d4 rolled 2)
201
- roll('1d6!>(1d2+3)').expression; // '1d6!>5'
202
- ```
203
-
204
- - **Sort flattens additive pools and drops per-pool brackets.** `(2d6+1d8)s`
205
- renders the combined pool as one sorted list `(2d6 + 1d8)s[3, 4, 5]` —
206
- rather than the per-pool form `2d6[3, 4] + 1d8[5]`. Totals are preserved;
207
- only the rendered breakdown loses pool boundaries.
208
- - **Outer parens drop from `result.expression` after threshold collapse.**
209
- `(1d20cs>19)cs=1` evaluates with `result.expression = '1d20cs>19cs=1'`
210
- because chained `cs` thresholds collapse into a single node. The notation
211
- re-parses to the same AST; only the textual form differs.
889
+ | Notation | `lex` | `parse` | `roll` (end to end) |
890
+ |----------|------:|--------:|--------------------:|
891
+ | `1d20` | 85 ns | 164 ns | **0.49 µs** (~2.0M rolls/s) |
892
+ | `2d6+3` | 98 ns | 215 ns | **0.77 µs** |
893
+ | `4d6kh3` | 122 ns | 245 ns | **1.2 µs** |
894
+ | `10d10>=6f1` | 161 ns | 336 ns | **2.0 µs** |
895
+ | `100d6` | 82 ns | 168 ns | **2.6 µs** |
896
+
897
+ The `roll` column pays for a fresh `SeededRNG` per call, which an injected RNG
898
+ avoids. Every roll also builds the `parts` tree; there is no opt-out and these
899
+ numbers include it. A 1000-die pool costs roughly 47x a `1d20` (~23 µs here),
900
+ while lexing and parsing stay flat at ~84 / ~168 ns.
901
+
902
+ <details>
903
+ <summary>Measurement protocol</summary>
904
+
905
+ Values are **p50**, from
906
+ [mitata](https://github.com/evanwashere/mitata) with forced per-iteration GC
907
+ (`.gc('inner')`), taken as the per-record median of four full
908
+ `bun run bench:json` passes, every row agreeing within 5% except
909
+ `lex / 4d6kh3` at 7%. Measured 2026-08-05 on Bun
910
+ 1.3.14, Apple M2 Pro, macOS, idle and on AC power. The `4d6kh3` and
911
+ `10d10>=6f1` `roll` figures were re-measured 2026-08-06 over three passes
912
+ agreeing within 4%, after per-die `'dc'` tag checks entered the keep/drop and
913
+ success-count paths; the remaining rows re-measured within noise. Read them as
914
+ two significant digits: another machine shifts every row, and a busy one
915
+ inflates the heavy cases most.
916
+
917
+ p50 rather than mean, because the mean here is effectively a GC-pause
918
+ histogram and swings ±40% between processes. Every bench body is JIT-primed
919
+ before measurement and pinned to mitata's batched sampling mode, so all cases
920
+ are timed the same way — mitata otherwise picks the mode from cold calls and
921
+ mis-times mid-weight cases by 10-30x.
922
+
923
+ </details>
924
+
925
+ Run `bun run bench` for the full suite, or `bench:lex` / `bench:parse` /
926
+ `bench:evaluate` / `bench:roll` for one stage. Cross-library numbers live in
927
+ the [competitor suite](https://github.com/edloidas/roll-parser/tree/master/bench/competitors)
928
+ (`bun run bench:competitors`). Bundle size is gated in CI by `size-limit`; the
929
+ budgets live in `package.json`.
930
+
931
+ ## Known limitations
932
+
933
+ - **Keep/drop does not echo into the render prefix.** `4d6kh3` renders as
934
+ `4d6[1, 6, ~~1~~, 3] = 10`, while every other modifier family does echo
935
+ (`8d6![…]`, `4d6sd[…]`, `10d10>=6f1[…]`). The dropped die is still marked;
936
+ only the `kh3` is missing, and `result.expression` has it.
937
+ - **`4d6d1` is a parse error, not "drop 1".** The bare `d` is the dice
938
+ operator, and reading `4d6d1` as "roll 4d6, then roll that many d1" is a
939
+ silent trap, so it throws `AMBIGUOUS_DICE_CHAIN`.
940
+ - **Threshold comparisons bind tight.** `1d6!>=5+2` parses as `(1d6!>=5)+2`;
941
+ parenthesize for a computed threshold, `1d6!>=(5+2)`. Success counts bind the
942
+ same way but are terminal, so `1d6>=5+2` errors outright — write
943
+ `1d6>=(5+2)`.
944
+ - **`result.expression` substitutes meta-expressions with their resolved
945
+ values,** so it does not round-trip through `parse` when they are present:
946
+ `roll('(1d4)d6').expression` is `'4d6'` when the `1d4` rolled 4, and
947
+ `roll('1d6!>(1d2+3)').expression` is `'1d6!>5'`.
948
+ - **Sort flattens additive pools.** `(2d6+1d8)s` renders as one combined sorted
949
+ list, `(2d6 + 1d8)s[2, 3, 6] = 11`, rather than `2d6[2, 6] + 1d8[3]`. Totals
950
+ are unaffected; only the breakdown loses pool boundaries.
951
+ - **Outer parentheses drop when crit thresholds collapse.** `(1d20cs>19)cs=1`
952
+ reports `expression: '1d20cs>19cs=1'` because chained `cs`/`cf` fold into one
953
+ node. It re-parses to the same AST; only the text differs.
954
+ - **Sorting a multi-sub-roll group is rejected.** `{2d6, 1d8}s` throws
955
+ `INVALID_SORT_TARGET`. Spec-correct sorting there is hierarchical — dice
956
+ within each sub-roll, then sub-rolls by total — and the evaluator only
957
+ flat-sorts, so the syntax is refused rather than shipped wrong. Single
958
+ sub-roll groups (`{2d6+1d8}s`) still work as the flat-pool escape hatch.
959
+ - **Division does not floor.** `7/2` totals `3.5`, not `3` — arithmetic is plain
960
+ IEEE-754 throughout. Wrap it when you need an integer: `floor(7/2)` totals `3`.
961
+ - **The power operator has no overflow guard.** `2**999` totals `5.357…e+300`.
962
+ Only a non-finite result throws `NON_FINITE_RESULT`, so finite-but-enormous
963
+ totals pass through unflagged.
964
+ - **Integer literals above `Number.MAX_SAFE_INTEGER` lose precision.** Totals are
965
+ JavaScript numbers, so `9007199254740993` evaluates to `9007199254740992`. Dice
966
+ `sides` past that ceiling are rejected with `INVALID_DICE_SIDES`, but plain
967
+ literals are not.
968
+
969
+ ## Versioning
970
+
971
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html), with the type-level
972
+ and randomness details spelled out below, because for a library like this one
973
+ they are where "breaking" is ambiguous.
974
+
975
+ **Unions are open.** `RollParserErrorCode`, `RollPart`, `ASTNode`, `TokenType`,
976
+ and `DieModifier` all gain members in minor releases — new notation means new
977
+ node types, new part types, and new error codes. Give every `switch` over them
978
+ a `default` arm. Members are only ever removed or renamed in a major.
979
+
980
+ **Types.** Widening a parameter, adding an optional option, and adding a field
981
+ to a returned object are all minor. Removing or narrowing anything you can name
982
+ from the public entry points is major. Every type reachable from a public
983
+ signature is exported — you should never need to reach into `dist/`.
984
+
985
+ **Randomness is stable within a major.** The seed → dice mapping holds for the
986
+ lifetime of a major version: the same seed and notation keep producing the same
987
+ dice across patches and minors, which is what makes seeds usable for replay and
988
+ test fixtures. One exception, and it is narrow — a genuine distribution bug
989
+ (bias, faulty rejection sampling) may change the mapping in a **minor** release,
990
+ never silently in a patch, and always with a `BREAKING` note in the changelog.
991
+ `RngState` snapshots carry the same binding and enforce it: they are stamped
992
+ with a format version, and restoring one from another version throws
993
+ `INCOMPATIBLE_RNG_STATE` instead of resuming a stream that never existed. If you
994
+ need a roll to survive a major upgrade, persist the `RollResult`, not the seed or
995
+ the state.
996
+
997
+ **What is deliberately mutable.** `RollResult.rolls` and the `parts` tree stay
998
+ mutable so you can annotate your own views; the AST and tokens are typed
999
+ `readonly` throughout, so the compiler rejects mutating a parsed node or token
1000
+ in place. See [Working with results](#working-with-results).
1001
+
1002
+ **Runtimes.** The supported floor is Node ≥ 22.12, current Bun, current Deno,
1003
+ Cloudflare Workers, any browser with ES2022, and TypeScript ≥ 5.0 for the
1004
+ shipped types. Raising a floor or dropping a runtime is a major. The library
1005
+ imports no `node:` builtins, and Node, Deno, Cloudflare Workers, and the browser
1006
+ each install the packed tarball in CI and assert a known roll from it — headless
1007
+ Chromium for the browser claim, Deno's npm resolution for Deno, and local
1008
+ `workerd` for Workers. Bun is the development runtime and runs the full test
1009
+ suite instead. Other edge runtimes are not in the matrix, but nothing in the
1010
+ library is aimed at a specific host.
1011
+
1012
+ ## Contributing
1013
+
1014
+ Bug reports, notation gaps, and pull requests are welcome — open an
1015
+ [issue](https://github.com/edloidas/roll-parser/issues) to start. See
1016
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the Bun-only toolchain, the pre-commit
1017
+ hook, commit conventions, and the release flow.
212
1018
 
213
1019
  ## License
214
1020
 
215
- [MIT](LICENSE) © [Mikita Taukachou](https://edloidas.com)
1021
+ [MIT](LICENSE) © [Mikita Taukachou](https://edloidas.io)