roll-parser 3.0.0-alpha.0 → 3.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +166 -30
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/format.d.ts +1 -1
  5. package/dist/cli/format.d.ts.map +1 -1
  6. package/dist/cli.js +1110 -277
  7. package/dist/cli.js.map +28 -0
  8. package/dist/errors.d.ts +7 -4
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/evaluator/evaluator.d.ts +63 -7
  11. package/dist/evaluator/evaluator.d.ts.map +1 -1
  12. package/dist/evaluator/index.d.ts +2 -2
  13. package/dist/evaluator/index.d.ts.map +1 -1
  14. package/dist/evaluator/modifiers/compare.d.ts +1 -1
  15. package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
  16. package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
  17. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  18. package/dist/evaluator/modifiers/explode.d.ts +8 -4
  19. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  20. package/dist/evaluator/modifiers/keep-drop.d.ts +1 -1
  21. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  22. package/dist/evaluator/modifiers/reroll.d.ts +4 -4
  23. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  24. package/dist/evaluator/modifiers/sort.d.ts +23 -0
  25. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  26. package/dist/evaluator/modifiers/success-count.d.ts +1 -1
  27. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  28. package/dist/index.d.ts +13 -13
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1112 -275
  31. package/dist/index.js.map +26 -0
  32. package/dist/lexer/lexer.d.ts +18 -3
  33. package/dist/lexer/lexer.d.ts.map +1 -1
  34. package/dist/lexer/tokens.d.ts +22 -2
  35. package/dist/lexer/tokens.d.ts.map +1 -1
  36. package/dist/parser/ast.d.ts +209 -24
  37. package/dist/parser/ast.d.ts.map +1 -1
  38. package/dist/parser/parser.d.ts +48 -5
  39. package/dist/parser/parser.d.ts.map +1 -1
  40. package/dist/rng/index.d.ts +2 -2
  41. package/dist/rng/index.d.ts.map +1 -1
  42. package/dist/rng/mock.d.ts +1 -1
  43. package/dist/rng/mock.d.ts.map +1 -1
  44. package/dist/rng/seeded.d.ts +8 -1
  45. package/dist/rng/seeded.d.ts.map +1 -1
  46. package/dist/roll.d.ts +6 -2
  47. package/dist/roll.d.ts.map +1 -1
  48. package/dist/testing.d.ts +1 -1
  49. package/dist/testing.d.ts.map +1 -1
  50. package/dist/testing.js +3 -0
  51. package/dist/testing.js.map +11 -0
  52. package/dist/types.d.ts +143 -2
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +22 -18
  55. package/src/cli/args.ts +2 -1
  56. package/src/cli/format.ts +8 -4
  57. package/src/cli/index.ts +22 -5
  58. package/src/errors.ts +15 -3
  59. package/src/evaluator/evaluator.ts +826 -111
  60. package/src/evaluator/index.ts +2 -2
  61. package/src/evaluator/modifiers/compare.ts +1 -1
  62. package/src/evaluator/modifiers/crit-threshold.ts +59 -0
  63. package/src/evaluator/modifiers/explode.ts +29 -25
  64. package/src/evaluator/modifiers/keep-drop.ts +1 -1
  65. package/src/evaluator/modifiers/reroll.ts +18 -25
  66. package/src/evaluator/modifiers/sort.ts +30 -0
  67. package/src/evaluator/modifiers/success-count.ts +2 -2
  68. package/src/index.ts +33 -15
  69. package/src/lexer/lexer.ts +101 -8
  70. package/src/lexer/tokens.ts +42 -2
  71. package/src/parser/ast.ts +397 -30
  72. package/src/parser/parser.ts +590 -67
  73. package/src/rng/index.ts +2 -2
  74. package/src/rng/mock.ts +1 -1
  75. package/src/rng/seeded.ts +31 -1
  76. package/src/roll.ts +14 -6
  77. package/src/testing.ts +1 -1
  78. package/src/types.ts +127 -2
  79. package/dist/index.mjs +0 -1724
  80. package/dist/testing.mjs +0 -39
