roll-parser 3.0.0-beta.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +107 -9
  2. package/MIGRATION.md +147 -0
  3. package/README.md +960 -154
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +15 -3
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +17 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -17
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +26 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +58 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +41 -75
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +906 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.js +15 -0
  37. package/dist/evaluator/modifiers/compare.js.map +1 -0
  38. package/dist/evaluator/modifiers/crit-threshold.d.ts +0 -1
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +14 -0
  45. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  46. package/dist/evaluator/modifiers/explode.d.ts +13 -5
  47. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  48. package/dist/evaluator/modifiers/explode.js +103 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +18 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  55. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  56. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  57. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  58. package/dist/evaluator/modifiers/reroll.d.ts +12 -4
  59. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  60. package/dist/evaluator/modifiers/reroll.js +62 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +4 -0
  63. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  64. package/dist/evaluator/modifiers/sort.js +13 -0
  65. package/dist/evaluator/modifiers/sort.js.map +1 -0
  66. package/dist/evaluator/modifiers/success-count.d.ts +2 -6
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +24 -0
  69. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  70. package/dist/index.d.ts +33 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +12 -2560
  73. package/dist/index.js.map +1 -26
  74. package/dist/lexer/lexer.d.ts +50 -5
  75. package/dist/lexer/lexer.d.ts.map +1 -1
  76. package/dist/lexer/lexer.js +260 -0
  77. package/dist/lexer/lexer.js.map +1 -0
  78. package/dist/lexer/tokens.d.ts +31 -6
  79. package/dist/lexer/tokens.d.ts.map +1 -1
  80. package/dist/lexer/tokens.js +42 -0
  81. package/dist/lexer/tokens.js.map +1 -0
  82. package/dist/parser/ast.d.ts +314 -160
  83. package/dist/parser/ast.d.ts.map +1 -1
  84. package/dist/parser/ast.js +52 -0
  85. package/dist/parser/ast.js.map +1 -0
  86. package/dist/parser/guards.d.ts +106 -0
  87. package/dist/parser/guards.d.ts.map +1 -0
  88. package/dist/parser/guards.js +121 -0
  89. package/dist/parser/guards.js.map +1 -0
  90. package/dist/parser/parser.d.ts +118 -14
  91. package/dist/parser/parser.d.ts.map +1 -1
  92. package/dist/parser/parser.js +751 -0
  93. package/dist/parser/parser.js.map +1 -0
  94. package/dist/rng/mock.d.ts +73 -12
  95. package/dist/rng/mock.d.ts.map +1 -1
  96. package/dist/rng/mock.js +30 -0
  97. package/dist/rng/mock.js.map +1 -0
  98. package/dist/rng/seeded.d.ts +141 -9
  99. package/dist/rng/seeded.d.ts.map +1 -1
  100. package/dist/rng/seeded.js +138 -0
  101. package/dist/rng/seeded.js.map +1 -0
  102. package/dist/rng/types.d.ts +57 -0
  103. package/dist/rng/types.d.ts.map +1 -1
  104. package/dist/rng/types.js +2 -0
  105. package/dist/rng/types.js.map +1 -0
  106. package/dist/roll.d.ts +58 -28
  107. package/dist/roll.d.ts.map +1 -1
  108. package/dist/roll.js +8 -0
  109. package/dist/roll.js.map +1 -0
  110. package/dist/testing.d.ts +5 -4
  111. package/dist/testing.d.ts.map +1 -1
  112. package/dist/testing.js +2 -41
  113. package/dist/testing.js.map +1 -11
  114. package/dist/types.d.ts +306 -44
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/types.js +8 -0
  117. package/dist/types.js.map +1 -0
  118. package/dist/version.d.ts +2 -0
  119. package/dist/version.d.ts.map +1 -0
  120. package/dist/version.js +2 -0
  121. package/dist/version.js.map +1 -0
  122. package/package.json +83 -34
  123. package/src/cli/args.ts +66 -10
  124. package/src/cli/format.ts +22 -3
  125. package/src/cli/index.ts +27 -84
  126. package/src/cli/main.ts +129 -0
  127. package/src/errors.ts +480 -27
  128. package/src/evaluator/die.ts +50 -0
  129. package/src/evaluator/env.ts +73 -0
  130. package/src/evaluator/evaluator.ts +653 -421
  131. package/src/evaluator/modifiers/crit-threshold.ts +3 -6
  132. package/src/evaluator/modifiers/die-bound.ts +39 -0
  133. package/src/evaluator/modifiers/explode.ts +60 -58
  134. package/src/evaluator/modifiers/flags.ts +61 -0
  135. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  136. package/src/evaluator/modifiers/reroll.ts +28 -49
  137. package/src/evaluator/modifiers/sort.ts +14 -1
  138. package/src/evaluator/modifiers/success-count.ts +5 -8
  139. package/src/index.ts +56 -35
  140. package/src/lexer/lexer.ts +107 -34
  141. package/src/lexer/tokens.ts +31 -6
  142. package/src/parser/ast.ts +323 -341
  143. package/src/parser/guards.ts +248 -0
  144. package/src/parser/parser.ts +419 -242
  145. package/src/rng/mock.ts +74 -13
  146. package/src/rng/seeded.ts +299 -64
  147. package/src/rng/types.ts +57 -0
  148. package/src/roll.ts +64 -47
  149. package/src/testing.ts +5 -9
  150. package/src/types.ts +310 -43
  151. package/src/version.ts +2 -0
  152. package/dist/cli.js +0 -2608
  153. package/dist/cli.js.map +0 -28
  154. package/dist/evaluator/index.d.ts +0 -8
  155. package/dist/evaluator/index.d.ts.map +0 -1
  156. package/dist/rng/index.d.ts +0 -8
  157. package/dist/rng/index.d.ts.map +0 -1
  158. package/src/evaluator/index.ts +0 -14
  159. package/src/rng/index.ts +0 -8
