236 lines
9.9 KiB
Markdown
236 lines
9.9 KiB
Markdown
# 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 `SeededRng` interface 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 `DistributionConfig` serializable 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`
|
|
|
|
```text
|
|
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:**
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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`
|
|
|
|
```text
|
|
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`:
|
|
|
|
```ts
|
|
{
|
|
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()` not `nextFloat()` for the base draw
|
|
|
|
### Package: `@suaveplan/noise`
|
|
|
|
```text
|
|
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:**
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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 |
|