package/CHANGELOG.md ADDED
@@ -0,0 +1,91 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [3.0.0-beta.0] - 2026-07-07
11
+
12
+ ### Changed
13
+
14
+ - **BREAKING:** package is now ESM-only. The `require` export condition shipped a file that was actually ESM (`bun build --target node` emits ESM), so it only ever worked via Node ≥22 `require(esm)` — which continues to work. Bundles are built with `--target node` (no `// @bun` pragma) and include linked sourcemaps; type declarations now resolve under `moduleResolution: node16/nodenext`, validated by `@arethetypeswrong/cli` + `publint` in CI ([#119](https://github.com/edloidas/roll-parser/issues/119))
15
+ - **BREAKING:** `cs`/`cf` are now independent overrides (Roll20 semantics). `1d20cf<3` keeps the default nat-20 crit instead of clearing it; `1d20cs>=19` keeps the default nat-1 fumble. The previous replace semantics required chaining `cscf<3` to preserve defaults ([#118](https://github.com/edloidas/roll-parser/issues/118))
16
+ - **BREAKING:** bare `NdXdY` dice chains (e.g. `4d6d1`) are rejected at parse time with `AMBIGUOUS_DICE_CHAIN` instead of silently parsing as `(4d6)d1` — roll 4d6, use the result as a count of d1 dice — which is almost never intended. Use `4d6dl1` to drop dice or `(4d6)d1` for nested dice ([#118](https://github.com/edloidas/roll-parser/issues/118))
17
+ - **BREAKING:** non-finite totals (`Infinity`/`NaN`, e.g. `2**1024`) now throw `NON_FINITE_RESULT` instead of returning a non-finite `total` ([#118](https://github.com/edloidas/roll-parser/issues/118))
18
+ - **BREAKING:** `1d1` no longer reports `fumble: true` — a d1 always rolls 1, so it is neither critical nor fumble, mirroring the existing `critical` guard ([#118](https://github.com/edloidas/roll-parser/issues/118))
19
+
20
+ ### Added
21
+
22
+ - Rich structured `RollResult.parts` output — a 16-variant discriminated union mirroring the AST 1:1, always built during evaluation. Each part carries its sub-total, resolved thresholds/specs (`ModifierSpec`, `ResolvedComparePoint`, `ResolvedCritThreshold`), source span, and (for dice parts) the same `DieResult` objects as `RollResult.rolls`. Group parts under keep/drop carry `keptIndices`; versus parts carry `degree`; successCount parts carry `successes`/`failures` with `total === successes - failures`. JSON-serializable with `parts.total === result.total` guaranteed ([#84](https://github.com/edloidas/roll-parser/issues/84))
23
+ - Source spans: lexer tokens carry an exclusive `end` offset, and every parser-produced AST node carries `start`/`end` offsets (`NodeSpan`) into the notation. `EvaluatorError` is stamped with the span of the tightest failing sub-expression, and the CLI caret uses it — `2d6+1d0+3` points at `1d0` ([#120](https://github.com/edloidas/roll-parser/issues/120))
24
+ - Root exports for the previously missing `GroupNode`/`SortNode`/`CritThresholdNode` types and `isGroup`/`isSort`/`isCritThreshold` guards ([#120](https://github.com/edloidas/roll-parser/issues/120))
25
+ - Benchmark suite (`bun bench`, mitata) covering parse, evaluate, and end-to-end roll paths ([#119](https://github.com/edloidas/roll-parser/issues/119))
26
+ - Coverage thresholds enforced in CI (100% functions, ≥95% lines per file); coverage was previously disabled in `bunfig.toml` and unmeasurable even with `--coverage` ([#119](https://github.com/edloidas/roll-parser/issues/119))
27
+ - CLI prints the notation with a caret under the error position for lexer/parser errors ([#118](https://github.com/edloidas/roll-parser/issues/118))
28
+ - Lexer hints at modifier splits for merged identifiers: `4d6khs` now suggests `kh1s` ([#118](https://github.com/edloidas/roll-parser/issues/118))
29
+ - `"./package.json"` export and `CHANGELOG.md` in the published tarball ([#119](https://github.com/edloidas/roll-parser/issues/119))
30
+
31
+ ### Fixed
32
+
33
+ - Dropped group sub-rolls no longer leak `success`/`failure` tags into `RollResult.successes`/`failures` — `{2d6>=4, 2d6>=4}kh1` counted successes from the dropped sub-roll, and the rendered output showed `**bold**` markers inside `~~strikethrough~~` ([#118](https://github.com/edloidas/roll-parser/issues/118))
34
+ - `SeededRNG.nextInt` no longer silently truncates ranges above 2^32 — a 53-bit two-draw sampling path covers up to `Number.MAX_SAFE_INTEGER`, and wider ranges throw `RangeError` instead of degrading ([#118](https://github.com/edloidas/roll-parser/issues/118))
35
+ - Deeply nested input (e.g. 20,000 parens) throws a typed `MAX_DEPTH_EXCEEDED` `ParseError` instead of an uncaught `RangeError` stack overflow that broke the `isRollParserError` contract ([#118](https://github.com/edloidas/roll-parser/issues/118))
36
+ - PF2e nat-20/nat-1 upgrade now survives standard (`!`) and penetrating (`!p`) explosion under `vs` — explosion continuation dice no longer make `extractNatural` bail as ambiguous, matching the compound (`!!`) behavior ([#118](https://github.com/edloidas/roll-parser/issues/118))
37
+ - Entire `package.json` (scripts, devDependencies, author email) is no longer embedded in every bundle via the `VERSION` import ([#119](https://github.com/edloidas/roll-parser/issues/119))
38
+ - CLI verbose output no longer leaks markdown for dropped group sub-rolls — `{1d8, 1d10}kh1 -v` printed `{~~1d8[2]~~, ...}` because only single-number spans were rewritten ([#118](https://github.com/edloidas/roll-parser/issues/118))
39
+ - Lexer errors report full code points for astral characters (`🎲` instead of a lone surrogate `�`); parser `expect()` errors name symbols and say `end of input` instead of `Expected RPAREN but got ''` ([#118](https://github.com/edloidas/roll-parser/issues/118))
40
+ - Parser now rejects `SuccessCount` wrapped in meta-expression positions — modifier count, dice count/sides (infix and prefix), Fate/percentile dice count, SuccessCount threshold and bare `fN` value, and compare-point values used by Explode/Reroll. `mergeMetaRolls` also strips `success`/`failure` modifier tags on meta-forwarded dice as defense-in-depth ([#69](https://github.com/edloidas/roll-parser/issues/69))
41
+ - Parser now rejects `Versus` wrapped in the same meta-expression positions so a PF2e `vs` outcome cannot be silently dropped by `mergeMetaRolls`, and the `parseVersus` chain guard unwraps `Grouped` so `(1d20 vs 15) vs 10` throws `NESTED_VERSUS` at parse time instead of at eval ([#70](https://github.com/edloidas/roll-parser/issues/70))
42
+ - Single-sub-roll Group passthrough on `cs`/`cf` no longer smuggles a buried multi-sub Group past the parser. `{{1d20, 1d20}kh1}cs>18`, `{{1d6, 2d8}+0}cs>5`, `{abs({1d6, 2d8})}cs>5`, and `{floor({1d6, 2d8}/1)}cs>5` now reject with `INVALID_CRIT_THRESHOLD_TARGET` via a new `containsMultiSubGroup` deep-walk in `rejectGroupTarget` ([#109](https://github.com/edloidas/roll-parser/issues/109))
43
+ - Single-sub-roll Group passthrough no longer flips Fate `+1` faces to fumble. `{4dF+1d6}cf`, `({4dF+1d6})cf`, and `{abs(4dF)}cf` now reject via a new `deepContainsFatePool` mirror of `deepContainsDicePool` used inside `containsFatePool`'s Group case ([#109](https://github.com/edloidas/roll-parser/issues/109))
44
+ - Single-sub-roll Group passthrough no longer smuggles `Versus` past the meta-expression rejection, so `{1d20 vs 15}cs>18`, `{1d20 vs 15}s`, `{1d20 vs 15}kh1`, `{1+(1d20 vs 15)}cs>18`, `{abs(1d20 vs 15)}cs>18`, and `4d6>={1d20 vs 15}` now throw `NESTED_VERSUS`. `parseModifier` also gained the `rejectVersusTarget` call it was missing — closing a pre-existing inconsistency where `(1d20 vs 15)kh1` rejected with a different error code while the brace form silently dropped `degree`/`natural` ([#109](https://github.com/edloidas/roll-parser/issues/109))
45
+
46
+ ### Notes
47
+
48
+ These are intentional behaviours documented for the first time, not changes — semantics are unchanged.
49
+
50
+ - Sort flattens additive pools: `(2d6+1d8)s` renders as one combined sorted list rather than per-pool brackets. Same applies to wrapped pools like `floor(4d6)s`. Totals are preserved ([#96](https://github.com/edloidas/roll-parser/issues/96))
51
+ - Outer parens drop from `result.expression` when chained `cs`/`cf` thresholds collapse: `(1d20cs>19)cs=1` renders as `1d20cs>19cs=1`. Re-parses to the same AST; only the textual form differs ([#96](https://github.com/edloidas/roll-parser/issues/96))
52
+
53
+ ## [3.0.0-alpha.0] - 2026-04-20
54
+
55
+ First alpha of the v3 rewrite. Stage 1 (core engine) is complete; Stage 2 (dice mechanics) is largely in place.
56
+
57
+ ### Added
58
+
59
+ Core engine (Stage 1):
60
+
61
+ - Lexer and token system ([#3](https://github.com/edloidas/roll-parser/issues/3))
62
+ - Pratt parser and AST types ([#4](https://github.com/edloidas/roll-parser/issues/4))
63
+ - Seedable RNG system with `SeededRNG` (xorshift128) and `MockRNG` helpers ([#5](https://github.com/edloidas/roll-parser/issues/5))
64
+ - AST evaluator with keep/drop modifiers (`kh`, `kl`, `dh`, `dl`) ([#6](https://github.com/edloidas/roll-parser/issues/6))
65
+ - Public `roll(notation, options)` API with `RollResult` / `DieResult` types ([#7](https://github.com/edloidas/roll-parser/issues/7))
66
+ - `roll-parser` CLI with `--help`, `--version`, `--verbose`, `--seed` flags; ESM + CJS dual build ([#8](https://github.com/edloidas/roll-parser/issues/8))
67
+
68
+ Dice mechanics (Stage 2):
69
+
70
+ - Percentile dice notation (`d%`) ([#35](https://github.com/edloidas/roll-parser/issues/35))
71
+ - Fate/Fudge dice (`dF`) ([#36](https://github.com/edloidas/roll-parser/issues/36))
72
+ - Math functions: `floor()`, `ceil()`, `round()`, `abs()`, `max()`, `min()` ([#37](https://github.com/edloidas/roll-parser/issues/37))
73
+ - Exploding dice — `!` (standard), `!!` (compound), `!p` (penetrating) — with optional compare points ([#38](https://github.com/edloidas/roll-parser/issues/38))
74
+ - Reroll mechanics (`r`, `ro`) with compare points ([#39](https://github.com/edloidas/roll-parser/issues/39))
75
+ - Success counting / dice pools with `>`, `>=`, `<`, `<=`, `=` operators and `fN` failure tagging ([#40](https://github.com/edloidas/roll-parser/issues/40))
76
+ - PF2e Degrees of Success via the `vs` keyword, with nat-20/1 upgrade/downgrade ([#41](https://github.com/edloidas/roll-parser/issues/41))
77
+
78
+ ### Changed
79
+
80
+ - **BREAKING:** complete rewrite from v2.x. Public API, semantics, and notation coverage are not compatible with the 2.x line. Pin to `roll-parser@2.3.2` for the legacy implementation.
81
+ - Public API surface trimmed: `Lexer`, `Parser`, `lex`, `TokenType`, `Token`, and mock RNG exports removed from the root entry. `createMockRng` moved to the `roll-parser/testing` subpath. Added typed `RollParserError` base class with `RollParserErrorCode` union and `isRollParserError()` type guard ([#24](https://github.com/edloidas/roll-parser/issues/24))
82
+
83
+ ### Fixed
84
+
85
+ - Modifier chaining now matches the Roll20 standard: chained modifiers are flattened and each applies independently to the full pool; drop sets are unioned ([#12](https://github.com/edloidas/roll-parser/issues/12))
86
+ - Dice count safety limit via `maxDice` option (default 10,000), enforced across the whole expression to prevent DoS via additive groups like `5000d6+5000d6` ([#19](https://github.com/edloidas/roll-parser/issues/19))
87
+ - Parser and evaluator correctness: duplicate `kept` modifier entries, implicit modifier count defaulting to 1 (`4d6kh` → `4d6kh1`), `critical` flag suppression when `sides === 1`, negative `--seed` CLI values ([#21](https://github.com/edloidas/roll-parser/issues/21))
88
+
89
+ [Unreleased]: https://github.com/edloidas/roll-parser/compare/v3.0.0-beta.0...HEAD
90
+ [3.0.0-beta.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0-beta.0
91
+ [3.0.0-alpha.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.0.0-alpha.0
package/README.md CHANGED
@@ -13,34 +13,32 @@ TypeScript-first, Bun-optimized, Pratt parser architecture.
13
13
 
14
14
  ## Status
15
15
 
16
- > **v3 Alpha** — Stage 1 (Core Engine) is complete with 300+ tests.
17
- > Stages 2–3 are planned.
16
+ > **v3 Alpha** — Stages 1–3 (core engine, system compatibility, advanced
17
+ > features incl. rich structured `parts` output) are implemented with 1,000+
18
+ > tests, 100% function coverage, and a property-based test suite.
18
19
  >
19
20
  > For production use, install [v2.3.2](https://www.npmjs.com/package/roll-parser/v/2.3.2).
20
21
 
21
22
  ## Features
22
23
 
23
- ### Stage 1: Core Engine (Complete)
24
-
25
- - Basic dice notation: `2d6`, `d20`, `4d6+4`
26
- - Full arithmetic: `+`, `-`, `*`, `/`, `%`, `**`
27
- - Parentheses: `(1d4+1)*2`
28
- - Keep/Drop modifiers: `4d6kh3`, `2d20kl1`, `4d6dl1`
29
- - Computed dice: `(1+1)d(3*2)`
30
- - Seedable RNG for reproducible rolls
31
-
32
- ### Stage 2: System Compatibility (Planned)
33
-
34
- - Exploding dice: `1d6!`, `1d6!!`, `1d6!p`
35
- - Reroll mechanics: `2d6r<2`, `2d6ro<3`
36
- - Success counting: `10d10>=6`, `10d10>=6f1`
37
- - Math functions: `floor()`, `ceil()`, `max()`, `min()`
38
-
39
- ### Stage 3: Advanced Features (Planned)
40
-
41
- - Variables: `1d20+@str`, `1d20+@{modifier}`
42
- - Grouped rolls: `{1d8, 1d10}kh1`
43
- - Rich JSON output with roll breakdown
24
+ - **Basic dice**: `2d6`, `d20`, `4d6+4`, percentile `d%`, Fate/Fudge `4dF`
25
+ - **Full arithmetic**: `+`, `-`, `*`, `/`, `%`, `**` (also `^`), parentheses
26
+ - **Computed dice**: `(1+1)d(3*2)`, `(1d4)d6`
27
+ - **Keep/Drop**: `4d6kh3`, `2d20kl1`, `4d6dl1`, shorthand `4d6k3`
28
+ - **Exploding dice**: standard `1d6!`, compound `1d6!!`, penetrating `1d6!p`, with compare points `1d6!>=5`
29
+ - **Rerolls**: recursive `2d6r<2`, once `2d6ro<3`
30
+ - **Success counting**: `10d10>=6`, with failure tagging `10d10>=6f1`
31
+ - **PF2e degrees of success**: `1d20+10 vs 25` with nat-20/nat-1 upgrade/downgrade
32
+ - **Math functions**: `floor()`, `ceil()`, `round()`, `abs()`, `max()`, `min()`
33
+ - **Variables**: `1d20+@str`, `1d20+@{sneak attack}`
34
+ - **Grouped rolls**: `{1d8, 1d10}kh1`, `{4d10+5d6}kh2`
35
+ - **Sorting**: `4d6s`, `4d6sd` (display-only)
36
+ - **Crit thresholds**: `1d20cs>=19`, `1d20cf<3` (display-only, independent overrides)
37
+ - **Structured `parts` output**: every result carries a typed evaluation tree mirroring the AST — per-sub-expression totals, dice, resolved thresholds — for chat-log/character-sheet rendering without re-parsing
38
+ - **Source spans**: every AST node and `RollPart` carries `start`/`end` offsets into the notation; evaluator errors point at the exact failing sub-expression
39
+ - **Seedable RNG** (xorshift128) for reproducible rolls, mock RNG for tests
40
+ - **Typed errors** with stable `code`s and input positions
41
+ - **Safety limits**: `maxDice`, `maxExplodeIterations`, `maxRerollIterations`, parse depth cap
44
42
 
45
43
  ## Installation
46
44
 
@@ -49,30 +47,168 @@ bun add roll-parser
49
47
  npm install roll-parser
50
48
  ```
51
49
 
50
+ The package is **ESM-only**. On Node.js ≥ 22 (required), CommonJS consumers
51
+ can still `require('roll-parser')` via `require(esm)`.
52
+
52
53
  ## Usage
53
54
 
54
55
  ```typescript
55
56
  import { roll } from 'roll-parser';
56
57
 
57
58
  const result = roll('4d6kh3');
58
- console.log(result.total); // e.g., 14
59
- console.log(result.notation); // "4d6kh3"
59
+ result.total; // e.g. 14
60
+ result.notation; // '4d6kh3'
61
+ result.rendered; // '4d6kh3[3, 6, ~~2~~, 5] = 14'
62
+ result.rolls; // per-die results with kept/dropped/critical flags
63
+ ```
64
+
65
+ ### Options
66
+
67
+ ```typescript
68
+ import { roll, SeededRNG } from 'roll-parser';
69
+
70
+ roll('4d6', { seed: 'character-1' }); // reproducible rolls
71
+ roll('4d6', { rng: new SeededRNG(42) }); // custom RNG instance (takes precedence)
72
+
73
+ roll('1d20+@str', {
74
+ context: { str: 4 }, // variable values
75
+ onMissingVariable: 'zero', // or 'throw' (default)
76
+ });
77
+
78
+ roll('100d6!', {
79
+ maxDice: 1_000, // total dice cap, default 10,000
80
+ maxExplodeIterations: 100, // per-die explosion cap, default 1,000
81
+ maxRerollIterations: 100, // per-die reroll cap, default 1,000
82
+ });
83
+ ```
84
+
85
+ ### Results
86
+
87
+ | Field | Type | Notes |
88
+ |-------|------|-------|
89
+ | `total` | `number` | Final computed total (always finite — overflow throws) |
90
+ | `notation` | `string` | Original input |
91
+ | `expression` | `string` | Normalized form; meta-expressions render as their resolved values |
92
+ | `rendered` | `string` | Markdown breakdown: `~~n~~` dropped, `**n**` success, `__n__` failure |
93
+ | `rolls` | `DieResult[]` | Every die: `sides`, `result`, `modifiers`, `critical`, `fumble` |
94
+ | `parts` | `RollPart` | Typed evaluation tree mirroring the AST 1:1 (see below) |
95
+ | `successes` / `failures` | `number?` | Present when success counting was used |
96
+ | `degree` / `natural` | `DegreeOfSuccess?` / `number?` | Present for top-level `vs` expressions |
97
+
98
+ ### Structured breakdown (`parts`)
99
+
100
+ `result.parts` is a discriminated union (16 variants — `dice`, `binaryOp`,
101
+ `modifier`, `versus`, …) where each part holds its own sub-total, resolved
102
+ thresholds/specs, and — for dice parts — the same `DieResult` objects as
103
+ `result.rolls`. JSON-serializable; `parts.total === result.total` always.
104
+
105
+ ```typescript
106
+ const r = roll('4d6kh3 + 2');
107
+ // r.parts = { type: 'binaryOp', operator: '+', total: 16,
108
+ // left: { type: 'modifier', specs: [{ kind: 'keep', selector: 'highest', count: 3 }],
109
+ // target: { type: 'dice', count: 4, sides: 6, rolls: [...], ... }, total: 14 },
110
+ // right: { type: 'literal', value: 2, total: 2 } }
111
+ ```
112
+
113
+ Meta-expressions (`(1d4)d6` counts, computed thresholds) appear as resolved
114
+ numbers in the owning part; their dice live in `result.rolls` tagged `'meta'`.
115
+
116
+ ### Error handling
117
+
118
+ All errors extend `RollParserError` with a typed `code`
119
+ (e.g. `DICE_LIMIT_EXCEEDED`, `DIVISION_BY_ZERO`, `AMBIGUOUS_DICE_CHAIN`);
120
+ lexer and parser errors also carry the input `position`.
121
+
122
+ ```typescript
123
+ import { isRollParserError } from 'roll-parser';
124
+
125
+ try {
126
+ roll(userInput);
127
+ } catch (error) {
128
+ if (isRollParserError(error)) console.error(error.code, error.message);
129
+ }
130
+ ```
131
+
132
+ ### Testing your integrations
133
+
134
+ ```typescript
135
+ import { createMockRng } from 'roll-parser/testing';
136
+
137
+ const result = roll('3d6', { rng: createMockRng([4, 2, 6]) });
138
+ result.total; // 12 — throws if the roll consumes more values than provided
60
139
  ```
61
140
 
62
141
  ### CLI
63
142
 
64
143
  ```bash
65
144
  roll-parser 2d6+3
66
- roll-parser 4d6kh3
145
+ roll-parser 4d6kh3 --verbose --seed test
67
146
  roll-parser --help
68
147
  ```
69
148
 
149
+ ## Notation reference
150
+
151
+ | Notation | Meaning |
152
+ |----------|---------|
153
+ | `NdX`, `dX`, `Nd%`, `NdF` | Roll N X-sided / percentile / Fate dice |
154
+ | `khN` `klN` `dhN` `dlN` (`kN`) | Keep/drop highest/lowest N (default 1) |
155
+ | `!` `!!` `!p` | Explode: standard / compound / penetrating (optional `>=T` etc.) |
156
+ | `r<T`, `ro<T` | Reroll recursively / once while condition matches |
157
+ | `>=T`, `>T`, `=T`, `<T`, `<=T` | Count successes against threshold (terminal) |
158
+ | `fT`, `f<T` | Failure threshold after success counting |
159
+ | `vs DC` | PF2e degree of success against a DC |
160
+ | `s`, `sd` | Sort dice ascending/descending (display-only) |
161
+ | `csT`, `cfT` | Override crit/fumble display thresholds independently |
162
+ | `@name`, `@{any name}` | Variable from `context` |
163
+ | `{a, b}khN` | Grouped rolls; keep/drop by sub-roll subtotal |
164
+ | `floor() ceil() round() abs() max() min()` | Math functions |
165
+
166
+ ## Performance
167
+
168
+ Run `bun bench` for the mitata suite (parse, evaluate, and end-to-end paths).
169
+ Indicative numbers on a modern x86-64 container (Bun 1.3):
170
+
171
+ | Operation | Time |
172
+ |-----------|------|
173
+ | `parse('2d6+3')` | ~0.4 µs |
174
+ | `roll('1d20+5')` | ~2.5 µs (~400k rolls/s) |
175
+ | `roll('4d6kh3')` | ~5 µs |
176
+ | `evaluate(parse('100d6'))` | ~17 µs |
177
+
178
+ Numbers include building the structured `parts` tree (always on).
179
+
70
180
  ## Known Limitations
71
181
 
72
- - **`4d6d1` parses as nested dice, not "drop 1".** The bare `d` token is always
73
- interpreted as the dice operator, so `4d6d1` becomes `(4d6)d1` (roll 4d6, then
74
- use the result as the count for d1). To drop dice, use the explicit `dl`
75
- (drop lowest) or `dh` (drop highest) modifiers: `4d6dl1`.
182
+ - **`4d6d1` is a parse error, not "drop 1".** The bare `d` token is the dice
183
+ operator, and `4d6d1` reading as "roll 4d6, use the result as a count of
184
+ d1 dice" is a silent trap so chaining `d` directly onto a dice expression
185
+ throws `AMBIGUOUS_DICE_CHAIN`. Use `4d6dl1` to drop dice, or `(4d6)d1` for
186
+ nested dice.
187
+ - **Threshold comparisons bind tight.** In explode and reroll thresholds,
188
+ `1d6!>=5+2` parses as `(1d6!>=5)+2` — the comparison binds to the dice
189
+ pool, not the arithmetic after it. For a computed threshold, parenthesize:
190
+ `1d6!>=(5+2)`. Success-count thresholds bind the same way, but wrapping the
191
+ resulting `SuccessCountNode` is a parse error (terminal by design), so
192
+ `1d6>=5+2` throws — parenthesize: `1d6>=(5+2)`.
193
+ - **Meta-expressions in `result.expression` are substituted with their
194
+ resolved values.** Dice counts, sides, modifier counts, and threshold
195
+ expressions are rendered as the integer they evaluated to — so
196
+ `result.expression` does not round-trip through `parse` when
197
+ meta-expressions are present.
198
+
199
+ ```typescript
200
+ roll('(1d4)d6').expression; // '2d6' (if 1d4 rolled 2)
201
+ roll('1d6!>(1d2+3)').expression; // '1d6!>5'
202
+ ```
203
+
204
+ - **Sort flattens additive pools and drops per-pool brackets.** `(2d6+1d8)s`
205
+ renders the combined pool as one sorted list — `(2d6 + 1d8)s[3, 4, 5]` —
206
+ rather than the per-pool form `2d6[3, 4] + 1d8[5]`. Totals are preserved;
207
+ only the rendered breakdown loses pool boundaries.
208
+ - **Outer parens drop from `result.expression` after threshold collapse.**
209
+ `(1d20cs>19)cs=1` evaluates with `result.expression = '1d20cs>19cs=1'`
210
+ because chained `cs` thresholds collapse into a single node. The notation
211
+ re-parses to the same AST; only the textual form differs.
76
212
 
77
213
  ## License
78
214
 
@@ -1 +1 @@
1
- {"version":3,"file":"args.d.ts","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;GAEG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzF;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,eAAe,CAyCzD"}
1
+ {"version":3,"file":"args.d.ts","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;GAEG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzF;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,eAAe,CA0CzD"}
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @module cli/format
7
7
  */
8
- import type { RollResult } from '../types';
8
+ import type { RollResult } from '../types.js';
9
9
  /**
10
10
  * Formats a roll result for terminal display.
11
11
  *
@@ -1 +1 @@
1
- {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/cli/format.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAMzE"}
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/cli/format.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAMzE"}