package/src/roll.ts CHANGED
@@ -4,71 +4,88 @@
4
4
  * @module roll
5
5
  */
6
6
 
7
- import type { RNG } from './rng/types.js';
8
- import type { EvaluateOptions, RollResult } from './types.js';
9
7
  import { evaluate } from './evaluator/evaluator.js';
10
- import { lex } from './lexer/lexer.js';
11
- import { Parser } from './parser/parser.js';
8
+ import { parse } from './parser/parser.js';
12
9
  import { SeededRNG } from './rng/seeded.js';
10
+ import type { RNG } from './rng/types.js';
11
+ import type { EvaluationOptions, RollResult } from './types.js';
13
12
 
14
13
  /**
15
- * Options for the roll function.
14
+ * Everything {@link roll} accepts on top of the shared {@link EvaluationOptions}:
15
+ * a randomness source, given either as a ready-made {@link RNG} or as a seed.
16
+ *
17
+ * @category Core
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import { roll, SeededRNG } from 'roll-parser';
22
+ *
23
+ * roll('4d6', { seed: 'character-1' }); // reproducible
24
+ * roll('4d6', { rng: new SeededRNG(42) }); // rng wins over seed
25
+ * roll('1d20+@str', { context: { str: 4 } });
26
+ * ```
16
27
  */
