roll-parser 3.0.0-beta.0 → 3.1.0

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