9.9 KiB
Design: enterprise-random-family
Context
@suaveplan/types defines Rng = { nextUint32(): number; nextFloat(): number } but ships zero implementations. The genesis @suaveplan/testing package contains an inline Mulberry32 seededRng for test fixtures — this inline copy stays (testing packages cannot take workspace deps), but the canonical implementations live here. The games codebase runs five different hand-rolled PRNG algorithms across bingo, baccarat, rummy, minesweeper, and fruit-machine — this change provides the single canonical set they all migrate to.
Three packages with zero inter-dependency between them at the leaf level: pseudo-random has no workspace deps, random depends on @suaveplan/types and @suaveplan/error, and noise depends only on @suaveplan/error.
Goals / Non-Goals
Goals:
- Ship
SeededRnginterface and four algorithm implementations (Mulberry32, SFC32, Xoshiro256++, CounterRng) with full offset/fork/checkpoint support - Ship the full high-level random utilities surface: strings, IDs, collections, objects, game primitives, and all statistical distributions with
DistributionConfigserializable dispatch - Ship spatial noise functions (Perlin, Simplex, Value, Worley, fBm, Blue noise)
- All three packages at 100% coverage (foundational layer)
- Full enterprise checklist for each
Non-Goals:
- Cryptographic PRNGs (ChaCha20-DRBG, HMAC-DRBG) — belong in
@suaveplan/crypto-sodium/crypto-browser - Gaussian Process regression, Monte Carlo integration, statistical hypothesis testing — future
@suaveplan/stats - Geo random geometry (
randomPoint,randomPolygon) — deferred to@suaveplan/geo/random - Changing
@suaveplan/testing's inline Mulberry32 — testing rule holds; it stays inline
Decisions
Package: @suaveplan/pseudo-random
src/
types/
seeded-rng.types.ts / seeded-rng.md # SeededRng interface + RngCheckpoint
algorithms/
mulberry32.ts / mulberry32.test.ts / mulberry32.md
sfc32.ts / sfc32.test.ts / sfc32.md
xoshiro256.ts / xoshiro256.test.ts / xoshiro256.md
counter-rng.ts / counter-rng.test.ts / counter-rng.md
seed/
normalize.ts / normalize.test.ts / normalize.md # normalizeSeed(string|number): number
utils/
shuffle.ts / shuffle.test.ts / shuffle.md # shuffle, shuffleInPlace
sample.ts / sample.test.ts / sample.md # sample, weightedSample
distributions.ts / distributions.test.ts / distributions.md # uniform int/float helpers
errors/
codes.ts / pseudo-random-error.ts / index.ts
constants/index.ts
index.ts
SeededRng interface:
interface SeededRng extends Rng {
readonly seed: number
readonly position: number
skip(n: number): this
reset(): this
at(offset: number): SeededRng
fork(id: number | string): SeededRng
checkpoint(): RngCheckpoint
restore(checkpoint: RngCheckpoint): this
}
Algorithm selection guide (exported as docs, not enforced):
Mulberry32Rng— 32-bit state, ~2^32 period. Best for trivial use cases, minimal memory footprint.Sfc32Rng— 128-bit state, period >2^127. Best default for games and simulations.Xoshiro256Rng— 256-bit state, period 2^256-1, supports fixed-size jump (2^128 and 2^192 steps). Best when independent parallel streams are needed at scale.CounterRng— splitmix64-based hash counter. O(1) seek to any position. Best for direct-address use cases like bingo ticket generation.
Seed normalization — normalizeSeed(seed: string | number): number:
- String seeds: splitmix64 mixing over char codes (replaces djb2/FNV-1a used in bingo)
- Numeric seeds: splitmix64 single-round mixing to avalanche input bias
- Rejects NaN, Infinity, non-safe-integers
fork(id) — derives a child seed via splitmix64(parentSeed ^ hash(id)). Child streams are statistically independent from the parent and from each other. Critical for game use cases:
const master = new Sfc32Rng(gameSeed)
const weather = master.fork('weather')
const enemies = master.fork('enemies')
const loot = master.fork('loot')
at(offset) — for linear-burn algorithms (Mulberry32, SFC32): constructs a new instance at seed and burns offset values at construction time (O(offset), cost paid once). For CounterRng: O(1), directly sets the counter.
Offset validation — no hard cap (the bingo 1,000,000 limit was app-specific). Throws PRNG.OFFSET.OUT_OF_RANGE only for negative values.
Package: @suaveplan/random
src/
source/
crypto-rng.ts / crypto-rng.test.ts / crypto-rng.md # cryptoRng(): Rng
default-rng.ts / default-rng.test.ts / default-rng.md # getDefaultRng / setDefaultRng
bytes/
random-bytes.ts / random-bytes.test.ts / random-bytes.md
strings/
random-string.ts / random-string.test.ts / random-string.md # presets + custom charset
ids/
uuid.ts / uuid.test.ts / uuid.md # randomUUID (v4+v7), randomULID, randomNanoid
numbers/
random-int.ts / random-int.test.ts / random-int.md
random-float.ts / random-float.test.ts / random-float.md
collections/
shuffle.ts / shuffle.test.ts / shuffle.md
sample.ts / sample.test.ts / sample.md
choice.ts / choice.test.ts / choice.md
weighted.ts / weighted.test.ts / weighted.md # weightedChoice, weightedSample
objects/
random-key.ts / random-key.test.ts / random-key.md
random-value.ts / ...
random-entry.ts / ...
random-subset.ts / ...
game/
dice.ts / dice.test.ts / dice.md # coin, dice, roll
distributions/
continuous/
normal.ts / normal.test.ts / normal.md
log-normal.ts / ...
exponential.ts / ...
gamma.ts / ...
beta.ts / ...
pareto.ts / ...
weibull.ts / ...
cauchy.ts / ...
logistic.ts / ...
discrete/
bernoulli.ts / bernoulli.test.ts / bernoulli.md
geometric.ts / ...
binomial.ts / ...
poisson.ts / ...
sampler.ts / sampler.test.ts / sampler.md # createSampler, sample, DistributionConfig
errors/
codes.ts / random-error.ts / index.ts
constants/index.ts
index.ts
cryptoRng() factory — returns an object implementing @suaveplan/types's Rng interface backed by crypto.getRandomValues:
{
nextUint32: () => { const buf = new Uint32Array(1); crypto.getRandomValues(buf); return buf[0] },
nextFloat: function() { return this.nextUint32() / 0x1_0000_0000 }
}
This is the only callsite for crypto.getRandomValues in @suaveplan/random. Guarded by tests/no-disallowed.test.ts.
Default source — module-level mutable (non-exported) singleton. getDefaultRng() returns it; setDefaultRng(rng) replaces it (test hook). Default is cryptoRng().
String presets (randomString(length, { type, rng? })):
| type | charset |
|---|---|
hex |
0-9a-f |
base64 |
A-Z, a-z, 0-9, +, / |
base64url |
A-Z, a-z, 0-9, -, _ |
url-safe |
A-Z, a-z, 0-9, -, _, . |
numeric |
0-9 |
alphanumeric |
A-Z, a-z, 0-9 |
distinguishable |
CDEHKMPRTUWXY012458 (no I/O/0/1 confusion) |
ascii-printable |
all printable ASCII 0x20-0x7E |
Custom charset via { characters: string } option.
UUID v7 — time-ordered, 48-bit millisecond timestamp in high bits, 74 random bits. Monotonically increasing within the same millisecond via sequence counter.
DistributionConfig tagged union — every distribution has a named-parameter config object. The type discriminant selects the algorithm; all other fields are the algorithm's parameters. createSampler(config, rng?) returns () => number. sample(config, rng?) is a one-shot convenience. Config objects are plain JSON-serializable data — rng is always external.
Implementation notes for distributions:
- Normal: Box-Muller transform
- LogNormal:
exp(normal(mu, sigma)) - Gamma: Marsaglia-Tsang method (k ≥ 1); Ahrens-Dieter for k < 1
- Beta: ratio of two Gamma samples
- Poisson: Knuth algorithm for λ ≤ 30; rejection method for larger λ
- All discrete distributions use
nextUint32()notnextFloat()for the base draw
Package: @suaveplan/noise
src/
perlin/
perlin.ts / perlin.test.ts / perlin.md # perlin2, perlin3 with seed
simplex/
simplex.ts / simplex.test.ts / simplex.md # simplex2, simplex3, simplex4
value/
value.ts / value.test.ts / value.md # value2, value3
worley/
worley.ts / worley.test.ts / worley.md # worley2, worley3, WorleyMode (F1|F2|F1-F2)
fbm/
fbm.ts / fbm.test.ts / fbm.md # fbm compositor, FbmOptions
blue/
blue.ts / blue.test.ts / blue.md # blueNoiseSample, Poisson disk sampling
errors/
codes.ts / noise-error.ts / index.ts
constants/index.ts
index.ts
All noise functions accept a seed: number parameter and return values normalised to [-1, 1] (except Worley which returns [0, 1] distances). No dependency on @suaveplan/pseudo-random — noise uses its own internal seeded hash permutation tables, which is the standard noise implementation pattern.
fBm compositor:
fbm(noiseFn, x, y, {
octaves: 6,
lacunarity: 2.0,
gain: 0.5,
seed: 0
}): number
Composes any 2D noise function with octave summation.
Blue noise — Bridson's Poisson disk sampling algorithm:
blueNoiseSample(width, height, minDistance, { seed?, rng? }): [number, number][]
Risks / Trade-offs
| Risk | Mitigation |
|---|---|
at(offset) is O(offset) for Mulberry32/SFC32 |
Document clearly; CounterRng is the O(1) alternative; no silent perf traps |
setDefaultRng is global mutable state |
Documented as test-only; tests restore previous value with afterEach; not exported from subpath exports |
fork child streams not proven independent |
Use normalizeSeed mixing + test with fast-check that fork('a') and fork('b') produce uncorrelated sequences across 10k samples |
| Simplex noise patent (expired 2022) | US patent 6,867,776 expired April 2022 — safe to use |
| Blue noise is O(n) output size | Cap at 100k points; throw NOISE.BLUE.SAMPLE_LIMIT_EXCEEDED above |