roll-parser 3.0.0-beta.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +142 -9
  2. package/MIGRATION.md +219 -0
  3. package/README.md +1026 -154
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +15 -3
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +18 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -17
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +27 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +89 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +41 -75
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +914 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.js +15 -0
  37. package/dist/evaluator/modifiers/compare.js.map +1 -0
  38. package/dist/evaluator/modifiers/crit-threshold.d.ts +57 -5
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +42 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +29 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +17 -0
  45. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  46. package/dist/evaluator/modifiers/explode.d.ts +13 -5
  47. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  48. package/dist/evaluator/modifiers/explode.js +105 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +47 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +25 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +15 -29
  55. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  56. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  57. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  58. package/dist/evaluator/modifiers/reroll.d.ts +12 -4
  59. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  60. package/dist/evaluator/modifiers/reroll.js +68 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +5 -1
  63. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  64. package/dist/evaluator/modifiers/sort.js +13 -0
  65. package/dist/evaluator/modifiers/sort.js.map +1 -0
  66. package/dist/evaluator/modifiers/success-count.d.ts +6 -7
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +25 -0
  69. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  70. package/dist/index.d.ts +33 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +12 -2560
  73. package/dist/index.js.map +1 -26
  74. package/dist/lexer/lexer.d.ts +50 -5
  75. package/dist/lexer/lexer.d.ts.map +1 -1
  76. package/dist/lexer/lexer.js +260 -0
  77. package/dist/lexer/lexer.js.map +1 -0
  78. package/dist/lexer/tokens.d.ts +31 -6
  79. package/dist/lexer/tokens.d.ts.map +1 -1
  80. package/dist/lexer/tokens.js +42 -0
  81. package/dist/lexer/tokens.js.map +1 -0
  82. package/dist/parser/ast.d.ts +324 -165
  83. package/dist/parser/ast.d.ts.map +1 -1
  84. package/dist/parser/ast.js +52 -0
  85. package/dist/parser/ast.js.map +1 -0
  86. package/dist/parser/guards.d.ts +106 -0
  87. package/dist/parser/guards.d.ts.map +1 -0
  88. package/dist/parser/guards.js +121 -0
  89. package/dist/parser/guards.js.map +1 -0
  90. package/dist/parser/parser.d.ts +118 -14
  91. package/dist/parser/parser.d.ts.map +1 -1
  92. package/dist/parser/parser.js +751 -0
  93. package/dist/parser/parser.js.map +1 -0
  94. package/dist/render.d.ts +95 -0
  95. package/dist/render.d.ts.map +1 -0
  96. package/dist/render.js +227 -0
  97. package/dist/render.js.map +1 -0
  98. package/dist/rng/mock.d.ts +73 -12
  99. package/dist/rng/mock.d.ts.map +1 -1
  100. package/dist/rng/mock.js +30 -0
  101. package/dist/rng/mock.js.map +1 -0
  102. package/dist/rng/seeded.d.ts +141 -9
  103. package/dist/rng/seeded.d.ts.map +1 -1
  104. package/dist/rng/seeded.js +138 -0
  105. package/dist/rng/seeded.js.map +1 -0
  106. package/dist/rng/types.d.ts +57 -0
  107. package/dist/rng/types.d.ts.map +1 -1
  108. package/dist/rng/types.js +2 -0
  109. package/dist/rng/types.js.map +1 -0
  110. package/dist/roll.d.ts +58 -28
  111. package/dist/roll.d.ts.map +1 -1
  112. package/dist/roll.js +8 -0
  113. package/dist/roll.js.map +1 -0
  114. package/dist/testing.d.ts +5 -4
  115. package/dist/testing.d.ts.map +1 -1
  116. package/dist/testing.js +2 -41
  117. package/dist/testing.js.map +1 -11
  118. package/dist/types.d.ts +349 -47
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/types.js +8 -0
  121. package/dist/types.js.map +1 -0
  122. package/dist/version.d.ts +2 -0
  123. package/dist/version.d.ts.map +1 -0
  124. package/dist/version.js +2 -0
  125. package/dist/version.js.map +1 -0
  126. package/package.json +93 -34
  127. package/src/cli/args.ts +66 -10
  128. package/src/cli/format.ts +37 -26
  129. package/src/cli/index.ts +27 -84
  130. package/src/cli/main.ts +129 -0
  131. package/src/errors.ts +480 -27
  132. package/src/evaluator/die.ts +51 -0
  133. package/src/evaluator/env.ts +105 -0
  134. package/src/evaluator/evaluator.ts +693 -434
  135. package/src/evaluator/modifiers/crit-threshold.ts +96 -14
  136. package/src/evaluator/modifiers/die-bound.ts +48 -0
  137. package/src/evaluator/modifiers/explode.ts +70 -62
  138. package/src/evaluator/modifiers/flags.ts +78 -0
  139. package/src/evaluator/modifiers/keep-drop.ts +129 -127
  140. package/src/evaluator/modifiers/reroll.ts +44 -56
  141. package/src/evaluator/modifiers/sort.ts +21 -2
  142. package/src/evaluator/modifiers/success-count.ts +24 -12
  143. package/src/index.ts +56 -35
  144. package/src/lexer/lexer.ts +107 -34
  145. package/src/lexer/tokens.ts +31 -6
  146. package/src/parser/ast.ts +333 -346
  147. package/src/parser/guards.ts +248 -0
  148. package/src/parser/parser.ts +419 -242
  149. package/src/render.ts +392 -0
  150. package/src/rng/mock.ts +74 -13
  151. package/src/rng/seeded.ts +299 -64
  152. package/src/rng/types.ts +57 -0
  153. package/src/roll.ts +64 -47
  154. package/src/testing.ts +5 -9
  155. package/src/types.ts +353 -46
  156. package/src/version.ts +2 -0
  157. package/dist/cli.js +0 -2608
  158. package/dist/cli.js.map +0 -28
  159. package/dist/evaluator/index.d.ts +0 -8
  160. package/dist/evaluator/index.d.ts.map +0 -1
  161. package/dist/rng/index.d.ts +0 -8
  162. package/dist/rng/index.d.ts.map +0 -1
  163. package/src/evaluator/index.ts +0 -14
  164. package/src/rng/index.ts +0 -8
