roll-parser 3.0.0-alpha.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +189 -0
- package/MIGRATION.md +147 -0
- package/README.md +985 -43
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +81 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +16 -4
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +17 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +36 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +83 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/errors.d.ts +332 -14
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +141 -0
- package/dist/errors.js.map +1 -0
- package/dist/evaluator/die.d.ts +26 -0
- package/dist/evaluator/die.d.ts.map +1 -0
- package/dist/evaluator/die.js +19 -0
- package/dist/evaluator/die.js.map +1 -0
- package/dist/evaluator/env.d.ts +58 -0
- package/dist/evaluator/env.d.ts.map +1 -0
- package/dist/evaluator/env.js +11 -0
- package/dist/evaluator/env.js.map +1 -0
- package/dist/evaluator/evaluator.d.ts +62 -40
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +906 -0
- package/dist/evaluator/evaluator.js.map +1 -0
- package/dist/evaluator/modifiers/compare.d.ts +1 -1
- package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
- package/dist/evaluator/modifiers/compare.js +15 -0
- package/dist/evaluator/modifiers/compare.js.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +27 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.js +23 -0
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
- package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
- package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
- package/dist/evaluator/modifiers/die-bound.js +14 -0
- package/dist/evaluator/modifiers/die-bound.js.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +18 -6
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +103 -0
- package/dist/evaluator/modifiers/explode.js.map +1 -0
- package/dist/evaluator/modifiers/flags.d.ts +37 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
- package/dist/evaluator/modifiers/flags.js +18 -0
- package/dist/evaluator/modifiers/flags.js.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js +82 -0
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +14 -6
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.js +62 -0
- package/dist/evaluator/modifiers/reroll.js.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +27 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
- package/dist/evaluator/modifiers/sort.js +13 -0
- package/dist/evaluator/modifiers/sort.js.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +2 -6
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +24 -0
- package/dist/evaluator/modifiers/success-count.js.map +1 -0
- package/dist/index.d.ts +35 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1723
- package/dist/index.js.map +1 -0
- package/dist/lexer/lexer.d.ts +68 -8
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +260 -0
- package/dist/lexer/lexer.js.map +1 -0
- package/dist/lexer/tokens.d.ts +52 -7
- package/dist/lexer/tokens.d.ts.map +1 -1
- package/dist/lexer/tokens.js +42 -0
- package/dist/lexer/tokens.js.map +1 -0
- package/dist/parser/ast.d.ts +419 -80
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js +52 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/guards.d.ts +106 -0
- package/dist/parser/guards.d.ts.map +1 -0
- package/dist/parser/guards.js +121 -0
- package/dist/parser/guards.js.map +1 -0
- package/dist/parser/parser.d.ts +162 -15
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +751 -0
- package/dist/parser/parser.js.map +1 -0
- package/dist/rng/mock.d.ts +74 -13
- package/dist/rng/mock.d.ts.map +1 -1
- package/dist/rng/mock.js +30 -0
- package/dist/rng/mock.js.map +1 -0
- package/dist/rng/seeded.d.ts +149 -10
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/rng/seeded.js +138 -0
- package/dist/rng/seeded.js.map +1 -0
- package/dist/rng/types.d.ts +57 -0
- package/dist/rng/types.d.ts.map +1 -1
- package/dist/rng/types.js +2 -0
- package/dist/rng/types.js.map +1 -0
- package/dist/roll.d.ts +59 -25
- package/dist/roll.d.ts.map +1 -1
- package/dist/roll.js +8 -0
- package/dist/roll.js.map +1 -0
- package/dist/testing.d.ts +5 -4
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -38
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +427 -24
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +93 -40
- package/src/cli/args.ts +66 -9
- package/src/cli/format.ts +30 -7
- package/src/cli/index.ts +27 -67
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +488 -23
- package/src/evaluator/die.ts +50 -0
- package/src/evaluator/env.ts +73 -0
- package/src/evaluator/evaluator.ts +1296 -349
- package/src/evaluator/modifiers/compare.ts +1 -1
- package/src/evaluator/modifiers/crit-threshold.ts +56 -0
- package/src/evaluator/modifiers/die-bound.ts +39 -0
- package/src/evaluator/modifiers/explode.ts +82 -76
- package/src/evaluator/modifiers/flags.ts +61 -0
- package/src/evaluator/modifiers/keep-drop.ts +124 -126
- package/src/evaluator/modifiers/reroll.ts +36 -64
- package/src/evaluator/modifiers/sort.ts +43 -0
- package/src/evaluator/modifiers/success-count.ts +6 -9
- package/src/index.ts +73 -34
- package/src/lexer/lexer.ts +201 -35
- package/src/lexer/tokens.ts +72 -7
- package/src/parser/ast.ts +453 -104
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +835 -135
- package/src/rng/mock.ts +75 -14
- package/src/rng/seeded.ts +323 -58
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +66 -41
- package/src/testing.ts +5 -9
- package/src/types.ts +416 -24
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -1775
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/index.mjs +0 -1724
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/dist/testing.mjs +0 -39
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
package/src/rng/mock.ts
CHANGED
|
@@ -4,15 +4,47 @@
|
|
|
4
4
|
* @module rng/mock
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { RNG } from './types';
|
|
7
|
+
import type { RNG } from './types.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Error thrown when
|
|
10
|
+
* Error thrown when a mock RNG runs out of predefined values.
|
|
11
11
|
*
|
|
12
|
-
* This is intentional behavior
|
|
13
|
-
*
|
|
12
|
+
* This is intentional behavior, not a limitation: a mock that wrapped around
|
|
13
|
+
* would silently pass a test whose expression rolls more dice than the author
|
|
14
|
+
* thought. Seeing this error means the notation consumed more draws than the
|
|
15
|
+
* sequence supplied — count the dice, including explosions, rerolls, and
|
|
16
|
+
* meta-expressions, and check the draw order in the roll-parser README.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately outside the `RollParserError` hierarchy — no `code`, and
|
|
19
|
+
* `isRollParserError` answers `false` for it. An exhausted mock is a bug in the
|
|
20
|
+
* test fixture, not a failure mode of the notation, so the
|
|
21
|
+
* `if (!isRollParserError(error)) throw error` line a consumer writes around
|
|
22
|
+
* `roll()` rethrows it and fails the test instead of routing it to a "bad dice"
|
|
23
|
+
* message. `instanceof` is the check here — there is no `code` to branch on.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* import { roll } from 'roll-parser';
|
|
28
|
+
* import { createMockRng, MockRNGExhaustedError } from 'roll-parser/testing';
|
|
29
|
+
*
|
|
30
|
+
* try {
|
|
31
|
+
* roll('4d6', { rng: createMockRng([1, 2, 3]) });
|
|
32
|
+
* } catch (error) {
|
|
33
|
+
* error instanceof MockRNGExhaustedError; // true
|
|
34
|
+
* (error as MockRNGExhaustedError).consumed; // 3
|
|
35
|
+
* (error as Error).message;
|
|
36
|
+
* // 'MockRNG exhausted: consumed 3 values, no more available'
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @category Testing
|
|
14
41
|
*/
|
|
15
42
|
export class MockRNGExhaustedError extends Error {
|
|
43
|
+
/**
|
|
44
|
+
* How many values the sequence handed out before running dry — equivalently,
|
|
45
|
+
* the length of the array that was passed to `createMockRng`. Compare it
|
|
46
|
+
* against the dice you expected to be rolled to find the miscount.
|
|
47
|
+
*/
|
|
16
48
|
readonly consumed: number;
|
|
17
49
|
|
|
18
50
|
constructor(consumed: number) {
|
|
@@ -23,29 +55,58 @@ export class MockRNGExhaustedError extends Error {
|
|
|
23
55
|
}
|
|
24
56
|
|
|
25
57
|
/**
|
|
26
|
-
* Creates a mock RNG that
|
|
58
|
+
* Creates a mock {@link RNG} that hands out predefined values in order — the
|
|
59
|
+
* way to write dice tests with exact expected totals.
|
|
60
|
+
*
|
|
61
|
+
* Two deliberate strictnesses, both there to surface a miscounted sequence
|
|
62
|
+
* instead of hiding it:
|
|
27
63
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
64
|
+
* - It never wraps around. Running out throws {@link MockRNGExhaustedError}.
|
|
65
|
+
* - `nextInt` rejects a scripted value outside the requested `[min, max]`
|
|
66
|
+
* with a `RangeError`, so `createMockRng([7])` cannot satisfy a `d6`.
|
|
30
67
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
68
|
+
* Both are the only failures that reach a caller of `roll()` without a
|
|
69
|
+
* roll-parser `code`, and only ever when a mock was injected — see
|
|
70
|
+
* {@link MockRNGExhaustedError} for why they stay outside the hierarchy.
|
|
71
|
+
*
|
|
72
|
+
* Draw order matters when the notation contains meta-expressions. Keep/drop
|
|
73
|
+
* counts (`4d6kh(1d2)`) are drawn *before* the pool; threshold expressions
|
|
74
|
+
* (`4d6cs>(1d2)`) are drawn *after* it. The README's Randomness section has
|
|
75
|
+
* the full tables.
|
|
76
|
+
*
|
|
77
|
+
* @param values - Values to return, in draw order (die faces for `nextInt`,
|
|
78
|
+
* floats in `[0, 1)` for `next`)
|
|
79
|
+
* @returns An `RNG` that replays `values`
|
|
33
80
|
*
|
|
34
81
|
* @example
|
|
35
82
|
* ```typescript
|
|
83
|
+
* import { createMockRng } from 'roll-parser/testing';
|
|
84
|
+
*
|
|
36
85
|
* const rng = createMockRng([4, 2, 6]);
|
|
37
|
-
* rng.nextInt(1, 6); //
|
|
38
|
-
* rng.nextInt(1, 6); //
|
|
39
|
-
* rng.nextInt(1, 6); //
|
|
40
|
-
* rng.nextInt(1, 6); //
|
|
86
|
+
* rng.nextInt(1, 6); // 4
|
|
87
|
+
* rng.nextInt(1, 6); // 2
|
|
88
|
+
* rng.nextInt(1, 6); // 6
|
|
89
|
+
* rng.nextInt(1, 6); // throws MockRNGExhaustedError
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* @example Pinning a roll
|
|
93
|
+
* ```typescript
|
|
94
|
+
* import { roll } from 'roll-parser';
|
|
95
|
+
* import { createMockRng } from 'roll-parser/testing';
|
|
96
|
+
*
|
|
97
|
+
* const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });
|
|
98
|
+
* result.total; // 14
|
|
99
|
+
* result.rendered; // '4d6[3, 6, ~~2~~, 5] = 14'
|
|
41
100
|
* ```
|
|
101
|
+
*
|
|
102
|
+
* @category Testing
|
|
42
103
|
*/
|
|
43
104
|
export function createMockRng(values: number[]): RNG {
|
|
44
105
|
let index = 0;
|
|
45
106
|
|
|
46
107
|
const getNext = (): number => {
|
|
47
108
|
const value = values[index];
|
|
48
|
-
if (value
|
|
109
|
+
if (value == null) {
|
|
49
110
|
throw new MockRNGExhaustedError(index);
|
|
50
111
|
}
|
|
51
112
|
index++;
|
package/src/rng/seeded.ts
CHANGED
|
@@ -1,122 +1,365 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Seedable RNG using
|
|
2
|
+
* Seedable RNG using the xoshiro128** algorithm.
|
|
3
3
|
*
|
|
4
4
|
* @module rng/seeded
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
import { RollParserError } from '../errors.js';
|
|
8
|
+
import type { RNG } from './types.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
|
19
|
-
* const
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
this.s2 = this.s1;
|
|
91
|
-
this.s1 = s;
|
|
292
|
+
const t = this.s1 << STATE_SHIFT;
|
|
92
293
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
354
|
+
// Ranges wider than 2^32 need two draws — a single uint32 can never
|
|
355
|
+
// produce the upper part of the range and would silently truncate it.
|
|
356
|
+
if (range > UINT32_SPACE) {
|
|
357
|
+
return lo + this.nextBoundedWide(range);
|
|
358
|
+
}
|
|
359
|
+
|
|
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;
|
|
120
363
|
let value: number;
|
|
121
364
|
do {
|
|
122
365
|
value = this.nextUint32();
|
|
@@ -124,4 +367,26 @@ export class SeededRNG implements RNG {
|
|
|
124
367
|
|
|
125
368
|
return lo + (value % range);
|
|
126
369
|
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Unbiased sampling in `[0, range)` for ranges above 2^32, built from two
|
|
373
|
+
* uint32 draws combined into a 53-bit integer (the largest width JS numbers
|
|
374
|
+
* represent exactly). Ranges beyond 2^53 cannot be sampled without bias —
|
|
375
|
+
* throw instead of silently degrading.
|
|
376
|
+
*/
|
|
377
|
+
private nextBoundedWide(range: number): number {
|
|
378
|
+
if (range > MAX_EXACT_INT) {
|
|
379
|
+
throw new RangeError(`nextInt range ${range} exceeds 2^53 and cannot be sampled exactly`);
|
|
380
|
+
}
|
|
381
|
+
|
|
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;
|
|
384
|
+
let value: number;
|
|
385
|
+
do {
|
|
386
|
+
// 32 high bits shifted up by 21 + top 21 bits of a second draw = 53 bits.
|
|
387
|
+
value = this.nextUint32() * HIGH_DRAW_SCALE + (this.nextUint32() >>> LOW_DRAW_SHIFT);
|
|
388
|
+
} while (value >= limit);
|
|
389
|
+
|
|
390
|
+
return value % range;
|
|
391
|
+
}
|
|
127
392
|
}
|
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
|
*/
|