17
- export type RollOptions = {
18
- /** Custom RNG instance (takes precedence over seed) */
28
+ export type RollOptions = EvaluationOptions & {
29
+ /**
30
+ * Randomness source. Takes precedence over `seed` — when both are given,
31
+ * `seed` is ignored.
32
+ */
19
33
  rng?: RNG;
20
- /** Seed for deterministic rolls (ignored if rng provided) */
34
+ /**
35
+ * Seed for a fresh `SeededRNG`. Equal seeds replay the same die sequence for
36
+ * the same notation. Ignored when `rng` is set.
37
+ */
21
38
  seed?: string | number;
22
- /** Maximum total dice allowed per evaluation (default: 10,000) */
23
- maxDice?: number;
24
- /** Maximum explosion iterations allowed per die (default: 1,000) */
25
- maxExplodeIterations?: number;
26
- /** Maximum reroll iterations allowed per die (default: 1,000) */
27
- maxRerollIterations?: number;
28
- /** Variable context for `@name` / `@{name}` references (default: empty) */
29
- context?: Record<string, number>;
30
- /** Behavior when a referenced variable is missing from context (default: 'throw') */
31
- onMissingVariable?: 'throw' | 'zero';
32
39
  };
33
40
 
34
41
  /**
35
- * Parses and evaluates a dice notation string.
42
+ * Parses and evaluates a dice notation string in one call — the main entry
43
+ * point of the library.
36
44
  *
37
- * @param notation - Dice notation (e.g., "2d6+3", "4d6kh3")
38
- * @param options - Optional configuration (RNG or seed)
39
- * @returns Complete roll result with total and metadata
45
+ * Equivalent to `evaluate(parse(notation), rng, { notation })`. Each call
46
+ * builds a fresh `SeededRNG` unless `options.rng` is supplied, so reuse
47
+ * {@link parse} + {@link evaluate} directly when rolling the same notation in
48
+ * a loop.
49
+ *
50
+ * @param notation - Dice notation, e.g. `'2d6+3'` or `'4d6kh3'`
51
+ * @param options - RNG or seed, plus the shared {@link EvaluationOptions}
52
+ * @returns Complete {@link RollResult} with total, per-die results and the
53
+ * structured `parts` tree
54
+ * @throws {LexerError} On an invalid character
55
+ * @throws {ParseError} On invalid syntax
56
+ * @throws {EvaluatorError} On a limit breach or an impossible expression
57
+ * @throws {RollParserError} `INVALID_EVALUATION_LIMIT` when a supplied limit is
58
+ * not an integer in range — raised before any die is rolled
59
+ * @throws {RollParserError} `INVALID_NOTATION_TYPE` when `notation` is not a
60
+ * string, so `isRollParserError` still filters untrusted input completely
40
61
  *
41
62
  * @example
42
63
  * ```typescript
64
+ * import { roll } from 'roll-parser';
65
+ *
43
66
  * // Random roll
44
- * const result = roll('2d6+3');
45
- * console.log(result.total); // 5-15
67
+ * roll('2d6+3').total; // 5..15
46
68
  *
47
- * // Seeded for reproducibility
48
- * const r1 = roll('4d6', { seed: 'test' });
49
- * const r2 = roll('4d6', { seed: 'test' });
50
- * r1.total === r2.total; // true
69
+ * // Seeded same seed, same sequence
70
+ * roll('2d6+3', { seed: 'demo' }).rendered; // '2d6[1, 6] + 3 = 10'
71
+ * roll('2d6+3', { seed: 'demo' }).total; // 10
72
+ * ```
51
73
  *
52
- * // Custom RNG for testing
53
- * const result = roll('1d20', { rng: createMockRng([15]) });
54
- * result.total; // 15
74
+ * @example Deterministic tests with the testing mock
75
+ * ```typescript
76
+ * import { roll } from 'roll-parser';
77
+ * import { createMockRng } from 'roll-parser/testing';
78
+ *
79
+ * const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });
80
+ * result.total; // 14
81
+ * result.rendered; // '4d6[3, 6, ~~2~~, 5] = 14'
55
82
  * ```
83
+ *
84
+ * @category Core
56
85
  */
57
86
  export function roll(notation: string, options: RollOptions = {}): RollResult {
58
- const rng = options.rng ?? new SeededRNG(options.seed);
59
- const tokens = lex(notation);
60
- const ast = new Parser(tokens).parse();
61
- const evalOptions: EvaluateOptions = { notation };
62
- if (options.maxDice != null) evalOptions.maxDice = options.maxDice;
63
- if (options.maxExplodeIterations != null) {
64
- evalOptions.maxExplodeIterations = options.maxExplodeIterations;
65
- }
66
- if (options.maxRerollIterations != null) {
67
- evalOptions.maxRerollIterations = options.maxRerollIterations;
68
- }
69
- if (options.context != null) evalOptions.context = options.context;
70
- if (options.onMissingVariable != null) {
71
- evalOptions.onMissingVariable = options.onMissingVariable;
72
- }
73
- return evaluate(ast, rng, evalOptions);
87
+ // Explicitly-undefined limit keys forward harmlessly — `evaluate` nullish-checks each.
88
+ const { rng, seed, ...limits } = options;
89
+
90
+ return evaluate(parse(notation), rng ?? new SeededRNG(seed), { ...limits, notation });
74
91
  }
package/src/testing.ts CHANGED
@@ -1,16 +1,12 @@
1
1
  /**
2
2
  * Test utilities for roll-parser consumers.
3
3
  *
4
- * Import from `roll-parser/testing` for deterministic dice testing.
4
+ * Import from `roll-parser/testing` for deterministic dice testing. The full
5
+ * TSDoc lives on the implementations in `./rng/mock.ts`; this entry point is
6
+ * a plain re-export barrel.
5
7
  *
6
8
  * @module testing
7
9
  */
8
10
 
9
- // Direct value exports force the bundler to inline the code
10
- import {
11
- MockRNGExhaustedError as _MockRNGExhaustedError,
12
- createMockRng as _createMockRng,
13
- } from './rng/mock.js';
14
-
15
- export const createMockRng = _createMockRng;
16
- export const MockRNGExhaustedError = _MockRNGExhaustedError;
11
+ export { createMockRng, MockRNGExhaustedError } from './rng/mock.js';
12
+ export type { RNG } from './rng/types.js';
package/src/types.ts CHANGED
@@ -7,52 +7,118 @@
7
7
  import type { ASTNode } from './parser/ast.js';
8
8
 
9
9
  /**
10
- * Comparison operator for compare points.
10
+ * Comparison operator for compare points. Spelled exactly as it appears in
11
+ * notation — `4d6!>=5` carries `'>='`, bare `f1` normalizes to `'='`.
12
+ *
13
+ * @category AST
11
14
  */
12
15
  export type CompareOp = '>' | '>=' | '<' | '<=' | '=';
13
16
 
14
17
  /**
15
- * A comparison threshold used by exploding dice, reroll, and success counting.
18
+ * A comparison threshold used by exploding dice, reroll, success counting, and
19
+ * crit-threshold overrides.
20
+ *
21
+ * The value is an {@link ASTNode} to support computed thresholds
22
+ * (`1d6!>(1d2+3)`), matching the pattern used by `DiceNode.count` and
23
+ * `DiceNode.sides`. The evaluated counterpart is {@link ResolvedComparePoint}.
16
24
  *
17
- * The value is an ASTNode to support computed thresholds (e.g., `>=ceil(5)`),
18
- * matching the pattern used by DiceNode.count and DiceNode.sides.
25
+ * @category AST
19
26
  */
20
27
  export type ComparePoint = {
21
- operator: CompareOp;
22
- value: ASTNode;
28
+ readonly operator: CompareOp;
29
+ readonly value: ASTNode;
23
30
  };
24
31
 
25
32
  /**
26
- * A ComparePoint whose value has been evaluated to a number. Used in
27
- * `RollPart` where meta-expressions are already resolved.
33
+ * A {@link ComparePoint} whose value has been evaluated to a number. Used
34
+ * throughout {@link RollPart}, where meta-expressions are already resolved
35
+ * `1d6!>(1d2+3)` surfaces as `{ operator: '>', value: 5 }`.
36
+ *
37
+ * @category Results
28
38
  */
29
39
  export type ResolvedComparePoint = {
30
- operator: CompareOp;
31
- value: number;
40
+ readonly operator: CompareOp;
41
+ readonly value: number;
32
42
  };
33
43
 
34
44
  /**
35
45
  * A resolved crit threshold — `'default'` means the per-die default rule
36
- * (`result === sides` for critical, `result === 1` for fumble).
46
+ * (`result === sides` for critical, `result === 1` for fumble), which is what
47
+ * bare `cs` / `cf` produce.
48
+ *
49
+ * @category Results
37
50
  */
38
51
  export type ResolvedCritThreshold = ResolvedComparePoint | 'default';
39
52
 
40
53
  /**
41
- * Modifier flags applied to individual die results.
54
+ * Tags attached to a {@link DieResult} by the evaluator. A die can carry more
55
+ * than one (an exploded die that was later dropped is `['dropped',
56
+ * 'exploded']`), and the set drives the markers in `RollResult.rendered`.
57
+ *
58
+ * | Tag | Meaning | Rendered as |
59
+ * |-----|---------|-------------|
60
+ * | `'kept'` | Counted toward the total. Every non-dropped die carries it. | plain |
61
+ * | `'dropped'` | Excluded from the total by `kh`/`kl`/`dh`/`dl` or group selection. | `~~n~~` |
62
+ * | `'exploded'` | Produced by, or the trigger of, an explosion (`!`, `!!`, `!p`). | plain |
63
+ * | `'rerolled'` | A discarded intermediate from `r` / `ro`, always paired with `'dropped'` — the replacement die carries no tag. | `~~n~~` (via `'dropped'`) |
64
+ * | `'min'` | Raised to a `minN` bound; `initialResult` keeps the raw face. | plain |
65
+ * | `'max'` | Lowered to a `maxN` bound; `initialResult` keeps the raw face. | plain |
66
+ * | `'success'` | Met a success-count threshold (`>=6`). | `**n**` |
67
+ * | `'failure'` | Met a failure threshold (`f1`). | `__n__` |
68
+ * | `'meta'` | Rolled by a meta-expression rather than by the visible pool. | not shown |
69
+ * | `'dc'` | The DC side of a `vs` comparison. Never part of the roll-side pool. | plain |
70
+ *
71
+ * `'meta'` is the one tag with no counterpart in the notation. Dice counts,
72
+ * sides, modifier counts and computed thresholds may themselves be dice
73
+ * (`(1d4)d6`, `4d6kh(1d2)`, `1d6!>(1d2+3)`). Those inner dice are not part of
74
+ * any pool, so they never appear in a {@link RollPart}; they are appended to
75
+ * `RollResult.rolls` tagged `'meta'` so an audit log can still show what the
76
+ * meta-expression rolled. Filter them out when summing or displaying a pool.
77
+ *
78
+ * `'dc'` marks the DC side of a `vs` comparison. Unlike `'meta'` these dice do
79
+ * render — `1d20[3] vs 2d10[5, 6]` shows both sides — but they are not part of
80
+ * the roll-side pool, so no modifier may sum, select, clamp, reroll, explode,
81
+ * or tally them. Filter them out when summing a pool, exactly as with `'meta'`.
82
+ *
83
+ * @category Results
42
84
  */
43
85
  export type DieModifier =
44
86
  | 'dropped'
45
87
  | 'kept'
46
88
  | 'exploded'
47
89
  | 'rerolled'
90
+ | 'min'
91
+ | 'max'
48
92
  | 'success'
49
93
  | 'failure'
50
- | 'meta';
94
+ | 'meta'
95
+ | 'dc';
51
96
 
52
97
  /**
53
98
  * PF2e Degree of Success. Produced by the `vs` operator when comparing a
54
99
  * roll against a Difficulty Class. Ordering is significant — natural 20
55
100
  * upgrades one step and natural 1 downgrades one step.
101
+ *
102
+ * Numeric by design: the enum members are plain numbers, so `--json` CLI
103
+ * output and `JSON.stringify` emit `0`–`3` and comparisons like
104
+ * `degree >= DegreeOfSuccess.Success` work.
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * import { DegreeOfSuccess, roll } from 'roll-parser';
109
+ * import { createMockRng } from 'roll-parser/testing';
110
+ *
111
+ * const result = roll('1d20+7 vs 15', { rng: createMockRng([12]) });
112
+ * result.degree; // DegreeOfSuccess.Success (2)
113
+ * result.natural; // 12 — the raw d20 face, used for the ±1 step
114
+ * result.rendered; // '1d20[12] + 7 vs 15 = Success'
115
+ *
116
+ * if (result.degree != null && result.degree >= DegreeOfSuccess.Success) {
117
+ * // hit
118
+ * }
119
+ * ```
120
+ *
121
+ * @category Results
56
122
  */
57
123
  export enum DegreeOfSuccess {
58
124
  CriticalFailure = 0,
@@ -62,7 +128,25 @@ export enum DegreeOfSuccess {
62
128
  }
63
129
 
64
130
  /**
65
- * Individual die roll result with metadata.
131
+ * One physical die and everything the evaluator learned about it.
132
+ *
133
+ * The same object is shared between `RollResult.rolls` and the `rolls[]` of
134
+ * the {@link RollPart} that produced it — there is no deep clone, so mutating
135
+ * a die is visible through both views.
136
+ *
137
+ * @example
138
+ * ```typescript
139
+ * import { roll } from 'roll-parser';
140
+ * import { createMockRng } from 'roll-parser/testing';
141
+ *
142
+ * const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });
143
+ * result.rolls[1];
144
+ * // { sides: 6, result: 6, modifiers: ['kept'], critical: true, fumble: false }
145
+ * result.rolls[2];
146
+ * // { sides: 6, result: 2, modifiers: ['dropped'], critical: false, fumble: false }
147
+ * ```
148
+ *
149
+ * @category Results
66
150
  */
67
151
  export type DieResult = {
68
152
  /**
@@ -89,22 +173,32 @@ export type DieResult = {
89
173
  };
90
174
 
91
175
  /**
92
- * Per-spec keep/drop entry inside a flattened modifier chain. Counts are
93
- * resolved at evaluation time (meta-expressions like `kh(1d2)` become the
94
- * rolled number).
176
+ * One entry inside a flattened keep/drop chain. Counts are resolved at
177
+ * evaluation time (meta-expressions like `kh(1d2)` become the rolled number).
178
+ *
179
+ * Chained keep/drop modifiers flatten into one list — `4d6kh3dl1` yields two
180
+ * specs against a single pool, each applied independently, with drop sets
181
+ * unioned (Roll20 semantics).
182
+ *
183
+ * @category Results
95
184
  */
96
- export type ModifierSpec = {
97
- kind: 'keep' | 'drop';
98
- selector: 'highest' | 'lowest';
99
- count: number;
185
+ export type KeepDropSpec = {
186
+ readonly kind: 'keep' | 'drop';
187
+ readonly selector: 'highest' | 'lowest';
188
+ readonly count: number;
100
189
  };
101
190
 
102
191
  /**
103
- * Fields shared by every RollPart variant. `start`/`end` mirror the source
104
- * span of the AST node the part was evaluated from — present whenever the
105
- * AST came from `parse()`, absent on hand-built ASTs.
192
+ * Fields shared by every {@link RollPart} variant. `start`/`end` mirror the
193
+ * source span of the AST node the part was evaluated from — present whenever
194
+ * the AST came from `parse()`, absent on hand-built ASTs.
195
+ *
196
+ * Useful for code that walks the parts tree generically: any part can be
197
+ * narrowed to this shape without switching on `type` first.
198
+ *
199
+ * @category Results
106
200
  */
107
- type RollPartBase = {
201
+ export type RollPartBase = {
108
202
  /** Sub-total this part contributed to its parent. */
109
203
  total: number;
110
204
  start?: number;
@@ -129,6 +223,63 @@ type RollPartBase = {
129
223
  * thresholds) are not surfaced as nested parts — their resolved numbers
130
224
  * appear in the owning part, and their dice are inspectable in
131
225
  * `RollResult.rolls` via the `'meta'` modifier tag.
226
+ *
227
+ * @example Walking the tree with an exhaustive switch
228
+ * ```typescript
229
+ * import type { RollPart } from 'roll-parser';
230
+ *
231
+ * function describe(part: RollPart): string {
232
+ * switch (part.type) {
233
+ * case 'literal':
234
+ * return String(part.value);
235
+ * case 'variable':
236
+ * return `@${part.name}`;
237
+ * case 'dice':
238
+ * return `${part.count}d${part.sides}[${part.rolls.map((d) => d.result).join(', ')}]`;
239
+ * case 'fateDice':
240
+ * return `${part.count}dF`;
241
+ * case 'grouped':
242
+ * return `(${describe(part.inner)})`;
243
+ * case 'binaryOp':
244
+ * return `${describe(part.left)} ${part.operator} ${describe(part.right)}`;
245
+ * case 'unaryOp':
246
+ * return `-${describe(part.operand)}`;
247
+ * case 'keepDrop':
248
+ * return `${describe(part.target)} [${part.specs.length} keep/drop]`;
249
+ * case 'explode':
250
+ * return `${describe(part.target)} (${part.variant} explode)`;
251
+ * case 'reroll':
252
+ * return `${describe(part.target)} (reroll${part.once ? ' once' : ''})`;
253
+ * case 'dieBound':
254
+ * return `${describe(part.target)} (${part.bound} ${part.value})`;
255
+ * case 'successCount':
256
+ * return `${describe(part.target)} => ${part.successes}-${part.failures}`;
257
+ * case 'versus':
258
+ * return `${describe(part.roll)} vs ${describe(part.dc)}`;
259
+ * case 'functionCall':
260
+ * return `${part.name}(${part.args.map(describe).join(', ')})`;
261
+ * case 'group':
262
+ * return `{${part.parts.map(describe).join(', ')}}`;
263
+ * case 'sort':
264
+ * return `${describe(part.target)} (${part.order})`;
265
+ * case 'critThreshold':
266
+ * return `${describe(part.target)} (crit override)`;
267
+ * }
268
+ * }
269
+ * ```
270
+ *
271
+ * @example Reading a concrete tree
272
+ * ```typescript
273
+ * import { roll } from 'roll-parser';
274
+ * import { createMockRng } from 'roll-parser/testing';
275
+ *
276
+ * const result = roll('4d6kh3 + 2', { rng: createMockRng([3, 6, 2, 5]) });
277
+ * result.parts.type; // 'binaryOp'
278
+ * result.parts.total; // 16 — always equal to result.total
279
+ * describe(result.parts); // '4d6[3, 6, 2, 5] [1 keep/drop] + 2'
280
+ * ```
281
+ *
282
+ * @category Results
132
283
  */
133
284
  export type RollPart =
134
285
  | (RollPartBase & { type: 'literal'; value: number })
@@ -143,7 +294,7 @@ export type RollPart =
143
294
  right: RollPart;
144
295
  })
145
296
  | (RollPartBase & { type: 'unaryOp'; operator: '-'; operand: RollPart })
146
- | (RollPartBase & { type: 'modifier'; specs: ModifierSpec[]; target: RollPart })
297
+ | (RollPartBase & { type: 'keepDrop'; specs: KeepDropSpec[]; target: RollPart })
147
298
  | (RollPartBase & {
148
299
  type: 'explode';
149
300
  variant: 'standard' | 'compound' | 'penetrating';
@@ -156,6 +307,7 @@ export type RollPart =
156
307
  condition: ResolvedComparePoint;
157
308
  target: RollPart;
158
309
  })
310
+ | (RollPartBase & { type: 'dieBound'; bound: 'min' | 'max'; value: number; target: RollPart })
159
311
  | (RollPartBase & {
160
312
  type: 'successCount';
161
313
  threshold: ResolvedComparePoint;
@@ -167,7 +319,18 @@ export type RollPart =
167
319
  | (RollPartBase & { type: 'versus'; roll: RollPart; dc: RollPart; degree: DegreeOfSuccess })
168
320
  | (RollPartBase & { type: 'functionCall'; name: string; args: RollPart[] })
169
321
  | (RollPartBase & { type: 'group'; parts: RollPart[]; keptIndices?: number[] })
170
- | (RollPartBase & { type: 'sort'; order: 'ascending' | 'descending'; target: RollPart })
322
+ | (RollPartBase & {
323
+ type: 'sort';
324
+ order: 'ascending' | 'descending';
325
+ /**
326
+ * Every die the target produced, in sorted order, sharing `DieResult`
327
+ * references with `target` — so flags set after the sort (`4d6s dl1`)
328
+ * show through both. Like `RollResult.rolls` it keeps `'meta'` dice,
329
+ * which `rendered` omits from the bracket.
330
+ */
331
+ rolls: DieResult[];
332
+ target: RollPart;
333
+ })
171
334
  | (RollPartBase & {
172
335
  type: 'critThreshold';
173
336
  successThresholds: ResolvedCritThreshold[];
@@ -175,13 +338,53 @@ export type RollPart =
175
338
  target: RollPart;
176
339
  });
177
340
 
178
- /** Convenience alias for consumers writing exhaustive switches. */
341
+ /**
342
+ * The 17 discriminant strings of {@link RollPart}. Convenience alias for
343
+ * consumers writing exhaustive switches or part-type lookup tables.
344
+ *
345
+ * @category Results
346
+ */
179
347
  export type RollPartType = RollPart['type'];
180
348
 
181
349
  /**
182
- * Complete roll result with all metadata.
350
+ * Complete roll result with all metadata — what {@link roll} and
351
+ * {@link evaluate} return.
352
+ *
353
+ * The top level is `Readonly` — a result describes one completed evaluation
354
+ * and is never re-targeted. The `rolls` array and the `parts` tree stay
355
+ * mutable so consumers can annotate or re-sort their own views.
356
+ *
357
+ * Fully JSON-serializable; this is exactly what the CLI's `--json` flag emits.
358
+ *
359
+ * @example
360
+ * ```typescript
361
+ * import { roll } from 'roll-parser';
362
+ * import { createMockRng } from 'roll-parser/testing';
363
+ *
364
+ * const result = roll('3d6', { rng: createMockRng([4, 2, 6]) });
365
+ *
366
+ * JSON.stringify(result);
367
+ * // {
368
+ * // "total": 12,
369
+ * // "notation": "3d6",
370
+ * // "expression": "3d6",
371
+ * // "rendered": "3d6[4, 2, 6] = 12",
372
+ * // "rolls": [
373
+ * // { "sides": 6, "result": 4, "modifiers": ["kept"], "critical": false, "fumble": false },
374
+ * // { "sides": 6, "result": 2, "modifiers": ["kept"], "critical": false, "fumble": false },
375
+ * // { "sides": 6, "result": 6, "modifiers": ["kept"], "critical": true, "fumble": false }
376
+ * // ],
377
+ * // "parts": {
378
+ * // "type": "dice", "count": 3, "sides": 6,
379
+ * // "rolls": [ ...the same three objects... ],
380
+ * // "total": 12, "start": 0, "end": 3
381
+ * // }
382
+ * // }
383
+ * ```
384
+ *
385
+ * @category Results
183
386
  */
184
- export type RollResult = {
387
+ export type RollResult = Readonly<{
185
388
  /** Final computed total */
186
389
  total: number;
187
390
  /** Original input notation */
@@ -190,20 +393,28 @@ export type RollResult = {
190
393
  expression: string;
191
394
  /** Rendered result with individual rolls shown */
192
395
  rendered: string;
193
- /** All individual die results */
396
+ /**
397
+ * All individual die results, in evaluation order.
398
+ *
399
+ * Shares `DieResult` object references with the `rolls[]` arrays inside
400
+ * `parts` — no deep clone. Mutating a die here is visible through the part
401
+ * tree and vice versa; clone first if that matters.
402
+ */
194
403
  rolls: DieResult[];
195
404
  /** Structured breakdown of the evaluated expression, mirroring the AST 1:1. */
196
405
  parts: RollPart;
197
406
  /**
198
407
  * Number of dice tagged as success across the whole expression. Present
199
408
  * only when a success-counting modifier was used. Independent of `total` —
200
- * arithmetic on top of a success count (e.g. `5d6>=5 * 2`) affects `total`
201
- * but not `successes`.
409
+ * arithmetic on top of a success count (e.g. `{5d6>=5}+2`) affects `total`
410
+ * but not `successes`. Success counts are terminal, so the group braces are
411
+ * required: `5d6>=5 * 2` is an `INVALID_SUCCESS_COUNT_TARGET` parse error.
202
412
  */
203
413
  successes?: number;
204
414
  /**
205
415
  * Number of dice tagged as failure across the whole expression. Present
206
- * only when a success-counting modifier with a fail threshold was used.
416
+ * whenever a success-counting modifier was used `0` when no failure
417
+ * threshold was given, since nothing can be tagged as a failure.
207
418
  */
208
419
  failures?: number;
209
420
  /**
@@ -216,22 +427,78 @@ export type RollResult = {
216
427
  * exactly one kept d20 was rolled on the roll side of a `vs` expression.
217
428
  */
218
429
  natural?: number;
219
- };
430
+ }>;
220
431
 
221
432
  /**
222
- * Options for the evaluate function.
433
+ * Evaluation guardrails and variable resolution, shared by `EvaluateOptions`
434
+ * and `RollOptions`. Every field is optional — the defaults bound adversarial
435
+ * notation without capping any realistic expression.
436
+ *
437
+ * Tighten them when the notation comes from users you do not control; the
438
+ * caps are the difference between a typed `EvaluatorError` and a request that
439
+ * rolls ten million dice. Parse depth is capped separately and
440
+ * unconditionally by {@link MAX_PARSE_DEPTH}.
441
+ *
442
+ * The three numeric limits fail closed: omit one — or pass `undefined` or
443
+ * `null`, the no-options path a partial config produces — and it takes its
444
+ * default, but supply anything else that is not a safe integer in range — a
445
+ * string, `NaN`, `±Infinity`, a negative, a fraction — and evaluation throws
446
+ * `INVALID_EVALUATION_LIMIT` before rolling. `maxDice` accepts `>= 1`, the two
447
+ * iteration limits `>= 0`. No coercion: `maxDice: Number(input)` on
448
+ * unparseable input rejects rather than quietly reverting to the permissive
449
+ * default.
450
+ *
451
+ * @example Untrusted input
452
+ * ```typescript
453
+ * import { isRollParserError, roll } from 'roll-parser';
454
+ *
455
+ * const limits = {
456
+ * maxDice: 100,
457
+ * maxExplodeIterations: 20,
458
+ * maxRerollIterations: 20,
459
+ * };
460
+ *
461
+ * try {
462
+ * roll('99999d6', limits).total;
463
+ * } catch (error) {
464
+ * isRollParserError(error) && error.code; // 'DICE_LIMIT_EXCEEDED'
465
+ * }
466
+ * ```
467
+ *
468
+ * @example Variables
469
+ * ```typescript
470
+ * import { roll } from 'roll-parser';
471
+ *
472
+ * roll('1d20+@str', { context: { str: 4 }, seed: 'demo' }).total; // 5
473
+ * roll('1d20+@str', { onMissingVariable: 'zero', seed: 'demo' }).total; // 1
474
+ * ```
475
+ *
476
+ * @category Core
223
477
  */
224
- export type EvaluateOptions = {
225
- /** Original notation string (for result metadata) */
226
- notation?: string;
227
- /** Maximum total dice allowed per evaluation (default: 10,000) */
478
+ export type EvaluationOptions = {
479
+ /** Maximum total dice allowed per evaluation; integer >= 1 (default: 10,000) */
228
480
  maxDice?: number;
229
- /** Maximum explosion iterations allowed per die (default: 1,000) */
481
+ /** Maximum explosion iterations allowed per die; integer >= 0 (default: 1,000) */
230
482
  maxExplodeIterations?: number;
231
- /** Maximum reroll iterations allowed per die (default: 1,000) */
483
+ /** Maximum reroll iterations allowed per die; integer >= 0 (default: 1,000) */
232
484
  maxRerollIterations?: number;
233
485
  /** Variable context for `@name` / `@{name}` references (default: empty) */
234
- context?: Record<string, number>;
486
+ context?: Readonly<Record<string, number>>;
235
487
  /** Behavior when a referenced variable is missing from context (default: 'throw') */
236
488
  onMissingVariable?: 'throw' | 'zero';
237
489
  };
490
+
491
+ /**
492
+ * Options for {@link evaluate}: the shared {@link EvaluationOptions} plus the
493
+ * original notation, which `evaluate` cannot recover from an AST.
494
+ *
495
+ * @category Core
496
+ */
497
+ export type EvaluateOptions = EvaluationOptions & {
498
+ /**
499
+ * Original notation string, echoed back as `RollResult.notation`. When
500
+ * omitted, falls back to the normalized `expression` reconstructed from the
501
+ * AST — {@link roll} always forwards the string the caller typed.
502
+ */
503
+ notation?: string;
504
+ };
package/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Generated by `bun run generate:version` from package.json — do not edit.
2
+ export const version = '3.0.0';