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.
- package/CHANGELOG.md +107 -9
- package/MIGRATION.md +147 -0
- package/README.md +960 -154
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +81 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +15 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +17 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +36 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +83 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/errors.d.ts +332 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +141 -0
- package/dist/errors.js.map +1 -0
- package/dist/evaluator/die.d.ts +26 -0
- package/dist/evaluator/die.d.ts.map +1 -0
- package/dist/evaluator/die.js +19 -0
- package/dist/evaluator/die.js.map +1 -0
- package/dist/evaluator/env.d.ts +58 -0
- package/dist/evaluator/env.d.ts.map +1 -0
- package/dist/evaluator/env.js +11 -0
- package/dist/evaluator/env.js.map +1 -0
- package/dist/evaluator/evaluator.d.ts +41 -75
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +906 -0
- package/dist/evaluator/evaluator.js.map +1 -0
- package/dist/evaluator/modifiers/compare.js +15 -0
- package/dist/evaluator/modifiers/compare.js.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +0 -1
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.js +23 -0
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
- package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
- package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
- package/dist/evaluator/modifiers/die-bound.js +14 -0
- package/dist/evaluator/modifiers/die-bound.js.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +13 -5
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +103 -0
- package/dist/evaluator/modifiers/explode.js.map +1 -0
- package/dist/evaluator/modifiers/flags.d.ts +37 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
- package/dist/evaluator/modifiers/flags.js +18 -0
- package/dist/evaluator/modifiers/flags.js.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js +82 -0
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +12 -4
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.js +62 -0
- package/dist/evaluator/modifiers/reroll.js.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +4 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
- package/dist/evaluator/modifiers/sort.js +13 -0
- package/dist/evaluator/modifiers/sort.js.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +2 -6
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +24 -0
- package/dist/evaluator/modifiers/success-count.js.map +1 -0
- package/dist/index.d.ts +33 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2560
- package/dist/index.js.map +1 -26
- package/dist/lexer/lexer.d.ts +50 -5
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +260 -0
- package/dist/lexer/lexer.js.map +1 -0
- package/dist/lexer/tokens.d.ts +31 -6
- package/dist/lexer/tokens.d.ts.map +1 -1
- package/dist/lexer/tokens.js +42 -0
- package/dist/lexer/tokens.js.map +1 -0
- package/dist/parser/ast.d.ts +314 -160
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js +52 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/guards.d.ts +106 -0
- package/dist/parser/guards.d.ts.map +1 -0
- package/dist/parser/guards.js +121 -0
- package/dist/parser/guards.js.map +1 -0
- package/dist/parser/parser.d.ts +118 -14
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +751 -0
- package/dist/parser/parser.js.map +1 -0
- package/dist/rng/mock.d.ts +73 -12
- package/dist/rng/mock.d.ts.map +1 -1
- package/dist/rng/mock.js +30 -0
- package/dist/rng/mock.js.map +1 -0
- package/dist/rng/seeded.d.ts +141 -9
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/rng/seeded.js +138 -0
- package/dist/rng/seeded.js.map +1 -0
- package/dist/rng/types.d.ts +57 -0
- package/dist/rng/types.d.ts.map +1 -1
- package/dist/rng/types.js +2 -0
- package/dist/rng/types.js.map +1 -0
- package/dist/roll.d.ts +58 -28
- package/dist/roll.d.ts.map +1 -1
- package/dist/roll.js +8 -0
- package/dist/roll.js.map +1 -0
- package/dist/testing.d.ts +5 -4
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -41
- package/dist/testing.js.map +1 -11
- package/dist/types.d.ts +306 -44
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +83 -34
- package/src/cli/args.ts +66 -10
- package/src/cli/format.ts +22 -3
- package/src/cli/index.ts +27 -84
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +480 -27
- package/src/evaluator/die.ts +50 -0
- package/src/evaluator/env.ts +73 -0
- package/src/evaluator/evaluator.ts +653 -421
- package/src/evaluator/modifiers/crit-threshold.ts +3 -6
- package/src/evaluator/modifiers/die-bound.ts +39 -0
- package/src/evaluator/modifiers/explode.ts +60 -58
- package/src/evaluator/modifiers/flags.ts +61 -0
- package/src/evaluator/modifiers/keep-drop.ts +124 -126
- package/src/evaluator/modifiers/reroll.ts +28 -49
- package/src/evaluator/modifiers/sort.ts +14 -1
- package/src/evaluator/modifiers/success-count.ts +5 -8
- package/src/index.ts +56 -35
- package/src/lexer/lexer.ts +107 -34
- package/src/lexer/tokens.ts +31 -6
- package/src/parser/ast.ts +323 -341
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +419 -242
- package/src/rng/mock.ts +74 -13
- package/src/rng/seeded.ts +299 -64
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +64 -47
- package/src/testing.ts +5 -9
- package/src/types.ts +310 -43
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -2608
- package/dist/cli.js.map +0 -28
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
package/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
|
-
|
|
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.
|
|
10
|
-
<a href="https://
|
|
11
|
-
<a href="https://
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
>
|
|
17
|
-
|
|
18
|
-
>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
+
roll('1d6', { maxDice: 0 }); // throws, code 'INVALID_EVALUATION_LIMIT'
|
|
607
|
+
```
|
|
69
608
|
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
onMissingVariable: 'zero', // or 'throw' (default)
|
|
76
|
-
});
|
|
634
|
+
```typescript
|
|
635
|
+
import { isRollParserError, roll } from 'roll-parser';
|
|
77
636
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
|
172
|
-
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
1021
|
+
[MIT](LICENSE) © [Mikita Taukachou](https://edloidas.io)
|