roll-parser 3.1.0 → 3.2.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 +26 -1
- package/MIGRATION.md +225 -8
- package/README.md +185 -60
- package/dist/evaluator/env.d.ts +27 -1
- package/dist/evaluator/env.d.ts.map +1 -1
- package/dist/evaluator/env.js.map +1 -1
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +80 -40
- package/dist/evaluator/evaluator.js.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.d.ts +13 -28
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.js +6 -12
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -1
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +7 -1
- package/dist/evaluator/modifiers/explode.js.map +1 -1
- package/dist/evaluator/modifiers/flags.d.ts +9 -2
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
- package/dist/evaluator/modifiers/flags.js +4 -11
- package/dist/evaluator/modifiers/flags.js.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.d.ts +1 -3
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
- package/dist/evaluator/modifiers/success-count.d.ts +6 -5
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +9 -6
- package/dist/evaluator/modifiers/success-count.js.map +1 -1
- package/dist/notation.d.ts +13 -0
- package/dist/notation.d.ts.map +1 -0
- package/dist/notation.js +8 -0
- package/dist/notation.js.map +1 -0
- package/dist/parser/guards.d.ts +38 -0
- package/dist/parser/guards.d.ts.map +1 -1
- package/dist/parser/guards.js +51 -0
- package/dist/parser/guards.js.map +1 -1
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +26 -2
- package/dist/parser/parser.js.map +1 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +18 -13
- package/dist/render.js.map +1 -1
- package/dist/types.d.ts +15 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
- package/src/evaluator/env.ts +27 -1
- package/src/evaluator/evaluator.ts +148 -53
- package/src/evaluator/modifiers/crit-threshold.ts +29 -48
- package/src/evaluator/modifiers/explode.ts +13 -4
- package/src/evaluator/modifiers/flags.ts +13 -13
- package/src/evaluator/modifiers/keep-drop.ts +1 -3
- package/src/evaluator/modifiers/sort.ts +1 -1
- package/src/evaluator/modifiers/success-count.ts +19 -12
- package/src/notation.ts +24 -0
- package/src/parser/guards.ts +92 -1
- package/src/parser/parser.ts +56 -1
- package/src/render.ts +24 -22
- package/src/types.ts +15 -3
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.2.0] - 2026-08-09
|
|
11
|
+
|
|
12
|
+
Every entry below is a correctness fix, but several change what a working
|
|
13
|
+
expression returns and five group forms that used to evaluate now throw.
|
|
14
|
+
[MIGRATION.md](MIGRATION.md#upgrading-from-310-to-320) walks each one with a
|
|
15
|
+
rewrite. Seeds are untouched: the same seed and notation roll the same faces
|
|
16
|
+
they did in 3.1.0.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Success counting a multi-sub-roll group now scores one success per sub-roll subtotal, the way keep/drop on the same node always did, so `{2d6, 2d6}>=10` finds the 11 it was missing. The dice stop being the units: `rendered` shows each sub-roll's own bracket, `rolls[].modifiers` comes back untagged, and `fT` thresholds weigh subtotals too. Targets whose arithmetic no per-die comparison ever reaches — `{3d20+5}>=21`, `{2d6*2}>=4`, `{2d6, 2d8}kh1>=4` — throw `INVALID_SUCCESS_COUNT_TARGET` instead of comparing faces against a number the sub-roll's math never produced ([#303](https://github.com/edloidas/roll-parser/issues/303))
|
|
21
|
+
- A second success count reaching the same pool through group braces (`{4d6>=5f1}<=2f6`) now re-scores it outright rather than appending to the inner pass's tags, so no die comes back both `'success'` and `'failure'` and `successes`/`failures` describe `total` again. The outermost count owns the tags; dropped dice come out untagged, while the DC side of a `vs` keeps tags from an inner count, since no pool pass may tally it. `total` never moved ([#297](https://github.com/edloidas/roll-parser/issues/297))
|
|
22
|
+
- Success counting a group that can never roll a die (`{3, 5, 7}>=4`, `{3, 5, 7}kh1>=4`) now throws `INVALID_SUCCESS_COUNT_TARGET` instead of returning the group's sum with `successes: 0`, which read the documented `successCount.total === successes - failures` invariant as `15 === 0`. A pool that is empty only at run time (`{3, 0d6}>=4`) totals 0 rather than the literal sum ([#304](https://github.com/edloidas/roll-parser/issues/304))
|
|
23
|
+
- Keep/drop on a single-sub-roll group whose total is not the sum of its kept faces now throws `INVALID_KEEP_DROP_TARGET` instead of silently discarding the rest of the arithmetic. `{2d6+3}kh2` lost the `+3`, and `{2d6-1d4}kh3` — which drops nothing — flipped the `1d4` from `-3` to `+3`; `{2d6*2}kh2` and `{abs(1d6-1d8)}kh1` are refused for the same reason `(2d6+3)kh2` already was. The check is structural, so identity terms (`{2d6+0}kh2`) go with them. Additive pools (`{4d6+2d8}kh3`) and multi-sub-roll groups, which select on subtotals, are untouched. A brace-wrapped success count now reports `INVALID_SUCCESS_COUNT_TARGET` at any nesting depth, matching what `(4d6>=5)kh1` always did ([#302](https://github.com/edloidas/roll-parser/issues/302))
|
|
24
|
+
- Penetrating explosion now records the face it rolled in `initialResult` on every die it appends, so the `initialResult ?? result` idiom `DieResult` documents recovers the natural face instead of the stored `raw - 1` — a natural 20 was indistinguishable from a 19. Bare `cs`/`cf` consequently judges a `!p` continuation by that face, so `1d6cf>5!p` and `1d6!pcf>5` agree where the README documented them as disagreeing. Separately, a `vs` roll whose explosion dice are clamped by `minN`/`maxN` (`1d20!min5 vs 30`) stops misreading the continuation as a second primary d20, which had been suppressing the natural-20/1 step ([#300](https://github.com/edloidas/roll-parser/issues/300))
|
|
25
|
+
- `cs`, `cf`, `s`, and `sd` adjacent to another modifier now keep a space in `expression` and `rendered`, so the normalized string re-parses. Those four codes end in a letter the lexer scans as an identifier, so maximal munch swallowed whatever followed: `1d20cs cf` normalized to `1d20cscf` and `4d6 s kh2` to `4d6s kh2`, neither of which round-trips through `parse`. `4dF` and `!p` end in letters but are their own tokens and stay flush ([#299](https://github.com/edloidas/roll-parser/issues/299))
|
|
26
|
+
- Demo site: the equation chip keeps explode and reroll dice. It read only `sort` out of the part tree, so any other pool-bearing modifier's dice went missing from the chip ([#313](https://github.com/edloidas/roll-parser/issues/313))
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
|
|
30
|
+
- `MIGRATION.md` gains a 3.1.0 → 3.2.0 section covering all five newly rejected forms with a rewrite for each, plus the values that moved: `initialResult` on `!p` dice, one `vs` degree, the `expression`/`rendered` spacing, and the totals and tags of nested and grouped success counts ([#300](https://github.com/edloidas/roll-parser/issues/300), [#293](https://github.com/edloidas/roll-parser/issues/293))
|
|
31
|
+
- README gains a `Reading crit and fumble` subsection showing the per-die loop over `result.rolls`, with `site/src/dice.ts` named as the working in-repo example. It documents the four traps such a loop hits — `'meta'` dice must be filtered, `'dc'` dice need a deliberate choice, `rendered` must never be spliced by bracket position, and the default crit rule fires on every pool's max face — and names `rendered`'s markdown as Discord-flavored, pointing at `renderBreakdown` for other dialects ([#293](https://github.com/edloidas/roll-parser/issues/293))
|
|
32
|
+
- README's `## Contents` is a grouped two-level index, and the seven overlapping notation-rejection bullets are folded into one table with a `Write instead` column. The nested-count tag-ownership rule moved to `RollResult`, where the fields it describes live ([#293](https://github.com/edloidas/roll-parser/issues/293))
|
|
33
|
+
|
|
10
34
|
## [3.1.0] - 2026-08-08
|
|
11
35
|
|
|
12
36
|
### Added
|
|
@@ -217,7 +241,8 @@ Dice mechanics (Stage 2):
|
|
|
217
241
|
- Dice count safety limit via `maxDice` option (default 10,000), enforced across the whole expression to prevent DoS via additive groups like `5000d6+5000d6` ([#19](https://github.com/edloidas/roll-parser/issues/19))
|
|
218
242
|
- Parser and evaluator correctness: duplicate `kept` modifier entries, implicit modifier count defaulting to 1 (`4d6kh` → `4d6kh1`), `critical` flag suppression when `sides === 1`, negative `--seed` CLI values ([#21](https://github.com/edloidas/roll-parser/issues/21))
|
|
219
243
|
|
|
220
|
-
[Unreleased]: https://github.com/edloidas/roll-parser/compare/v3.
|
|
244
|
+
[Unreleased]: https://github.com/edloidas/roll-parser/compare/v3.2.0...HEAD
|
|
245
|
+
[3.2.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.2.0
|
|
221
246
|
[3.1.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.1.0
|
|
222
247
|
[3.0.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0
|
|
223
248
|
[3.0.0-beta.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0-beta.0
|
package/MIGRATION.md
CHANGED
|
@@ -1,8 +1,227 @@
|
|
|
1
1
|
# Migration notes
|
|
2
2
|
|
|
3
|
-
Newest first.
|
|
4
|
-
|
|
5
|
-
[Upgrading from
|
|
3
|
+
Newest first.
|
|
4
|
+
|
|
5
|
+
- [Upgrading from 3.1.0 to 3.2.0](#upgrading-from-310-to-320)
|
|
6
|
+
- [Upgrading from 3.0.0 to 3.1.0](#upgrading-from-300-to-310)
|
|
7
|
+
- [Migrating from v2 to v3](#migrating-from-v2-to-v3)
|
|
8
|
+
- [Upgrading from a v3 pre-release](#upgrading-from-a-v3-pre-release)
|
|
9
|
+
|
|
10
|
+
## Upgrading from 3.1.0 to 3.2.0
|
|
11
|
+
|
|
12
|
+
Nothing was removed from the API, no type changed, and the seed → dice mapping
|
|
13
|
+
is untouched — the same seed and notation roll the same faces they did in
|
|
14
|
+
3.1.0. What moves falls in two buckets. Several group forms that used to
|
|
15
|
+
evaluate now throw a parse error — `{3, 5, 7}>=4`, `{2d6+3}kh2`, `{2d6*2}kh2`,
|
|
16
|
+
`{3d20+5}>=21`, and `{2d6, 2d8}kh1>=4` — each listed below with a rewrite. The
|
|
17
|
+
rest are values that moved: `initialResult` on `!p` dice, one `vs` degree, the
|
|
18
|
+
`expression` and `rendered` strings, and the totals and tags of nested and
|
|
19
|
+
grouped success counts.
|
|
20
|
+
|
|
21
|
+
**`!p` continuation dice now carry `initialResult`.** A penetrating explosion
|
|
22
|
+
stores `raw - 1` on each die it appends, and used to record the face it
|
|
23
|
+
replaced nowhere, so the `initialResult ?? result` idiom `DieResult` documents
|
|
24
|
+
returned the decremented value and a natural 20 was indistinguishable from a
|
|
25
|
+
19.
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { roll } from 'roll-parser';
|
|
29
|
+
import { createMockRng } from 'roll-parser/testing';
|
|
30
|
+
|
|
31
|
+
const die = roll('1d20!p', { rng: createMockRng([20, 20, 5]) }).rolls[1];
|
|
32
|
+
|
|
33
|
+
die.result; // 19
|
|
34
|
+
die.initialResult; // 20 — was `undefined`
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Standard `!` is unchanged: its appended dice store what they rolled, so they
|
|
38
|
+
still carry no `initialResult`. Two things can bite:
|
|
39
|
+
|
|
40
|
+
- **Deep-equality assertions against a `!p` pool fail.** The appended dice gain
|
|
41
|
+
a field. Assert on what you care about, or add `initialResult` to the
|
|
42
|
+
expected die.
|
|
43
|
+
- **`--json` and `JSON.stringify(result)` payloads grow** by one number per
|
|
44
|
+
appended `!p` die. Expressions without `!p` are unchanged.
|
|
45
|
+
|
|
46
|
+
**Bare `cs`/`cf` judges a `!p` die by the face it rolled.** The side you do not
|
|
47
|
+
override reads the natural face, which now exists on these dice — so a
|
|
48
|
+
continuation that rolled its maximum is critical even though it displays one
|
|
49
|
+
less, matching the flags plain `1d6!p` already set.
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
const result = roll('1d6!pcf>5', { rng: createMockRng([6, 6, 3]) });
|
|
53
|
+
|
|
54
|
+
result.rendered; // '1d6!pcf>5[6, 5, 2] = 13'
|
|
55
|
+
result.rolls[1].critical; // true — was `false`, judged by the stored 5
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
If you assert `critical` or `fumble` on `!p` continuation dice, re-derive the
|
|
59
|
+
expected flag from `initialResult ?? result`. Order stops mattering here:
|
|
60
|
+
`1d6cf>5!p` and `1d6!pcf>5` now agree, where 3.1.0's README documented them as
|
|
61
|
+
disagreeing, and [Modifiers](README.md#modifiers) is rewritten to match. An
|
|
62
|
+
explicit threshold is still a predicate over a die's current `result`, so `!!`,
|
|
63
|
+
`minN`, and `maxN` remain order-sensitive.
|
|
64
|
+
|
|
65
|
+
**Adjacent bare modifiers keep a space in `expression` and `rendered`.** `cs`,
|
|
66
|
+
`cf`, `s`, and `sd` with no threshold or count end in a letter the lexer scans
|
|
67
|
+
as an identifier, so the normalized form used to re-lex as one token and no
|
|
68
|
+
longer round-tripped through `parse`.
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
roll('1d20cs cf', { rng: createMockRng([20]) }).expression; // '1d20cs cf' — was '1d20cscf'
|
|
72
|
+
roll('4d6 s kh2', { rng: createMockRng([1, 2, 3, 4]) }).rendered;
|
|
73
|
+
// '4d6s[~~1~~, ~~2~~, 3, 4] = 7' — the prefix was '4d6skh2'
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Only those four codes are affected; `4dF` and `!p` end in letters but are their
|
|
77
|
+
own tokens, so they stay flush. Snapshot assertions over `expression` or
|
|
78
|
+
`rendered` for an affected expression need re-recording.
|
|
79
|
+
|
|
80
|
+
**One `vs` case stops discarding its natural face.** Only `vs` expressions
|
|
81
|
+
whose roll side both appends explosion dice and clamps them — `!` or `!!`
|
|
82
|
+
combined with `minN`/`maxN` — are affected: a clamped continuation used to be
|
|
83
|
+
counted as a second primary d20, and two primaries suppress the natural 20 /
|
|
84
|
+
natural 1 step. Without the clamp (`1d20! vs 30`) the continuation never looked
|
|
85
|
+
like a primary, and those degrees are unchanged.
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { DegreeOfSuccess } from 'roll-parser';
|
|
89
|
+
|
|
90
|
+
const result = roll('1d20!min5 vs 30', { rng: createMockRng([20, 2, 12]) });
|
|
91
|
+
|
|
92
|
+
result.natural; // 20 — was `undefined`
|
|
93
|
+
result.degree === DegreeOfSuccess.Success; // true — was `Failure`
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
If you pinned `natural` or `degree` on such a roll, re-record it.
|
|
97
|
+
|
|
98
|
+
**Success counting a dice-less group is now a parse error.** `{3, 5, 7}>=4`
|
|
99
|
+
used to return the group's sum with `successes: 0`, so the
|
|
100
|
+
`successCount.total === successes - failures` invariant the README offers read
|
|
101
|
+
`15 === 0`. There are no dice to tally, and whether a group's units should be
|
|
102
|
+
its subtotals is undecided, so the form is refused instead.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
roll('{3, 5, 7}>=4'); // throws 'INVALID_SUCCESS_COUNT_TARGET' — was 15
|
|
106
|
+
roll('{3, 5, 7}kh1>=4'); // throws 'INVALID_SUCCESS_COUNT_TARGET' — was 7
|
|
107
|
+
roll('{3, 0d6}>=4').total; // 0 — was 3
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Groups holding at least one pool are untouched — `{3, 1d6}>=4` counts the `1d6`
|
|
111
|
+
exactly as it did. `0d6>=4` also still parses and totals 0; only targets that
|
|
112
|
+
can never roll a die are rejected. There is no in-notation rewrite for a
|
|
113
|
+
literal-only group: replace the literals with the pool they stood in for
|
|
114
|
+
(`3d6>=4`), or drop the count and keep the group's sum (`{3, 5, 7}`).
|
|
115
|
+
|
|
116
|
+
**A second success count re-scores the pool instead of adding to it.** Group
|
|
117
|
+
braces let one count wrap another (`{4d6>=5f1}<=2f6`), which the parse-time
|
|
118
|
+
reject on a direct `4d6>=5>=1` never reached. The inner pass used to leave its
|
|
119
|
+
tags behind, so a die could come back both `'success'` and `'failure'` and the
|
|
120
|
+
top-level counts stopped describing the total. The outermost count now owns the
|
|
121
|
+
tags outright.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
const result = roll('{4d6>=5f1}<=2f6', { rng: createMockRng([6, 5, 2, 1]) });
|
|
125
|
+
|
|
126
|
+
result.total; // 1
|
|
127
|
+
result.successes; // 2 — was 4
|
|
128
|
+
result.failures; // 1 — was 0
|
|
129
|
+
result.rendered; // '{4d6>=5f1}<=2f6[__6__, 5, **2**, **1**] = 1'
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`total` never moved — it always came from the outermost count, so a test
|
|
133
|
+
asserting only `total` needs no change. What changed is `successes`,
|
|
134
|
+
`failures`, `rolls[].modifiers`, and `rendered`, which now agree with it, so
|
|
135
|
+
`successCount.total === successes - failures` holds again. A lone
|
|
136
|
+
count is untouched, and so is a nested pair whose thresholds agree
|
|
137
|
+
(`{4d6>=5}>=5`). Dropped dice come out untagged; only the DC side of a `vs`
|
|
138
|
+
keeps tags from an inner count, since no pool pass may tally it.
|
|
139
|
+
|
|
140
|
+
**Keep/drop on a single-sub-roll group now takes added dice terms only.** The
|
|
141
|
+
flat-pool form totals the faces it kept, so any term in the sub-roll that is not
|
|
142
|
+
an added die face was discarded from the total with no error and no marker.
|
|
143
|
+
`{2d6+3}kh2` lost the `+3`, and `{2d6-1d4}kh3` — which drops nothing — flipped
|
|
144
|
+
the `1d4` from `-3` to `+3`. The parser already refused `(2d6+3)kh2` for exactly
|
|
145
|
+
this reason; the brace form now does too.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
roll('{2d6+3}kh2', { rng: createMockRng([4, 5]) }); // throws 'INVALID_KEEP_DROP_TARGET' — was 9
|
|
149
|
+
roll('{2d6-1d4}kh3', { rng: createMockRng([4, 5, 3]) }); // throws 'INVALID_KEEP_DROP_TARGET' — was 12
|
|
150
|
+
roll('{2d6*2}kh2', { rng: createMockRng([4, 5]) }); // throws 'INVALID_KEEP_DROP_TARGET'
|
|
151
|
+
roll('{2d6+0}kh2', { rng: createMockRng([4, 5]) }); // throws 'INVALID_KEEP_DROP_TARGET' — identity terms too
|
|
152
|
+
roll('{4d6+2d8}kh3', { rng: createMockRng([6, 6, 6, 1, 8, 1]) }).total; // 20 — unchanged
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Also newly rejected, same cause: a function-wrapped pool (`{abs(1d6-1d8)}kh1`),
|
|
156
|
+
and a success count, which returned the face sum rather than the tally.
|
|
157
|
+
`{4d6>=5}kh1` throws `INVALID_SUCCESS_COUNT_TARGET`, not the keep/drop code — a
|
|
158
|
+
single-sub-roll group used to hide the count from the reject that `(4d6>=5)kh1`
|
|
159
|
+
has always hit, and both spellings now report the same thing.
|
|
160
|
+
|
|
161
|
+
The check is structural rather than arithmetic, so identity terms are refused
|
|
162
|
+
with the rest even though their total was already right: `{2d6+0}kh2`,
|
|
163
|
+
`{2d6*1}kh2`, and `{2d6+1d8-0}kh3` all throw.
|
|
164
|
+
|
|
165
|
+
Additive pools are the form this syntax exists for and are untouched —
|
|
166
|
+
`{4d6+2d8}kh3`, `{2d6kh1+1d8}kh2`, `{{1d6, 1d8}+2d6}kh2`. Multi-sub-roll groups
|
|
167
|
+
never had the bug, since keep/drop compares subtotals there: `{2d6+3, 1d8}kh1`
|
|
168
|
+
still works. To keep a rejected expression, move the arithmetic outside the
|
|
169
|
+
selection (`{2d6}kh2+3`) or give each term its own sub-roll.
|
|
170
|
+
|
|
171
|
+
**Success counting a multi-sub-roll group now scores subtotals, not dice.**
|
|
172
|
+
`{2d6, 2d6}>=10` used to compare all four faces against 10 and find nothing,
|
|
173
|
+
while `{2d6, 2d6}kh1` on the same node saw two compound dice worth 11 and 3.
|
|
174
|
+
Roll20 counts one success per sub-roll total, and that is what the count does
|
|
175
|
+
now.
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
const result = roll('{2d6, 2d6}>=10', { rng: createMockRng([6, 5, 2, 1]) });
|
|
179
|
+
|
|
180
|
+
result.total; // 1 — was 0
|
|
181
|
+
result.successes; // 1 — was 0
|
|
182
|
+
result.rendered; // '{2d6[6, 5], 2d6[2, 1]}>=10 = 1'
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Four things move with it:
|
|
186
|
+
|
|
187
|
+
- **`rendered`** shows each sub-roll's own bracket instead of one flat pool —
|
|
188
|
+
the dice are no longer the units.
|
|
189
|
+
- **`rolls[].modifiers`** comes back untagged: no `'success'` or `'failure'` on
|
|
190
|
+
any die, for the same reason. Read `successes` / `failures` instead.
|
|
191
|
+
- **`fT` thresholds** score subtotals too, so `{4d6+2d8, 3d20+3,
|
|
192
|
+
5d10+1}>=40f<=10` weighs three subtotals rather than 21 faces.
|
|
193
|
+
- **A literal sub-roll** is a subtotal like any other, so it is scored rather
|
|
194
|
+
than skipped.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
const literal = roll('{3, 1d6}>=4f<=3', { rng: createMockRng([6]) });
|
|
198
|
+
|
|
199
|
+
literal.successes; // 1
|
|
200
|
+
literal.failures; // 1 — was 0
|
|
201
|
+
literal.total; // 0 — was 1
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Single-sub-roll groups (`{4d6}>=5`, `{2d6+1d8}>=5`) are the flat-pool form and
|
|
205
|
+
are unchanged.
|
|
206
|
+
|
|
207
|
+
**Two group spellings of a success count are now parse errors.** Both used to
|
|
208
|
+
reach the flat counter with the subtotals already gone.
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
roll('{3d20+5}>=21'); // throws 'INVALID_SUCCESS_COUNT_TARGET' — was a count of bare faces
|
|
212
|
+
roll('{2d6, 2d8}kh1>=4'); // throws 'INVALID_SUCCESS_COUNT_TARGET' — was a count of loose dice
|
|
213
|
+
roll('({2d6, 2d8})>=4'); // throws 'INVALID_SUCCESS_COUNT_TARGET'
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- **`{3d20+5}>=21`** hits the single-sub-roll rule keep/drop already has: that
|
|
217
|
+
form compares one face at a time, so a scalar term never reaches the
|
|
218
|
+
comparison. A scaled or function-wrapped pool goes with it — `{2d6*2}>=4` and
|
|
219
|
+
`{floor(2d6/2)}>=2` throw too, exactly as `{2d6*2}kh2` does. To keep it, fold
|
|
220
|
+
the scalar into the threshold: `3d20>=16` counts the same dice.
|
|
221
|
+
- **`{2d6, 2d8}kh1>=4`** covers every way a multi-sub-roll group can arrive
|
|
222
|
+
somewhere other than as the count's direct target — `{{2d6, 2d8}}>=4` and
|
|
223
|
+
`({2d6, 2d8})>=4` throw the same code. To keep it, count the group directly:
|
|
224
|
+
`{2d6, 2d8}>=4`.
|
|
6
225
|
|
|
7
226
|
## Upgrading from 3.0.0 to 3.1.0
|
|
8
227
|
|
|
@@ -48,8 +267,8 @@ group sub-roll nested inside another one used to emit mismatched delimiters —
|
|
|
48
267
|
`{{(1d6[1]), 1d8[4]}, ({(1d10[1]), 1d12[3]})}`. Anything parsing that output
|
|
49
268
|
should be reading `--json` instead.
|
|
50
269
|
|
|
51
|
-
**New, optional: `roll-parser/render`.** A subpath
|
|
52
|
-
`renderBreakdown(result, marks?)` rebuilds the breakdown from `result.parts`
|
|
270
|
+
**New, optional: `renderBreakdown` from `roll-parser/render`.** A subpath
|
|
271
|
+
export whose `renderBreakdown(result, marks?)` rebuilds the breakdown from `result.parts`
|
|
53
272
|
with markers you choose, so you no longer have to regex the markdown out of
|
|
54
273
|
`rendered`. With no marks its output is byte-identical to `rendered`, which
|
|
55
274
|
makes it a drop-in starting point:
|
|
@@ -100,9 +319,7 @@ classes. v3 has `roll(notation, options)` for the common path, and
|
|
|
100
319
|
| `convert(obj)`, `Roll`, `WodRoll`, `Result` | removed — there are no roll-description objects to build or convert |
|
|
101
320
|
|
|
102
321
|
**Result fields moved.** v2 returned `Result { notation, value, rolls }` where
|
|
103
|
-
`rolls` was a plain `number[]`.
|
|
104
|
-
`createMockRng` (exported by `roll-parser/testing`) so their numbers are exact
|
|
105
|
-
rather than whatever the dice happened to do:
|
|
322
|
+
`rolls` was a plain `number[]`.
|
|
106
323
|
|
|
107
324
|
<!-- readme-test: skip -->
|
|
108
325
|
```typescript
|
package/README.md
CHANGED
|
@@ -68,8 +68,9 @@ roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) }).total; // 14, every run
|
|
|
68
68
|
- **Safe on untrusted input.** Dice count, explosion depth, reroll depth, and
|
|
69
69
|
parse depth are all bounded, and every failure is a typed error with a stable
|
|
70
70
|
code and a source span.
|
|
71
|
-
- **Small and fast.** ≈
|
|
72
|
-
`parse`, ≈213 B for the testing entry
|
|
71
|
+
- **Small and fast.** ≈12.7 kB brotli for the whole library, ≈5.5 kB for just
|
|
72
|
+
`parse`, ≈1.4 kB for `roll-parser/render`, ≈213 B for the testing entry
|
|
73
|
+
point. Zero runtime dependencies, zero
|
|
73
74
|
`node:` imports. A `1d20` round trip takes about 0.5 µs.
|
|
74
75
|
- **Tested.** 1,500+ tests behind CI-enforced coverage floors — 100% of
|
|
75
76
|
functions, 98% of lines — including every code example in this README, which
|
|
@@ -84,22 +85,28 @@ roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) }).total; // 14, every run
|
|
|
84
85
|
|
|
85
86
|
## Contents
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
**Start here**
|
|
89
|
+
|
|
90
|
+
- [Install](#install) — [CDN](#cdn--browser-without-a-bundler) · [Upgrading](#upgrading)
|
|
91
|
+
- [Notation reference](#notation-reference) — [Dice](#dice) · [Arithmetic](#arithmetic-and-functions) · [Modifiers](#modifiers) · [Pools and checks](#pools-and-checks)
|
|
89
92
|
- [Recipes by game system](#recipes-by-game-system)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
- [
|
|
94
|
-
- [
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
- [
|
|
99
|
-
- [
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
93
|
+
|
|
94
|
+
**Using results**
|
|
95
|
+
|
|
96
|
+
- [Working with results](#working-with-results) — [RollResult](#rollresult) · [The parts tree](#the-parts-tree)
|
|
97
|
+
- [Rendering](#rendering) — [Custom markers](#custom-markers) · [Reading crit and fumble](#reading-crit-and-fumble)
|
|
98
|
+
|
|
99
|
+
**Configuring a roll**
|
|
100
|
+
|
|
101
|
+
- [Options](#options) — [Safety limits](#safety-limits)
|
|
102
|
+
- [Randomness](#randomness) — [Seeded rolls](#seeded-rolls) · [Replay](#replay-and-saveresume) · [Custom RNGs](#custom-rngs) · [Testing](#testing)
|
|
103
|
+
|
|
104
|
+
**Reference**
|
|
105
|
+
|
|
106
|
+
- [Error handling](#error-handling) — [Notation errors](#notation-errors) · [Error classes](#error-classes) · [Error codes](#error-codes)
|
|
107
|
+
- [Using the parser directly](#using-the-parser-directly) · [TypeScript](#typescript)
|
|
108
|
+
- [CLI](#cli) · [Performance](#performance) · [Known limitations](#known-limitations)
|
|
109
|
+
- [Versioning](#versioning) · [Contributing](#contributing) · [License](#license)
|
|
103
110
|
|
|
104
111
|
## Install
|
|
105
112
|
|
|
@@ -145,9 +152,9 @@ as one request:
|
|
|
145
152
|
|
|
146
153
|
### Upgrading
|
|
147
154
|
|
|
148
|
-
[MIGRATION.md](MIGRATION.md) covers every upgrade path, newest first
|
|
149
|
-
|
|
150
|
-
|
|
155
|
+
[MIGRATION.md](MIGRATION.md) covers every upgrade path, newest first. v3 is a
|
|
156
|
+
complete rewrite and the 2.x API is not compatible; to stay on the old line,
|
|
157
|
+
pin `roll-parser@2.3.2`.
|
|
151
158
|
|
|
152
159
|
## Notation reference
|
|
153
160
|
|
|
@@ -157,8 +164,9 @@ wherever the two overlap: `kh`/`kl`/`dh`/`dl`, `!`/`!!`/`!p`, `r`/`ro`, success
|
|
|
157
164
|
and failure counting, `cs`/`cf`, grouped rolls, and `floor`/`ceil`/`round`/`abs`
|
|
158
165
|
all use Roll20's spelling and semantics. It is not a strict superset — sorting
|
|
159
166
|
(`s`), PF2e `vs` checks, `max`/`min`, and computed dice counts and sides are
|
|
160
|
-
additions, and
|
|
161
|
-
after a success count
|
|
167
|
+
additions, and three Roll20 forms are deliberately rejected (`4d6d1`, arithmetic
|
|
168
|
+
after a success count, and a counted single-sub-roll group folding its own math
|
|
169
|
+
into each die, `{3d20+5}>=21`) — see [Known limitations](#known-limitations).
|
|
162
170
|
|
|
163
171
|
Notation is case-insensitive and whitespace-tolerant — `2 D 20 KH 1` and
|
|
164
172
|
`2d20kh1` are the same expression, newlines included. The one exception is
|
|
@@ -220,15 +228,17 @@ Chained keep/drop modifiers do **not** nest. `4d6kh3dl1` flattens into two
|
|
|
220
228
|
specs applied independently to the same pool, with the dropped sets unioned —
|
|
221
229
|
the Roll20 rule.
|
|
222
230
|
|
|
223
|
-
`cs` / `cf` covers the whole pool,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
`
|
|
231
|
+
`cs` / `cf` covers the whole pool, dice that `!`, `!p`, `r`, and `ro` add
|
|
232
|
+
included, and position does not matter — `1d6cs<2!` and `1d6!cs<2` flag the
|
|
233
|
+
same dice. The side you leave alone keeps the default rule, reading each die's
|
|
234
|
+
natural face, so writing `cf>5` does not disturb `critical`: `1d6cf>5!p` and
|
|
235
|
+
`1d6!pcf>5` both agree with plain `1d6!p` on the appended die.
|
|
236
|
+
|
|
237
|
+
One case stays order-sensitive: an explicit threshold reads a die's current
|
|
238
|
+
value, so modifiers that rewrite it rather than add a die (`!!`, `minN`,
|
|
239
|
+
`maxN`) are judged on whatever that value is when the threshold runs. With
|
|
240
|
+
`[6, 6, 3]`, `1d6cs>10!!` reports no critical while `1d6!!cs>10` reports one,
|
|
241
|
+
judging the compounded 15.
|
|
232
242
|
|
|
233
243
|
### Pools and checks
|
|
234
244
|
|
|
@@ -238,13 +248,19 @@ than they rolled, the side you *don't* override moves with position —
|
|
|
238
248
|
| `fT` / `f<cmp>T` | Subtract dice meeting the failure threshold — `10d10>=6f1`, `10d10>=6f<=2` |
|
|
239
249
|
| `<roll> vs <dc>` | PF2e degree of success, with nat-20/nat-1 upgrade and downgrade |
|
|
240
250
|
| `{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 |
|
|
251
|
+
| `{a+b}khN` | Single-sub-roll group: keep/drop selects across the flattened pool — `+`-joined pools only, a literal or any math throws |
|
|
252
|
+
| `{a, b}<cmp>T` | Grouped roll: each sub-roll's subtotal is counted, not its dice — `{2d6, 2d6}>=10` |
|
|
253
|
+
| `{a+b}<cmp>T` | Single-sub-roll group: counts across the flattened pool — `+`-joined pools only, a literal or any math throws |
|
|
242
254
|
|
|
243
255
|
> [!IMPORTANT]
|
|
244
256
|
> Success counting is **terminal** — no modifier or arithmetic applies to it
|
|
245
257
|
> directly, so `10d10>=6 + 2` is a parse error. Put the arithmetic inside the
|
|
246
258
|
> threshold, `10d10>=(4+2)`, or wrap the count in group braces to operate on its
|
|
247
259
|
> result: `{10d10>=6}+2`.
|
|
260
|
+
>
|
|
261
|
+
> Braces also let a second count re-score the same pool — `{4d6>=5}<=2f5`. The
|
|
262
|
+
> outermost count owns the result; see
|
|
263
|
+
> [RollResult](#rollresult) for what that means for the fields you read.
|
|
248
264
|
|
|
249
265
|
> [!WARNING]
|
|
250
266
|
> A bare `d` after a dice expression is rejected: `4d6d1` throws
|
|
@@ -293,7 +309,7 @@ pool.total; // 5 — successes minus failures
|
|
|
293
309
|
| `notation` | `string` | Exactly what you passed in |
|
|
294
310
|
| `expression` | `string` | Normalized form; meta-expressions appear as their resolved values |
|
|
295
311
|
| `rendered` | `string` | Markdown breakdown with per-die markers |
|
|
296
|
-
| `rolls` | `DieResult[]` | Every die in order: `sides`, `result`, `modifiers`, `critical`, `fumble` |
|
|
312
|
+
| `rolls` | `DieResult[]` | Every die in order, meta-expression dice included: `sides`, `result`, `modifiers`, `critical`, `fumble` |
|
|
297
313
|
| `parts` | `RollPart` | Typed evaluation tree mirroring the AST 1:1 |
|
|
298
314
|
| `successes` / `failures` | `number?` | Present only when success counting was used |
|
|
299
315
|
| `degree` / `natural` | `DegreeOfSuccess?` / `number?` | Present only for a top-level `vs` |
|
|
@@ -324,6 +340,12 @@ JSON.stringify(roll('3d6', { rng: createMockRng([4, 2, 6]) }));
|
|
|
324
340
|
> `rolls[]` and the `rolls[]` inside `parts` hold the *same* `DieResult`
|
|
325
341
|
> objects — no deep clone, so annotating a die is visible through both views.
|
|
326
342
|
|
|
343
|
+
When counts nest through braces (`{4d6>=5}<=2f5`), the outermost one owns the
|
|
344
|
+
result: every die is judged against its thresholds alone, so `successes`,
|
|
345
|
+
`failures`, `rolls[].modifiers`, and `rendered` all agree with it, and dice it
|
|
346
|
+
does not count come out untagged. The DC side of a `vs` is the exception — it
|
|
347
|
+
sits outside every pool pass and keeps whatever tagged it.
|
|
348
|
+
|
|
327
349
|
### The parts tree
|
|
328
350
|
|
|
329
351
|
`result.parts` is a 16-variant discriminated union mirroring the AST one-to-one.
|
|
@@ -379,7 +401,9 @@ dice land in `result.rolls` tagged `'meta'` so an audit log can still show them.
|
|
|
379
401
|
|
|
380
402
|
`result.rendered` uses markdown markers, so a Discord bot or chat log can print
|
|
381
403
|
it as-is: `~~n~~` for a die dropped by keep/drop or group selection, `**n**` for
|
|
382
|
-
a success, `__n__` for a failure.
|
|
404
|
+
a success, `__n__` for a failure. The dialect is Discord's, not a universal one
|
|
405
|
+
— Telegram MarkdownV2 rejects `**bold**` and spells strike `~n~` — so expect a
|
|
406
|
+
transform, or emit your own dialect with [`renderBreakdown`](#custom-markers).
|
|
383
407
|
|
|
384
408
|
```typescript
|
|
385
409
|
roll('4d6kh3', { seed: 'demo' }).rendered; // '4d6[1, 6, ~~1~~, 3] = 10'
|
|
@@ -424,6 +448,79 @@ exactly one of `dropped`, `success`, or `failure` — a dropped die is never
|
|
|
424
448
|
also shown as a success. With no marks the output is byte-identical to
|
|
425
449
|
`result.rendered`; a property test pins that.
|
|
426
450
|
|
|
451
|
+
#### Reading crit and fumble
|
|
452
|
+
|
|
453
|
+
`rendered` carries no marker for a crit or a fumble — those live on the dice.
|
|
454
|
+
`die.critical` and `die.fumble` already reflect any `cs` / `cf` override, so a
|
|
455
|
+
UI that builds its own elements reads the booleans rather than the string.
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
const result = roll('4d20cs>=19', { rng: createMockRng([20, 19, 7, 1]) });
|
|
459
|
+
|
|
460
|
+
const marked = result.rolls
|
|
461
|
+
.filter((die) => !die.modifiers.includes('meta'))
|
|
462
|
+
.map((die) => {
|
|
463
|
+
let text = String(die.result);
|
|
464
|
+
if (die.critical) text = `<b>${text}</b>`;
|
|
465
|
+
if (die.fumble) text = `<i>${text}</i>`;
|
|
466
|
+
return text;
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
marked.join(', '); // '<b>20</b>, <b>19</b>, 7, <i>1</i>'
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The 19 is critical only because `cs>=19` said so — the booleans are where the
|
|
473
|
+
override lands. Compose the two marks rather than branching on one: overlapping
|
|
474
|
+
thresholds set both, and `1d6cs>=3cf<=4` rolling a 3 does exactly that.
|
|
475
|
+
|
|
476
|
+
The [playground](https://roll-parser.edloidas.io/) does exactly this — its
|
|
477
|
+
[source](https://github.com/edloidas/roll-parser/blob/master/site/src/dice.ts)
|
|
478
|
+
turns the same booleans into CSS classes, a tooltip, and a legend.
|
|
479
|
+
|
|
480
|
+
That loop is right for a plain pool. Four decisions it cannot dodge:
|
|
481
|
+
|
|
482
|
+
- **`'meta'` dice are not yours to show.** They resolve counts, sides, and
|
|
483
|
+
modifier arguments, and they carry ordinary crit flags — in `4d6kh(1d2)` the
|
|
484
|
+
`1d2` rolling a 2 hits its max face and arrives `critical: true`. The filter
|
|
485
|
+
above is not optional.
|
|
486
|
+
- **`'dc'` dice are a judgment call.** They do render — `1d20 vs 2d10` shows
|
|
487
|
+
`1d20[5] vs 2d10[10, 10]` — but they sit outside the roll-side pool while
|
|
488
|
+
still taking the default crit rule, so that `[10, 10]` arrives as two crits
|
|
489
|
+
nobody rolled for. Drop them and a per-die zip has no dice left for a
|
|
490
|
+
two-number bracket; keep them and the DC side lights up on its own faces.
|
|
491
|
+
[`renderBreakdown`](#custom-markers) does not decide this for you — it marks
|
|
492
|
+
every flagged die — but its callbacks receive the die, so gate on the tag.
|
|
493
|
+
For a hand-rolled walk, `result.parts` keeps the two sides apart: the
|
|
494
|
+
`versus` part carries `roll` and `dc` as separate subtrees.
|
|
495
|
+
- **Never splice `rendered` by bracket position.** `{2d6, 2d8}kh1` strikes the
|
|
496
|
+
dropped sub-roll as a whole, `{2d6[6, 6], ~~2d8[1, 1]~~}`, yet its inner dice
|
|
497
|
+
each carry `'dropped'` too — marking every `'dropped'` die into that string
|
|
498
|
+
yields `~~2d8[~~1~~, ~~1~~]~~`. Rebuild with
|
|
499
|
+
[`renderBreakdown`](#custom-markers) instead of patching the baked string.
|
|
500
|
+
- **Density is your call.** The default rule flags the max face of any pool,
|
|
501
|
+
not just d20s — `3d6` rolling a 6 is `critical: true` — so marking it
|
|
502
|
+
unconditionally makes ordinary pools glitter. Gate on `die.sides === 20`, or
|
|
503
|
+
on whatever your table's rule is. `d1` and Fate dice are the exceptions:
|
|
504
|
+
having no exceptional face, they stay `false` unless `cs` / `cf` says so.
|
|
505
|
+
|
|
506
|
+
The claims above, in code:
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
import { renderBreakdown } from 'roll-parser/render';
|
|
510
|
+
|
|
511
|
+
roll('1d6cs>=3cf<=4', { rng: createMockRng([3]) }).rolls[0]?.fumble; // true
|
|
512
|
+
roll('4d6kh(1d2)', { rng: createMockRng([2, 3, 6, 2, 5]) }).rolls[0]?.critical; // true
|
|
513
|
+
roll('1d20 vs 2d10', { rng: createMockRng([5, 10, 10]) }).rolls[2]?.critical; // true
|
|
514
|
+
roll('3dF', { rng: createMockRng([1, 0, -1]) }).rolls[0]?.critical; // false
|
|
515
|
+
roll('{2d6, 2d8}kh1', { rng: createMockRng([6, 6, 1, 1]) }).rendered;
|
|
516
|
+
// '{2d6[6, 6], ~~2d8[1, 1]~~} = 12'
|
|
517
|
+
|
|
518
|
+
// Gating a mark on the `'dc'` tag keeps the DC side unmarked
|
|
519
|
+
renderBreakdown(roll('1d20 vs 2d10', { rng: createMockRng([5, 10, 10]) }), {
|
|
520
|
+
critical: (die, text) => (die.modifiers.includes('dc') ? text : `<b>${text}</b>`),
|
|
521
|
+
}); // '1d20[5] vs 2d10[10, 10] = Critical Failure'
|
|
522
|
+
```
|
|
523
|
+
|
|
427
524
|
## Randomness
|
|
428
525
|
|
|
429
526
|
Every die is drawn through the `RNG` interface — no roll path bypasses the RNG
|
|
@@ -967,16 +1064,12 @@ Values are **p50**, from
|
|
|
967
1064
|
two significant digits: another machine shifts every row, and a busy one
|
|
968
1065
|
inflates the heavy cases most.
|
|
969
1066
|
|
|
970
|
-
The `10d10>=6f1` `roll` figure
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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.
|
|
1067
|
+
The `4d6sd` row and the `10d10>=6f1` `roll` figure come from a later five-pass
|
|
1068
|
+
measurement on the same machine, after the per-die `'dc'` tag checks were
|
|
1069
|
+
hoisted out of the keep/drop, success-count, and sort loops. That session was
|
|
1070
|
+
not idle and those two read bimodally with roughly 10% between the modes, so
|
|
1071
|
+
take them as a recovered range rather than point values. Every other row is as
|
|
1072
|
+
first measured.
|
|
980
1073
|
|
|
981
1074
|
p50 rather than mean, because the mean here is effectively a GC-pause
|
|
982
1075
|
histogram and swings ±40% between processes. Every bench body is JIT-primed
|
|
@@ -996,32 +1089,64 @@ any commit that regresses a case past 1.75x. Bundle size is gated in CI by
|
|
|
996
1089
|
|
|
997
1090
|
## Known limitations
|
|
998
1091
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1092
|
+
### Notation the parser rejects
|
|
1093
|
+
|
|
1094
|
+
| Form | Throws | Write instead |
|
|
1095
|
+
|------|--------|---------------|
|
|
1096
|
+
| `{2d6+3}kh2`, `{2d6-1d4}kh3`, `{2d6*2}kh2`, `{2d6+0}kh2` | `INVALID_KEEP_DROP_TARGET` | `{2d6}kh2 + 3`, or one sub-roll per term: `{2d6+3, 1d8}kh1` |
|
|
1097
|
+
| `{3d20+5}>=21`, `{2d6-1d4}>=3`, `{2d6*2}>=4`, `{floor(2d6/2)}>=2` | `INVALID_SUCCESS_COUNT_TARGET` | fold the scalar into the threshold: `3d20>=16` |
|
|
1098
|
+
| `{3, 5, 7}>=4` | `INVALID_SUCCESS_COUNT_TARGET` | give the group a pool: `3d6>=4` |
|
|
1099
|
+
| `{{2d6, 2d8}}>=4`, `({2d6, 2d8})>=4`, `{2d6, 2d8}kh1>=4` | `INVALID_SUCCESS_COUNT_TARGET` | count the group directly: `{2d6, 2d8}>=4` |
|
|
1100
|
+
| `{4d6>=5}kh1` | `INVALID_SUCCESS_COUNT_TARGET` | the count is already terminal — select first, `{4d6kh1}>=5` |
|
|
1101
|
+
| `{2d6, 1d8}s` | `INVALID_SORT_TARGET` | flatten to one sub-roll: `{2d6+1d8}s` |
|
|
1102
|
+
| `4d6d1` | `AMBIGUOUS_DICE_CHAIN` | `4d6dl1` to drop a die, `(4d6)d1` for nested dice |
|
|
1103
|
+
|
|
1104
|
+
The first two rows share one cause: a single-sub-roll group compares dice one
|
|
1105
|
+
face at a time, so a literal, a subtracted or scaled pool, or a function wrapper
|
|
1106
|
+
never reaches the comparison. The check is structural rather than arithmetic, so
|
|
1107
|
+
an identity term (`{2d6+0}kh2`) is refused with the rest. Adding pools
|
|
1108
|
+
(`{4d6+2d8}kh3`, `{2d6+1d8}>=5`) is the form these braces exist for and always
|
|
1109
|
+
works.
|
|
1110
|
+
|
|
1111
|
+
A counted group needs at least one die somewhere in it — one real pool is
|
|
1112
|
+
enough, and the other sub-rolls may be literals: `{3, 1d6}>=4` scores both
|
|
1113
|
+
subtotals, the literal `3` included. A pool that is merely empty at run time
|
|
1114
|
+
(`0d6>=4`) still parses and totals 0. Subtotals exist only while the count holds
|
|
1115
|
+
the group directly, which is why the fourth row throws rather than falling back
|
|
1116
|
+
to loose faces; the DC side of a `vs` is exempt (`{1d20 vs {2d6, 2d8}}>=5`
|
|
1117
|
+
counts the d20), since no pool pass tallies a DC.
|
|
1118
|
+
|
|
1119
|
+
Sorting a multi-sub-roll group is refused rather than shipped wrong: spec-correct
|
|
1120
|
+
sorting there is hierarchical — dice within each sub-roll, then sub-rolls by
|
|
1121
|
+
total — and the evaluator only flat-sorts.
|
|
1122
|
+
|
|
1123
|
+
### Rendering and `expression`
|
|
1124
|
+
|
|
1125
|
+
- **Keep/drop does not echo into the render prefix** — see
|
|
1126
|
+
[Rendering](#rendering). The dropped die is still marked; only the `kh3` is
|
|
1127
|
+
missing, and `result.expression` has it.
|
|
1010
1128
|
- **`result.expression` substitutes meta-expressions with their resolved
|
|
1011
1129
|
values,** so it does not round-trip through `parse` when they are present:
|
|
1012
1130
|
`roll('(1d4)d6').expression` is `'4d6'` when the `1d4` rolled 4, and
|
|
1013
1131
|
`roll('1d6!>(1d2+3)').expression` is `'1d6!>5'`.
|
|
1132
|
+
- **Adjacent bare modifiers keep a space.** `cs`, `cf`, `s`, and `sd` without a
|
|
1133
|
+
threshold or count end an identifier the lexer would extend into whatever
|
|
1134
|
+
follows, so `expression` and `rendered` separate them the way the input had
|
|
1135
|
+
to: `roll('1d20cs cf').expression` is `'1d20cs cf'`, and `4d6 s kh2` comes
|
|
1136
|
+
back as `'4d6s kh2'`. Everything else stays flush — `4d6cs>4cf<2`.
|
|
1014
1137
|
- **Sort flattens additive pools.** `(2d6+1d8)s` renders as one combined sorted
|
|
1015
1138
|
list, `(2d6 + 1d8)s[2, 3, 6] = 11`, rather than `2d6[2, 6] + 1d8[3]`. Totals
|
|
1016
1139
|
are unaffected; only the breakdown loses pool boundaries.
|
|
1017
1140
|
- **Outer parentheses drop when crit thresholds collapse.** `(1d20cs>19)cs=1`
|
|
1018
1141
|
reports `expression: '1d20cs>19cs=1'` because chained `cs`/`cf` fold into one
|
|
1019
1142
|
node. It re-parses to the same AST; only the text differs.
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1143
|
+
|
|
1144
|
+
### Arithmetic and precedence
|
|
1145
|
+
|
|
1146
|
+
- **Threshold comparisons bind tight.** `1d6!>=5+2` parses as `(1d6!>=5)+2`;
|
|
1147
|
+
parenthesize for a computed threshold, `1d6!>=(5+2)`. Success counts bind the
|
|
1148
|
+
same way but are terminal, so `1d6>=5+2` errors outright — write
|
|
1149
|
+
`1d6>=(5+2)`, or `{1d6>=5}+2` to add to the count itself.
|
|
1025
1150
|
- **Division does not floor.** `7/2` totals `3.5`, not `3` — arithmetic is plain
|
|
1026
1151
|
IEEE-754 throughout. Wrap it when you need an integer: `floor(7/2)` totals `3`.
|
|
1027
1152
|
- **The power operator has no overflow guard.** `2**999` totals `5.357…e+300`.
|