package/src/rng/seeded.ts CHANGED
@@ -1,128 +1,365 @@
1
1
  /**
2
- * Seedable RNG using xorshift128 algorithm.
2
+ * Seedable RNG using the xoshiro128** algorithm.
3
3
  *
4
4
  * @module rng/seeded
5
5
  */
6
6
 
7
+ import { RollParserError } from '../errors.js';
7
8
  import type { RNG } from './types.js';
8
9
 
9
10
  /**
10
- * Seedable pseudo-random number generator using xorshift128.
11
+ * A snapshot of {@link SeededRNG}'s internal state: a format version followed
12
+ * by the four state words as unsigned 32-bit integers. Pass one back to the
13
+ * constructor to resume the exact sequence it was taken from.
11
14
  *
12
- * Produces reproducible sequences from identical seeds.
13
- * Period: 2^128 - 1
15
+ * Opaque and bound to the major version, the same contract seeds carry: the
16
+ * words mean nothing outside the engine that produced them, and a release that
17
+ * changes that engine bumps the leading version. A snapshot from a different
18
+ * version is rejected with an `INCOMPATIBLE_RNG_STATE` error rather than
19
+ * resumed under the wrong semantics — safe to hold in memory or serialize for
20
+ * a save file, and loud rather than silent across an upgrade.
21
+ *
22
+ * @category RNG
23
+ */
24
+ export type RngState = readonly [version: number, s0: number, s1: number, s2: number, s3: number];
25
+
26
+ /**
27
+ * Format version stamped into every {@link RngState}. Bumped by any change to
28
+ * the engine or the word layout, which is what makes a stale snapshot
29
+ * detectable instead of silently divergent.
30
+ */
31
+ const RNG_STATE_VERSION = 1;
32
+
33
+ /** Length of an {@link RngState}: the version word plus four state words. */
34
+ const RNG_STATE_LENGTH = 5;
35
+
36
+ /**
37
+ * Rotates the low 32 bits of `value` left by `bits`, returning a signed int32.
38
+ * Callers that need the unsigned reading apply `>>> 0` themselves.
39
+ */
40
+ const rotl = (value: number, bits: number): number => (value << bits) | (value >>> (32 - bits));
41
+
42
+ /**
43
+ * Draws discarded after seeding. cyrb128's four outputs are XOR-derived from
44
+ * one another, so a short run-in is a cheap hedge against that correlation
45
+ * surfacing in the first draws.
46
+ */
47
+ const WARMUP_DRAWS = 8;
48
+
49
+ /** 2^32 — the size of the uint32 output space. */
50
+ const UINT32_SPACE = 0x100000000;
51
+
52
+ /** 2^53 — the widest integer JavaScript numbers represent exactly. */
53
+ const MAX_EXACT_INT = 2 ** 53;
54
+
55
+ /** 2^21 — shift applied to the high draw when composing a 53-bit value. */
56
+ const HIGH_DRAW_SCALE = 0x200000;
57
+
58
+ /** Bits discarded from the low draw so the two draws total 53 bits. */
59
+ const LOW_DRAW_SHIFT = 11;
60
+
61
+ /** cyrb128 offset basis — one per output word. */
62
+ const CYRB128_BASIS_1 = 1779033703;
63
+ const CYRB128_BASIS_2 = 3144134277;
64
+ const CYRB128_BASIS_3 = 1013904242;
65
+ const CYRB128_BASIS_4 = 2773480762;
66
+
67
+ /** xoshiro128** output-scrambler multipliers and rotations. */
68
+ const SCRAMBLE_MULTIPLIER_1 = 5;
69
+ const SCRAMBLE_ROTATION = 7;
70
+ const SCRAMBLE_MULTIPLIER_2 = 9;
71
+
72
+ /** xoshiro128** state-transition shift and rotation. */
73
+ const STATE_SHIFT = 9;
74
+ const STATE_ROTATION = 11;
75
+
76
+ /** cyrb128 mixing multipliers — one per output word. */
77
+ const CYRB128_MULTIPLIER_1 = 597399067;
78
+ const CYRB128_MULTIPLIER_2 = 2869860233;
79
+ const CYRB128_MULTIPLIER_3 = 951274213;
80
+ const CYRB128_MULTIPLIER_4 = 2716044179;
81
+
82
+ /**
83
+ * Rejects a snapshot this engine cannot resume — restoring foreign words would
84
+ * continue a stream that never existed.
85
+ *
86
+ * @throws {RollParserError} `INCOMPATIBLE_RNG_STATE` if the snapshot is not a
87
+ * current-version {@link RngState}
88
+ */
89
+ function assertRestorable(state: RngState): void {
90
+ if (state.length !== RNG_STATE_LENGTH) {
91
+ throw new RollParserError(
92
+ `Incompatible RngState: expected ${RNG_STATE_LENGTH} values, received ${state.length}`,
93
+ 'INCOMPATIBLE_RNG_STATE',
94
+ );
95
+ }
96
+
97
+ if (state[0] !== RNG_STATE_VERSION) {
98
+ throw new RollParserError(
99
+ `Incompatible RngState version: expected ${RNG_STATE_VERSION}, received ${state[0]}`,
100
+ 'INCOMPATIBLE_RNG_STATE',
101
+ );
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Seedable pseudo-random number generator using xoshiro128**. The default
107
+ * randomness source — `roll(notation)` builds one per call, and
108
+ * `roll(notation, { seed })` builds one from your seed.
109
+ *
110
+ * Period 2^128 - 1. Every seed is stringified and hashed with cyrb128 into
111
+ * the full 128-bit state, so numeric seeds keep all 53 bits and unrelated
112
+ * strings are overwhelmingly unlikely to share a state; an omitted seed hashes
113
+ * `Date.now()` together with two `Math.random()` draws, roughly 100 bits of
114
+ * width rather than 32 — wide enough that concurrently created generators are
115
+ * very unlikely to collide, though unpredictability stays bounded by the host
116
+ * engine's `Math.random()` seeding. The first 8 draws are discarded as a run-in.
117
+ *
118
+ * Stringifying means `42` and `'42'` are the same seed — the two forms share
119
+ * one namespace, which matters when seeds arrive from a CLI flag or JSON.
120
+ *
121
+ * {@link state} snapshots the state words as a versioned {@link RngState}, and
122
+ * passing one to the constructor resumes that exact sequence — restore copies
123
+ * the words verbatim, skipping both the hash and the run-in. A snapshot from
124
+ * another format version throws `INCOMPATIBLE_RNG_STATE`; within the current
125
+ * version the type is the contract, and a hand-built tuple is coerced to 32
126
+ * bits per word rather than rejected.
127
+ *
128
+ * Reproducibility guarantee: the same seed and the same notation produce the
129
+ * same dice for the lifetime of a major version, and `RngState` carries that
130
+ * binding. The one exception is a genuine distribution bug — bias, faulty
131
+ * rejection sampling — which may change the mapping in a minor release, never
132
+ * silently in a patch, and always with a `BREAKING` changelog note. The
133
+ * sequence is *not* cryptographically secure. To pin a roll beyond that,
134
+ * persist the {@link RollResult} rather than re-deriving it from a seed.
14
135
  *
15
136
  * @example
16
137
  * ```typescript
138
+ * import { SeededRNG, roll } from 'roll-parser';
139
+ *
17
140
  * // Same seed = same sequence
18
- * const rng1 = new SeededRNG('test-seed');
19
- * const rng2 = new SeededRNG('test-seed');
20
- * rng1.nextInt(1, 6) === rng2.nextInt(1, 6); // true
141
+ * const a = new SeededRNG('test-seed');
142
+ * const b = new SeededRNG('test-seed');
143
+ * a.nextInt(1, 6) === b.nextInt(1, 6); // true
144
+ *
145
+ * // An injected instance keeps advancing across rolls; `{ seed }` restarts
146
+ * // the stream on every call.
147
+ * const rng = new SeededRNG('demo');
148
+ * roll('1d20', { rng }).total; // 1
149
+ * roll('1d20', { rng }).total; // 20 — the stream moved on
150
+ * roll('1d20', { seed: 'demo' }).total; // 1, every single time
21
151
  * ```
152
+ *
153
+ * @category RNG
22
154
  */
23
155
  export class SeededRNG implements RNG {
156
+ // ! Signed int32 on purpose. Every read below is bitwise, so the sequence is
157
+ // ! identical to uint32 words — but normalizing with `>>> 0` on write costs
158
+ // ! ~2x per draw, since a word above 2^31 leaves V8's Smi representation.
159
+ // ! Exposing a word to a caller needs `>>> 0` at that boundary.
24
160
  private s0: number;
25
161
  private s1: number;
26
162
  private s2: number;
27
163
  private s3: number;
28
164
 
29
- constructor(seed?: string | number) {
30
- // Initialize state to zero, will be set by initState
165
+ constructor(seed?: string | number | RngState) {
31
166
  this.s0 = 0;
32
167
  this.s1 = 0;
33
168
  this.s2 = 0;
34
169
  this.s3 = 0;
35
170
 
171
+ if (seed !== null && typeof seed === 'object') {
172
+ assertRestorable(seed);
173
+
174
+ // ! No hashing and no warm-up — either would diverge the resumed stream.
175
+ // ! Signed on write to keep the Smi invariant above; `state()` re-widens.
176
+ this.s0 = seed[1] | 0;
177
+ this.s1 = seed[2] | 0;
178
+ this.s2 = seed[3] | 0;
179
+ this.s3 = seed[4] | 0;
180
+ this.guardZeroState();
181
+ return;
182
+ }
183
+
36
184
  this.initState(seed);
37
185
 
38
- // Warm-up: discard first 20 values for better initial distribution
39
- for (let i = 0; i < 20; i++) {
186
+ for (let i = 0; i < WARMUP_DRAWS; i++) {
40
187
  this.nextUint32();
41
188
  }
42
189
  }
43
190
 
44
191
  private initState(seed?: string | number): void {
45
- const numSeed =
46
- seed == null
47
- ? (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0
48
- : typeof seed === 'string'
49
- ? this.hashString(seed)
50
- : seed >>> 0;
51
-
52
- // Split seed into 4 state values using splitmix32
53
- let s = numSeed;
54
- const state: number[] = [];
55
-
56
- for (let i = 0; i < 4; i++) {
57
- s = (s + 0x9e3779b9) >>> 0;
58
- let z = s;
59
- z = Math.imul(z ^ (z >>> 16), 0x85ebca6b) >>> 0;
60
- z = Math.imul(z ^ (z >>> 13), 0xc2b2ae35) >>> 0;
61
- state.push((z ^ (z >>> 16)) >>> 0);
62
- }
63
-
64
- this.s0 = state[0] ?? 0;
65
- this.s1 = state[1] ?? 0;
66
- this.s2 = state[2] ?? 0;
67
- this.s3 = state[3] ?? 0;
192
+ this.hashSeed(this.toSeedString(seed));
193
+ this.guardZeroState();
194
+ }
68
195
 
69
- // Ensure non-zero state (xorshift requires at least one non-zero)
196
+ /** All-zero is a fixed point for xoshiro — at least one word must be non-zero. */
197
+ private guardZeroState(): void {
70
198
  if (this.s0 === 0 && this.s1 === 0 && this.s2 === 0 && this.s3 === 0) {
71
199
  this.s0 = 1;
72
200
  }
73
201
  }
74
202
 
75
- private hashString(str: string): number {
76
- // djb2 hash algorithm
77
- let hash = 5381;
203
+ /**
204
+ * Returns the current state as a format version followed by four unsigned
205
+ * 32-bit words. Feeding the snapshot back to the constructor resumes this
206
+ * exact sequence; the source instance is untouched, and the two then advance
207
+ * independently.
208
+ *
209
+ * The words are {@link RngState} — opaque, restorable within the major
210
+ * version that produced them, and rejected with `INCOMPATIBLE_RNG_STATE`
211
+ * outside it.
212
+ *
213
+ * @returns A snapshot of the version and the four state words
214
+ *
215
+ * @example Replay a roll that was never seeded
216
+ * ```typescript
217
+ * import { SeededRNG, roll } from 'roll-parser';
218
+ *
219
+ * const rng = new SeededRNG();
220
+ * const snapshot = rng.state();
221
+ *
222
+ * const first = roll('1d20', { rng });
223
+ * const replay = roll('1d20', { rng: new SeededRNG(snapshot) });
224
+ * first.total === replay.total; // true
225
+ * ```
226
+ *
227
+ * Not a fork primitive. A restored generator replays the parent's stream, so
228
+ * children taken at different points are the same sequence at an offset, not
229
+ * independent substreams — derive a seed per entity instead.
230
+ *
231
+ * @example Per-entity streams — derive seeds, do not fork state
232
+ * ```typescript
233
+ * import { SeededRNG } from 'roll-parser';
234
+ *
235
+ * const goblin = new SeededRNG('world:goblin');
236
+ * const orc = new SeededRNG('world:orc');
237
+ * ```
238
+ */
239
+ state(): RngState {
240
+ return [RNG_STATE_VERSION, this.s0 >>> 0, this.s1 >>> 0, this.s2 >>> 0, this.s3 >>> 0];
241
+ }
242
+
243
+ /**
244
+ * Normalizes the constructor seed into a string. Numbers are stringified
245
+ * rather than coerced to uint32, so all 53 exact bits reach the hash.
246
+ */
247
+ private toSeedString(seed?: string | number): string {
248
+ // ! The library's one permitted `Math.random()` site — both draws stay here.
249
+ if (seed == null) return `${Date.now()}-${Math.random()}-${Math.random()}`;
250
+ return String(seed);
251
+ }
252
+
253
+ /** cyrb128 — hashes the seed into all four state words at once. */
254
+ private hashSeed(str: string): void {
255
+ let h1 = CYRB128_BASIS_1;
256
+ let h2 = CYRB128_BASIS_2;
257
+ let h3 = CYRB128_BASIS_3;
258
+ let h4 = CYRB128_BASIS_4;
259
+
78
260
  for (let i = 0; i < str.length; i++) {
79
- hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
261
+ const k = str.charCodeAt(i);
262
+ h1 = h2 ^ Math.imul(h1 ^ k, CYRB128_MULTIPLIER_1);
263
+ h2 = h3 ^ Math.imul(h2 ^ k, CYRB128_MULTIPLIER_2);
264
+ h3 = h4 ^ Math.imul(h3 ^ k, CYRB128_MULTIPLIER_3);
265
+ h4 = h1 ^ Math.imul(h4 ^ k, CYRB128_MULTIPLIER_4);
80
266
  }
81
- return hash;
267
+
268
+ h1 = Math.imul(h3 ^ (h1 >>> 18), CYRB128_MULTIPLIER_1);
269
+ h2 = Math.imul(h4 ^ (h2 >>> 22), CYRB128_MULTIPLIER_2);
270
+ h3 = Math.imul(h1 ^ (h3 >>> 17), CYRB128_MULTIPLIER_3);
271
+ h4 = Math.imul(h2 ^ (h4 >>> 19), CYRB128_MULTIPLIER_4);
272
+
273
+ // ! Matches reference cyrb128, which folds `h1` first and derives the other
274
+ // ! three against the folded value. A revision withdrawn in 2023 used the
275
+ // ! unmixed `h1` and its mirrors still circulate — matching one rewrites
276
+ // ! every seeded sequence. Pinned by `rng.test.ts`.
277
+ const mixed = h1 ^ h2 ^ h3 ^ h4;
278
+
279
+ this.s0 = mixed;
280
+ this.s1 = h2 ^ mixed;
281
+ this.s2 = h3 ^ mixed;
282
+ this.s3 = h4 ^ mixed;
82
283
  }
83
284
 
84
285
  private nextUint32(): number {
85
- // xorshift128 algorithm
86
- let t = this.s3;
87
- const s = this.s0;
286
+ // xoshiro128** 1.1 — these constants are part of the algorithm, not tunables.
287
+ // ! Scrambling `s1`, not `s0`, is what makes this 1.1; 1.0 used `s0` and was
288
+ // ! withdrawn for it.
289
+ const scrambled = rotl(Math.imul(this.s1, SCRAMBLE_MULTIPLIER_1), SCRAMBLE_ROTATION);
290
+ const result = Math.imul(scrambled, SCRAMBLE_MULTIPLIER_2) >>> 0;
88
291
 
89
- this.s3 = this.s2;
90
- this.s2 = this.s1;
91
- this.s1 = s;
292
+ const t = this.s1 << STATE_SHIFT;
92
293
 
93
- t ^= t << 11;
94
- t ^= t >>> 8;
95
- this.s0 = (t ^ s ^ (s >>> 19)) >>> 0;
294
+ this.s2 ^= this.s0;
295
+ this.s3 ^= this.s1;
296
+ this.s1 ^= this.s2;
297
+ this.s0 ^= this.s3;
298
+ this.s2 ^= t;
299
+ this.s3 = rotl(this.s3, STATE_ROTATION);
96
300
 
97
- return this.s0;
301
+ return result;
98
302
  }
99
303
 
304
+ /**
305
+ * Returns a float in `[0, 1)`, derived from one uint32 draw. Resolution is
306
+ * 2^-32, not the full 2^-53 a double can hold.
307
+ *
308
+ * Not used by the evaluator — dice go through {@link nextInt}.
309
+ *
310
+ * @returns A float in `[0, 1)`
311
+ */
100
312
  next(): number {
101
- // Convert uint32 to [0, 1) float
102
- return this.nextUint32() / 0x100000000;
313
+ return this.nextUint32() / UINT32_SPACE;
103
314
  }
104
315
 
316
+ /**
317
+ * Returns an integer in the inclusive range `[min, max]`, uniformly
318
+ * distributed — rejection sampling removes the modulo bias a plain
319
+ * `% range` would introduce.
320
+ *
321
+ * Bounds handling, in order:
322
+ * - `min > max` is normalized by swapping, so `nextInt(6, 1)` behaves as
323
+ * `nextInt(1, 6)`. (The mock RNG throws instead; see {@link RNG.nextInt}.)
324
+ * - `min === max` returns that value without consuming a draw.
325
+ * - Ranges wider than 2^32 use two draws composed into a 53-bit value.
326
+ * - Ranges wider than 2^53 cannot be sampled exactly and throw a
327
+ * `RangeError` rather than silently skewing.
328
+ *
329
+ * @param min - Lower bound, inclusive
330
+ * @param max - Upper bound, inclusive
331
+ * @returns An integer in `[min, max]`
332
+ * @throws {RangeError} If `max - min + 1` exceeds 2^53
333
+ *
334
+ * @example
335
+ * ```typescript
336
+ * import { SeededRNG } from 'roll-parser';
337
+ *
338
+ * const rng = new SeededRNG('demo');
339
+ * rng.nextInt(1, 6); // 1..6
340
+ * rng.nextInt(3, 3); // 3, always
341
+ * rng.nextInt(1, Number.MAX_SAFE_INTEGER); // fine — two-draw path
342
+ * ```
343
+ */
105
344
  nextInt(min: number, max: number): number {
106
- // Handle inverted bounds
107
345
  const lo = min > max ? max : min;
108
346
  const hi = min > max ? min : max;
109
347
 
110
348
  const range = hi - lo + 1;
111
349
 
112
- // Single value case
113
350
  if (range <= 1) {
114
351
  return lo;
115
352
  }
116
353
 
117
354
  // Ranges wider than 2^32 need two draws — a single uint32 can never
118
355
  // produce the upper part of the range and would silently truncate it.
119
- if (range > 0x100000000) {
356
+ if (range > UINT32_SPACE) {
120
357
  return lo + this.nextBoundedWide(range);
121
358
  }
122
359
 
123
- // Rejection sampling for unbiased distribution
124
- // Avoids modulo bias by rejecting values that would cause uneven distribution
125
- const threshold = (0x100000000 - range) % range;
360
+ // Rejection sampling: discard the leading `[0, threshold)` values so the
361
+ // accepted zone `[threshold, 2^32)` is an exact multiple of `range`.
362
+ const threshold = (UINT32_SPACE - range) % range;
126
363
  let value: number;
127
364
  do {
128
365
  value = this.nextUint32();
@@ -138,18 +375,16 @@ export class SeededRNG implements RNG {
138
375
  * throw instead of silently degrading.
139
376
  */
140
377
  private nextBoundedWide(range: number): number {
141
- const MAX_53 = 2 ** 53;
142
- if (range > MAX_53) {
378
+ if (range > MAX_EXACT_INT) {
143
379
  throw new RangeError(`nextInt range ${range} exceeds 2^53 and cannot be sampled exactly`);
144
380
  }
145
381
 
146
- // Largest multiple of `range` below 2^53 — values at or above it are
147
- // rejected to avoid modulo bias.
148
- const limit = Math.floor(MAX_53 / range) * range;
382
+ // Largest multiple of `range` below 2^53 — values at or above it would bias the modulo.
383
+ const limit = Math.floor(MAX_EXACT_INT / range) * range;
149
384
  let value: number;
150
385
  do {
151
386
  // 32 high bits shifted up by 21 + top 21 bits of a second draw = 53 bits.
152
- value = this.nextUint32() * 0x200000 + (this.nextUint32() >>> 11);
387
+ value = this.nextUint32() * HIGH_DRAW_SCALE + (this.nextUint32() >>> LOW_DRAW_SHIFT);
153
388
  } while (value >= limit);
154
389
 
155
390
  return value % range;
package/src/rng/types.ts CHANGED
@@ -8,6 +8,57 @@
8
8
 
9
9
  /**
10
10
  * Random Number Generator interface for dice rolling.
11
+ *
12
+ * Guaranteed by every implementation:
13
+ *
14
+ * - `next()` returns a float in `[0, 1)`.
15
+ * - `nextInt(min, max)` returns an integer in `[min, max]` when `min <= max`.
16
+ * - `nextInt(n, n)` returns `n`.
17
+ *
18
+ * Deliberately NOT guaranteed: behavior when `min > max`. The two shipped
19
+ * implementations differ, and each difference is load-bearing — see
20
+ * {@link nextInt}. Evaluator code never inverts its bounds, so callers should
21
+ * treat inverted bounds as a programming error rather than an API.
22
+ *
23
+ * The evaluator only ever calls `nextInt`, once per die, left to right;
24
+ * `next` exists for implementations that want a float source of their own.
25
+ * Two shipped implementations satisfy the interface — {@link SeededRNG} and
26
+ * the mock from `roll-parser/testing` — and anything structurally compatible
27
+ * works, so a crypto-backed or table-driven generator drops straight in.
28
+ *
29
+ * @example A crypto-backed RNG
30
+ * ```typescript
31
+ * import { roll, type RNG } from 'roll-parser';
32
+ *
33
+ * const cryptoRng: RNG = {
34
+ * next: () => crypto.getRandomValues(new Uint32Array(1))[0]! / 2 ** 32,
35
+ * nextInt: (min, max) => min + Math.floor(cryptoRng.next() * (max - min + 1)),
36
+ * };
37
+ *
38
+ * roll('4d6kh3', { rng: cryptoRng }).total; // 3..18
39
+ * ```
40
+ *
41
+ * @example Wrapping an RNG to log every draw
42
+ * ```typescript
43
+ * import { SeededRNG, roll, type RNG } from 'roll-parser';
44
+ *
45
+ * function withLog(inner: RNG, log: number[]): RNG {
46
+ * return {
47
+ * next: () => inner.next(),
48
+ * nextInt: (min, max) => {
49
+ * const value = inner.nextInt(min, max);
50
+ * log.push(value);
51
+ * return value;
52
+ * },
53
+ * };
54
+ * }
55
+ *
56
+ * const draws: number[] = [];
57
+ * roll('4d6kh3', { rng: withLog(new SeededRNG('demo'), draws) });
58
+ * draws; // [4, 3, 3, 3] — every face, including the dropped one
59
+ * ```
60
+ *
61
+ * @category RNG
11
62
  */
12
63
  export type RNG = {
13
64
  /**
@@ -18,6 +69,12 @@ export type RNG = {
18
69
  /**
19
70
  * Returns a random integer in the inclusive range [min, max].
20
71
  *
72
+ * Inverted bounds (`min > max`) are implementation-defined:
73
+ * `SeededRNG` normalizes by swapping them, so `nextInt(6, 1)` yields the
74
+ * same sequence as `nextInt(1, 6)`; the mock RNG from `roll-parser/testing`
75
+ * raises `RangeError`, because a scripted value can never satisfy an empty
76
+ * range and silently accepting it would hide a miscounted test sequence.
77
+ *
21
78
  * @param min - Minimum value (inclusive)
22
79
  * @param max - Maximum value (inclusive)
23
80
  */
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';