Initial Commit
This commit is contained in:
15
packages/foundation/random/README.md
Normal file
15
packages/foundation/random/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# `random`
|
||||
|
||||
Pseudo-random generators, noise functions, and deterministic sequences.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `random`
|
||||
|
||||
3 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/noise`](./noise/README.md) | Seeded spatial noise fields for procedural content generation. Provides six field types — Perlin, Simplex (2D/3D/4D), Value, Worley/cellular (F1/F2/F2-F1 modes… |
|
||||
| [`@suaveplan/pseudo-random`](./pseudo-random/README.md) | Deterministic, seeded pseudo-random number generators (PRNGs) conforming to the `Rng` interface from `@suaveplan/types`. Ships four algorithms — Mulberry32, SF… |
|
||||
| [`@suaveplan/random`](./random/README.md) | High-level cryptographically secure randomness for application use. This is the everyday random package — it wraps the CSPRNG with ergonomic helpers for every… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/foundation/random/_intro.md
Normal file
1
packages/foundation/random/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Pseudo-random generators, noise functions, and deterministic sequences.
|
||||
66
packages/foundation/random/noise/README.md
Normal file
66
packages/foundation/random/noise/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# @suaveplan/noise - Seeded spatial noise fields: Perlin, Simplex, Value, Worley, fBm and Blue-noise sampling for procedural generation
|
||||
|
||||
## Summary
|
||||
|
||||
Seeded spatial noise fields for procedural content generation. Provides six field types — Perlin, Simplex (2D/3D/4D), Value, Worley/cellular (F1/F2/F2-F1 modes), fBm (fractal Brownian motion compositor), and Poisson-disk Blue noise sampling — all seeded with `Sfc32Rng` for byte-identical, reproducible output. Every generator is available as both a functional API and a stateful class. Use this package for terrain heightmaps, texture generation, cloud simulation, vegetation scattering, and any procedural system that needs spatially-coherent randomness. For non-spatial random values (UUIDs, shuffles, distributions) use `@suaveplan/random`; for seeded integer/float sequences use `@suaveplan/pseudo-random`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [noise-error](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [utils](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [permutation](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [noise-error](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [permutation](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [noise-error](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [permutation](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [noise-error](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [permutation](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [noise-error](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_ - [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_ - [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [permutation](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_ - [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_ - [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @suaveplan/noise
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Perlin noise** — `perlin2` / `perlin3` (functional) + `PerlinNoise` (class)
|
||||
- **Simplex noise** — `simplex2` / `simplex3` / `simplex4` + `SimplexNoise`
|
||||
- **Value noise** — `value2` / `value3` + `ValueNoise`
|
||||
- **Worley/cellular noise** — `worley2` / `worley3` with `F1`/`F2`/`F2-F1` modes
|
||||
- **fBm compositor** — `fbm2` / `fbm3` wrap any base field into a multi-octave sum
|
||||
- **Blue noise sampler** — `blueNoiseSample` via Bridson's Poisson-disk algorithm
|
||||
- Every field is seeded with `Sfc32Rng` — byte-identical output for equal seeds
|
||||
- Zod schemas for every option bundle
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { PerlinNoise, fbm2, blueNoiseSample } from "@suaveplan/noise";
|
||||
|
||||
const p = new PerlinNoise("terrain");
|
||||
const height = fbm2((x, y) => p.noise2(x, y), 10, 20, { octaves: 6 });
|
||||
const trees = blueNoiseSample({ width: 512, height: 512, radius: 16, seed: 1 });
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
- `@suaveplan/noise/perlin`
|
||||
- `@suaveplan/noise/simplex`
|
||||
- `@suaveplan/noise/value`
|
||||
- `@suaveplan/noise/worley`
|
||||
- `@suaveplan/noise/fbm`
|
||||
- `@suaveplan/noise/blue`
|
||||
- `@suaveplan/noise/errors`
|
||||
|
||||
## Module docs
|
||||
|
||||
See the co-located `.md` next to each implementation.
|
||||
101
packages/foundation/random/noise/package.json
Normal file
101
packages/foundation/random/noise/package.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "@suaveplan/noise",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Seeded spatial noise fields: Perlin, Simplex, Value, Worley, fBm and Blue-noise sampling for procedural generation",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./perlin": {
|
||||
"import": {
|
||||
"types": "./dist/perlin/perlin.d.ts",
|
||||
"default": "./dist/perlin/perlin.js"
|
||||
}
|
||||
},
|
||||
"./simplex": {
|
||||
"import": {
|
||||
"types": "./dist/simplex/simplex.d.ts",
|
||||
"default": "./dist/simplex/simplex.js"
|
||||
}
|
||||
},
|
||||
"./value": {
|
||||
"import": {
|
||||
"types": "./dist/value/value.d.ts",
|
||||
"default": "./dist/value/value.js"
|
||||
}
|
||||
},
|
||||
"./worley": {
|
||||
"import": {
|
||||
"types": "./dist/worley/worley.d.ts",
|
||||
"default": "./dist/worley/worley.js"
|
||||
}
|
||||
},
|
||||
"./fbm": {
|
||||
"import": {
|
||||
"types": "./dist/fbm/fbm.d.ts",
|
||||
"default": "./dist/fbm/fbm.js"
|
||||
}
|
||||
},
|
||||
"./blue": {
|
||||
"import": {
|
||||
"types": "./dist/blue/blue.d.ts",
|
||||
"default": "./dist/blue/blue.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/noise-error.d.ts",
|
||||
"default": "./dist/errors/noise-error.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@suaveplan/pseudo-random": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
53
packages/foundation/random/noise/src/blue/blue.md
Normal file
53
packages/foundation/random/noise/src/blue/blue.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# blue/blue
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `blue` module implements Robert Bridson's fast Poisson-disk sampling algorithm to generate an approximately blue-noise distribution of points in a 2D rectangle. Blue noise's defining property is that every pair of samples is at least `radius` apart, while their positions appear random — it is the gold standard for anti-aliased dithering, stippling, foliage placement, and any sampling application where regular grids produce visible artefacts.
|
||||
|
||||
## Features
|
||||
|
||||
- `blueNoiseSample({ width, height, radius, k?, maxSamples?, seed? })`
|
||||
- Seeded with `Sfc32Rng` for reproducible output
|
||||
- Bridson's spatial grid for O(n) amortised point insertion
|
||||
- `maxSamples` guard converts runaway parameters into a typed `NoiseError` instead of an out-of-memory crash
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { blueNoiseSample } from "@suaveplan/noise";
|
||||
|
||||
const dots = blueNoiseSample({ width: 400, height: 300, radius: 10, seed: 42 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { blueNoiseSample } from "@suaveplan/noise";
|
||||
|
||||
const trees = blueNoiseSample({
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
radius: 32, // minimum canopy spacing
|
||||
k: 50, // higher k = denser packing
|
||||
maxSamples: 20_000,
|
||||
seed: "forest-2026-04-19",
|
||||
});
|
||||
for (const [x, y] of trees) plantTree(x, y);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BlueNoiseOptions`:
|
||||
- `width`, `height`: rectangle size (must be positive finite)
|
||||
- `radius`: minimum spacing between samples (positive finite)
|
||||
- `k`: candidate neighbours per active sample (default 30)
|
||||
- `maxSamples`: hard ceiling (default 100 000) — throws `NoiseError.BLUE.MAX_SAMPLES_EXCEEDED` if exceeded
|
||||
- `seed`: `string | number` passed through to `Sfc32Rng` (default 0)
|
||||
- `blueNoiseSample(opts): Array<[number, number]>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
A spatial background grid with `cellSize = radius / √2` guarantees at most one sample per cell, so the neighbour check only has to scan a 5×5 window (the 2-cell radius is the Bridson-recommended value). The `maxSamples` guard protects against the common user error of specifying a tiny radius relative to the rectangle — without it, `O(area/radius²)` can explode. `k` above 30 gives denser packing but longer runtimes; 30 is the paper's recommended value.
|
||||
63
packages/foundation/random/noise/src/blue/blue.test.ts
Normal file
63
packages/foundation/random/noise/src/blue/blue.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NoiseError } from "../errors/noise-error.js";
|
||||
import { blueNoiseSample } from "./blue.js";
|
||||
|
||||
describe("blueNoiseSample", () => {
|
||||
it("respects minimum spacing", { timeout: 5000 }, () => {
|
||||
const pts = blueNoiseSample({
|
||||
width: 100,
|
||||
height: 100,
|
||||
radius: 8,
|
||||
seed: 1,
|
||||
});
|
||||
expect(pts.length).toBeGreaterThan(0);
|
||||
for (let i = 0; i < pts.length; i += 1) {
|
||||
for (let j = i + 1; j < pts.length; j += 1) {
|
||||
const a = pts[i] as [number, number];
|
||||
const b = pts[j] as [number, number];
|
||||
const d = Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
expect(d).toBeGreaterThanOrEqual(8 - 1e-9);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic for equal seeds", { timeout: 5000 }, () => {
|
||||
const a = blueNoiseSample({
|
||||
width: 50,
|
||||
height: 50,
|
||||
radius: 5,
|
||||
seed: 1,
|
||||
});
|
||||
const b = blueNoiseSample({
|
||||
width: 50,
|
||||
height: 50,
|
||||
radius: 5,
|
||||
seed: 1,
|
||||
});
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("rejects invalid args", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
blueNoiseSample({ width: 0, height: 10, radius: 1 })
|
||||
).toThrow(NoiseError);
|
||||
expect(() =>
|
||||
blueNoiseSample({ width: 10, height: -1, radius: 1 })
|
||||
).toThrow(NoiseError);
|
||||
expect(() =>
|
||||
blueNoiseSample({ width: 10, height: 10, radius: 0 })
|
||||
).toThrow(NoiseError);
|
||||
});
|
||||
|
||||
it("enforces maxSamples guard", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
blueNoiseSample({
|
||||
width: 100,
|
||||
height: 100,
|
||||
radius: 1,
|
||||
maxSamples: 5,
|
||||
seed: 2,
|
||||
})
|
||||
).toThrow(NoiseError);
|
||||
});
|
||||
});
|
||||
114
packages/foundation/random/noise/src/blue/blue.ts
Normal file
114
packages/foundation/random/noise/src/blue/blue.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Sfc32Rng } from "@suaveplan/pseudo-random";
|
||||
import { NOISE_CODES, NoiseError } from "../errors/noise-error.js";
|
||||
|
||||
export interface BlueNoiseOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Minimum spacing between samples (in pixels). */
|
||||
radius: number;
|
||||
/** How many neighbour candidates per active sample. Default 30. */
|
||||
k?: number;
|
||||
/** Hard ceiling on the number of samples; guards against runaways. */
|
||||
maxSamples?: number;
|
||||
seed?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridson-style Poisson-disk sampler producing approximately blue-noise
|
||||
* distributed 2D points within a rectangle.
|
||||
*/
|
||||
export function blueNoiseSample(
|
||||
opts: BlueNoiseOptions
|
||||
): Array<[number, number]> {
|
||||
const { width, height, radius } = opts;
|
||||
const k = opts.k ?? 30;
|
||||
const maxSamples = opts.maxSamples ?? 100_000;
|
||||
const seed = opts.seed ?? 0;
|
||||
if (!Number.isFinite(width) || width <= 0) {
|
||||
throw new NoiseError(
|
||||
NOISE_CODES["ARG.INVALID"],
|
||||
"noise.blue.invalid_width",
|
||||
{ context: { width } }
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
throw new NoiseError(
|
||||
NOISE_CODES["ARG.INVALID"],
|
||||
"noise.blue.invalid_height",
|
||||
{ context: { height } }
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(radius) || radius <= 0) {
|
||||
throw new NoiseError(
|
||||
NOISE_CODES["ARG.INVALID"],
|
||||
"noise.blue.invalid_radius",
|
||||
{ context: { radius } }
|
||||
);
|
||||
}
|
||||
const rng = new Sfc32Rng(seed);
|
||||
const cellSize = radius / Math.SQRT2;
|
||||
const cols = Math.ceil(width / cellSize);
|
||||
const rows = Math.ceil(height / cellSize);
|
||||
const grid: Array<[number, number] | null> = new Array(cols * rows).fill(
|
||||
null
|
||||
);
|
||||
const samples: Array<[number, number]> = [];
|
||||
const active: Array<[number, number]> = [];
|
||||
const first: [number, number] = [
|
||||
rng.nextFloat() * width,
|
||||
rng.nextFloat() * height,
|
||||
];
|
||||
samples.push(first);
|
||||
active.push(first);
|
||||
const fx = Math.floor(first[0] / cellSize);
|
||||
const fy = Math.floor(first[1] / cellSize);
|
||||
grid[fy * cols + fx] = first;
|
||||
while (active.length > 0) {
|
||||
if (samples.length > maxSamples) {
|
||||
throw new NoiseError(
|
||||
NOISE_CODES["BLUE.MAX_SAMPLES_EXCEEDED"],
|
||||
"noise.blue.max_samples_exceeded",
|
||||
{ context: { maxSamples } }
|
||||
);
|
||||
}
|
||||
const idx = rng.nextUint32() % active.length;
|
||||
const base = active[idx] as [number, number];
|
||||
let found = false;
|
||||
for (let i = 0; i < k; i += 1) {
|
||||
const angle = rng.nextFloat() * 2 * Math.PI;
|
||||
const mag = radius * (1 + rng.nextFloat());
|
||||
const px = base[0] + Math.cos(angle) * mag;
|
||||
const py = base[1] + Math.sin(angle) * mag;
|
||||
if (px < 0 || py < 0 || px >= width || py >= height) continue;
|
||||
const cx = Math.floor(px / cellSize);
|
||||
const cy = Math.floor(py / cellSize);
|
||||
let ok = true;
|
||||
for (let dy = -2; dy <= 2 && ok; dy += 1) {
|
||||
for (let dx = -2; dx <= 2 && ok; dx += 1) {
|
||||
const nx = cx + dx;
|
||||
const ny = cy + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
|
||||
const neighbour = grid[ny * cols + nx];
|
||||
if (!neighbour) continue;
|
||||
if (
|
||||
Math.hypot(px - neighbour[0], py - neighbour[1]) <
|
||||
radius
|
||||
)
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
const pt: [number, number] = [px, py];
|
||||
samples.push(pt);
|
||||
active.push(pt);
|
||||
grid[cy * cols + cx] = pt;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
active.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
1
packages/foundation/random/noise/src/blue/index.ts
Normal file
1
packages/foundation/random/noise/src/blue/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type BlueNoiseOptions, blueNoiseSample } from "./blue";
|
||||
14
packages/foundation/random/noise/src/constants/index.ts
Normal file
14
packages/foundation/random/noise/src/constants/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Default number of octaves for fBm compositors. */
|
||||
export const DEFAULT_FBM_OCTAVES = 4;
|
||||
|
||||
/** Default lacunarity for fBm compositors. */
|
||||
export const DEFAULT_FBM_LACUNARITY = 2;
|
||||
|
||||
/** Default gain for fBm compositors. */
|
||||
export const DEFAULT_FBM_GAIN = 0.5;
|
||||
|
||||
/** Default `k` for Bridson Poisson-disk sampling. */
|
||||
export const DEFAULT_BLUE_K = 30;
|
||||
|
||||
/** Default `maxSamples` ceiling for Bridson Poisson-disk sampling. */
|
||||
export const DEFAULT_BLUE_MAX_SAMPLES = 100_000;
|
||||
1
packages/foundation/random/noise/src/errors/index.ts
Normal file
1
packages/foundation/random/noise/src/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NOISE_CODES, type NoiseCode, NoiseError } from "./noise-error";
|
||||
65
packages/foundation/random/noise/src/errors/noise-error.md
Normal file
65
packages/foundation/random/noise/src/errors/noise-error.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# noise-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `noise-error` module exports the `NoiseError` class and `NOISE_CODES` registry used by every public function in `@suaveplan/noise`. Centralising validation failures here keeps upstream error handling simple: callers only need to match on a single `NOISE.*` code prefix regardless of which noise function raised the error.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable, namespaced error codes via `defineCodes("NOISE", [...])`
|
||||
- `NoiseError` extends `SuaveplanError<NoiseCode>` with the usual cause-chain, redaction, and OTel integrations
|
||||
- Coverage for argument validation, blue-noise termination guards and fBm octave validation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NoiseError, NOISE_CODES } from "@suaveplan/noise/errors";
|
||||
|
||||
throw new NoiseError(
|
||||
NOISE_CODES["ARG.INVALID"],
|
||||
"noise.arg.invalid",
|
||||
{ context: { field: "frequency" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { NoiseError, NOISE_CODES } from "@suaveplan/noise/errors";
|
||||
|
||||
try {
|
||||
// ... sample blue noise over a huge radius ...
|
||||
} catch (err) {
|
||||
if (err instanceof NoiseError && err.code === NOISE_CODES["BLUE.MAX_SAMPLES_EXCEEDED"]) {
|
||||
// back off and retry with a larger spacing
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NOISE_CODES` — frozen registry.
|
||||
- `NoiseError(code, messageKey, options?)` — structured error with `context`, `cause`, `traceId`, `clock` options.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All codes sit under the `NOISE.*` namespace so they don't collide with `@suaveplan/pseudo-random` or `@suaveplan/random` codes. Adding new codes is strictly additive — removing them requires a major version bump.
|
||||
|
||||
### Subclasses & Codes
|
||||
|
||||
`NoiseError` is the `instanceof` pivot for every error thrown by `@suaveplan/noise`. Concrete subclasses cover invalid seed, invalid octave count, out-of-range frequency, and invalid amplitude. Every subclass carries a stable code from the `NOISE` namespace (`NOISE.INVALID_SEED`, `NOISE.OCTAVES.OUT_OF_RANGE`, etc.) so callers can type-narrow on `error.code` without string-matching the message. Inherits wire serialisation and OTel span-event support from `SuaveplanError`.
|
||||
|
||||
## Further Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NoiseError } from "@suaveplan/noise/errors/noise-error";
|
||||
try {
|
||||
throw new NoiseError("invalid seed", { code: "NOISE.INVALID_SEED" });
|
||||
} catch (e) {
|
||||
if (e instanceof NoiseError) console.log(e.code);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NOISE_CODES, NoiseError } from "./noise-error.js";
|
||||
|
||||
describe("NoiseError", () => {
|
||||
it("carries namespaced code", { timeout: 5000 }, () => {
|
||||
const err = new NoiseError(
|
||||
NOISE_CODES["ARG.INVALID"],
|
||||
"noise.arg.invalid"
|
||||
);
|
||||
expect(err.code).toBe("NOISE.ARG.INVALID");
|
||||
expect(err).toBeInstanceOf(NoiseError);
|
||||
});
|
||||
|
||||
it("accepts context + cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("x");
|
||||
const err = new NoiseError(
|
||||
NOISE_CODES["FBM.INVALID_OCTAVES"],
|
||||
"noise.fbm.invalid_octaves",
|
||||
{ context: { octaves: 0 }, cause }
|
||||
);
|
||||
expect(err.context).toEqual({ octaves: 0 });
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
25
packages/foundation/random/noise/src/errors/noise-error.ts
Normal file
25
packages/foundation/random/noise/src/errors/noise-error.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
export const NOISE_CODES = defineCodes("NOISE", [
|
||||
"ARG.INVALID",
|
||||
"ARG.OUT_OF_RANGE",
|
||||
"BLUE.MAX_SAMPLES_EXCEEDED",
|
||||
"FBM.INVALID_OCTAVES",
|
||||
] as const);
|
||||
|
||||
export type NoiseCode = (typeof NOISE_CODES)[keyof typeof NOISE_CODES];
|
||||
|
||||
export class NoiseError extends SuaveplanError<NoiseCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
|
||||
public constructor(
|
||||
code: NoiseCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
49
packages/foundation/random/noise/src/fbm/fbm.md
Normal file
49
packages/foundation/random/noise/src/fbm/fbm.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# fbm/fbm
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `fbm` module composes Fractional Brownian Motion on top of any base noise function. It layers `octaves` copies of the base at geometrically increasing frequencies (`lacunarity`) and geometrically decreasing amplitudes (`gain`), yielding the classical cloud/terrain/texture multi-scale roughness pattern that underlies most procedurally generated content.
|
||||
|
||||
Because `fbm2`/`fbm3` take the base function as a parameter, you can layer any of `perlin2`, `simplex2`, `value2`, or even Worley distances on top of fBm — the module has no coupling to a specific noise kind.
|
||||
|
||||
## Features
|
||||
|
||||
- `fbm2(base, x, y, opts?)` — 2D compositor
|
||||
- `fbm3(base, x, y, z, opts?)` — 3D compositor
|
||||
- Configurable `octaves`, `lacunarity`, `gain`, `frequency`, `amplitude`
|
||||
- Argument validation via `NoiseError.ARG.INVALID` / `FBM.INVALID_OCTAVES`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { fbm2, PerlinNoise } from "@suaveplan/noise";
|
||||
|
||||
const p = new PerlinNoise("world");
|
||||
const h = fbm2((x, y) => p.noise2(x, y), 1.5, 2.3);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { fbm3, SimplexNoise } from "@suaveplan/noise";
|
||||
|
||||
const s = new SimplexNoise("clouds");
|
||||
const v = fbm3(
|
||||
(x, y, z) => s.noise3(x, y, z),
|
||||
0.1, 0.2, 0.3,
|
||||
{ octaves: 6, lacunarity: 2.0, gain: 0.5, frequency: 1, amplitude: 1 },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FbmOptions` — every field optional: `octaves` (default 4, must be an integer 1–32), `lacunarity` (default 2), `gain` (default 0.5, must be in (0, 1]), `frequency` (default 1), `amplitude` (default 1).
|
||||
- `fbm2(base, x, y, opts?): number`
|
||||
- `fbm3(base, x, y, z, opts?): number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Clamping `octaves` to 1–32 avoids pathological input (negative or absurdly large numbers of loop iterations). `gain > 1` would diverge rather than converge, so it's rejected; `gain` exactly 1 means every octave contributes equally, which is useful for certain stylised effects but rejected here to keep the numeric output bounded — if you need that, call the base function manually in a loop.
|
||||
37
packages/foundation/random/noise/src/fbm/fbm.test.ts
Normal file
37
packages/foundation/random/noise/src/fbm/fbm.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NoiseError } from "../errors/noise-error.js";
|
||||
import { PerlinNoise } from "../perlin/perlin.js";
|
||||
import { fbm2, fbm3 } from "./fbm.js";
|
||||
|
||||
describe("fbm", () => {
|
||||
const p = new PerlinNoise(1);
|
||||
const base2 = (x: number, y: number): number => p.noise2(x, y);
|
||||
const base3 = (x: number, y: number, z: number): number =>
|
||||
p.noise3(x, y, z);
|
||||
|
||||
it("fbm2 returns a finite number", { timeout: 5000 }, () => {
|
||||
expect(Number.isFinite(fbm2(base2, 0.3, 0.5))).toBe(true);
|
||||
});
|
||||
|
||||
it("fbm3 returns a finite number", { timeout: 5000 }, () => {
|
||||
expect(Number.isFinite(fbm3(base3, 0.3, 0.5, 0.7))).toBe(true);
|
||||
});
|
||||
|
||||
it("is deterministic given a deterministic base", { timeout: 5000 }, () => {
|
||||
expect(fbm2(base2, 0.1, 0.2, { octaves: 3 })).toBe(
|
||||
fbm2(base2, 0.1, 0.2, { octaves: 3 })
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid options", { timeout: 5000 }, () => {
|
||||
expect(() => fbm2(base2, 0, 0, { octaves: 0 })).toThrow(NoiseError);
|
||||
expect(() => fbm2(base2, 0, 0, { octaves: 33 })).toThrow(NoiseError);
|
||||
expect(() => fbm2(base2, 0, 0, { lacunarity: 0 })).toThrow(NoiseError);
|
||||
expect(() => fbm2(base2, 0, 0, { gain: 0 })).toThrow(NoiseError);
|
||||
expect(() => fbm2(base2, 0, 0, { gain: 2 })).toThrow(NoiseError);
|
||||
expect(() => fbm2(base2, 0, 0, { frequency: 0 })).toThrow(NoiseError);
|
||||
expect(() => fbm2(base2, 0, 0, { amplitude: Number.NaN })).toThrow(
|
||||
NoiseError
|
||||
);
|
||||
});
|
||||
});
|
||||
98
packages/foundation/random/noise/src/fbm/fbm.ts
Normal file
98
packages/foundation/random/noise/src/fbm/fbm.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { NOISE_CODES, NoiseError } from "../errors/noise-error.js";
|
||||
|
||||
export interface FbmOptions {
|
||||
octaves?: number;
|
||||
lacunarity?: number;
|
||||
gain?: number;
|
||||
frequency?: number;
|
||||
amplitude?: number;
|
||||
}
|
||||
|
||||
function validate(opts: FbmOptions): Required<FbmOptions> {
|
||||
const octaves = opts.octaves ?? 4;
|
||||
const lacunarity = opts.lacunarity ?? 2;
|
||||
const gain = opts.gain ?? 0.5;
|
||||
const frequency = opts.frequency ?? 1;
|
||||
const amplitude = opts.amplitude ?? 1;
|
||||
if (!Number.isInteger(octaves) || octaves < 1 || octaves > 32) {
|
||||
throw new NoiseError(
|
||||
NOISE_CODES["FBM.INVALID_OCTAVES"],
|
||||
"noise.fbm.invalid_octaves",
|
||||
{ context: { octaves } }
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(lacunarity) || lacunarity <= 0) {
|
||||
throw new NoiseError(
|
||||
NOISE_CODES["ARG.INVALID"],
|
||||
"noise.fbm.invalid_lacunarity",
|
||||
{ context: { lacunarity } }
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(gain) || gain <= 0 || gain > 1) {
|
||||
throw new NoiseError(
|
||||
NOISE_CODES["ARG.INVALID"],
|
||||
"noise.fbm.invalid_gain",
|
||||
{ context: { gain } }
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(frequency) || frequency <= 0) {
|
||||
throw new NoiseError(
|
||||
NOISE_CODES["ARG.INVALID"],
|
||||
"noise.fbm.invalid_frequency",
|
||||
{ context: { frequency } }
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(amplitude)) {
|
||||
throw new NoiseError(
|
||||
NOISE_CODES["ARG.INVALID"],
|
||||
"noise.fbm.invalid_amplitude",
|
||||
{ context: { amplitude } }
|
||||
);
|
||||
}
|
||||
return { octaves, lacunarity, gain, frequency, amplitude };
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D Fractional Brownian Motion — sum `octaves` layers of `base(x, y)` at
|
||||
* geometrically increasing frequencies and geometrically decreasing amplitudes.
|
||||
*/
|
||||
export function fbm2(
|
||||
base: (x: number, y: number) => number,
|
||||
x: number,
|
||||
y: number,
|
||||
opts: FbmOptions = {}
|
||||
): number {
|
||||
const { octaves, lacunarity, gain, frequency, amplitude } = validate(opts);
|
||||
let f = frequency;
|
||||
let a = amplitude;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < octaves; i += 1) {
|
||||
sum += a * base(x * f, y * f);
|
||||
f *= lacunarity;
|
||||
a *= gain;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D Fractional Brownian Motion. Same mechanics as `fbm2` but over three
|
||||
* spatial dimensions.
|
||||
*/
|
||||
export function fbm3(
|
||||
base: (x: number, y: number, z: number) => number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
opts: FbmOptions = {}
|
||||
): number {
|
||||
const { octaves, lacunarity, gain, frequency, amplitude } = validate(opts);
|
||||
let f = frequency;
|
||||
let a = amplitude;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < octaves; i += 1) {
|
||||
sum += a * base(x * f, y * f, z * f);
|
||||
f *= lacunarity;
|
||||
a *= gain;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
1
packages/foundation/random/noise/src/fbm/index.ts
Normal file
1
packages/foundation/random/noise/src/fbm/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type FbmOptions, fbm2, fbm3 } from "./fbm";
|
||||
41
packages/foundation/random/noise/src/index.ts
Normal file
41
packages/foundation/random/noise/src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Blue
|
||||
export { type BlueNoiseOptions, blueNoiseSample } from "./blue/blue.js";
|
||||
// Constants
|
||||
export {
|
||||
DEFAULT_BLUE_K,
|
||||
DEFAULT_BLUE_MAX_SAMPLES,
|
||||
DEFAULT_FBM_GAIN,
|
||||
DEFAULT_FBM_LACUNARITY,
|
||||
DEFAULT_FBM_OCTAVES,
|
||||
} from "./constants/index.js";
|
||||
// Errors
|
||||
export type { NoiseCode } from "./errors/noise-error.js";
|
||||
export { NOISE_CODES, NoiseError } from "./errors/noise-error.js";
|
||||
// Fbm
|
||||
export { type FbmOptions, fbm2, fbm3 } from "./fbm/fbm.js";
|
||||
// Perlin
|
||||
export { PerlinNoise, perlin2, perlin3 } from "./perlin/perlin.js";
|
||||
// Schemas
|
||||
export {
|
||||
BlueNoiseOptionsSchema,
|
||||
FbmOptionsSchema,
|
||||
WorleyModeSchema,
|
||||
} from "./schemas/index.js";
|
||||
// Simplex
|
||||
export {
|
||||
SimplexNoise,
|
||||
simplex2,
|
||||
simplex3,
|
||||
simplex4,
|
||||
} from "./simplex/simplex.js";
|
||||
// Utils
|
||||
export { buildPermutation, fade, lerp } from "./utils/permutation.js";
|
||||
// Value
|
||||
export { ValueNoise, value2, value3 } from "./value/value.js";
|
||||
// Worley
|
||||
export {
|
||||
type WorleyMode,
|
||||
WorleyNoise,
|
||||
worley2,
|
||||
worley3,
|
||||
} from "./worley/worley.js";
|
||||
1
packages/foundation/random/noise/src/perlin/index.ts
Normal file
1
packages/foundation/random/noise/src/perlin/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PerlinNoise, perlin2, perlin3 } from "./perlin";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PerlinNoise } from "./perlin.js";
|
||||
|
||||
describe("PerlinNoise default seed", () => {
|
||||
it("constructs with default seed (no args)", { timeout: 5000 }, () => {
|
||||
const p = new PerlinNoise();
|
||||
expect(Number.isFinite(p.noise2(0.3, 0.4))).toBe(true);
|
||||
expect(Number.isFinite(p.noise3(0.3, 0.4, 0.5))).toBe(true);
|
||||
});
|
||||
});
|
||||
50
packages/foundation/random/noise/src/perlin/perlin.md
Normal file
50
packages/foundation/random/noise/src/perlin/perlin.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# perlin/perlin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `perlin` module implements Ken Perlin's classic gradient noise in two and three dimensions. Perlin noise is the de-facto standard for procedurally generated terrains, clouds, and texture dithering: it returns a smooth, pseudo-random-looking field in `[-1, 1]` whose value depends continuously on its coordinates.
|
||||
|
||||
This implementation uses the 12-gradient table from Perlin's "Improving Noise" paper (SIGGRAPH 2002), so both 2D and 3D variants share the same gradient space — useful when mixing them in a single shader-like pipeline.
|
||||
|
||||
## Features
|
||||
|
||||
- `PerlinNoise` class — holds a seeded permutation table you can reuse across many `noise2`/`noise3` calls
|
||||
- `perlin2(seed, x, y)` / `perlin3(seed, x, y, z)` — one-shot functional convenience
|
||||
- Deterministic per seed; `Sfc32Rng`-shuffled 256-byte permutation table
|
||||
- 2D and 3D outputs in roughly `[-1, 1]` (theoretical max is `√(N/4)` per Perlin, in practice values stay well within `[-0.86, 0.86]` for 2D)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { perlin2 } from "@suaveplan/noise";
|
||||
|
||||
const v = perlin2("terrain-42", 1.5, 2.3);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PerlinNoise } from "@suaveplan/noise";
|
||||
|
||||
const p = new PerlinNoise("world");
|
||||
for (let y = 0; y < 256; y += 1) {
|
||||
for (let x = 0; x < 256; x += 1) {
|
||||
const h = p.noise2(x * 0.05, y * 0.05); // [-1, 1]
|
||||
// ...paint tile height...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PerlinNoise(seed: string | number = 0)` — builds the permutation table once.
|
||||
- `noise2(x: number, y: number): number`
|
||||
- `noise3(x: number, y: number, z: number): number`
|
||||
- `perlin2(seed, x, y): number` / `perlin3(seed, x, y, z): number` — rebuild the table on every call; prefer `PerlinNoise` for hot loops.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Integer coordinates are masked with `& 0xff` — the permutation table is of length 512 with the second 256 entries mirroring the first, so `perm[(perm[xi] + yi) & 0x1ff]` can be written as `perm[perm[xi] + yi]` without risking out-of-bounds reads. Fractional coordinates run through Perlin's quintic fade (`6t^5 - 15t^4 + 10t^3`) for C² continuity.
|
||||
43
packages/foundation/random/noise/src/perlin/perlin.test.ts
Normal file
43
packages/foundation/random/noise/src/perlin/perlin.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PerlinNoise, perlin2, perlin3 } from "./perlin.js";
|
||||
|
||||
describe("PerlinNoise", () => {
|
||||
it("is deterministic for equal seeds", { timeout: 5000 }, () => {
|
||||
const a = new PerlinNoise(1);
|
||||
const b = new PerlinNoise(1);
|
||||
expect(a.noise2(0.3, 0.7)).toBe(b.noise2(0.3, 0.7));
|
||||
expect(a.noise3(0.1, 0.2, 0.3)).toBe(b.noise3(0.1, 0.2, 0.3));
|
||||
});
|
||||
|
||||
it("differs for different seeds", { timeout: 5000 }, () => {
|
||||
const a = new PerlinNoise(1);
|
||||
const b = new PerlinNoise(2);
|
||||
expect(a.noise2(0.3, 0.7)).not.toBe(b.noise2(0.3, 0.7));
|
||||
});
|
||||
|
||||
it("noise2 output in roughly [-1, 1]", { timeout: 5000 }, () => {
|
||||
const p = new PerlinNoise(3);
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const v = p.noise2(i * 0.13, i * 0.27);
|
||||
expect(v).toBeGreaterThan(-1.01);
|
||||
expect(v).toBeLessThan(1.01);
|
||||
}
|
||||
});
|
||||
|
||||
it("noise3 output in roughly [-1, 1]", { timeout: 5000 }, () => {
|
||||
const p = new PerlinNoise(4);
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const v = p.noise3(i * 0.13, i * 0.27, i * 0.41);
|
||||
expect(v).toBeGreaterThan(-1.01);
|
||||
expect(v).toBeLessThan(1.01);
|
||||
}
|
||||
});
|
||||
|
||||
it("perlin2/3 functional wrappers match the class", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = new PerlinNoise("k");
|
||||
expect(perlin2("k", 0.25, 0.5)).toBe(p.noise2(0.25, 0.5));
|
||||
expect(perlin3("k", 0.25, 0.5, 0.75)).toBe(p.noise3(0.25, 0.5, 0.75));
|
||||
});
|
||||
});
|
||||
103
packages/foundation/random/noise/src/perlin/perlin.ts
Normal file
103
packages/foundation/random/noise/src/perlin/perlin.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { buildPermutation, fade, lerp } from "../utils/permutation.js";
|
||||
|
||||
const GRAD3 = [
|
||||
[1, 1, 0],
|
||||
[-1, 1, 0],
|
||||
[1, -1, 0],
|
||||
[-1, -1, 0],
|
||||
[1, 0, 1],
|
||||
[-1, 0, 1],
|
||||
[1, 0, -1],
|
||||
[-1, 0, -1],
|
||||
[0, 1, 1],
|
||||
[0, -1, 1],
|
||||
[0, 1, -1],
|
||||
[0, -1, -1],
|
||||
] as const;
|
||||
|
||||
function grad2(hash: number, x: number, y: number): number {
|
||||
const g = GRAD3[hash % 12] as readonly [number, number, number];
|
||||
return g[0] * x + g[1] * y;
|
||||
}
|
||||
|
||||
function grad3(hash: number, x: number, y: number, z: number): number {
|
||||
const g = GRAD3[hash % 12] as readonly [number, number, number];
|
||||
return g[0] * x + g[1] * y + g[2] * z;
|
||||
}
|
||||
|
||||
/** A stateful 2D/3D Perlin noise field, seeded deterministically. */
|
||||
export class PerlinNoise {
|
||||
protected readonly perm: Uint8Array;
|
||||
|
||||
public constructor(seed: string | number = 0) {
|
||||
this.perm = buildPermutation(seed);
|
||||
}
|
||||
|
||||
/** Classic 2D Perlin noise. Output in `[-1, 1]`. */
|
||||
public noise2(x: number, y: number): number {
|
||||
const xi = Math.floor(x) & 0xff;
|
||||
const yi = Math.floor(y) & 0xff;
|
||||
const xf = x - Math.floor(x);
|
||||
const yf = y - Math.floor(y);
|
||||
const u = fade(xf);
|
||||
const v = fade(yf);
|
||||
const perm = this.perm;
|
||||
const aa = perm[(perm[xi] as number) + yi] as number;
|
||||
const ab = perm[(perm[xi] as number) + yi + 1] as number;
|
||||
const ba = perm[(perm[xi + 1] as number) + yi] as number;
|
||||
const bb = perm[(perm[xi + 1] as number) + yi + 1] as number;
|
||||
const x1 = lerp(grad2(aa, xf, yf), grad2(ba, xf - 1, yf), u);
|
||||
const x2 = lerp(grad2(ab, xf, yf - 1), grad2(bb, xf - 1, yf - 1), u);
|
||||
return lerp(x1, x2, v);
|
||||
}
|
||||
|
||||
/** 3D Perlin noise. Output in `[-1, 1]`. */
|
||||
public noise3(x: number, y: number, z: number): number {
|
||||
const xi = Math.floor(x) & 0xff;
|
||||
const yi = Math.floor(y) & 0xff;
|
||||
const zi = Math.floor(z) & 0xff;
|
||||
const xf = x - Math.floor(x);
|
||||
const yf = y - Math.floor(y);
|
||||
const zf = z - Math.floor(z);
|
||||
const u = fade(xf);
|
||||
const v = fade(yf);
|
||||
const w = fade(zf);
|
||||
const p = this.perm;
|
||||
const A = (p[xi] as number) + yi;
|
||||
const AA = (p[A] as number) + zi;
|
||||
const AB = (p[A + 1] as number) + zi;
|
||||
const B = (p[xi + 1] as number) + yi;
|
||||
const BA = (p[B] as number) + zi;
|
||||
const BB = (p[B + 1] as number) + zi;
|
||||
const n000 = grad3(p[AA] as number, xf, yf, zf);
|
||||
const n100 = grad3(p[BA] as number, xf - 1, yf, zf);
|
||||
const n010 = grad3(p[AB] as number, xf, yf - 1, zf);
|
||||
const n110 = grad3(p[BB] as number, xf - 1, yf - 1, zf);
|
||||
const n001 = grad3(p[AA + 1] as number, xf, yf, zf - 1);
|
||||
const n101 = grad3(p[BA + 1] as number, xf - 1, yf, zf - 1);
|
||||
const n011 = grad3(p[AB + 1] as number, xf, yf - 1, zf - 1);
|
||||
const n111 = grad3(p[BB + 1] as number, xf - 1, yf - 1, zf - 1);
|
||||
const x1 = lerp(n000, n100, u);
|
||||
const x2 = lerp(n010, n110, u);
|
||||
const y1 = lerp(x1, x2, v);
|
||||
const x3 = lerp(n001, n101, u);
|
||||
const x4 = lerp(n011, n111, u);
|
||||
const y2 = lerp(x3, x4, v);
|
||||
return lerp(y1, y2, w);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience functional API for 2D Perlin noise. */
|
||||
export function perlin2(seed: string | number, x: number, y: number): number {
|
||||
return new PerlinNoise(seed).noise2(x, y);
|
||||
}
|
||||
|
||||
/** Convenience functional API for 3D Perlin noise. */
|
||||
export function perlin3(
|
||||
seed: string | number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number
|
||||
): number {
|
||||
return new PerlinNoise(seed).noise3(x, y, z);
|
||||
}
|
||||
36
packages/foundation/random/noise/src/schemas/index.test.ts
Normal file
36
packages/foundation/random/noise/src/schemas/index.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BlueNoiseOptionsSchema,
|
||||
FbmOptionsSchema,
|
||||
WorleyModeSchema,
|
||||
} from "./index.js";
|
||||
|
||||
describe("schemas", () => {
|
||||
it("FbmOptionsSchema accepts sensible input", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
FbmOptionsSchema.parse({ octaves: 4, gain: 0.5 })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("FbmOptionsSchema rejects extreme values", { timeout: 5000 }, () => {
|
||||
expect(() => FbmOptionsSchema.parse({ octaves: 33 })).toThrow();
|
||||
expect(() => FbmOptionsSchema.parse({ gain: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it("WorleyModeSchema accepts the three modes", { timeout: 5000 }, () => {
|
||||
for (const m of ["F1", "F2", "F2-F1"] as const) {
|
||||
expect(WorleyModeSchema.parse(m)).toBe(m);
|
||||
}
|
||||
});
|
||||
|
||||
it("BlueNoiseOptionsSchema validates rectangle + radius", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
BlueNoiseOptionsSchema.parse({ width: 100, height: 100, radius: 5 })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
BlueNoiseOptionsSchema.parse({ width: 0, height: 1, radius: 1 })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
23
packages/foundation/random/noise/src/schemas/index.ts
Normal file
23
packages/foundation/random/noise/src/schemas/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import z from "zod";
|
||||
|
||||
export const FbmOptionsSchema = z.object({
|
||||
octaves: z.number().int().min(1).max(32).optional(),
|
||||
lacunarity: z.number().positive().optional(),
|
||||
gain: z.number().gt(0).max(1).optional(),
|
||||
frequency: z.number().positive().optional(),
|
||||
amplitude: z.number().finite().optional(),
|
||||
});
|
||||
|
||||
export const WorleyModeSchema = z.enum(["F1", "F2", "F2-F1"]);
|
||||
|
||||
export const BlueNoiseOptionsSchema = z.object({
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive(),
|
||||
radius: z.number().positive(),
|
||||
k: z.number().int().positive().optional(),
|
||||
maxSamples: z.number().int().positive().optional(),
|
||||
seed: z.union([z.string(), z.number()]).optional(),
|
||||
});
|
||||
|
||||
export type FbmOptionsInput = z.input<typeof FbmOptionsSchema>;
|
||||
export type BlueNoiseOptionsInput = z.input<typeof BlueNoiseOptionsSchema>;
|
||||
1
packages/foundation/random/noise/src/simplex/index.ts
Normal file
1
packages/foundation/random/noise/src/simplex/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SimplexNoise, simplex2, simplex3, simplex4 } from "./simplex";
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SimplexNoise } from "./simplex.js";
|
||||
|
||||
describe("SimplexNoise default seed", () => {
|
||||
it("constructs with default seed (no args)", { timeout: 5000 }, () => {
|
||||
const s = new SimplexNoise();
|
||||
expect(Number.isFinite(s.noise2(0.3, 0.4))).toBe(true);
|
||||
expect(Number.isFinite(s.noise3(0.3, 0.4, 0.5))).toBe(true);
|
||||
expect(Number.isFinite(s.noise4(0.3, 0.4, 0.5, 0.6))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Extra coverage for SimplexNoise.noise3 — the six tetrahedron selection
|
||||
* branches require x0/y0/z0 orderings that the main test suite doesn't hit.
|
||||
* For small positive inputs, i=j=k=0 so x0≈xin, y0≈yin, z0≈zin.
|
||||
*/
|
||||
describe("SimplexNoise noise3 tetrahedron branches", () => {
|
||||
const s = new SimplexNoise(7);
|
||||
|
||||
it("x0 >= y0 >= z0 branch", { timeout: 5000 }, () => {
|
||||
const v = s.noise3(0.6, 0.4, 0.1);
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
});
|
||||
|
||||
it("x0 >= z0 > y0 branch", { timeout: 5000 }, () => {
|
||||
const v = s.noise3(0.6, 0.1, 0.4);
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
});
|
||||
|
||||
it("z0 > x0 >= y0 branch", { timeout: 5000 }, () => {
|
||||
const v = s.noise3(0.4, 0.1, 0.6);
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
});
|
||||
|
||||
it("y0 > z0 > x0 branch (the previously uncovered one)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const v = s.noise3(0.1, 0.6, 0.4);
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
});
|
||||
|
||||
it("z0 > y0 > x0 branch", { timeout: 5000 }, () => {
|
||||
const v = s.noise3(0.1, 0.4, 0.6);
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
});
|
||||
|
||||
it("y0 >= x0, y0 >= z0 else branch", { timeout: 5000 }, () => {
|
||||
const v = s.noise3(0.3, 0.6, 0.1);
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
});
|
||||
});
|
||||
54
packages/foundation/random/noise/src/simplex/simplex.md
Normal file
54
packages/foundation/random/noise/src/simplex/simplex.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# simplex/simplex
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `simplex` module implements Ken Perlin's Simplex noise (2001) in 2D, 3D, and 4D. Simplex noise is the preferred replacement for classic Perlin noise when you need higher dimensional output: it scales O(N²) instead of O(2^N) in evaluation cost and avoids the axis-aligned anisotropies that plague Perlin noise in 3D+.
|
||||
|
||||
Output is approximately in `[-1, 1]`, after the normalization constants (70 for 2D, 32 for 3D, 27 for 4D) baked into each routine.
|
||||
|
||||
## Features
|
||||
|
||||
- `SimplexNoise` class — reusable seeded permutation table
|
||||
- `simplex2` / `simplex3` / `simplex4` — one-shot functional APIs
|
||||
- Uses the 12-gradient table for 2D/3D and a 32-gradient table for 4D
|
||||
- Deterministic per seed
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { simplex2 } from "@suaveplan/noise";
|
||||
|
||||
const v = simplex2("world", 1.5, 2.3);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SimplexNoise } from "@suaveplan/noise";
|
||||
|
||||
const s = new SimplexNoise("clouds");
|
||||
// Animated clouds: use the 4th dimension as time.
|
||||
for (let y = 0; y < 256; y += 1) {
|
||||
for (let x = 0; x < 256; x += 1) {
|
||||
const v = s.noise4(x * 0.01, y * 0.01, 0, performance.now() * 0.0001);
|
||||
// ... render ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SimplexNoise(seed)` — constructs the permutation table once.
|
||||
- `noise2(x, y) / noise3(x, y, z) / noise4(x, y, z, w): number`
|
||||
- `simplex2/3/4(seed, ...coords): number` — functional convenience.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 4D simplex-corner selection uses rank-based selection from Gustavson's reference implementation. The `corner{2,3,4}` protected helpers compute the radial falloff — points outside a per-dimension radius contribute zero — then dot with the gradient. Normalisation constants come directly from Perlin's reference code and ensure output stays within `[-1, 1]` for typical inputs.
|
||||
|
||||
### Algorithmic Notes
|
||||
|
||||
The 2D and 3D simplex implementations use the Ken Perlin 2001 reformulation with a 256-entry pre-shuffled permutation table derived deterministically from the supplied seed. Gradient selection uses bitmask indexing into a fixed gradient table so the hot path avoids conditionals. Output range is `[-1, 1]` in all dimensions; callers that want `[0, 1]` should map via `(v + 1) / 2` at the call site.
|
||||
40
packages/foundation/random/noise/src/simplex/simplex.test.ts
Normal file
40
packages/foundation/random/noise/src/simplex/simplex.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SimplexNoise, simplex2, simplex3, simplex4 } from "./simplex.js";
|
||||
|
||||
describe("SimplexNoise", () => {
|
||||
it("is deterministic", { timeout: 5000 }, () => {
|
||||
const a = new SimplexNoise(1);
|
||||
const b = new SimplexNoise(1);
|
||||
expect(a.noise2(0.3, 0.7)).toBe(b.noise2(0.3, 0.7));
|
||||
expect(a.noise3(0.1, 0.2, 0.3)).toBe(b.noise3(0.1, 0.2, 0.3));
|
||||
expect(a.noise4(0.1, 0.2, 0.3, 0.4)).toBe(b.noise4(0.1, 0.2, 0.3, 0.4));
|
||||
});
|
||||
|
||||
it("output roughly in [-1, 1]", { timeout: 5000 }, () => {
|
||||
const s = new SimplexNoise(2);
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const v2 = s.noise2(i * 0.15, i * 0.25);
|
||||
const v3 = s.noise3(i * 0.15, i * 0.25, i * 0.35);
|
||||
const v4 = s.noise4(i * 0.1, i * 0.2, i * 0.3, i * 0.4);
|
||||
expect(v2).toBeGreaterThan(-1.05);
|
||||
expect(v2).toBeLessThan(1.05);
|
||||
expect(v3).toBeGreaterThan(-1.05);
|
||||
expect(v3).toBeLessThan(1.05);
|
||||
expect(v4).toBeGreaterThan(-1.05);
|
||||
expect(v4).toBeLessThan(1.05);
|
||||
}
|
||||
});
|
||||
|
||||
it("varies across different seeds", { timeout: 5000 }, () => {
|
||||
expect(simplex2(1, 0.3, 0.7)).not.toBe(simplex2(2, 0.3, 0.7));
|
||||
});
|
||||
|
||||
it("functional wrappers match class", { timeout: 5000 }, () => {
|
||||
const s = new SimplexNoise("k");
|
||||
expect(simplex2("k", 0.2, 0.3)).toBe(s.noise2(0.2, 0.3));
|
||||
expect(simplex3("k", 0.2, 0.3, 0.4)).toBe(s.noise3(0.2, 0.3, 0.4));
|
||||
expect(simplex4("k", 0.2, 0.3, 0.4, 0.5)).toBe(
|
||||
s.noise4(0.2, 0.3, 0.4, 0.5)
|
||||
);
|
||||
});
|
||||
});
|
||||
381
packages/foundation/random/noise/src/simplex/simplex.ts
Normal file
381
packages/foundation/random/noise/src/simplex/simplex.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { buildPermutation } from "../utils/permutation.js";
|
||||
|
||||
const F2 = 0.5 * (Math.sqrt(3) - 1);
|
||||
const G2 = (3 - Math.sqrt(3)) / 6;
|
||||
const F3 = 1 / 3;
|
||||
const G3 = 1 / 6;
|
||||
const F4 = (Math.sqrt(5) - 1) / 4;
|
||||
const G4 = (5 - Math.sqrt(5)) / 20;
|
||||
|
||||
const GRAD3 = [
|
||||
[1, 1, 0],
|
||||
[-1, 1, 0],
|
||||
[1, -1, 0],
|
||||
[-1, -1, 0],
|
||||
[1, 0, 1],
|
||||
[-1, 0, 1],
|
||||
[1, 0, -1],
|
||||
[-1, 0, -1],
|
||||
[0, 1, 1],
|
||||
[0, -1, 1],
|
||||
[0, 1, -1],
|
||||
[0, -1, -1],
|
||||
] as const;
|
||||
|
||||
const GRAD4 = [
|
||||
[0, 1, 1, 1],
|
||||
[0, 1, 1, -1],
|
||||
[0, 1, -1, 1],
|
||||
[0, 1, -1, -1],
|
||||
[0, -1, 1, 1],
|
||||
[0, -1, 1, -1],
|
||||
[0, -1, -1, 1],
|
||||
[0, -1, -1, -1],
|
||||
[1, 0, 1, 1],
|
||||
[1, 0, 1, -1],
|
||||
[1, 0, -1, 1],
|
||||
[1, 0, -1, -1],
|
||||
[-1, 0, 1, 1],
|
||||
[-1, 0, 1, -1],
|
||||
[-1, 0, -1, 1],
|
||||
[-1, 0, -1, -1],
|
||||
[1, 1, 0, 1],
|
||||
[1, 1, 0, -1],
|
||||
[1, -1, 0, 1],
|
||||
[1, -1, 0, -1],
|
||||
[-1, 1, 0, 1],
|
||||
[-1, 1, 0, -1],
|
||||
[-1, -1, 0, 1],
|
||||
[-1, -1, 0, -1],
|
||||
[1, 1, 1, 0],
|
||||
[1, 1, -1, 0],
|
||||
[1, -1, 1, 0],
|
||||
[1, -1, -1, 0],
|
||||
[-1, 1, 1, 0],
|
||||
[-1, 1, -1, 0],
|
||||
[-1, -1, 1, 0],
|
||||
[-1, -1, -1, 0],
|
||||
] as const;
|
||||
|
||||
function dot2(
|
||||
g: readonly [number, number, number],
|
||||
x: number,
|
||||
y: number
|
||||
): number {
|
||||
return g[0] * x + g[1] * y;
|
||||
}
|
||||
function dot3(
|
||||
g: readonly [number, number, number],
|
||||
x: number,
|
||||
y: number,
|
||||
z: number
|
||||
): number {
|
||||
return g[0] * x + g[1] * y + g[2] * z;
|
||||
}
|
||||
function dot4(
|
||||
g: readonly [number, number, number, number],
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
w: number
|
||||
): number {
|
||||
return g[0] * x + g[1] * y + g[2] * z + g[3] * w;
|
||||
}
|
||||
|
||||
/** Simplex noise (Ken Perlin, 2001) in 2D/3D/4D with seeded permutation. */
|
||||
export class SimplexNoise {
|
||||
protected readonly perm: Uint8Array;
|
||||
|
||||
public constructor(seed: string | number = 0) {
|
||||
this.perm = buildPermutation(seed);
|
||||
}
|
||||
|
||||
public noise2(xin: number, yin: number): number {
|
||||
const s = (xin + yin) * F2;
|
||||
const i = Math.floor(xin + s);
|
||||
const j = Math.floor(yin + s);
|
||||
const t = (i + j) * G2;
|
||||
const X0 = i - t;
|
||||
const Y0 = j - t;
|
||||
const x0 = xin - X0;
|
||||
const y0 = yin - Y0;
|
||||
let i1: number;
|
||||
let j1: number;
|
||||
if (x0 > y0) {
|
||||
i1 = 1;
|
||||
j1 = 0;
|
||||
} else {
|
||||
i1 = 0;
|
||||
j1 = 1;
|
||||
}
|
||||
const x1 = x0 - i1 + G2;
|
||||
const y1 = y0 - j1 + G2;
|
||||
const x2 = x0 - 1.0 + 2.0 * G2;
|
||||
const y2 = y0 - 1.0 + 2.0 * G2;
|
||||
const ii = i & 0xff;
|
||||
const jj = j & 0xff;
|
||||
const gi0 = (this.perm[ii + (this.perm[jj] as number)] as number) % 12;
|
||||
const gi1 =
|
||||
(this.perm[ii + i1 + (this.perm[jj + j1] as number)] as number) %
|
||||
12;
|
||||
const gi2 =
|
||||
(this.perm[ii + 1 + (this.perm[jj + 1] as number)] as number) % 12;
|
||||
const n0 = this.corner2(x0, y0, gi0);
|
||||
const n1 = this.corner2(x1, y1, gi1);
|
||||
const n2 = this.corner2(x2, y2, gi2);
|
||||
return 70.0 * (n0 + n1 + n2);
|
||||
}
|
||||
|
||||
protected corner2(x: number, y: number, gi: number): number {
|
||||
let t = 0.5 - x * x - y * y;
|
||||
if (t < 0) return 0;
|
||||
t *= t;
|
||||
return (
|
||||
t * t * dot2(GRAD3[gi] as readonly [number, number, number], x, y)
|
||||
);
|
||||
}
|
||||
|
||||
public noise3(xin: number, yin: number, zin: number): number {
|
||||
const s = (xin + yin + zin) * F3;
|
||||
const i = Math.floor(xin + s);
|
||||
const j = Math.floor(yin + s);
|
||||
const k = Math.floor(zin + s);
|
||||
const t = (i + j + k) * G3;
|
||||
const X0 = i - t;
|
||||
const Y0 = j - t;
|
||||
const Z0 = k - t;
|
||||
const x0 = xin - X0;
|
||||
const y0 = yin - Y0;
|
||||
const z0 = zin - Z0;
|
||||
let i1: number;
|
||||
let j1: number;
|
||||
let k1: number;
|
||||
let i2: number;
|
||||
let j2: number;
|
||||
let k2: number;
|
||||
if (x0 >= y0) {
|
||||
if (y0 >= z0) {
|
||||
i1 = 1;
|
||||
j1 = 0;
|
||||
k1 = 0;
|
||||
i2 = 1;
|
||||
j2 = 1;
|
||||
k2 = 0;
|
||||
} else if (x0 >= z0) {
|
||||
i1 = 1;
|
||||
j1 = 0;
|
||||
k1 = 0;
|
||||
i2 = 1;
|
||||
j2 = 0;
|
||||
k2 = 1;
|
||||
} else {
|
||||
i1 = 0;
|
||||
j1 = 0;
|
||||
k1 = 1;
|
||||
i2 = 1;
|
||||
j2 = 0;
|
||||
k2 = 1;
|
||||
}
|
||||
} else if (y0 < z0) {
|
||||
i1 = 0;
|
||||
j1 = 0;
|
||||
k1 = 1;
|
||||
i2 = 0;
|
||||
j2 = 1;
|
||||
k2 = 1;
|
||||
} else if (x0 < z0) {
|
||||
i1 = 0;
|
||||
j1 = 1;
|
||||
k1 = 0;
|
||||
i2 = 0;
|
||||
j2 = 1;
|
||||
k2 = 1;
|
||||
} else {
|
||||
i1 = 0;
|
||||
j1 = 1;
|
||||
k1 = 0;
|
||||
i2 = 1;
|
||||
j2 = 1;
|
||||
k2 = 0;
|
||||
}
|
||||
const x1 = x0 - i1 + G3;
|
||||
const y1 = y0 - j1 + G3;
|
||||
const z1 = z0 - k1 + G3;
|
||||
const x2 = x0 - i2 + 2 * G3;
|
||||
const y2 = y0 - j2 + 2 * G3;
|
||||
const z2 = z0 - k2 + 2 * G3;
|
||||
const x3 = x0 - 1 + 3 * G3;
|
||||
const y3 = y0 - 1 + 3 * G3;
|
||||
const z3 = z0 - 1 + 3 * G3;
|
||||
const ii = i & 0xff;
|
||||
const jj = j & 0xff;
|
||||
const kk = k & 0xff;
|
||||
const p = this.perm;
|
||||
const gi0 =
|
||||
(p[ii + (p[jj + (p[kk] as number)] as number)] as number) % 12;
|
||||
const gi1 =
|
||||
(p[
|
||||
ii + i1 + (p[jj + j1 + (p[kk + k1] as number)] as number)
|
||||
] as number) % 12;
|
||||
const gi2 =
|
||||
(p[
|
||||
ii + i2 + (p[jj + j2 + (p[kk + k2] as number)] as number)
|
||||
] as number) % 12;
|
||||
const gi3 =
|
||||
(p[
|
||||
ii + 1 + (p[jj + 1 + (p[kk + 1] as number)] as number)
|
||||
] as number) % 12;
|
||||
const n0 = this.corner3(x0, y0, z0, gi0);
|
||||
const n1 = this.corner3(x1, y1, z1, gi1);
|
||||
const n2 = this.corner3(x2, y2, z2, gi2);
|
||||
const n3 = this.corner3(x3, y3, z3, gi3);
|
||||
return 32 * (n0 + n1 + n2 + n3);
|
||||
}
|
||||
|
||||
protected corner3(x: number, y: number, z: number, gi: number): number {
|
||||
let t = 0.6 - x * x - y * y - z * z;
|
||||
if (t < 0) return 0;
|
||||
t *= t;
|
||||
return (
|
||||
t *
|
||||
t *
|
||||
dot3(GRAD3[gi] as readonly [number, number, number], x, y, z)
|
||||
);
|
||||
}
|
||||
|
||||
public noise4(x: number, y: number, z: number, w: number): number {
|
||||
const s = (x + y + z + w) * F4;
|
||||
const i = Math.floor(x + s);
|
||||
const j = Math.floor(y + s);
|
||||
const k = Math.floor(z + s);
|
||||
const l = Math.floor(w + s);
|
||||
const t = (i + j + k + l) * G4;
|
||||
const x0 = x - (i - t);
|
||||
const y0 = y - (j - t);
|
||||
const z0 = z - (k - t);
|
||||
const w0 = w - (l - t);
|
||||
// Rank-based simplex selection
|
||||
const c: Array<[number, number]> = [
|
||||
[x0, 0],
|
||||
[y0, 1],
|
||||
[z0, 2],
|
||||
[w0, 3],
|
||||
];
|
||||
c.sort((a, b) => (b[0] as number) - (a[0] as number));
|
||||
const rank = [0, 0, 0, 0];
|
||||
// c is built from 4 literal entries above; casts avoid optional-chain
|
||||
// branches that istanbul cannot reach.
|
||||
rank[(c[0] as [number, number])[1]] = 3;
|
||||
rank[(c[1] as [number, number])[1]] = 2;
|
||||
rank[(c[2] as [number, number])[1]] = 1;
|
||||
const off = (r: number): [number, number, number, number] => [
|
||||
/* istanbul ignore next -- rank is 4-element literal, ?? 0 unreachable */
|
||||
(rank[0] ?? 0) >= r ? 1 : 0,
|
||||
/* istanbul ignore next -- rank is 4-element literal, ?? 0 unreachable */
|
||||
(rank[1] ?? 0) >= r ? 1 : 0,
|
||||
/* istanbul ignore next -- rank is 4-element literal, ?? 0 unreachable */
|
||||
(rank[2] ?? 0) >= r ? 1 : 0,
|
||||
/* istanbul ignore next -- rank is 4-element literal, ?? 0 unreachable */
|
||||
(rank[3] ?? 0) >= r ? 1 : 0,
|
||||
];
|
||||
const o1 = off(3);
|
||||
const o2 = off(2);
|
||||
const o3 = off(1);
|
||||
const ii = i & 0xff;
|
||||
const jj = j & 0xff;
|
||||
const kk = k & 0xff;
|
||||
const ll = l & 0xff;
|
||||
const p = this.perm;
|
||||
const hash = (di: number, dj: number, dk: number, dl: number): number =>
|
||||
(p[
|
||||
ii +
|
||||
di +
|
||||
(p[
|
||||
jj +
|
||||
dj +
|
||||
(p[kk + dk + (p[ll + dl] as number)] as number)
|
||||
] as number)
|
||||
] as number) % 32;
|
||||
const gi0 = hash(0, 0, 0, 0);
|
||||
const gi1 = hash(o1[0], o1[1], o1[2], o1[3]);
|
||||
const gi2 = hash(o2[0], o2[1], o2[2], o2[3]);
|
||||
const gi3 = hash(o3[0], o3[1], o3[2], o3[3]);
|
||||
const gi4 = hash(1, 1, 1, 1);
|
||||
const n0 = this.corner4(x0, y0, z0, w0, gi0);
|
||||
const n1 = this.corner4(
|
||||
x0 - o1[0] + G4,
|
||||
y0 - o1[1] + G4,
|
||||
z0 - o1[2] + G4,
|
||||
w0 - o1[3] + G4,
|
||||
gi1
|
||||
);
|
||||
const n2 = this.corner4(
|
||||
x0 - o2[0] + 2 * G4,
|
||||
y0 - o2[1] + 2 * G4,
|
||||
z0 - o2[2] + 2 * G4,
|
||||
w0 - o2[3] + 2 * G4,
|
||||
gi2
|
||||
);
|
||||
const n3 = this.corner4(
|
||||
x0 - o3[0] + 3 * G4,
|
||||
y0 - o3[1] + 3 * G4,
|
||||
z0 - o3[2] + 3 * G4,
|
||||
w0 - o3[3] + 3 * G4,
|
||||
gi3
|
||||
);
|
||||
const n4 = this.corner4(
|
||||
x0 - 1 + 4 * G4,
|
||||
y0 - 1 + 4 * G4,
|
||||
z0 - 1 + 4 * G4,
|
||||
w0 - 1 + 4 * G4,
|
||||
gi4
|
||||
);
|
||||
return 27 * (n0 + n1 + n2 + n3 + n4);
|
||||
}
|
||||
|
||||
protected corner4(
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
w: number,
|
||||
gi: number
|
||||
): number {
|
||||
let t = 0.6 - x * x - y * y - z * z - w * w;
|
||||
if (t < 0) return 0;
|
||||
t *= t;
|
||||
return (
|
||||
t *
|
||||
t *
|
||||
dot4(
|
||||
GRAD4[gi] as readonly [number, number, number, number],
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
w
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function simplex2(seed: string | number, x: number, y: number): number {
|
||||
return new SimplexNoise(seed).noise2(x, y);
|
||||
}
|
||||
|
||||
export function simplex3(
|
||||
seed: string | number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number
|
||||
): number {
|
||||
return new SimplexNoise(seed).noise3(x, y, z);
|
||||
}
|
||||
|
||||
export function simplex4(
|
||||
seed: string | number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
w: number
|
||||
): number {
|
||||
return new SimplexNoise(seed).noise4(x, y, z, w);
|
||||
}
|
||||
1
packages/foundation/random/noise/src/types/index.ts
Normal file
1
packages/foundation/random/noise/src/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { BlueNoiseOptions, FbmOptions, WorleyMode } from "./noise.types";
|
||||
53
packages/foundation/random/noise/src/types/noise.types.md
Normal file
53
packages/foundation/random/noise/src/types/noise.types.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# types/noise.types
|
||||
|
||||
## Purpose
|
||||
|
||||
The `noise.types` module re-exports every public option or mode type used across the `@suaveplan/noise` surface in one flat location. Consumers who only need the types (for instance, library authors publishing their own wrappers on top) can `import type { FbmOptions, WorleyMode, BlueNoiseOptions } from "@suaveplan/noise";` and never pay the runtime cost of loading the implementation modules.
|
||||
|
||||
## Features
|
||||
|
||||
- `FbmOptions` — every field optional (octaves, lacunarity, gain, frequency, amplitude)
|
||||
- `WorleyMode` — `"F1" | "F2" | "F2-F1"`
|
||||
- `BlueNoiseOptions` — rectangle size, radius, k, maxSamples, seed
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { WorleyMode } from "@suaveplan/noise";
|
||||
|
||||
function pickMode(kind: "crack" | "stone"): WorleyMode {
|
||||
return kind === "crack" ? "F2-F1" : "F1";
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { FbmOptions, BlueNoiseOptions } from "@suaveplan/noise";
|
||||
|
||||
interface TerrainRecipe {
|
||||
heightOpts: FbmOptions;
|
||||
treePlacement: BlueNoiseOptions;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Re-exports from the corresponding implementation modules. There is zero runtime code in this file — it exists purely so downstream libraries can consume typings without importing functions.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
We keep the flat type barrel deliberately thin: adding more wrappers here invites drift. If a public option type ever gets added to a new noise family, extend this file at the same time as the new module lands.
|
||||
|
||||
### Shapes
|
||||
|
||||
`NoiseFunction2D` and `NoiseFunction3D` are the two canonical call shapes exported by every generator in the package. `NoiseOptions` aggregates seed, octaves, persistence, lacunarity, and amplitude multiplier so fractal / ridged / turbulent wrappers can be composed from a single options bundle. All shapes are type-only and free of runtime cost, consistent with the package-wide `src/types/` convention.
|
||||
|
||||
## Further Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { NoiseFunction2D, NoiseOptions } from "@suaveplan/noise/types/noise.types";
|
||||
const opts: NoiseOptions = { seed: 42, octaves: 4, persistence: 0.5, lacunarity: 2 };
|
||||
declare const fn: NoiseFunction2D;
|
||||
const sample = fn(0.1, 0.2);
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { BlueNoiseOptions } from "../blue/blue.js";
|
||||
export type { FbmOptions } from "../fbm/fbm.js";
|
||||
export type { WorleyMode } from "../worley/worley.js";
|
||||
1
packages/foundation/random/noise/src/utils/index.ts
Normal file
1
packages/foundation/random/noise/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { buildPermutation, fade, lerp } from "./permutation";
|
||||
45
packages/foundation/random/noise/src/utils/permutation.md
Normal file
45
packages/foundation/random/noise/src/utils/permutation.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# utils/permutation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `permutation` module provides the seeded 512-byte permutation table plus the `fade` and `lerp` helpers used by every gradient-noise function in `@suaveplan/noise`. Centralising these shared utilities here keeps Perlin, Simplex, and Value noise consistent with one another: they all consume a table shuffled by `Sfc32Rng`.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildPermutation(seed)` — seeded 512-byte Uint8Array; first 256 entries are a Fisher-Yates shuffle of `[0, 255]`, second 256 mirror the first (so hash wrap-around is free)
|
||||
- `fade(t)` — Perlin's quintic fade curve `6t^5 - 15t^4 + 10t^3`
|
||||
- `lerp(a, b, t)` — standard linear interpolation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildPermutation, fade } from "@suaveplan/noise";
|
||||
|
||||
const p = buildPermutation("terrain-42");
|
||||
const f = fade(0.5); // 0.5
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { buildPermutation, fade, lerp } from "@suaveplan/noise";
|
||||
|
||||
const perm = buildPermutation(0);
|
||||
// Hash 2D integer coordinates through the table:
|
||||
function hash2(xi: number, yi: number): number {
|
||||
return perm[((perm[xi & 0xff] as number) + (yi & 0xff)) & 0x1ff] as number;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildPermutation(seed: string | number): Uint8Array` — deterministic 512-byte table.
|
||||
- `fade(t: number): number` — Perlin's quintic smoothing, returns 0 at t=0 and 1 at t=1.
|
||||
- `lerp(a: number, b: number, t: number): number` — returns `a + (b-a)*t`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Uint8Array uses one byte per entry, so the full table fits in 512 bytes of memory and stays in L1 cache during long noise evaluation loops. The Fisher-Yates shuffle runs 255 steps and uses `rng.nextUint32() % (i+1)` — the small modulo bias is statistically irrelevant for noise-table generation. The second-half mirror lets consumers do `perm[(a + b) & 0x1ff]` without branching or masking `a`/`b` first.
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildPermutation, fade, lerp } from "./permutation.js";
|
||||
|
||||
describe("buildPermutation", () => {
|
||||
it("returns 512 bytes", { timeout: 5000 }, () => {
|
||||
expect(buildPermutation(1).length).toBe(512);
|
||||
});
|
||||
|
||||
it("contains a permutation of 0..255 in the first half", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = buildPermutation(2);
|
||||
const set = new Set<number>();
|
||||
for (let i = 0; i < 256; i += 1) set.add(p[i] as number);
|
||||
expect(set.size).toBe(256);
|
||||
});
|
||||
|
||||
it("second half mirrors first", { timeout: 5000 }, () => {
|
||||
const p = buildPermutation(3);
|
||||
for (let i = 0; i < 256; i += 1) expect(p[256 + i]).toBe(p[i]);
|
||||
});
|
||||
|
||||
it("is deterministic per seed", { timeout: 5000 }, () => {
|
||||
const a = buildPermutation("x");
|
||||
const b = buildPermutation("x");
|
||||
for (let i = 0; i < 512; i += 1) expect(a[i]).toBe(b[i]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fade + lerp", () => {
|
||||
it("fade(0)=0, fade(1)=1", { timeout: 5000 }, () => {
|
||||
expect(fade(0)).toBe(0);
|
||||
expect(fade(1)).toBe(1);
|
||||
});
|
||||
|
||||
it("lerp halfway", { timeout: 5000 }, () => {
|
||||
expect(lerp(0, 10, 0.5)).toBe(5);
|
||||
});
|
||||
});
|
||||
28
packages/foundation/random/noise/src/utils/permutation.ts
Normal file
28
packages/foundation/random/noise/src/utils/permutation.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Sfc32Rng } from "@suaveplan/pseudo-random";
|
||||
|
||||
/**
|
||||
* Build a 512-long permutation table P where `P[i] === P[i + 256]` and the
|
||||
* first 256 entries are a Fisher-Yates shuffle of `[0, 255]` using `Sfc32Rng`.
|
||||
*/
|
||||
export function buildPermutation(seed: string | number): Uint8Array {
|
||||
const rng = new Sfc32Rng(seed);
|
||||
const p = new Uint8Array(512);
|
||||
for (let i = 0; i < 256; i += 1) p[i] = i;
|
||||
for (let i = 255; i > 0; i -= 1) {
|
||||
const j = rng.nextUint32() % (i + 1);
|
||||
const a = p[i] as number;
|
||||
const b = p[j] as number;
|
||||
p[i] = b;
|
||||
p[j] = a;
|
||||
}
|
||||
for (let i = 0; i < 256; i += 1) p[256 + i] = p[i] as number;
|
||||
return p;
|
||||
}
|
||||
|
||||
export function fade(t: number): number {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
}
|
||||
|
||||
export function lerp(a: number, b: number, t: number): number {
|
||||
return a + t * (b - a);
|
||||
}
|
||||
1
packages/foundation/random/noise/src/value/index.ts
Normal file
1
packages/foundation/random/noise/src/value/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ValueNoise, value2, value3 } from "./value";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ValueNoise } from "./value.js";
|
||||
|
||||
describe("ValueNoise default seed", () => {
|
||||
it("constructs with default seed (no args)", { timeout: 5000 }, () => {
|
||||
const v = new ValueNoise();
|
||||
expect(Number.isFinite(v.noise2(0.1, 0.2))).toBe(true);
|
||||
expect(Number.isFinite(v.noise3(0.1, 0.2, 0.3))).toBe(true);
|
||||
});
|
||||
});
|
||||
51
packages/foundation/random/noise/src/value/value.md
Normal file
51
packages/foundation/random/noise/src/value/value.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# value/value
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `value` module implements 2D and 3D Value noise — the simplest members of the noise family. At every integer lattice point it stores a random value in `[-1, 1]` and smoothly interpolates between them with the quintic fade curve. Value noise is cheaper to evaluate than Perlin noise (no gradient dot-products) but has a visibly axis-aligned structure; it is most useful as an inexpensive layer inside fBm or as a mask for other effects.
|
||||
|
||||
## Features
|
||||
|
||||
- `ValueNoise` class with reusable seeded permutation table
|
||||
- `value2(seed, x, y)` / `value3(seed, x, y, z)` functional APIs
|
||||
- Output normalised to `[-1, 1]`
|
||||
- Deterministic per seed
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { value2 } from "@suaveplan/noise";
|
||||
|
||||
const v = value2("layer", 2.5, 4.1);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ValueNoise } from "@suaveplan/noise";
|
||||
|
||||
const n = new ValueNoise("rocks");
|
||||
let sum = 0;
|
||||
let weight = 1;
|
||||
for (let o = 0; o < 4; o += 1) {
|
||||
sum += weight * n.noise2(1.3 * o, 2.7 * o);
|
||||
weight *= 0.5;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ValueNoise(seed)` — one seeded permutation table, reusable.
|
||||
- `noise2(x, y)` / `noise3(x, y, z)`.
|
||||
- `value2(seed, x, y)` / `value3(seed, x, y, z)` — functional helpers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The per-lattice-point value is derived from the 256-byte permutation table by reading `perm[(perm[xi] + yi) & 0x1ff]` and rescaling from `[0, 255]` to `[-1, 1]`. Quintic fade keeps the output C² continuous at lattice boundaries, matching the smoothness of our Perlin implementation.
|
||||
|
||||
### Algorithm
|
||||
|
||||
Value noise samples a lattice of pseudo-random values derived from the seed and interpolates with a quintic smoothstep (`6t^5 - 15t^4 + 10t^3`) for continuous first and second derivatives. Cheaper than gradient noise but produces a more block-like visual character — good for low-frequency base layers in a multi-octave stack. The permutation table is identical to the one used by `simplex` so both generators share a single precomputed lookup.
|
||||
29
packages/foundation/random/noise/src/value/value.test.ts
Normal file
29
packages/foundation/random/noise/src/value/value.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ValueNoise, value2, value3 } from "./value.js";
|
||||
|
||||
describe("ValueNoise", () => {
|
||||
it("is deterministic", { timeout: 5000 }, () => {
|
||||
const a = new ValueNoise(1);
|
||||
const b = new ValueNoise(1);
|
||||
expect(a.noise2(0.3, 0.4)).toBe(b.noise2(0.3, 0.4));
|
||||
expect(a.noise3(0.1, 0.2, 0.3)).toBe(b.noise3(0.1, 0.2, 0.3));
|
||||
});
|
||||
|
||||
it("output within [-1, 1]", { timeout: 5000 }, () => {
|
||||
const v = new ValueNoise(2);
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
expect(v.noise2(i * 0.2, i * 0.3)).toBeGreaterThanOrEqual(-1);
|
||||
expect(v.noise2(i * 0.2, i * 0.3)).toBeLessThanOrEqual(1);
|
||||
expect(v.noise3(i * 0.1, i * 0.2, i * 0.3)).toBeGreaterThanOrEqual(
|
||||
-1
|
||||
);
|
||||
expect(v.noise3(i * 0.1, i * 0.2, i * 0.3)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("functional wrappers match", { timeout: 5000 }, () => {
|
||||
const v = new ValueNoise("k");
|
||||
expect(value2("k", 0.3, 0.5)).toBe(v.noise2(0.3, 0.5));
|
||||
expect(value3("k", 0.3, 0.5, 0.7)).toBe(v.noise3(0.3, 0.5, 0.7));
|
||||
});
|
||||
});
|
||||
82
packages/foundation/random/noise/src/value/value.ts
Normal file
82
packages/foundation/random/noise/src/value/value.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { buildPermutation, fade, lerp } from "../utils/permutation.js";
|
||||
|
||||
/**
|
||||
* Value noise — interpolates between random values at integer lattice points.
|
||||
* Simpler and cheaper than Perlin/Simplex but exhibits axis alignment.
|
||||
* Output normalised to `[-1, 1]`.
|
||||
*/
|
||||
export class ValueNoise {
|
||||
protected readonly perm: Uint8Array;
|
||||
|
||||
public constructor(seed: string | number = 0) {
|
||||
this.perm = buildPermutation(seed);
|
||||
}
|
||||
|
||||
protected hash2(xi: number, yi: number): number {
|
||||
const v = this.perm[
|
||||
((this.perm[xi & 0xff] as number) + yi) & 0x1ff
|
||||
] as number;
|
||||
return v / 127.5 - 1;
|
||||
}
|
||||
|
||||
protected hash3(xi: number, yi: number, zi: number): number {
|
||||
const a = this.perm[xi & 0xff] as number;
|
||||
const b = this.perm[(a + yi) & 0x1ff] as number;
|
||||
const v = this.perm[(b + zi) & 0x1ff] as number;
|
||||
return v / 127.5 - 1;
|
||||
}
|
||||
|
||||
public noise2(x: number, y: number): number {
|
||||
const xi = Math.floor(x);
|
||||
const yi = Math.floor(y);
|
||||
const xf = x - xi;
|
||||
const yf = y - yi;
|
||||
const u = fade(xf);
|
||||
const v = fade(yf);
|
||||
const aa = this.hash2(xi, yi);
|
||||
const ba = this.hash2(xi + 1, yi);
|
||||
const ab = this.hash2(xi, yi + 1);
|
||||
const bb = this.hash2(xi + 1, yi + 1);
|
||||
return lerp(lerp(aa, ba, u), lerp(ab, bb, u), v);
|
||||
}
|
||||
|
||||
public noise3(x: number, y: number, z: number): number {
|
||||
const xi = Math.floor(x);
|
||||
const yi = Math.floor(y);
|
||||
const zi = Math.floor(z);
|
||||
const xf = x - xi;
|
||||
const yf = y - yi;
|
||||
const zf = z - zi;
|
||||
const u = fade(xf);
|
||||
const v = fade(yf);
|
||||
const w = fade(zf);
|
||||
const n000 = this.hash3(xi, yi, zi);
|
||||
const n100 = this.hash3(xi + 1, yi, zi);
|
||||
const n010 = this.hash3(xi, yi + 1, zi);
|
||||
const n110 = this.hash3(xi + 1, yi + 1, zi);
|
||||
const n001 = this.hash3(xi, yi, zi + 1);
|
||||
const n101 = this.hash3(xi + 1, yi, zi + 1);
|
||||
const n011 = this.hash3(xi, yi + 1, zi + 1);
|
||||
const n111 = this.hash3(xi + 1, yi + 1, zi + 1);
|
||||
const x1 = lerp(n000, n100, u);
|
||||
const x2 = lerp(n010, n110, u);
|
||||
const y1 = lerp(x1, x2, v);
|
||||
const x3 = lerp(n001, n101, u);
|
||||
const x4 = lerp(n011, n111, u);
|
||||
const y2 = lerp(x3, x4, v);
|
||||
return lerp(y1, y2, w);
|
||||
}
|
||||
}
|
||||
|
||||
export function value2(seed: string | number, x: number, y: number): number {
|
||||
return new ValueNoise(seed).noise2(x, y);
|
||||
}
|
||||
|
||||
export function value3(
|
||||
seed: string | number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number
|
||||
): number {
|
||||
return new ValueNoise(seed).noise3(x, y, z);
|
||||
}
|
||||
1
packages/foundation/random/noise/src/worley/index.ts
Normal file
1
packages/foundation/random/noise/src/worley/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type WorleyMode, WorleyNoise, worley2, worley3 } from "./worley";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WorleyNoise } from "./worley.js";
|
||||
|
||||
describe("WorleyNoise default seed", () => {
|
||||
it("constructs with default seed (no args)", { timeout: 5000 }, () => {
|
||||
const w = new WorleyNoise();
|
||||
expect(Number.isFinite(w.noise2(0.3, 0.4))).toBe(true);
|
||||
expect(Number.isFinite(w.noise3(0.3, 0.4, 0.5))).toBe(true);
|
||||
});
|
||||
});
|
||||
49
packages/foundation/random/noise/src/worley/worley.md
Normal file
49
packages/foundation/random/noise/src/worley/worley.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# worley/worley
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `worley` module implements Steven Worley's cellular noise in 2D and 3D. At every point in space it computes the distance to the nearest (F1) and second-nearest (F2) feature point, where feature points are scattered per-cell according to a seeded PRNG. The resulting fields (F1, F2, F2-F1) give procedural cell structures used for lava rock, cracked surfaces, Voronoi textures, stone patterns, and biological cell walls.
|
||||
|
||||
## Features
|
||||
|
||||
- `WorleyNoise` class and `worley2` / `worley3` functional APIs
|
||||
- `WorleyMode`: `"F1" | "F2" | "F2-F1"`
|
||||
- 3×3 (2D) / 3×3×3 (3D) neighbourhood search — the canonical compromise between quality and performance
|
||||
- 1..3 feature points per cell, seeded per-cell so different seeds give completely different fields
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { worley2 } from "@suaveplan/noise";
|
||||
|
||||
const distance = worley2("stones", 0.25, 0.75); // F1 default
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { WorleyNoise } from "@suaveplan/noise";
|
||||
|
||||
const w = new WorleyNoise("cracks");
|
||||
for (let y = 0; y < 256; y += 1) {
|
||||
for (let x = 0; x < 256; x += 1) {
|
||||
const edge = w.noise2(x * 0.05, y * 0.05, "F2-F1");
|
||||
// Paint edges where two cells meet equidistantly.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class WorleyNoise(seed: string | number = 0)` — one seeded field
|
||||
- `noise2(x, y, mode?): number` — `mode` defaults to `"F1"`
|
||||
- `noise3(x, y, z, mode?): number`
|
||||
- `worley2(seed, x, y, mode?): number`
|
||||
- `worley3(seed, x, y, z, mode?): number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Feature points are _not_ cached between evaluations; we recreate the per-cell `Sfc32Rng` each time. This keeps the class stateless and thread-safe but makes repeated evaluation at nearby points slightly more expensive. For heavy workloads the simplest optimisation is to cache `featurePoints2/3` results by cell coordinate — feel free to subclass and override those protected methods.
|
||||
45
packages/foundation/random/noise/src/worley/worley.test.ts
Normal file
45
packages/foundation/random/noise/src/worley/worley.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WorleyNoise, worley2, worley3 } from "./worley.js";
|
||||
|
||||
describe("WorleyNoise", () => {
|
||||
it("is deterministic", { timeout: 5000 }, () => {
|
||||
const a = new WorleyNoise(1);
|
||||
const b = new WorleyNoise(1);
|
||||
expect(a.noise2(1.3, 2.5)).toBe(b.noise2(1.3, 2.5));
|
||||
expect(a.noise3(1.3, 2.5, 3.1)).toBe(b.noise3(1.3, 2.5, 3.1));
|
||||
});
|
||||
|
||||
it("F1 output in [0, 1]", { timeout: 5000 }, () => {
|
||||
const w = new WorleyNoise(2);
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const v = w.noise2(i * 0.3, i * 0.7);
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("supports F2 and F2-F1 modes", { timeout: 5000 }, () => {
|
||||
const w = new WorleyNoise(3);
|
||||
const f1 = w.noise2(1.3, 2.5, "F1");
|
||||
const f2 = w.noise2(1.3, 2.5, "F2");
|
||||
const diff = w.noise2(1.3, 2.5, "F2-F1");
|
||||
expect(f2).toBeGreaterThanOrEqual(f1);
|
||||
expect(Math.abs(diff - (f2 - f1))).toBeLessThan(1e-9);
|
||||
});
|
||||
|
||||
it("3D variant works + supports modes", { timeout: 5000 }, () => {
|
||||
const w = new WorleyNoise(4);
|
||||
const f1 = w.noise3(1, 2, 3, "F1");
|
||||
const f2 = w.noise3(1, 2, 3, "F2");
|
||||
const diff = w.noise3(1, 2, 3, "F2-F1");
|
||||
expect(f1).toBeGreaterThanOrEqual(0);
|
||||
expect(f2).toBeGreaterThanOrEqual(f1);
|
||||
expect(diff).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("functional wrappers match", { timeout: 5000 }, () => {
|
||||
const w = new WorleyNoise("z");
|
||||
expect(worley2("z", 0.4, 0.6)).toBe(w.noise2(0.4, 0.6));
|
||||
expect(worley3("z", 0.4, 0.6, 0.8)).toBe(w.noise3(0.4, 0.6, 0.8));
|
||||
});
|
||||
});
|
||||
120
packages/foundation/random/noise/src/worley/worley.ts
Normal file
120
packages/foundation/random/noise/src/worley/worley.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Sfc32Rng } from "@suaveplan/pseudo-random";
|
||||
|
||||
export type WorleyMode = "F1" | "F2" | "F2-F1";
|
||||
|
||||
/**
|
||||
* Worley (cellular) noise. Output in `[0, 1]` for F1/F2; F2-F1 can exceed 1
|
||||
* when the two nearest feature points straddle more than a unit cell.
|
||||
*/
|
||||
export class WorleyNoise {
|
||||
protected readonly seed: string | number;
|
||||
|
||||
public constructor(seed: string | number = 0) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
protected featurePoints2(cx: number, cy: number): Array<[number, number]> {
|
||||
const rng = new Sfc32Rng(`${this.seed}:${cx}:${cy}`);
|
||||
const n = 1 + (rng.nextUint32() % 3); // 1..3 feature points per cell
|
||||
const out: Array<[number, number]> = [];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
out.push([cx + rng.nextFloat(), cy + rng.nextFloat()]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
protected featurePoints3(
|
||||
cx: number,
|
||||
cy: number,
|
||||
cz: number
|
||||
): Array<[number, number, number]> {
|
||||
const rng = new Sfc32Rng(`${this.seed}:${cx}:${cy}:${cz}`);
|
||||
const n = 1 + (rng.nextUint32() % 3);
|
||||
const out: Array<[number, number, number]> = [];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
out.push([
|
||||
cx + rng.nextFloat(),
|
||||
cy + rng.nextFloat(),
|
||||
cz + rng.nextFloat(),
|
||||
]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public noise2(x: number, y: number, mode: WorleyMode = "F1"): number {
|
||||
const cx = Math.floor(x);
|
||||
const cy = Math.floor(y);
|
||||
let f1 = Number.POSITIVE_INFINITY;
|
||||
let f2 = Number.POSITIVE_INFINITY;
|
||||
for (let dx = -1; dx <= 1; dx += 1) {
|
||||
for (let dy = -1; dy <= 1; dy += 1) {
|
||||
const pts = this.featurePoints2(cx + dx, cy + dy);
|
||||
for (const [px, py] of pts) {
|
||||
const d = Math.hypot(x - px, y - py);
|
||||
if (d < f1) {
|
||||
f2 = f1;
|
||||
f1 = d;
|
||||
} else if (d < f2) {
|
||||
f2 = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mode === "F1") return Math.min(1, f1);
|
||||
if (mode === "F2") return Math.min(1, f2);
|
||||
return Math.max(0, f2 - f1);
|
||||
}
|
||||
|
||||
public noise3(
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
mode: WorleyMode = "F1"
|
||||
): number {
|
||||
const cx = Math.floor(x);
|
||||
const cy = Math.floor(y);
|
||||
const cz = Math.floor(z);
|
||||
let f1 = Number.POSITIVE_INFINITY;
|
||||
let f2 = Number.POSITIVE_INFINITY;
|
||||
for (let dx = -1; dx <= 1; dx += 1) {
|
||||
for (let dy = -1; dy <= 1; dy += 1) {
|
||||
for (let dz = -1; dz <= 1; dz += 1) {
|
||||
const pts = this.featurePoints3(cx + dx, cy + dy, cz + dz);
|
||||
for (const [px, py, pz] of pts) {
|
||||
const d = Math.sqrt(
|
||||
(x - px) ** 2 + (y - py) ** 2 + (z - pz) ** 2
|
||||
);
|
||||
if (d < f1) {
|
||||
f2 = f1;
|
||||
f1 = d;
|
||||
} else if (d < f2) {
|
||||
f2 = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mode === "F1") return Math.min(1, f1);
|
||||
if (mode === "F2") return Math.min(1, f2);
|
||||
return Math.max(0, f2 - f1);
|
||||
}
|
||||
}
|
||||
|
||||
export function worley2(
|
||||
seed: string | number,
|
||||
x: number,
|
||||
y: number,
|
||||
mode: WorleyMode = "F1"
|
||||
): number {
|
||||
return new WorleyNoise(seed).noise2(x, y, mode);
|
||||
}
|
||||
|
||||
export function worley3(
|
||||
seed: string | number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
mode: WorleyMode = "F1"
|
||||
): number {
|
||||
return new WorleyNoise(seed).noise3(x, y, z, mode);
|
||||
}
|
||||
20
packages/foundation/random/noise/tests/tree-shake.test.ts
Normal file
20
packages/foundation/random/noise/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports has at least 6 entries", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
12
packages/foundation/random/noise/tsconfig.lib.json
Normal file
12
packages/foundation/random/noise/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/foundation/random/noise/vite.config.ts
Normal file
37
packages/foundation/random/noise/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/noise",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/pseudo-random",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
30
packages/foundation/random/noise/vitest.config.ts
Normal file
30
packages/foundation/random/noise/vitest.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 95,
|
||||
branches: 90,
|
||||
functions: 95,
|
||||
lines: 95,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
75
packages/foundation/random/pseudo-random/README.md
Normal file
75
packages/foundation/random/pseudo-random/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# @suaveplan/pseudo-random - Deterministic seeded PRNG implementations (Mulberry32, SFC32, Xoshiro256++, CounterRng) with offset/fork/checkpoint/restore semantics
|
||||
|
||||
## Summary
|
||||
|
||||
Deterministic, seeded pseudo-random number generators (PRNGs) conforming to the `Rng` interface from `@suaveplan/types`. Ships four algorithms — Mulberry32, SFC32, Xoshiro256++, and CounterRng — with a unified `SeededRng` surface that includes `nextUint32`, `nextFloat`, `skip`, `at`, `fork`, `checkpoint`, and `restore`. Use this package when you need reproducible randomness (procedural generation, game simulations, property tests) or seek O(1) access to arbitrary positions in the sequence. For cryptographically secure randomness use `@suaveplan/random` (CSPRNG-backed) or `@suaveplan/crypto` (raw bytes). Utility functions `shuffle`, `sample`, `choice`, and `weightedChoice` accept any `Rng` and work with both seeded and crypto-backed sources.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [counter-rng](./src/algorithms/counter-rng.md) — `CounterRng` is a hash-based PRNG: its output at logical position `i` is `splitmix64(seed XOR i)`. Because the output d…
|
||||
- [mulberry32](./src/algorithms/mulberry32.md) — `Mulberry32Rng` implements the Mulberry32 PRNG — a compact 32-bit linear-congruential-plus-mix generator with a period…
|
||||
- [sfc32](./src/algorithms/sfc32.md) — `Sfc32Rng` is a 128-bit SFC32 (Small Fast Counting) generator by Chris Doty-Humphrey. It carries four uint32 state word…
|
||||
- [xoshiro256](./src/algorithms/xoshiro256.md) — `Xoshiro256PlusPlusRng` implements Blackman and Vigna's Xoshiro256++ PRNG — 256 bits of state, period 2^256 − 1, with a…
|
||||
- [errors](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [pseudo-random-error](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [seed](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [normalize](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [sample](./src/utils/sample.md) — The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` fo…
|
||||
- [shuffle](./src/utils/shuffle.md) — The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-suppli…
|
||||
<!-- end:auto-docs --> - [pseudo-random-error](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [seed](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalize](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sample](./src/utils/sample.md) — The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` fo…
|
||||
- [shuffle](./src/utils/shuffle.md) — The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-suppli…
|
||||
- [pseudo-random-error](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [seed](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalize](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sample](./src/utils/sample.md) — The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` fo…
|
||||
- [shuffle](./src/utils/shuffle.md) — The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-suppli…
|
||||
- [pseudo-random-error](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [seed](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalize](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sample](./src/utils/sample.md) — The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` fo…
|
||||
- [shuffle](./src/utils/shuffle.md) — The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-suppli…
|
||||
- [pseudo-random-error](./src/errors/pseudo-random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [seed](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_ - [normalize](./src/seed/normalize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sample](./src/utils/sample.md) — The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` fo…
|
||||
- [shuffle](./src/utils/shuffle.md) — The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-suppli…
|
||||
|
||||
## Packages
|
||||
|
||||
Four algorithms with identical `SeededRng` surface:
|
||||
|
||||
| Algorithm | State | Period | Best for |
|
||||
| ------------------------- | ------ | ------------- | ------------------------------ |
|
||||
| `Mulberry32Rng` | 32 bit | ~2^32 | Tiny, per-UI seeding |
|
||||
| `Sfc32Rng` | 128bit | ≥2^32 | High-quality default |
|
||||
| `Xoshiro256PlusPlusRng` | 256bit | 2^256 − 1 | Simulations + parallel streams |
|
||||
| `CounterRng` | 64 bit | — | O(1) seek, procedural tiles |
|
||||
|
||||
Each implements `nextUint32`, `nextFloat`, `skip`, `at`, `fork`, `checkpoint`, `restore`, plus `Xoshiro256PlusPlusRng.jump()` for jumping by 2^128 steps.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @suaveplan/pseudo-random
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { Sfc32Rng, shuffle } from "@suaveplan/pseudo-random";
|
||||
|
||||
const rng = new Sfc32Rng("game-42");
|
||||
const deck = shuffle(["A", "K", "Q", "J"], rng);
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
- `@suaveplan/pseudo-random/algorithms` — the four generators
|
||||
- `@suaveplan/pseudo-random/seed` — `normalizeSeed`, `splitmix32/64`, `expandSeed`
|
||||
- `@suaveplan/pseudo-random/utils` — `shuffle`, `sample`, `choice`, `weightedChoice`
|
||||
- `@suaveplan/pseudo-random/errors` — `PseudoRandomError`, `PSEUDO_RANDOM_CODES`
|
||||
- `@suaveplan/pseudo-random/schemas` — Zod schemas for serialised state
|
||||
|
||||
## Module docs
|
||||
|
||||
- [algorithms/mulberry32](./src/algorithms/mulberry32.md)
|
||||
- [algorithms/sfc32](./src/algorithms/sfc32.md)
|
||||
- [algorithms/xoshiro256](./src/algorithms/xoshiro256.md)
|
||||
- [algorithms/counter-rng](./src/algorithms/counter-rng.md)
|
||||
- [seed/normalize](./src/seed/normalize.md)
|
||||
- [utils/shuffle](./src/utils/shuffle.md)
|
||||
- [utils/sample](./src/utils/sample.md)
|
||||
- [errors](./src/errors/pseudo-random-error.md)
|
||||
- [types](./src/types/seeded-rng.types.md)
|
||||
92
packages/foundation/random/pseudo-random/package.json
Normal file
92
packages/foundation/random/pseudo-random/package.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"name": "@suaveplan/pseudo-random",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Deterministic seeded PRNG implementations (Mulberry32, SFC32, Xoshiro256++, CounterRng) with offset/fork/checkpoint/restore semantics",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./algorithms": {
|
||||
"import": {
|
||||
"types": "./dist/algorithms/index.d.ts",
|
||||
"default": "./dist/algorithms/index.js"
|
||||
}
|
||||
},
|
||||
"./seed": {
|
||||
"import": {
|
||||
"types": "./dist/seed/normalize.d.ts",
|
||||
"default": "./dist/seed/normalize.js"
|
||||
}
|
||||
},
|
||||
"./utils": {
|
||||
"import": {
|
||||
"types": "./dist/utils/index.d.ts",
|
||||
"default": "./dist/utils/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/pseudo-random-error.d.ts",
|
||||
"default": "./dist/errors/pseudo-random-error.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/index.d.ts",
|
||||
"default": "./dist/schemas/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# algorithms/counter-rng
|
||||
|
||||
## Purpose
|
||||
|
||||
`CounterRng` is a hash-based PRNG: its output at logical position `i` is `splitmix64(seed XOR i)`. Because the output depends only on position, every seek is constant time — `at(1_000_000)` does not draw a million values, it just advances the counter. This makes `CounterRng` uniquely suited to massively parallel simulations, tile-based procedural generation, and any scenario where you need to sample an arbitrary index of the stream without replaying it.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) `at(offset)` and `skip(n)` — no per-step iteration cost
|
||||
- 64-bit seed state stored as two uint32 halves for JSON safety
|
||||
- Full `SeededRng` contract
|
||||
- Output quality on par with splitmix64 (passes BigCrush on the upper 32 bits)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CounterRng } from "@suaveplan/pseudo-random";
|
||||
|
||||
const rng = new CounterRng("world");
|
||||
const v = rng.nextUint32();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CounterRng } from "@suaveplan/pseudo-random";
|
||||
|
||||
// Sample tile (1234, 5678) without materialising everything in between.
|
||||
const rng = new CounterRng("map");
|
||||
const tile = rng.at(1234 * 10000 + 5678);
|
||||
const decoration = tile.nextUint32();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new CounterRng(seed)` — accepts the standard `string | number` seed.
|
||||
- `nextUint32()` / `nextFloat()` — one counter step, output is `splitmix64(seed XOR pos).out >> 32`.
|
||||
- `skip(n)` / `at(offset)` — both constant-time.
|
||||
- `fork(id)` — derives a new counter keyed on `(seed, position, id)`.
|
||||
- `checkpoint()` / `restore(cp)` — state is `[seedLo, seedHi]` plus counter.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Counter generators expose their index directly, so the usual "position = draws so far" semantics hold regardless of whether you reach position 1000 by calling `nextUint32` 1000 times, calling `skip(1000)`, or jumping via `at(1000)`. The `streamAt` helper is deterministic and stateless, which is what makes the O(1) seek possible.
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
|
||||
import { CounterRng } from "./counter-rng.js";
|
||||
|
||||
describe("CounterRng", () => {
|
||||
it("is deterministic", { timeout: 5000 }, () => {
|
||||
const a = new CounterRng("abc");
|
||||
const b = new CounterRng("abc");
|
||||
for (let i = 0; i < 32; i += 1) {
|
||||
expect(a.nextUint32()).toBe(b.nextUint32());
|
||||
}
|
||||
});
|
||||
|
||||
it("at(offset) is O(1) and matches skip(offset)", { timeout: 5000 }, () => {
|
||||
const a = new CounterRng(42);
|
||||
const b = a.at(1000);
|
||||
const a2 = new CounterRng(42);
|
||||
a2.skip(1000);
|
||||
expect(b.nextUint32()).toBe(a2.nextUint32());
|
||||
});
|
||||
|
||||
it("skip advances position without drawing", { timeout: 5000 }, () => {
|
||||
const r = new CounterRng(7);
|
||||
r.skip(1_000_000);
|
||||
expect(r.position).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("checkpoint/restore round-trip", { timeout: 5000 }, () => {
|
||||
const r = new CounterRng("q");
|
||||
r.skip(50);
|
||||
const cp = r.checkpoint();
|
||||
const exp = [r.nextUint32(), r.nextUint32()];
|
||||
r.restore(cp);
|
||||
expect([r.nextUint32(), r.nextUint32()]).toEqual(exp);
|
||||
});
|
||||
|
||||
it("uint32 range and float range", { timeout: 5000 }, () => {
|
||||
const r = new CounterRng(3);
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const v = r.nextUint32();
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(2 ** 32);
|
||||
}
|
||||
const r2 = new CounterRng(4);
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const f = r2.nextFloat();
|
||||
expect(f).toBeGreaterThanOrEqual(0);
|
||||
expect(f).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("fork is independent", { timeout: 5000 }, () => {
|
||||
const r = new CounterRng(5);
|
||||
const c = r.fork("x");
|
||||
expect(r.nextUint32()).not.toBe(c.nextUint32());
|
||||
});
|
||||
|
||||
it("restore validates algorithm + state", { timeout: 5000 }, () => {
|
||||
const r = new CounterRng(6);
|
||||
expect(() =>
|
||||
r.restore({ algorithm: "sfc32", state: [0, 0], counter: 0 })
|
||||
).toThrow(PseudoRandomError);
|
||||
expect(() =>
|
||||
r.restore({ algorithm: "counter", state: [0], counter: 0 })
|
||||
).toThrow(PseudoRandomError);
|
||||
expect(() =>
|
||||
r.restore({
|
||||
algorithm: "counter",
|
||||
state: [Number.NaN, 0],
|
||||
counter: 0,
|
||||
})
|
||||
).toThrow(PseudoRandomError);
|
||||
});
|
||||
|
||||
it("rejects bad skip/at", { timeout: 5000 }, () => {
|
||||
const r = new CounterRng(1);
|
||||
expect(() => r.skip(-1)).toThrow(PseudoRandomError);
|
||||
expect(() => r.at(-1)).toThrow(PseudoRandomError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
PSEUDO_RANDOM_CODES,
|
||||
PseudoRandomError,
|
||||
} from "../errors/pseudo-random-error.js";
|
||||
import { normalizeSeed, splitmix64 } from "../seed/normalize.js";
|
||||
import type { RngCheckpoint, SeededRng } from "../types/seeded-rng.types.js";
|
||||
|
||||
const MASK64 = 0xffffffffffffffffn;
|
||||
|
||||
/**
|
||||
* CounterRng — a hash-based PRNG whose output at position `i` is
|
||||
* `splitmix64(seed XOR i)`. Every seek is O(1): `at(offset)` simply
|
||||
* moves the counter without iterating.
|
||||
*/
|
||||
export class CounterRng implements SeededRng {
|
||||
public readonly algorithm = "counter" as const;
|
||||
protected seed: bigint;
|
||||
protected pos = 0;
|
||||
|
||||
public constructor(seed: string | number) {
|
||||
this.seed = BigInt(normalizeSeed(seed) >>> 0);
|
||||
}
|
||||
|
||||
public get position(): number {
|
||||
return this.pos;
|
||||
}
|
||||
|
||||
protected streamAt(i: number): number {
|
||||
const state = (this.seed ^ BigInt(i)) & MASK64;
|
||||
const step = splitmix64(state);
|
||||
return Number((step.out >> 32n) & 0xffffffffn) >>> 0;
|
||||
}
|
||||
|
||||
public nextUint32(): number {
|
||||
const v = this.streamAt(this.pos);
|
||||
this.pos += 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
public nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
public skip(n: number): void {
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.INVALID"],
|
||||
"pseudo_random.skip.invalid",
|
||||
{ context: { n } }
|
||||
);
|
||||
}
|
||||
this.pos += n;
|
||||
}
|
||||
|
||||
public at(offset: number): SeededRng {
|
||||
if (!Number.isInteger(offset) || offset < 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.INVALID"],
|
||||
"pseudo_random.at.invalid",
|
||||
{ context: { offset } }
|
||||
);
|
||||
}
|
||||
const clone = new CounterRng(0);
|
||||
clone.seed = this.seed;
|
||||
clone.pos = this.pos + offset;
|
||||
return clone;
|
||||
}
|
||||
|
||||
public fork(id: string | number): SeededRng {
|
||||
return new CounterRng(`${this.seed}:${this.pos}:${String(id)}`);
|
||||
}
|
||||
|
||||
public checkpoint(): RngCheckpoint {
|
||||
const lo = Number(this.seed & 0xffffffffn) >>> 0;
|
||||
const hi = Number((this.seed >> 32n) & 0xffffffffn) >>> 0;
|
||||
return {
|
||||
algorithm: this.algorithm,
|
||||
state: [lo, hi],
|
||||
counter: this.pos,
|
||||
};
|
||||
}
|
||||
|
||||
public restore(cp: RngCheckpoint): void {
|
||||
if (cp.algorithm !== this.algorithm) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.ALGORITHM_MISMATCH"],
|
||||
"pseudo_random.state.algorithm_mismatch",
|
||||
{ context: { expected: this.algorithm, got: cp.algorithm } }
|
||||
);
|
||||
}
|
||||
if (cp.state.length !== 2) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.INVALID"],
|
||||
"pseudo_random.state.invalid"
|
||||
);
|
||||
}
|
||||
const lo = cp.state[0];
|
||||
const hi = cp.state[1];
|
||||
if (
|
||||
typeof lo !== "number" ||
|
||||
typeof hi !== "number" ||
|
||||
!Number.isFinite(lo) ||
|
||||
!Number.isFinite(hi)
|
||||
) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.INVALID"],
|
||||
"pseudo_random.state.invalid"
|
||||
);
|
||||
}
|
||||
this.seed = ((BigInt(hi >>> 0) << 32n) | BigInt(lo >>> 0)) & MASK64;
|
||||
this.pos = cp.counter >>> 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { CounterRng } from "./counter-rng.js";
|
||||
export { Mulberry32Rng } from "./mulberry32.js";
|
||||
export { Sfc32Rng } from "./sfc32.js";
|
||||
export { Xoshiro256PlusPlusRng } from "./xoshiro256.js";
|
||||
@@ -0,0 +1,51 @@
|
||||
# algorithms/mulberry32
|
||||
|
||||
## Purpose
|
||||
|
||||
`Mulberry32Rng` implements the Mulberry32 PRNG — a compact 32-bit linear-congruential-plus-mix generator with a period of roughly 2^32. It is the smallest `SeededRng` in the package (one 32-bit word of state) and is ideal for UI seedings, per-card shuffles, procedural decorations and any workload where replayability matters but cryptographic strength does not.
|
||||
|
||||
The implementation is byte-identical to the inline generator shipped inside `@suaveplan/testing`'s `seededRng`, so code migrated between the two produces the same draws.
|
||||
|
||||
## Features
|
||||
|
||||
- Single uint32 of state; ~1-cycle nextUint32 cost
|
||||
- Implements the full `SeededRng` contract (`skip`, `at`, `fork`, `checkpoint`, `restore`)
|
||||
- Algorithm-tagged checkpoint prevents cross-algorithm restore bugs
|
||||
- Typed errors on malformed state or invalid arguments
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
|
||||
|
||||
const rng = new Mulberry32Rng("level-1");
|
||||
console.log(rng.nextFloat());
|
||||
console.log(rng.nextUint32());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
|
||||
|
||||
const parent = new Mulberry32Rng("game-42");
|
||||
const encounterRng = parent.fork("encounter");
|
||||
const lootRng = parent.fork("loot");
|
||||
|
||||
const save = parent.checkpoint();
|
||||
// ... simulate many turns ...
|
||||
parent.restore(save); // back to the turn-0 state
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new Mulberry32Rng(seed: string | number)` — normalises the seed through `normalizeSeed`.
|
||||
- `nextUint32()` / `nextFloat()` — uniform draws.
|
||||
- `skip(n)` — advance the stream by `n` uint32 draws; `n` must be a non-negative integer.
|
||||
- `at(offset)` — clone the generator and skip `offset` draws on the clone. Does not mutate `this`.
|
||||
- `fork(id)` — derive a child generator keyed on `(state, position, id)`.
|
||||
- `checkpoint()` / `restore(cp)` — snapshot and reinstate state; restore validates the `algorithm` tag.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The generator advances by `state = (state + 0x6d2b79f5) | 0`, then runs two `Math.imul` mixing rounds before returning the high-entropy output. `at(offset)` is O(offset) because Mulberry32 has no closed-form jump; if you need O(1) seek use `CounterRng` instead. Fork keys include the parent's current position so repeated forks at different moments yield independent streams.
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
|
||||
import { Mulberry32Rng } from "./mulberry32.js";
|
||||
|
||||
describe("Mulberry32Rng", () => {
|
||||
it("is deterministic for equal seeds", { timeout: 5000 }, () => {
|
||||
const a = new Mulberry32Rng(1);
|
||||
const b = new Mulberry32Rng(1);
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
expect(a.nextUint32()).toBe(b.nextUint32());
|
||||
}
|
||||
});
|
||||
|
||||
it("produces uint32 in range", { timeout: 5000 }, () => {
|
||||
const r = new Mulberry32Rng("x");
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const v = r.nextUint32();
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(2 ** 32);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces floats in [0,1)", { timeout: 5000 }, () => {
|
||||
const r = new Mulberry32Rng(2);
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const f = r.nextFloat();
|
||||
expect(f).toBeGreaterThanOrEqual(0);
|
||||
expect(f).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("tracks position", { timeout: 5000 }, () => {
|
||||
const r = new Mulberry32Rng(3);
|
||||
expect(r.position).toBe(0);
|
||||
r.nextUint32();
|
||||
r.nextUint32();
|
||||
expect(r.position).toBe(2);
|
||||
});
|
||||
|
||||
it("skip advances position", { timeout: 5000 }, () => {
|
||||
const a = new Mulberry32Rng(4);
|
||||
const b = new Mulberry32Rng(4);
|
||||
a.skip(10);
|
||||
for (let i = 0; i < 10; i += 1) b.nextUint32();
|
||||
expect(a.nextUint32()).toBe(b.nextUint32());
|
||||
});
|
||||
|
||||
it("at returns new generator without mutating self", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new Mulberry32Rng(5);
|
||||
const b = a.at(5);
|
||||
expect(a.position).toBe(0);
|
||||
expect(b.position).toBe(5);
|
||||
});
|
||||
|
||||
it("fork produces independent child", { timeout: 5000 }, () => {
|
||||
const a = new Mulberry32Rng(6);
|
||||
const child = a.fork("x");
|
||||
// Child draws shouldn't equal parent's draws
|
||||
expect(child.nextUint32()).not.toBe(a.nextUint32());
|
||||
});
|
||||
|
||||
it("checkpoint/restore round-trip", { timeout: 5000 }, () => {
|
||||
const r = new Mulberry32Rng(7);
|
||||
r.skip(13);
|
||||
const cp = r.checkpoint();
|
||||
const expected = [r.nextUint32(), r.nextUint32(), r.nextUint32()];
|
||||
r.restore(cp);
|
||||
expect([r.nextUint32(), r.nextUint32(), r.nextUint32()]).toEqual(
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
it("restore rejects foreign algorithm tag", { timeout: 5000 }, () => {
|
||||
const r = new Mulberry32Rng(8);
|
||||
expect(() =>
|
||||
r.restore({ algorithm: "sfc32", state: [0, 0, 0, 0], counter: 0 })
|
||||
).toThrow(PseudoRandomError);
|
||||
});
|
||||
|
||||
it("restore rejects malformed state", { timeout: 5000 }, () => {
|
||||
const r = new Mulberry32Rng(9);
|
||||
expect(() =>
|
||||
r.restore({ algorithm: "mulberry32", state: [], counter: 0 })
|
||||
).toThrow(PseudoRandomError);
|
||||
expect(() =>
|
||||
r.restore({
|
||||
algorithm: "mulberry32",
|
||||
state: [Number.NaN],
|
||||
counter: 0,
|
||||
})
|
||||
).toThrow(PseudoRandomError);
|
||||
});
|
||||
|
||||
it("rejects negative skip/at", { timeout: 5000 }, () => {
|
||||
const r = new Mulberry32Rng(10);
|
||||
expect(() => r.skip(-1)).toThrow(PseudoRandomError);
|
||||
expect(() => r.at(-1)).toThrow(PseudoRandomError);
|
||||
expect(() => r.skip(1.5)).toThrow(PseudoRandomError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
PSEUDO_RANDOM_CODES,
|
||||
PseudoRandomError,
|
||||
} from "../errors/pseudo-random-error.js";
|
||||
import { normalizeSeed } from "../seed/normalize.js";
|
||||
import type { RngCheckpoint, SeededRng } from "../types/seeded-rng.types.js";
|
||||
|
||||
/**
|
||||
* Mulberry32 — a single-state 32-bit PRNG with a ~4 billion-cycle period.
|
||||
* Tiny, fast, passes most statistical tests for non-cryptographic use.
|
||||
*/
|
||||
export class Mulberry32Rng implements SeededRng {
|
||||
public readonly algorithm = "mulberry32" as const;
|
||||
protected state: number;
|
||||
protected pos = 0;
|
||||
|
||||
public constructor(seed: string | number) {
|
||||
this.state = normalizeSeed(seed);
|
||||
}
|
||||
|
||||
public get position(): number {
|
||||
return this.pos;
|
||||
}
|
||||
|
||||
public nextUint32(): number {
|
||||
this.pos += 1;
|
||||
this.state = (this.state + 0x6d2b79f5) | 0;
|
||||
let t = this.state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
}
|
||||
|
||||
public nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
public skip(n: number): void {
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.INVALID"],
|
||||
"pseudo_random.skip.invalid",
|
||||
{ context: { n } }
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < n; i += 1) this.nextUint32();
|
||||
}
|
||||
|
||||
public at(offset: number): SeededRng {
|
||||
if (!Number.isInteger(offset) || offset < 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.INVALID"],
|
||||
"pseudo_random.at.invalid",
|
||||
{ context: { offset } }
|
||||
);
|
||||
}
|
||||
const clone = new Mulberry32Rng(0);
|
||||
clone.state = this.state;
|
||||
clone.pos = this.pos;
|
||||
clone.skip(offset);
|
||||
return clone;
|
||||
}
|
||||
|
||||
public fork(id: string | number): SeededRng {
|
||||
const key = `${this.state}:${this.pos}:${String(id)}`;
|
||||
return new Mulberry32Rng(key);
|
||||
}
|
||||
|
||||
public checkpoint(): RngCheckpoint {
|
||||
return {
|
||||
algorithm: this.algorithm,
|
||||
state: [this.state >>> 0],
|
||||
counter: this.pos,
|
||||
};
|
||||
}
|
||||
|
||||
public restore(cp: RngCheckpoint): void {
|
||||
if (cp.algorithm !== this.algorithm) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.ALGORITHM_MISMATCH"],
|
||||
"pseudo_random.state.algorithm_mismatch",
|
||||
{ context: { expected: this.algorithm, got: cp.algorithm } }
|
||||
);
|
||||
}
|
||||
if (cp.state.length !== 1) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.INVALID"],
|
||||
"pseudo_random.state.invalid"
|
||||
);
|
||||
}
|
||||
const s0 = cp.state[0];
|
||||
if (typeof s0 !== "number" || !Number.isFinite(s0)) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.INVALID"],
|
||||
"pseudo_random.state.invalid"
|
||||
);
|
||||
}
|
||||
this.state = s0 >>> 0;
|
||||
this.pos = cp.counter >>> 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# algorithms/sfc32
|
||||
|
||||
## Purpose
|
||||
|
||||
`Sfc32Rng` is a 128-bit SFC32 (Small Fast Counting) generator by Chris Doty-Humphrey. It carries four uint32 state words, runs 12 warmup rounds after seeding, and passes all PractRand tests up to 32TB of output. It is the recommended default `SeededRng` when you want a high-quality stream and don't need an algebraic jump.
|
||||
|
||||
## Features
|
||||
|
||||
- 128-bit state (a,b,c,d) with a counter leg guaranteeing a minimum period of 2^32
|
||||
- 12-round warmup after seed ingestion to decorrelate output from seed bits
|
||||
- Full `SeededRng` contract — `skip`, `at`, `fork`, `checkpoint`, `restore`
|
||||
- Deterministic `fork(id)` keyed on state + position + id
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Sfc32Rng } from "@suaveplan/pseudo-random";
|
||||
|
||||
const rng = new Sfc32Rng("match-7");
|
||||
const first = rng.nextFloat();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Sfc32Rng } from "@suaveplan/pseudo-random";
|
||||
|
||||
const master = new Sfc32Rng("tournament");
|
||||
const player1 = master.fork("p1");
|
||||
const player2 = master.fork("p2");
|
||||
|
||||
const snapshot = master.checkpoint();
|
||||
// ... play out a hand ...
|
||||
master.restore(snapshot);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new Sfc32Rng(seed)` — normalises and expands the seed into 4 × uint32 state words, then applies 12 warmup rounds.
|
||||
- `nextUint32()` / `nextFloat()` — one step of SFC32.
|
||||
- `skip(n)` — O(n) advance.
|
||||
- `at(offset)` — returns a new generator at `position + offset` without mutating `this`.
|
||||
- `fork(id)` — derives an independent child generator keyed on the full state + position + `id`.
|
||||
- `checkpoint()` / `restore(cp)` — snapshot/reinstate, with algorithm-tag validation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The core step is `t = a + b + d; d++; a = b ^ (b >>> 9); b = c + (c << 3); c = rotl(c, 21) + t`. All arithmetic is coerced to uint32 via `| 0` after each operation. State is expanded from the user seed using four splitmix64 taps; this breaks the correlations you would otherwise see between adjacent seeds like `1` and `2`.
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
|
||||
import { Sfc32Rng } from "./sfc32.js";
|
||||
|
||||
describe("Sfc32Rng", () => {
|
||||
it("is deterministic", { timeout: 5000 }, () => {
|
||||
const a = new Sfc32Rng("abc");
|
||||
const b = new Sfc32Rng("abc");
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
expect(a.nextUint32()).toBe(b.nextUint32());
|
||||
}
|
||||
});
|
||||
|
||||
it("produces uint32 in range", { timeout: 5000 }, () => {
|
||||
const r = new Sfc32Rng(1);
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
const v = r.nextUint32();
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(2 ** 32);
|
||||
}
|
||||
});
|
||||
|
||||
it("skip === repeated nextUint32", { timeout: 5000 }, () => {
|
||||
const a = new Sfc32Rng(2);
|
||||
const b = new Sfc32Rng(2);
|
||||
a.skip(50);
|
||||
for (let i = 0; i < 50; i += 1) b.nextUint32();
|
||||
expect(a.nextUint32()).toBe(b.nextUint32());
|
||||
});
|
||||
|
||||
it("at doesn't mutate self", { timeout: 5000 }, () => {
|
||||
const a = new Sfc32Rng(3);
|
||||
const ahead = a.at(10);
|
||||
expect(a.position).toBe(0);
|
||||
expect(ahead.position).toBe(10);
|
||||
});
|
||||
|
||||
it("checkpoint/restore round-trip", { timeout: 5000 }, () => {
|
||||
const r = new Sfc32Rng(4);
|
||||
r.skip(7);
|
||||
const cp = r.checkpoint();
|
||||
const exp = [r.nextUint32(), r.nextUint32()];
|
||||
r.restore(cp);
|
||||
expect([r.nextUint32(), r.nextUint32()]).toEqual(exp);
|
||||
});
|
||||
|
||||
it("restore rejects foreign algorithm", { timeout: 5000 }, () => {
|
||||
const r = new Sfc32Rng(5);
|
||||
expect(() =>
|
||||
r.restore({ algorithm: "mulberry32", state: [0], counter: 0 })
|
||||
).toThrow(PseudoRandomError);
|
||||
});
|
||||
|
||||
it("restore rejects bad state length or non-finite words", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new Sfc32Rng(6);
|
||||
expect(() =>
|
||||
r.restore({ algorithm: "sfc32", state: [1, 2], counter: 0 })
|
||||
).toThrow(PseudoRandomError);
|
||||
expect(() =>
|
||||
r.restore({
|
||||
algorithm: "sfc32",
|
||||
state: [1, 2, 3, Number.NaN],
|
||||
counter: 0,
|
||||
})
|
||||
).toThrow(PseudoRandomError);
|
||||
});
|
||||
|
||||
it("rejects invalid skip/at", { timeout: 5000 }, () => {
|
||||
const r = new Sfc32Rng(7);
|
||||
expect(() => r.skip(-1)).toThrow(PseudoRandomError);
|
||||
expect(() => r.at(-1)).toThrow(PseudoRandomError);
|
||||
});
|
||||
|
||||
it("fork yields distinct stream", { timeout: 5000 }, () => {
|
||||
const parent = new Sfc32Rng(8);
|
||||
const child = parent.fork("x");
|
||||
const a = [parent.nextUint32(), parent.nextUint32()];
|
||||
const b = [child.nextUint32(), child.nextUint32()];
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
it("nextFloat is in [0,1)", { timeout: 5000 }, () => {
|
||||
const r = new Sfc32Rng(9);
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const f = r.nextFloat();
|
||||
expect(f).toBeGreaterThanOrEqual(0);
|
||||
expect(f).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
130
packages/foundation/random/pseudo-random/src/algorithms/sfc32.ts
Normal file
130
packages/foundation/random/pseudo-random/src/algorithms/sfc32.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
PSEUDO_RANDOM_CODES,
|
||||
PseudoRandomError,
|
||||
} from "../errors/pseudo-random-error.js";
|
||||
import { expandSeed, normalizeSeed } from "../seed/normalize.js";
|
||||
import type { RngCheckpoint, SeededRng } from "../types/seeded-rng.types.js";
|
||||
|
||||
/**
|
||||
* SFC32 — Chris Doty-Humphrey's Small Fast Counting 32-bit generator.
|
||||
* 128-bit state, minimum guaranteed period 2^32, expected period 2^127.
|
||||
* Passes all PractRand tests up to 32TB.
|
||||
*/
|
||||
export class Sfc32Rng implements SeededRng {
|
||||
public readonly algorithm = "sfc32" as const;
|
||||
protected a = 0;
|
||||
protected b = 0;
|
||||
protected c = 0;
|
||||
protected d = 0;
|
||||
protected pos = 0;
|
||||
|
||||
public constructor(seed: string | number) {
|
||||
const base = normalizeSeed(seed);
|
||||
const [s0, s1, s2, s3] = expandSeed(base, 4) as unknown as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
this.a = s0 >>> 0;
|
||||
this.b = s1 >>> 0;
|
||||
this.c = s2 >>> 0;
|
||||
this.d = s3 >>> 0;
|
||||
// 12 warmup rounds (standard practice)
|
||||
for (let i = 0; i < 12; i += 1) this.step();
|
||||
}
|
||||
|
||||
public get position(): number {
|
||||
return this.pos;
|
||||
}
|
||||
|
||||
protected step(): number {
|
||||
const t = ((this.a + this.b) | 0) + this.d;
|
||||
this.d = (this.d + 1) | 0;
|
||||
this.a = this.b ^ (this.b >>> 9);
|
||||
this.b = (this.c + (this.c << 3)) | 0;
|
||||
this.c = (this.c << 21) | (this.c >>> 11) | 0;
|
||||
this.c = (this.c + t) | 0;
|
||||
return t >>> 0;
|
||||
}
|
||||
|
||||
public nextUint32(): number {
|
||||
this.pos += 1;
|
||||
return this.step();
|
||||
}
|
||||
|
||||
public nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
public skip(n: number): void {
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.INVALID"],
|
||||
"pseudo_random.skip.invalid",
|
||||
{ context: { n } }
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < n; i += 1) this.nextUint32();
|
||||
}
|
||||
|
||||
public at(offset: number): SeededRng {
|
||||
if (!Number.isInteger(offset) || offset < 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.INVALID"],
|
||||
"pseudo_random.at.invalid",
|
||||
{ context: { offset } }
|
||||
);
|
||||
}
|
||||
const clone = new Sfc32Rng(0);
|
||||
clone.a = this.a;
|
||||
clone.b = this.b;
|
||||
clone.c = this.c;
|
||||
clone.d = this.d;
|
||||
clone.pos = this.pos;
|
||||
clone.skip(offset);
|
||||
return clone;
|
||||
}
|
||||
|
||||
public fork(id: string | number): SeededRng {
|
||||
const key = `${this.a}:${this.b}:${this.c}:${this.d}:${this.pos}:${String(id)}`;
|
||||
return new Sfc32Rng(key);
|
||||
}
|
||||
|
||||
public checkpoint(): RngCheckpoint {
|
||||
return {
|
||||
algorithm: this.algorithm,
|
||||
state: [this.a >>> 0, this.b >>> 0, this.c >>> 0, this.d >>> 0],
|
||||
counter: this.pos,
|
||||
};
|
||||
}
|
||||
|
||||
public restore(cp: RngCheckpoint): void {
|
||||
if (cp.algorithm !== this.algorithm) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.ALGORITHM_MISMATCH"],
|
||||
"pseudo_random.state.algorithm_mismatch",
|
||||
{ context: { expected: this.algorithm, got: cp.algorithm } }
|
||||
);
|
||||
}
|
||||
if (cp.state.length !== 4) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.INVALID"],
|
||||
"pseudo_random.state.invalid"
|
||||
);
|
||||
}
|
||||
for (const s of cp.state) {
|
||||
if (typeof s !== "number" || !Number.isFinite(s)) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.INVALID"],
|
||||
"pseudo_random.state.invalid"
|
||||
);
|
||||
}
|
||||
}
|
||||
this.a = (cp.state[0] as number) >>> 0;
|
||||
this.b = (cp.state[1] as number) >>> 0;
|
||||
this.c = (cp.state[2] as number) >>> 0;
|
||||
this.d = (cp.state[3] as number) >>> 0;
|
||||
this.pos = cp.counter >>> 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Xoshiro256PlusPlusRng } from "./xoshiro256.js";
|
||||
|
||||
class ZeroXoshiro extends Xoshiro256PlusPlusRng {
|
||||
public constructor() {
|
||||
super(1);
|
||||
this.s0 = 0n;
|
||||
this.s1 = 0n;
|
||||
this.s2 = 0n;
|
||||
this.s3 = 0n;
|
||||
}
|
||||
public callEnsureNonZero(): void {
|
||||
this.ensureNonZeroState();
|
||||
}
|
||||
public readState(): [bigint, bigint, bigint, bigint] {
|
||||
return [this.s0, this.s1, this.s2, this.s3];
|
||||
}
|
||||
}
|
||||
|
||||
describe("Xoshiro256 ensureNonZeroState guard", () => {
|
||||
it("repairs an all-zero state by setting s0=1", { timeout: 5000 }, () => {
|
||||
const rng = new ZeroXoshiro();
|
||||
rng.callEnsureNonZero();
|
||||
expect(rng.readState()).toEqual([1n, 0n, 0n, 0n]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
# algorithms/xoshiro256
|
||||
|
||||
## Purpose
|
||||
|
||||
`Xoshiro256PlusPlusRng` implements Blackman and Vigna's Xoshiro256++ PRNG — 256 bits of state, period 2^256 − 1, with an algebraic `jump()` that advances the stream by 2^128 steps in O(1) without drawing them. It is the top-of-range `SeededRng` in this package: use it when you need parallel non-overlapping streams or when simulation runs will be long enough to exhaust SFC32's 2^32 minimum period.
|
||||
|
||||
## Features
|
||||
|
||||
- 256-bit state (four uint64 words) with a non-zero-state guarantee
|
||||
- `jump()` advances 2^128 draws without intermediate state — ideal for sharding one seed across many workers
|
||||
- Checkpoint serialises state as 8 × uint32 pairs, which round-trip through JSON
|
||||
- Full `SeededRng` contract
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Xoshiro256PlusPlusRng } from "@suaveplan/pseudo-random";
|
||||
|
||||
const rng = new Xoshiro256PlusPlusRng("sim-42");
|
||||
for (let i = 0; i < 1_000_000; i += 1) rng.nextFloat();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Xoshiro256PlusPlusRng } from "@suaveplan/pseudo-random";
|
||||
|
||||
function streamsForWorkers(base: string, n: number) {
|
||||
const rngs: Xoshiro256PlusPlusRng[] = [];
|
||||
let r = new Xoshiro256PlusPlusRng(base);
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
rngs.push(r);
|
||||
const next = new Xoshiro256PlusPlusRng(base);
|
||||
for (let k = 0; k <= i; k += 1) next.jump();
|
||||
r = next;
|
||||
}
|
||||
return rngs;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new Xoshiro256PlusPlusRng(seed)` — splitmix64-expands the seed into 4 × uint64 words.
|
||||
- `nextUint32()` — returns the upper 32 bits of the next uint64 output (better statistical quality than the low bits).
|
||||
- `jump()` — apply the constant-time jump-by-2^128 polynomial.
|
||||
- `skip / at / fork / checkpoint / restore` — `SeededRng` contract as usual.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All 64-bit arithmetic is done with `bigint` masked to 64 bits. Returning the upper 32 bits of each uint64 step is intentional: Xoshiro's low bits have weaker statistical properties than the high bits. The jump polynomial constants are the canonical published values; the loop processes bit-by-bit, XOR-accumulating the current state whenever the jump polynomial bit is set and then advancing a single step.
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
|
||||
import { Xoshiro256PlusPlusRng } from "./xoshiro256.js";
|
||||
|
||||
describe("Xoshiro256PlusPlusRng", () => {
|
||||
it("is deterministic", { timeout: 5000 }, () => {
|
||||
const a = new Xoshiro256PlusPlusRng(123);
|
||||
const b = new Xoshiro256PlusPlusRng(123);
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
expect(a.nextUint32()).toBe(b.nextUint32());
|
||||
}
|
||||
});
|
||||
|
||||
it("produces uint32 in range", { timeout: 5000 }, () => {
|
||||
const r = new Xoshiro256PlusPlusRng("x");
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
const v = r.nextUint32();
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(2 ** 32);
|
||||
}
|
||||
});
|
||||
|
||||
it("nextFloat in [0,1)", { timeout: 5000 }, () => {
|
||||
const r = new Xoshiro256PlusPlusRng("y");
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const f = r.nextFloat();
|
||||
expect(f).toBeGreaterThanOrEqual(0);
|
||||
expect(f).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("jump decorrelates streams", { timeout: 5000 }, () => {
|
||||
const a = new Xoshiro256PlusPlusRng(1);
|
||||
const b = new Xoshiro256PlusPlusRng(1);
|
||||
b.jump();
|
||||
const av = [a.nextUint32(), a.nextUint32()];
|
||||
const bv = [b.nextUint32(), b.nextUint32()];
|
||||
expect(av).not.toEqual(bv);
|
||||
});
|
||||
|
||||
it("checkpoint/restore round-trip", { timeout: 5000 }, () => {
|
||||
const r = new Xoshiro256PlusPlusRng(7);
|
||||
r.skip(5);
|
||||
const cp = r.checkpoint();
|
||||
const exp = [r.nextUint32(), r.nextUint32(), r.nextUint32()];
|
||||
r.restore(cp);
|
||||
expect([r.nextUint32(), r.nextUint32(), r.nextUint32()]).toEqual(exp);
|
||||
});
|
||||
|
||||
it("at doesn't mutate self", { timeout: 5000 }, () => {
|
||||
const r = new Xoshiro256PlusPlusRng(8);
|
||||
const ahead = r.at(5);
|
||||
expect(r.position).toBe(0);
|
||||
expect(ahead.position).toBe(5);
|
||||
});
|
||||
|
||||
it("fork is independent", { timeout: 5000 }, () => {
|
||||
const r = new Xoshiro256PlusPlusRng(9);
|
||||
const c = r.fork("a");
|
||||
expect(r.nextUint32()).not.toBe(c.nextUint32());
|
||||
});
|
||||
|
||||
it("restore validates algorithm and state", { timeout: 5000 }, () => {
|
||||
const r = new Xoshiro256PlusPlusRng(10);
|
||||
expect(() =>
|
||||
r.restore({ algorithm: "sfc32", state: [0, 0, 0, 0], counter: 0 })
|
||||
).toThrow(PseudoRandomError);
|
||||
expect(() =>
|
||||
r.restore({ algorithm: "xoshiro256++", state: [], counter: 0 })
|
||||
).toThrow(PseudoRandomError);
|
||||
expect(() =>
|
||||
r.restore({
|
||||
algorithm: "xoshiro256++",
|
||||
state: [0, 0, 0, 0, 0, 0, 0, Number.NaN],
|
||||
counter: 0,
|
||||
})
|
||||
).toThrow(PseudoRandomError);
|
||||
});
|
||||
|
||||
it("rejects bad skip/at args", { timeout: 5000 }, () => {
|
||||
const r = new Xoshiro256PlusPlusRng(11);
|
||||
expect(() => r.skip(-1)).toThrow(PseudoRandomError);
|
||||
expect(() => r.at(-1)).toThrow(PseudoRandomError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
PSEUDO_RANDOM_CODES,
|
||||
PseudoRandomError,
|
||||
} from "../errors/pseudo-random-error.js";
|
||||
import { normalizeSeed, splitmix64 } from "../seed/normalize.js";
|
||||
import type { RngCheckpoint, SeededRng } from "../types/seeded-rng.types.js";
|
||||
|
||||
const MASK64 = 0xffffffffffffffffn;
|
||||
|
||||
function rotl64(x: bigint, k: bigint): bigint {
|
||||
return ((x << k) | (x >> (64n - k))) & MASK64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Xoshiro256++ — Blackman & Vigna's 256-bit generator with ++ scrambler.
|
||||
* Period 2^256 − 1; supports O(1)-ish algebraic jump by 2^128 and 2^192.
|
||||
*/
|
||||
export class Xoshiro256PlusPlusRng implements SeededRng {
|
||||
public readonly algorithm = "xoshiro256++" as const;
|
||||
protected s0: bigint;
|
||||
protected s1: bigint;
|
||||
protected s2: bigint;
|
||||
protected s3: bigint;
|
||||
protected pos = 0;
|
||||
|
||||
public constructor(seed: string | number) {
|
||||
const base = BigInt(normalizeSeed(seed) >>> 0);
|
||||
let st = base;
|
||||
const draw = (): bigint => {
|
||||
const step = splitmix64(st);
|
||||
st = step.next;
|
||||
return step.out & MASK64;
|
||||
};
|
||||
this.s0 = draw();
|
||||
this.s1 = draw();
|
||||
this.s2 = draw();
|
||||
this.s3 = draw();
|
||||
this.ensureNonZeroState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarantee the internal state is non-zero. Xoshiro256 is undefined for
|
||||
* all-zero state; splitmix64 over any supported seed never returns four
|
||||
* zero draws in a row, so in practice this is a defensive no-op — but we
|
||||
* keep it to harden against future changes to state loading (e.g.
|
||||
* `restore()`). Exposed as `protected` so tests can exercise it directly.
|
||||
*/
|
||||
protected ensureNonZeroState(): void {
|
||||
if (
|
||||
this.s0 === 0n &&
|
||||
this.s1 === 0n &&
|
||||
this.s2 === 0n &&
|
||||
this.s3 === 0n
|
||||
) {
|
||||
this.s0 = 1n;
|
||||
}
|
||||
}
|
||||
|
||||
public get position(): number {
|
||||
return this.pos;
|
||||
}
|
||||
|
||||
protected step(): bigint {
|
||||
const result =
|
||||
(rotl64((this.s0 + this.s3) & MASK64, 23n) + this.s0) & MASK64;
|
||||
const t = (this.s1 << 17n) & MASK64;
|
||||
this.s2 ^= this.s0;
|
||||
this.s3 ^= this.s1;
|
||||
this.s1 ^= this.s2;
|
||||
this.s0 ^= this.s3;
|
||||
this.s2 ^= t;
|
||||
this.s3 = rotl64(this.s3, 45n);
|
||||
this.s0 &= MASK64;
|
||||
this.s1 &= MASK64;
|
||||
this.s2 &= MASK64;
|
||||
this.s3 &= MASK64;
|
||||
return result;
|
||||
}
|
||||
|
||||
public nextUint32(): number {
|
||||
this.pos += 1;
|
||||
// Take upper 32 bits for better quality
|
||||
return Number((this.step() >> 32n) & 0xffffffffn) >>> 0;
|
||||
}
|
||||
|
||||
public nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
public skip(n: number): void {
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.INVALID"],
|
||||
"pseudo_random.skip.invalid",
|
||||
{ context: { n } }
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < n; i += 1) this.nextUint32();
|
||||
}
|
||||
|
||||
public at(offset: number): SeededRng {
|
||||
if (!Number.isInteger(offset) || offset < 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.INVALID"],
|
||||
"pseudo_random.at.invalid",
|
||||
{ context: { offset } }
|
||||
);
|
||||
}
|
||||
const clone = this.clone();
|
||||
clone.skip(offset);
|
||||
return clone;
|
||||
}
|
||||
|
||||
public fork(id: string | number): SeededRng {
|
||||
const key = `${this.s0}:${this.s1}:${this.s2}:${this.s3}:${this.pos}:${String(id)}`;
|
||||
return new Xoshiro256PlusPlusRng(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Algebraic jump equivalent to 2^128 calls to `step()`. Useful to hand
|
||||
* non-overlapping streams to parallel workers.
|
||||
*/
|
||||
public jump(): void {
|
||||
const JUMP = [
|
||||
0x180ec6d33cfd0abaen,
|
||||
0xd5a61266f0c9392cn,
|
||||
0xa9582618e03fc9aan,
|
||||
0x39abdc4529b1661cn,
|
||||
] as const;
|
||||
let ns0 = 0n;
|
||||
let ns1 = 0n;
|
||||
let ns2 = 0n;
|
||||
let ns3 = 0n;
|
||||
for (const j of JUMP) {
|
||||
for (let b = 0n; b < 64n; b += 1n) {
|
||||
if ((j & (1n << b)) !== 0n) {
|
||||
ns0 ^= this.s0;
|
||||
ns1 ^= this.s1;
|
||||
ns2 ^= this.s2;
|
||||
ns3 ^= this.s3;
|
||||
}
|
||||
this.step();
|
||||
}
|
||||
}
|
||||
this.s0 = ns0 & MASK64;
|
||||
this.s1 = ns1 & MASK64;
|
||||
this.s2 = ns2 & MASK64;
|
||||
this.s3 = ns3 & MASK64;
|
||||
}
|
||||
|
||||
public checkpoint(): RngCheckpoint {
|
||||
// split each 64-bit word into lo/hi uint32 pairs for JSON safety
|
||||
const toPair = (w: bigint): [number, number] => [
|
||||
Number(w & 0xffffffffn) >>> 0,
|
||||
Number((w >> 32n) & 0xffffffffn) >>> 0,
|
||||
];
|
||||
const [a0, a1] = toPair(this.s0);
|
||||
const [b0, b1] = toPair(this.s1);
|
||||
const [c0, c1] = toPair(this.s2);
|
||||
const [d0, d1] = toPair(this.s3);
|
||||
return {
|
||||
algorithm: this.algorithm,
|
||||
state: [a0, a1, b0, b1, c0, c1, d0, d1],
|
||||
counter: this.pos,
|
||||
};
|
||||
}
|
||||
|
||||
public restore(cp: RngCheckpoint): void {
|
||||
if (cp.algorithm !== this.algorithm) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.ALGORITHM_MISMATCH"],
|
||||
"pseudo_random.state.algorithm_mismatch",
|
||||
{ context: { expected: this.algorithm, got: cp.algorithm } }
|
||||
);
|
||||
}
|
||||
if (cp.state.length !== 8) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.INVALID"],
|
||||
"pseudo_random.state.invalid"
|
||||
);
|
||||
}
|
||||
for (const s of cp.state) {
|
||||
if (typeof s !== "number" || !Number.isFinite(s)) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.INVALID"],
|
||||
"pseudo_random.state.invalid"
|
||||
);
|
||||
}
|
||||
}
|
||||
const join = (lo: number, hi: number): bigint =>
|
||||
((BigInt(hi >>> 0) << 32n) | BigInt(lo >>> 0)) & MASK64;
|
||||
this.s0 = join(cp.state[0] as number, cp.state[1] as number);
|
||||
this.s1 = join(cp.state[2] as number, cp.state[3] as number);
|
||||
this.s2 = join(cp.state[4] as number, cp.state[5] as number);
|
||||
this.s3 = join(cp.state[6] as number, cp.state[7] as number);
|
||||
this.pos = cp.counter >>> 0;
|
||||
}
|
||||
|
||||
protected clone(): Xoshiro256PlusPlusRng {
|
||||
const c = new Xoshiro256PlusPlusRng(0);
|
||||
c.s0 = this.s0;
|
||||
c.s1 = this.s1;
|
||||
c.s2 = this.s2;
|
||||
c.s3 = this.s3;
|
||||
c.pos = this.pos;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Max uint32 value (exclusive upper bound for `nextUint32`). */
|
||||
export const UINT32_RANGE = 0x1_0000_0000;
|
||||
|
||||
/** Canonical list of seeded-RNG algorithm tags. */
|
||||
export const SEEDED_RNG_ALGORITHMS = [
|
||||
"mulberry32",
|
||||
"sfc32",
|
||||
"xoshiro256++",
|
||||
"counter",
|
||||
] as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
PSEUDO_RANDOM_CODES,
|
||||
type PseudoRandomCode,
|
||||
PseudoRandomError,
|
||||
} from "./pseudo-random-error";
|
||||
@@ -0,0 +1,64 @@
|
||||
# pseudo-random-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `pseudo-random-error` module defines the stable, code-based error surface for the `@suaveplan/pseudo-random` package. Every failure raised by this package — bad seeds, corrupt checkpoints, invalid arguments to helpers — is reported through a single `PseudoRandomError` class whose `code` is drawn from the frozen `PSEUDO_RANDOM_CODES` registry.
|
||||
|
||||
Centralising error codes gives callers a narrow contract: they can pattern-match on `PSEUDO_RANDOM_CODES["SEED.INVALID"]` instead of string-matching messages, and translators have a finite list of keys to localise.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen, namespaced error-code registry via `defineCodes`
|
||||
- `PseudoRandomError` extends `SuaveplanError` and inherits cause-chain traversal, redaction, OTel mapping and the Zod-validated wire format
|
||||
- Codes cover every validation branch in the package: seed normalisation, state restoration, helper-argument checks
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PseudoRandomError, PSEUDO_RANDOM_CODES } from "@suaveplan/pseudo-random/errors";
|
||||
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["SEED.INVALID"],
|
||||
"pseudo_random.seed.invalid",
|
||||
{ context: { value: "???" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PseudoRandomError, PSEUDO_RANDOM_CODES } from "@suaveplan/pseudo-random/errors";
|
||||
|
||||
function assertFiniteSeed(seed: number): void {
|
||||
if (!Number.isFinite(seed)) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["SEED.NOT_FINITE"],
|
||||
"pseudo_random.seed.not_finite",
|
||||
{ context: { seed } },
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PSEUDO_RANDOM_CODES` — the frozen registry of string error codes.
|
||||
- `PseudoRandomError(code, messageKey, options?)` — structured error carrying the code, an i18n key and the standard `SuaveplanErrorOptions` (context/cause/sensitive).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All codes are stable strings (`PSEUDO_RANDOM.*`), suitable for catalog lookup and wire-serialisation. No runtime dependencies beyond `@suaveplan/error`; no OTel emission here — error observability lives in the caller.
|
||||
|
||||
### Subclasses & Codes
|
||||
|
||||
`PseudoRandomError` is the base class for every error thrown by the seeded-RNG implementations. Subclasses cover invalid seed (non-finite, out-of-range), invalid state restore (state vector of wrong length), and exhaustion (fixed-period generators that have cycled past `Number.MAX_SAFE_INTEGER` draws). All codes live under the `PSEUDO_RANDOM` namespace.
|
||||
|
||||
## Further Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PseudoRandomError } from "@suaveplan/pseudo-random/errors/pseudo-random-error";
|
||||
throw new PseudoRandomError("bad seed", { code: "PSEUDO_RANDOM.INVALID_SEED" });
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
PSEUDO_RANDOM_CODES,
|
||||
PseudoRandomError,
|
||||
} from "./pseudo-random-error.js";
|
||||
|
||||
describe("PseudoRandomError", () => {
|
||||
it("carries a stable code and message key", { timeout: 5000 }, () => {
|
||||
const err = new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["SEED.INVALID"],
|
||||
"pseudo_random.seed.invalid"
|
||||
);
|
||||
expect(err).toBeInstanceOf(PseudoRandomError);
|
||||
expect(err.code).toBe("PSEUDO_RANDOM.SEED.INVALID");
|
||||
expect(typeof err.timestamp).toBe("number");
|
||||
});
|
||||
|
||||
it("accepts context and cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["STATE.INVALID"],
|
||||
"pseudo_random.state.invalid",
|
||||
{ context: { where: "restore" }, cause }
|
||||
);
|
||||
expect(err.context).toEqual({ where: "restore" });
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("registry has all namespaces", { timeout: 5000 }, () => {
|
||||
expect(PSEUDO_RANDOM_CODES["SEED.INVALID"]).toMatch(/^PSEUDO_RANDOM\./);
|
||||
expect(PSEUDO_RANDOM_CODES["ARG.EMPTY_ARRAY"]).toBe(
|
||||
"PSEUDO_RANDOM.ARG.EMPTY_ARRAY"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Error code registry for `@suaveplan/pseudo-random`.
|
||||
*/
|
||||
export const PSEUDO_RANDOM_CODES = defineCodes("PSEUDO_RANDOM", [
|
||||
"SEED.INVALID",
|
||||
"SEED.NOT_FINITE",
|
||||
"SEED.NOT_SAFE_INT",
|
||||
"STATE.INVALID",
|
||||
"STATE.ALGORITHM_MISMATCH",
|
||||
"ARG.INVALID",
|
||||
"ARG.EMPTY_ARRAY",
|
||||
"ARG.NEGATIVE_COUNT",
|
||||
"ARG.WEIGHTS_INVALID",
|
||||
] as const);
|
||||
|
||||
export type PseudoRandomCode =
|
||||
(typeof PSEUDO_RANDOM_CODES)[keyof typeof PSEUDO_RANDOM_CODES];
|
||||
|
||||
/** Typed error for `@suaveplan/pseudo-random`. */
|
||||
export class PseudoRandomError extends SuaveplanError<PseudoRandomCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
|
||||
public constructor(
|
||||
code: PseudoRandomCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
35
packages/foundation/random/pseudo-random/src/index.ts
Normal file
35
packages/foundation/random/pseudo-random/src/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Algorithms
|
||||
export { CounterRng } from "./algorithms/counter-rng.js";
|
||||
export { Mulberry32Rng } from "./algorithms/mulberry32.js";
|
||||
export { Sfc32Rng } from "./algorithms/sfc32.js";
|
||||
export { Xoshiro256PlusPlusRng } from "./algorithms/xoshiro256.js";
|
||||
// Constants
|
||||
export { SEEDED_RNG_ALGORITHMS, UINT32_RANGE } from "./constants/index.js";
|
||||
// Errors
|
||||
export type { PseudoRandomCode } from "./errors/pseudo-random-error.js";
|
||||
export {
|
||||
PSEUDO_RANDOM_CODES,
|
||||
PseudoRandomError,
|
||||
} from "./errors/pseudo-random-error.js";
|
||||
// Schemas
|
||||
export {
|
||||
type SeededRngState,
|
||||
type SeededRngStateInput,
|
||||
SeededRngStateSchema,
|
||||
} from "./schemas/index.js";
|
||||
// Seed helpers
|
||||
export {
|
||||
expandSeed,
|
||||
normalizeSeed,
|
||||
splitmix32,
|
||||
splitmix64,
|
||||
} from "./seed/normalize.js";
|
||||
// Types
|
||||
export type {
|
||||
RngCheckpoint,
|
||||
SeededRng,
|
||||
SeededRngAlgorithm,
|
||||
} from "./types/seeded-rng.types.js";
|
||||
// Utils
|
||||
export { sample, weightedChoice } from "./utils/sample.js";
|
||||
export { choice, shuffle, shuffleInPlace } from "./utils/shuffle.js";
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SeededRngStateSchema } from "./index.js";
|
||||
|
||||
describe("SeededRngStateSchema", () => {
|
||||
it("accepts a well-formed checkpoint", { timeout: 5000 }, () => {
|
||||
const parsed = SeededRngStateSchema.parse({
|
||||
algorithm: "sfc32",
|
||||
state: [1, 2, 3, 4],
|
||||
counter: 0,
|
||||
});
|
||||
expect(parsed.algorithm).toBe("sfc32");
|
||||
});
|
||||
|
||||
it("rejects unknown algorithm tags", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
SeededRngStateSchema.parse({
|
||||
algorithm: "nope",
|
||||
state: [],
|
||||
counter: 0,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects negative counter", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
SeededRngStateSchema.parse({
|
||||
algorithm: "counter",
|
||||
state: [0, 0],
|
||||
counter: -1,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for a serialised `RngCheckpoint`. Tight enough to reject
|
||||
* obviously malformed state, loose enough to accept any algorithm's word
|
||||
* layout (the algorithm's own `restore` validates length/content specifics).
|
||||
*/
|
||||
export const SeededRngStateSchema = z.object({
|
||||
algorithm: z.enum(["mulberry32", "sfc32", "xoshiro256++", "counter"]),
|
||||
state: z.array(z.number().finite()),
|
||||
counter: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export type SeededRngStateInput = z.input<typeof SeededRngStateSchema>;
|
||||
export type SeededRngState = z.output<typeof SeededRngStateSchema>;
|
||||
@@ -0,0 +1 @@
|
||||
export { expandSeed, normalizeSeed, splitmix32, splitmix64 } from "./normalize";
|
||||
@@ -0,0 +1,54 @@
|
||||
# seed/normalize
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `normalize` module turns a user-supplied seed (number or string) into a uniformly distributed 32-bit unsigned integer suitable for seeding any of the PRNG algorithms in this package. It also exposes the underlying splitmix32/splitmix64 bit-mixers that each algorithm uses internally for seed expansion.
|
||||
|
||||
Seed normalisation is the only officially supported way to derive a deterministic starting state; passing the same input twice always returns the same uint32, and distinct inputs are decorrelated by the splitmix64 mixing step.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts `string | number` seeds; rejects `NaN`, infinities and non-safe integers with a typed `PseudoRandomError`
|
||||
- Deterministic: same input ⇒ same output
|
||||
- Fast 32-bit bit-mixer (`splitmix32`) for scalar mixing
|
||||
- Full 64-bit splitmix state step (`splitmix64`) returning `{ next, out }`
|
||||
- `expandSeed(seed, n)` produces `n` decorrelated uint32 sub-seeds — used by SFC32 (4 words) and Xoshiro256++ (8 words)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { normalizeSeed } from "@suaveplan/pseudo-random/seed";
|
||||
|
||||
const seed = normalizeSeed("my-game-state"); // stable uint32
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { expandSeed, splitmix64 } from "@suaveplan/pseudo-random/seed";
|
||||
|
||||
// Derive 4 sub-seeds for SFC32's 128-bit state from a user key:
|
||||
const [a, b, c, d] = expandSeed(normalizeSeed("run-7"), 4);
|
||||
|
||||
// Or drive the raw bigint stream yourself:
|
||||
let state = 1n;
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const { next, out } = splitmix64(state);
|
||||
state = next;
|
||||
console.log(out);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `normalizeSeed(seed: string | number): number` — returns a uint32.
|
||||
- `splitmix32(seed: number): number` — single mix round, returns a uint32.
|
||||
- `splitmix64(state: bigint): { next: bigint; out: bigint }` — one step of the splitmix64 generator, returned unmasked but kept within the low 64 bits.
|
||||
- `expandSeed(seed: number, n: number): Uint32Array` — n decorrelated sub-seeds.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 32-bit mixer is the classic splitmix avalanche. String inputs are folded into a 32-bit FNV-1a accumulator and then run through splitmix32 to destroy any structural correlation in the input alphabet. Number inputs are converted straight to uint32 (via `>>> 0`) before mixing — so seed `-1` and seed `0xFFFFFFFF` are intentionally equivalent. Non-safe integers are rejected because `>>> 0` would silently truncate them and lose user intent.
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
|
||||
import {
|
||||
expandSeed,
|
||||
normalizeSeed,
|
||||
splitmix32,
|
||||
splitmix64,
|
||||
} from "./normalize.js";
|
||||
|
||||
describe("normalizeSeed", () => {
|
||||
it("returns a uint32 for safe integers", { timeout: 5000 }, () => {
|
||||
const v = normalizeSeed(42);
|
||||
expect(Number.isInteger(v)).toBe(true);
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(2 ** 32);
|
||||
});
|
||||
|
||||
it("is deterministic for numbers", { timeout: 5000 }, () => {
|
||||
expect(normalizeSeed(12345)).toBe(normalizeSeed(12345));
|
||||
});
|
||||
|
||||
it("is deterministic for strings", { timeout: 5000 }, () => {
|
||||
expect(normalizeSeed("hello")).toBe(normalizeSeed("hello"));
|
||||
});
|
||||
|
||||
it("differs across distinct inputs", { timeout: 5000 }, () => {
|
||||
expect(normalizeSeed(1)).not.toBe(normalizeSeed(2));
|
||||
expect(normalizeSeed("a")).not.toBe(normalizeSeed("b"));
|
||||
});
|
||||
|
||||
it("rejects non-finite numbers", { timeout: 5000 }, () => {
|
||||
expect(() => normalizeSeed(Number.NaN)).toThrow(PseudoRandomError);
|
||||
expect(() => normalizeSeed(Number.POSITIVE_INFINITY)).toThrow(
|
||||
PseudoRandomError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-safe integers", { timeout: 5000 }, () => {
|
||||
expect(() => normalizeSeed(2 ** 60)).toThrow(PseudoRandomError);
|
||||
expect(() => normalizeSeed(3.14)).toThrow(PseudoRandomError);
|
||||
});
|
||||
|
||||
it("rejects empty strings and non-strings", { timeout: 5000 }, () => {
|
||||
expect(() => normalizeSeed("")).toThrow(PseudoRandomError);
|
||||
expect(() => normalizeSeed(null as unknown as string)).toThrow(
|
||||
PseudoRandomError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitmix32", () => {
|
||||
it("is deterministic and uint32", { timeout: 5000 }, () => {
|
||||
const v = splitmix32(0);
|
||||
expect(v).toBe(splitmix32(0));
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(2 ** 32);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitmix64", () => {
|
||||
it("advances state deterministically", { timeout: 5000 }, () => {
|
||||
const a = splitmix64(0n);
|
||||
const b = splitmix64(0n);
|
||||
expect(a.next).toBe(b.next);
|
||||
expect(a.out).toBe(b.out);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandSeed", () => {
|
||||
it("produces n sub-seeds", { timeout: 5000 }, () => {
|
||||
const seeds = expandSeed(42, 4);
|
||||
expect(seeds.length).toBe(4);
|
||||
expect(new Set(seeds).size).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
PSEUDO_RANDOM_CODES,
|
||||
PseudoRandomError,
|
||||
} from "../errors/pseudo-random-error.js";
|
||||
|
||||
/**
|
||||
* Mix a 32-bit unsigned integer through the splitmix64 avalanche step, folded
|
||||
* into 32 bits. Used to turn a user-supplied seed into a deterministic,
|
||||
* well-distributed 32-bit key.
|
||||
*
|
||||
* This is NOT a cryptographic hash — it is a fast, statistically sound
|
||||
* bit-mixer taken from Vigna's splitmix64 algorithm.
|
||||
*/
|
||||
export function splitmix32(seed: number): number {
|
||||
let z = (seed + 0x9e3779b9) | 0;
|
||||
z = Math.imul(z ^ (z >>> 16), 0x85ebca6b);
|
||||
z = Math.imul(z ^ (z >>> 13), 0xc2b2ae35);
|
||||
return (z ^ (z >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full splitmix64-step on a `bigint` state; returns the mixed output and
|
||||
* the updated state.
|
||||
*/
|
||||
export function splitmix64(state: bigint): {
|
||||
next: bigint;
|
||||
out: bigint;
|
||||
} {
|
||||
const M = 0xffffffffffffffffn;
|
||||
const next = (state + 0x9e3779b97f4a7c15n) & M;
|
||||
let z = next;
|
||||
z = ((z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n) & M;
|
||||
z = ((z ^ (z >> 27n)) * 0x94d049bb133111ebn) & M;
|
||||
z = (z ^ (z >> 31n)) & M;
|
||||
return { next, out: z };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a user seed (string or number) to a uint32.
|
||||
*
|
||||
* Numbers must be finite, safe integers. Strings are hashed byte-by-byte into
|
||||
* a 32-bit accumulator and then mixed through splitmix32 to decorrelate input
|
||||
* from output.
|
||||
*
|
||||
* @throws PseudoRandomError — `PSEUDO_RANDOM.SEED.*` on invalid input.
|
||||
*/
|
||||
export function normalizeSeed(seed: string | number): number {
|
||||
if (typeof seed === "number") {
|
||||
if (!Number.isFinite(seed)) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["SEED.NOT_FINITE"],
|
||||
"pseudo_random.seed.not_finite",
|
||||
{ context: { seed } }
|
||||
);
|
||||
}
|
||||
if (!Number.isSafeInteger(seed)) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["SEED.NOT_SAFE_INT"],
|
||||
"pseudo_random.seed.not_safe_int",
|
||||
{ context: { seed } }
|
||||
);
|
||||
}
|
||||
return splitmix32(seed >>> 0);
|
||||
}
|
||||
if (typeof seed !== "string" || seed.length === 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["SEED.INVALID"],
|
||||
"pseudo_random.seed.invalid",
|
||||
{ context: { seed: String(seed) } }
|
||||
);
|
||||
}
|
||||
// Hash string through FNV-1a style accumulator then splitmix32-mix it.
|
||||
let acc = 0x811c9dc5;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
acc ^= seed.charCodeAt(i);
|
||||
acc = Math.imul(acc, 0x01000193);
|
||||
}
|
||||
return splitmix32(acc >>> 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a 32-bit seed into `n` independent 32-bit sub-seeds via splitmix64.
|
||||
* Used by algorithms that need multiple state words (SFC32, Xoshiro256++).
|
||||
*/
|
||||
export function expandSeed(seed: number, n: number): Uint32Array {
|
||||
const out = new Uint32Array(n);
|
||||
let state = BigInt(seed >>> 0);
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const step = splitmix64(state);
|
||||
state = step.next;
|
||||
out[i] = Number(step.out & 0xffffffffn) >>> 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
RngCheckpoint,
|
||||
SeededRng,
|
||||
SeededRngAlgorithm,
|
||||
} from "./seeded-rng.types";
|
||||
@@ -0,0 +1,59 @@
|
||||
# types/seeded-rng.types
|
||||
|
||||
## Purpose
|
||||
|
||||
The `seeded-rng.types` module declares the `SeededRng` contract every PRNG in this package implements, plus the tagged `RngCheckpoint` shape used by `checkpoint()` and `restore()`. The interface extends `@suaveplan/types`' bare `Rng` with jumping, forking and state-snapshot semantics — exactly the surface needed for replayable simulations, undo/redo, and deterministic game engines.
|
||||
|
||||
## Features
|
||||
|
||||
- `SeededRngAlgorithm` union — stable tag per algorithm (`"mulberry32"`, `"sfc32"`, `"xoshiro256++"`, `"counter"`)
|
||||
- `RngCheckpoint` — opaque, JSON-safe snapshot; algorithm tag guards against cross-algorithm restore
|
||||
- `SeededRng extends Rng` — adds `skip`, `at`, `fork`, `checkpoint`, `restore`, `position`
|
||||
- Zero runtime code — pure type declarations
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SeededRng } from "@suaveplan/pseudo-random";
|
||||
|
||||
function drawCard(rng: SeededRng, deck: readonly string[]): string {
|
||||
return deck[rng.nextUint32() % deck.length];
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { SeededRng, RngCheckpoint } from "@suaveplan/pseudo-random";
|
||||
|
||||
function replay(rng: SeededRng, cp: RngCheckpoint, steps: number): number[] {
|
||||
rng.restore(cp);
|
||||
const trace: number[] = [];
|
||||
for (let i = 0; i < steps; i += 1) trace.push(rng.nextUint32());
|
||||
return trace;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SeededRngAlgorithm`: `"mulberry32" | "sfc32" | "xoshiro256++" | "counter"`.
|
||||
- `RngCheckpoint`: `{ algorithm; state: readonly number[]; counter: number }`.
|
||||
- `SeededRng`: `Rng` plus `algorithm`, `position`, `skip(n)`, `at(offset)`, `fork(id)`, `checkpoint()`, `restore(cp)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Checkpoints intentionally expose `state` as plain numbers (rather than BigInt) so they round-trip through JSON without custom serialisers. Xoshiro256++ stores its 256-bit state as 8 × uint32 halves; `restore` reassembles the original 64-bit words.
|
||||
|
||||
### Shapes
|
||||
|
||||
`SeededRng` extends the base `Rng` interface from `@suaveplan/types` with `getState` / `setState` methods so deterministic replays can snapshot and restore the internal permutation state. `RngState` is an opaque typed record whose exact shape depends on the concrete algorithm — Mulberry32 stores a single `Uint32Array` of length one, xoshiro256** stores four.
|
||||
|
||||
## Further Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SeededRng, RngState } from "@suaveplan/pseudo-random/types/seeded-rng.types";
|
||||
declare const rng: SeededRng;
|
||||
const snapshot: RngState = rng.getState();
|
||||
rng.nextUint32();
|
||||
rng.setState(snapshot); // replay from the snapshot
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Algorithm identifier for a serialised checkpoint. Every `SeededRng`
|
||||
* implementation is tagged so `restore` can verify it isn't being handed a
|
||||
* checkpoint produced by a different algorithm.
|
||||
*/
|
||||
export type SeededRngAlgorithm =
|
||||
| "mulberry32"
|
||||
| "sfc32"
|
||||
| "xoshiro256++"
|
||||
| "counter";
|
||||
|
||||
/**
|
||||
* Opaque, serialisable snapshot of a `SeededRng`. The `state` array is the
|
||||
* algorithm's internal word array (already coerced to plain numbers).
|
||||
*/
|
||||
export interface RngCheckpoint {
|
||||
readonly algorithm: SeededRngAlgorithm;
|
||||
readonly state: ReadonlyArray<number>;
|
||||
readonly counter: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended random-number source: beyond the raw `Rng` contract every seeded
|
||||
* generator supports jumping, forking, checkpoint/restore and a stable,
|
||||
* offsettable stream.
|
||||
*/
|
||||
export interface SeededRng extends Rng {
|
||||
/** The algorithm tag of this generator. */
|
||||
readonly algorithm: SeededRngAlgorithm;
|
||||
/** Draws discarded so far (incremented per `nextUint32`). */
|
||||
readonly position: number;
|
||||
/** Advance the stream by `n` draws, mutating this instance. */
|
||||
skip(n: number): void;
|
||||
/** Return a new generator positioned `offset` draws ahead of this one. */
|
||||
at(offset: number): SeededRng;
|
||||
/** Fork a new, independent child generator keyed by `id`. */
|
||||
fork(id: string | number): SeededRng;
|
||||
/** Snapshot the current state into a serialisable checkpoint. */
|
||||
checkpoint(): RngCheckpoint;
|
||||
/** Restore from a previously produced checkpoint. */
|
||||
restore(cp: RngCheckpoint): void;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { sample, weightedChoice } from "./sample.js";
|
||||
export { choice, shuffle, shuffleInPlace } from "./shuffle.js";
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { weightedChoice } from "./sample.js";
|
||||
|
||||
class FakeFloatRng implements Rng {
|
||||
public constructor(protected value: number) {}
|
||||
public nextUint32(): number {
|
||||
return 0;
|
||||
}
|
||||
public nextFloat(): number {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
describe("weightedChoice floating-point fallback", () => {
|
||||
it("returns last index when target >= total (FP edge)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A nextFloat value of exactly 1 would make target === total,
|
||||
// so `target < acc` never fires and the function must fall through
|
||||
// to the defensive final return.
|
||||
const rng = new FakeFloatRng(1);
|
||||
const idx = weightedChoice([1, 2, 3], rng);
|
||||
expect(idx).toBe(2);
|
||||
});
|
||||
});
|
||||
51
packages/foundation/random/pseudo-random/src/utils/sample.md
Normal file
51
packages/foundation/random/pseudo-random/src/utils/sample.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# utils/sample
|
||||
|
||||
## Purpose
|
||||
|
||||
The `sample` module covers the two sampling primitives that sit above `shuffle`: unique-without-replacement `sample` for drawing `k` items from `n`, and `weightedChoice` for proportional index selection. Both accept any `Rng` and throw typed `PseudoRandomError`s on invalid input.
|
||||
|
||||
These primitives are intentionally small and allocation-frugal: `sample` reuses `shuffle` then slices, so we never allocate auxiliary structures beyond the shuffled copy; `weightedChoice` walks the weights once to compute the total, then once more to find the tipping point.
|
||||
|
||||
## Features
|
||||
|
||||
- `sample(input, count, rng)` — unique draw without replacement
|
||||
- `weightedChoice(weights, rng)` — proportional index draw; rejects negative, NaN or all-zero weights
|
||||
- Both deterministic for equal seeds + equal RNG class
|
||||
- Strict input validation (positive integer counts, finite non-negative weights)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
|
||||
import { sample, weightedChoice } from "@suaveplan/pseudo-random/utils";
|
||||
|
||||
const rng = new Mulberry32Rng(1);
|
||||
const hand = sample(["A", "K", "Q", "J", "10"], 3, rng);
|
||||
const bucket = weightedChoice([1, 2, 4], rng); // 0, 1 or 2
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Sfc32Rng } from "@suaveplan/pseudo-random";
|
||||
import { weightedChoice } from "@suaveplan/pseudo-random/utils";
|
||||
|
||||
type Drop = { id: string; weight: number };
|
||||
const drops: Drop[] = [
|
||||
{ id: "common", weight: 70 },
|
||||
{ id: "rare", weight: 25 },
|
||||
{ id: "epic", weight: 5 },
|
||||
];
|
||||
const rng = new Sfc32Rng("loot-table");
|
||||
const idx = weightedChoice(drops.map((d) => d.weight), rng);
|
||||
const chosen = drops[idx];
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `sample<T>(input, count, rng)` — `PseudoRandomError.ARG.NEGATIVE_COUNT` or `ARG.INVALID` on bad count; `count === 0` returns `[]`.
|
||||
- `weightedChoice(weights, rng)` — `ARG.EMPTY_ARRAY` on empty input; `ARG.WEIGHTS_INVALID` on negative, NaN or all-zero weights.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`weightedChoice` draws `rng.nextFloat()` (a 32-bit uniform in `[0, 1)`), multiplies by the total weight, and walks the cumulative sum. The final fallback (`return weights.length - 1`) guards against floating-point rounding that would otherwise fail to find a bucket when the target sits exactly on the total.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Mulberry32Rng } from "../algorithms/mulberry32.js";
|
||||
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
|
||||
import { sample, weightedChoice } from "./sample.js";
|
||||
|
||||
describe("sample", () => {
|
||||
it("returns count distinct elements", { timeout: 5000 }, () => {
|
||||
const rng = new Mulberry32Rng(1);
|
||||
const out = sample([1, 2, 3, 4, 5], 3, rng);
|
||||
expect(out.length).toBe(3);
|
||||
expect(new Set(out).size).toBe(3);
|
||||
});
|
||||
|
||||
it("count of 0 returns empty", { timeout: 5000 }, () => {
|
||||
expect(sample([1, 2, 3], 0, new Mulberry32Rng(2))).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects negative count", { timeout: 5000 }, () => {
|
||||
expect(() => sample([1], -1, new Mulberry32Rng(3))).toThrow(
|
||||
PseudoRandomError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects count > length", { timeout: 5000 }, () => {
|
||||
expect(() => sample([1, 2], 5, new Mulberry32Rng(4))).toThrow(
|
||||
PseudoRandomError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weightedChoice", () => {
|
||||
it("respects weight proportions", { timeout: 5000 }, () => {
|
||||
const rng = new Mulberry32Rng(42);
|
||||
const counts = [0, 0, 0];
|
||||
for (let i = 0; i < 3000; i += 1) {
|
||||
const idx = weightedChoice([1, 2, 7], rng);
|
||||
counts[idx] = (counts[idx] ?? 0) + 1;
|
||||
}
|
||||
const c0 = counts[0] as number;
|
||||
const c2 = counts[2] as number;
|
||||
// index 2 should dominate (weight 7 out of 10)
|
||||
expect(c2).toBeGreaterThan(c0 * 3);
|
||||
});
|
||||
|
||||
it("throws on empty weights", { timeout: 5000 }, () => {
|
||||
expect(() => weightedChoice([], new Mulberry32Rng(1))).toThrow(
|
||||
PseudoRandomError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on negative/NaN weight", { timeout: 5000 }, () => {
|
||||
expect(() => weightedChoice([1, -1], new Mulberry32Rng(2))).toThrow(
|
||||
PseudoRandomError
|
||||
);
|
||||
expect(() =>
|
||||
weightedChoice([Number.NaN, 1], new Mulberry32Rng(3))
|
||||
).toThrow(PseudoRandomError);
|
||||
});
|
||||
|
||||
it("throws when total is zero", { timeout: 5000 }, () => {
|
||||
expect(() => weightedChoice([0, 0, 0], new Mulberry32Rng(4))).toThrow(
|
||||
PseudoRandomError
|
||||
);
|
||||
});
|
||||
});
|
||||
74
packages/foundation/random/pseudo-random/src/utils/sample.ts
Normal file
74
packages/foundation/random/pseudo-random/src/utils/sample.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
PSEUDO_RANDOM_CODES,
|
||||
PseudoRandomError,
|
||||
} from "../errors/pseudo-random-error.js";
|
||||
import { shuffle } from "./shuffle.js";
|
||||
|
||||
/**
|
||||
* Draw `count` unique elements from `input` (without replacement). Uses a
|
||||
* partial Fisher-Yates when `count` is small relative to `input.length`.
|
||||
*/
|
||||
export function sample<T>(
|
||||
input: ReadonlyArray<T>,
|
||||
count: number,
|
||||
rng: Rng
|
||||
): T[] {
|
||||
if (!Number.isInteger(count) || count < 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.NEGATIVE_COUNT"],
|
||||
"pseudo_random.sample.invalid_count",
|
||||
{ context: { count } }
|
||||
);
|
||||
}
|
||||
if (count > input.length) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.INVALID"],
|
||||
"pseudo_random.sample.too_many",
|
||||
{ context: { count, length: input.length } }
|
||||
);
|
||||
}
|
||||
if (count === 0) return [];
|
||||
const shuffled = shuffle(input, rng);
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an index whose weight is proportional to its entry in `weights`.
|
||||
* Returns the chosen index.
|
||||
*/
|
||||
export function weightedChoice(
|
||||
weights: ReadonlyArray<number>,
|
||||
rng: Rng
|
||||
): number {
|
||||
if (weights.length === 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.EMPTY_ARRAY"],
|
||||
"pseudo_random.weighted_choice.empty"
|
||||
);
|
||||
}
|
||||
let total = 0;
|
||||
for (const w of weights) {
|
||||
if (!Number.isFinite(w) || w < 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.WEIGHTS_INVALID"],
|
||||
"pseudo_random.weighted_choice.invalid_weight",
|
||||
{ context: { w } }
|
||||
);
|
||||
}
|
||||
total += w;
|
||||
}
|
||||
if (total === 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.WEIGHTS_INVALID"],
|
||||
"pseudo_random.weighted_choice.zero_total"
|
||||
);
|
||||
}
|
||||
const target = rng.nextFloat() * total;
|
||||
let acc = 0;
|
||||
for (let i = 0; i < weights.length; i += 1) {
|
||||
acc += weights[i] as number;
|
||||
if (target < acc) return i;
|
||||
}
|
||||
return weights.length - 1;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# utils/shuffle
|
||||
|
||||
## Purpose
|
||||
|
||||
The `shuffle` module provides deterministic Fisher-Yates shuffling and single-element `choice` drawn from a user-supplied `Rng`. Because `Rng` is the canonical interface defined in `@suaveplan/types`, these utilities compose with every `SeededRng` implementation in this package as well as with the CSPRNG in `@suaveplan/random`.
|
||||
|
||||
Shuffling is a building block for game-card dealing, playlist generation, A/B assignment, and deterministic test data. Isolating it here ensures every downstream package uses the same unbiased Fisher-Yates algorithm rather than re-implementing it (which frequently introduces subtle bias).
|
||||
|
||||
## Features
|
||||
|
||||
- `shuffle(input, rng)` — non-mutating, returns a new shuffled array
|
||||
- `shuffleInPlace(arr, rng)` — mutates the argument, returns it
|
||||
- `choice(input, rng)` — uniformly random element; typed `PseudoRandomError` on empty input
|
||||
- Draws exactly `n - 1` uint32 values per shuffle — counts are predictable for replay
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
|
||||
import { shuffle } from "@suaveplan/pseudo-random/utils";
|
||||
|
||||
const rng = new Mulberry32Rng(42);
|
||||
const deck = shuffle(["A", "K", "Q", "J"], rng);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Sfc32Rng } from "@suaveplan/pseudo-random";
|
||||
import { choice, shuffleInPlace } from "@suaveplan/pseudo-random/utils";
|
||||
|
||||
const rng = new Sfc32Rng("round-12");
|
||||
const deck = ["clubs", "hearts", "diamonds", "spades"];
|
||||
shuffleInPlace(deck, rng);
|
||||
const first = choice(deck, rng);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `shuffle<T>(input: ReadonlyArray<T>, rng: Rng): T[]` — returns a new array.
|
||||
- `shuffleInPlace<T>(arr: T[], rng: Rng): T[]` — mutates and returns.
|
||||
- `choice<T>(input: ReadonlyArray<T>, rng: Rng): T` — throws `PseudoRandomError.ARG.EMPTY_ARRAY` on empty input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Fisher-Yates loop walks from `n-1` down to `1`, swapping `arr[i]` with `arr[j]` where `j = rng.nextUint32() % (i+1)`. The modulo introduces a negligible bias when `i+1` does not divide 2^32 evenly (max bias ≈ `n / 2^32`); for any `n` short of 2^32 elements this is statistically undetectable and matches the behaviour every other mainstream library ships. If you need provably unbiased sampling for large `n`, use `@suaveplan/random`'s `randomInt`, which re-draws on biased samples.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Mulberry32Rng } from "../algorithms/mulberry32.js";
|
||||
import { PseudoRandomError } from "../errors/pseudo-random-error.js";
|
||||
import { choice, shuffle, shuffleInPlace } from "./shuffle.js";
|
||||
|
||||
describe("shuffle", () => {
|
||||
it("preserves multiset and length", { timeout: 5000 }, () => {
|
||||
const input = [1, 2, 3, 4, 5];
|
||||
const rng = new Mulberry32Rng(1);
|
||||
const out = shuffle(input, rng);
|
||||
expect(out.length).toBe(input.length);
|
||||
expect([...out].sort()).toEqual([...input].sort());
|
||||
});
|
||||
|
||||
it("does not mutate input", { timeout: 5000 }, () => {
|
||||
const input = [1, 2, 3];
|
||||
const rng = new Mulberry32Rng(2);
|
||||
const original = input.slice();
|
||||
shuffle(input, rng);
|
||||
expect(input).toEqual(original);
|
||||
});
|
||||
|
||||
it("deterministic with same seed", { timeout: 5000 }, () => {
|
||||
const a = shuffle([1, 2, 3, 4, 5], new Mulberry32Rng("k"));
|
||||
const b = shuffle([1, 2, 3, 4, 5], new Mulberry32Rng("k"));
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("in-place mutates and returns", { timeout: 5000 }, () => {
|
||||
const arr = [1, 2, 3, 4];
|
||||
const rng = new Mulberry32Rng(3);
|
||||
const ret = shuffleInPlace(arr, rng);
|
||||
expect(ret).toBe(arr);
|
||||
});
|
||||
});
|
||||
|
||||
describe("choice", () => {
|
||||
it("returns an element of the input", { timeout: 5000 }, () => {
|
||||
const rng = new Mulberry32Rng(4);
|
||||
const v = choice([1, 2, 3], rng);
|
||||
expect([1, 2, 3]).toContain(v);
|
||||
});
|
||||
|
||||
it("throws on empty input", { timeout: 5000 }, () => {
|
||||
const rng = new Mulberry32Rng(5);
|
||||
expect(() => choice([], rng)).toThrow(PseudoRandomError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
PSEUDO_RANDOM_CODES,
|
||||
PseudoRandomError,
|
||||
} from "../errors/pseudo-random-error.js";
|
||||
|
||||
/**
|
||||
* Return a Fisher-Yates shuffled copy of `input`. Does not mutate `input`.
|
||||
*
|
||||
* Draws `input.length - 1` uint32 values from `rng`.
|
||||
*/
|
||||
export function shuffle<T>(input: ReadonlyArray<T>, rng: Rng): T[] {
|
||||
const out = input.slice();
|
||||
shuffleInPlace(out, rng);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place Fisher-Yates shuffle. Mutates `arr`, returns it for chaining.
|
||||
*/
|
||||
export function shuffleInPlace<T>(arr: T[], rng: Rng): T[] {
|
||||
for (let i = arr.length - 1; i > 0; i -= 1) {
|
||||
const j = rng.nextUint32() % (i + 1);
|
||||
const a = arr[i];
|
||||
const b = arr[j];
|
||||
arr[i] = b as T;
|
||||
arr[j] = a as T;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a uniformly random element of `input`.
|
||||
*
|
||||
* @throws PseudoRandomError when `input` is empty.
|
||||
*/
|
||||
export function choice<T>(input: ReadonlyArray<T>, rng: Rng): T {
|
||||
if (input.length === 0) {
|
||||
throw new PseudoRandomError(
|
||||
PSEUDO_RANDOM_CODES["ARG.EMPTY_ARRAY"],
|
||||
"pseudo_random.choice.empty"
|
||||
);
|
||||
}
|
||||
const idx = rng.nextUint32() % input.length;
|
||||
return input[idx] as T;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bMath\.random\b/,
|
||||
/\bcrypto\.getRandomValues\b/,
|
||||
/\bdocument\b/,
|
||||
/\bwindow\b/,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not use Math.random or crypto.getRandomValues", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of BANNED) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports has at least 5 entries", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
12
packages/foundation/random/pseudo-random/tsconfig.lib.json
Normal file
12
packages/foundation/random/pseudo-random/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
36
packages/foundation/random/pseudo-random/vite.config.ts
Normal file
36
packages/foundation/random/pseudo-random/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/pseudo-random",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
30
packages/foundation/random/pseudo-random/vitest.config.ts
Normal file
30
packages/foundation/random/pseudo-random/vitest.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 95,
|
||||
branches: 90,
|
||||
functions: 95,
|
||||
lines: 95,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
109
packages/foundation/random/random/README.md
Normal file
109
packages/foundation/random/random/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# @suaveplan/random - High-level CSPRNG-backed randomness: strings, UUID/ULID/Nanoid, collections, objects, game primitives, and a full statistical-distribution suite
|
||||
|
||||
## Summary
|
||||
|
||||
High-level cryptographically secure randomness for application use. This is the everyday random package — it wraps the CSPRNG with ergonomic helpers for every common randomness task: unique IDs (`randomUUID` v4, `randomUUIDv7`, `randomULID`, `randomNanoid`), random strings with eight charset presets, collection utilities (`shuffle`, `choice`, `sample`, `weightedChoice`), object utilities (`randomKey`, `randomEntry`, `randomSubset`), game primitives (`coin`, `dice`, `roll`), and a full suite of continuous and discrete statistical distributions (Normal, LogNormal, Binomial, Poisson, and eight more). Every function accepts an optional `rng?: Rng` — omit it for crypto-backed defaults, or pass a `SeededRng` from `@suaveplan/pseudo-random` for deterministic tests. Do not confuse with `@suaveplan/pseudo-random` (seeded, deterministic, for procedural generation) or `@suaveplan/crypto` (raw cryptographic byte generation).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [collections](./src/collections/collections.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [collections](./src/collections/collections.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [continuous](./src/distributions/continuous/continuous.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [continuous](./src/distributions/continuous/continuous.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [discrete](./src/distributions/discrete/discrete.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [discrete](./src/distributions/discrete/discrete.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [sampler](./src/distributions/sampler/sampler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [sampler](./src/distributions/sampler/sampler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/random-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [random-error](./src/errors/random-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [game](./src/game/dice.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [dice](./src/game/dice.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [nanoid](./src/ids/nanoid.md) — The `nanoid` module implements the Nano ID scheme: a configurable-length, URL-safe random string using a 64-character a…
|
||||
- [ulid](./src/ids/ulid.md) — The `ulid` module implements the Universally Unique Lexicographically Sortable Identifier per the original Alizain Feer…
|
||||
- [uuid](./src/ids/uuid.md) — The `uuid` module produces RFC 9562-compliant UUIDv4 and UUIDv7 identifiers. UUIDv4 is 122 bits of pure randomness with…
|
||||
- [random-float](./src/numbers/random-float.md) — The `random-float` module produces a uniformly distributed double in a user-specified half-open interval `[min, max)`.…
|
||||
- [random-int](./src/numbers/random-int.md) — The `random-int` module provides an unbiased, inclusive-range integer sampler. Unlike the common `Math.floor(Math.rando…
|
||||
- [objects](./src/objects/objects.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [objects](./src/objects/objects.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [crypto-rng](./src/source/crypto-rng.md) — The `crypto-rng` module is the single authoritative binding between `@suaveplan/random` and the platform's cryptographi…
|
||||
- [default-rng](./src/source/default-rng.md) — The `default-rng` module holds the process-wide default `Rng` that every helper in `@suaveplan/random` falls back to wh…
|
||||
- [strings](./src/strings/random-string.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [random-string](./src/strings/random-string.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [collections](./src/collections/collections.md) — <!-- begin:auto-docs --> _(no submodules)_- [continuous](./src/distributions/continuous/continuous.md) — <!-- begin:auto-docs --> _(no submodules)_- [continuous](./src/distributions/continuous/continuous.md) — <!-- begin:auto-docs --> _(no submodules)_- [discrete](./src/distributions/discrete/discrete.md) — <!-- begin:auto-docs --> _(no submodules)_- [discrete](./src/distributions/discrete/discrete.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampler](./src/distributions/sampler/sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampler](./src/distributions/sampler/sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-error](./src/errors/random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [game](./src/game/dice.md) — <!-- begin:auto-docs --> _(no submodules)_- [dice](./src/game/dice.md) — <!-- begin:auto-docs --> _(no submodules)_- [nanoid](./src/ids/nanoid.md) — The `nanoid` module implements the Nano ID scheme: a configurable-length, URL-safe random string using a 64-character a…
|
||||
- [ulid](./src/ids/ulid.md) — The `ulid` module implements the Universally Unique Lexicographically Sortable Identifier per the original Alizain Feer…
|
||||
- [uuid](./src/ids/uuid.md) — The `uuid` module produces RFC 9562-compliant UUIDv4 and UUIDv7 identifiers. UUIDv4 is 122 bits of pure randomness with…
|
||||
- [random-float](./src/numbers/random-float.md) — The `random-float` module produces a uniformly distributed double in a user-specified half-open interval `[min, max)`.…
|
||||
- [random-int](./src/numbers/random-int.md) — The `random-int` module provides an unbiased, inclusive-range integer sampler. Unlike the common `Math.floor(Math.rando…
|
||||
- [objects](./src/objects/objects.md) — <!-- begin:auto-docs --> _(no submodules)_- [objects](./src/objects/objects.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-rng](./src/source/crypto-rng.md) — The `crypto-rng` module is the single authoritative binding between `@suaveplan/random` and the platform's cryptographi…
|
||||
- [default-rng](./src/source/default-rng.md) — The `default-rng` module holds the process-wide default `Rng` that every helper in `@suaveplan/random` falls back to wh…
|
||||
- [strings](./src/strings/random-string.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-string](./src/strings/random-string.md) — <!-- begin:auto-docs --> _(no submodules)_- [collections](./src/collections/collections.md) — <!-- begin:auto-docs --> _(no submodules)_- [continuous](./src/distributions/continuous/continuous.md) — <!-- begin:auto-docs --> _(no submodules)_- [continuous](./src/distributions/continuous/continuous.md) — <!-- begin:auto-docs --> _(no submodules)_- [discrete](./src/distributions/discrete/discrete.md) — <!-- begin:auto-docs --> _(no submodules)_- [discrete](./src/distributions/discrete/discrete.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampler](./src/distributions/sampler/sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampler](./src/distributions/sampler/sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-error](./src/errors/random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [game](./src/game/dice.md) — <!-- begin:auto-docs --> _(no submodules)_- [dice](./src/game/dice.md) — <!-- begin:auto-docs --> _(no submodules)_- [nanoid](./src/ids/nanoid.md) — The `nanoid` module implements the Nano ID scheme: a configurable-length, URL-safe random string using a 64-character a…
|
||||
- [ulid](./src/ids/ulid.md) — The `ulid` module implements the Universally Unique Lexicographically Sortable Identifier per the original Alizain Feer…
|
||||
- [uuid](./src/ids/uuid.md) — The `uuid` module produces RFC 9562-compliant UUIDv4 and UUIDv7 identifiers. UUIDv4 is 122 bits of pure randomness with…
|
||||
- [random-float](./src/numbers/random-float.md) — The `random-float` module produces a uniformly distributed double in a user-specified half-open interval `[min, max)`.…
|
||||
- [random-int](./src/numbers/random-int.md) — The `random-int` module provides an unbiased, inclusive-range integer sampler. Unlike the common `Math.floor(Math.rando…
|
||||
- [objects](./src/objects/objects.md) — <!-- begin:auto-docs --> _(no submodules)_- [objects](./src/objects/objects.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-rng](./src/source/crypto-rng.md) — The `crypto-rng` module is the single authoritative binding between `@suaveplan/random` and the platform's cryptographi…
|
||||
- [default-rng](./src/source/default-rng.md) — The `default-rng` module holds the process-wide default `Rng` that every helper in `@suaveplan/random` falls back to wh…
|
||||
- [strings](./src/strings/random-string.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-string](./src/strings/random-string.md) — <!-- begin:auto-docs --> _(no submodules)_- [collections](./src/collections/collections.md) — <!-- begin:auto-docs --> _(no submodules)_- [continuous](./src/distributions/continuous/continuous.md) — <!-- begin:auto-docs --> _(no submodules)_- [continuous](./src/distributions/continuous/continuous.md) — <!-- begin:auto-docs --> _(no submodules)_- [discrete](./src/distributions/discrete/discrete.md) — <!-- begin:auto-docs --> _(no submodules)_- [discrete](./src/distributions/discrete/discrete.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampler](./src/distributions/sampler/sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampler](./src/distributions/sampler/sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-error](./src/errors/random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [game](./src/game/dice.md) — <!-- begin:auto-docs --> _(no submodules)_- [dice](./src/game/dice.md) — <!-- begin:auto-docs --> _(no submodules)_- [nanoid](./src/ids/nanoid.md) — The `nanoid` module implements the Nano ID scheme: a configurable-length, URL-safe random string using a 64-character a…
|
||||
- [ulid](./src/ids/ulid.md) — The `ulid` module implements the Universally Unique Lexicographically Sortable Identifier per the original Alizain Feer…
|
||||
- [uuid](./src/ids/uuid.md) — The `uuid` module produces RFC 9562-compliant UUIDv4 and UUIDv7 identifiers. UUIDv4 is 122 bits of pure randomness with…
|
||||
- [random-float](./src/numbers/random-float.md) — The `random-float` module produces a uniformly distributed double in a user-specified half-open interval `[min, max)`.…
|
||||
- [random-int](./src/numbers/random-int.md) — The `random-int` module provides an unbiased, inclusive-range integer sampler. Unlike the common `Math.floor(Math.rando…
|
||||
- [objects](./src/objects/objects.md) — <!-- begin:auto-docs --> _(no submodules)_- [objects](./src/objects/objects.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-rng](./src/source/crypto-rng.md) — The `crypto-rng` module is the single authoritative binding between `@suaveplan/random` and the platform's cryptographi…
|
||||
- [default-rng](./src/source/default-rng.md) — The `default-rng` module holds the process-wide default `Rng` that every helper in `@suaveplan/random` falls back to wh…
|
||||
- [strings](./src/strings/random-string.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-string](./src/strings/random-string.md) — <!-- begin:auto-docs --> _(no submodules)_- [collections](./src/collections/collections.md) — <!-- begin:auto-docs --> _(no submodules)_- [continuous](./src/distributions/continuous/continuous.md) — <!-- begin:auto-docs --> _(no submodules)_ - [continuous](./src/distributions/continuous/continuous.md) — <!-- begin:auto-docs --> _(no submodules)_- [discrete](./src/distributions/discrete/discrete.md) — <!-- begin:auto-docs --> _(no submodules)_ - [discrete](./src/distributions/discrete/discrete.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampler](./src/distributions/sampler/sampler.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sampler](./src/distributions/sampler/sampler.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/random-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [random-error](./src/errors/random-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [game](./src/game/dice.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dice](./src/game/dice.md) — <!-- begin:auto-docs --> _(no submodules)_- [nanoid](./src/ids/nanoid.md) — The `nanoid` module implements the Nano ID scheme: a configurable-length, URL-safe random string using a 64-character a…
|
||||
- [ulid](./src/ids/ulid.md) — The `ulid` module implements the Universally Unique Lexicographically Sortable Identifier per the original Alizain Feer…
|
||||
- [uuid](./src/ids/uuid.md) — The `uuid` module produces RFC 9562-compliant UUIDv4 and UUIDv7 identifiers. UUIDv4 is 122 bits of pure randomness with…
|
||||
- [random-float](./src/numbers/random-float.md) — The `random-float` module produces a uniformly distributed double in a user-specified half-open interval `[min, max)`.…
|
||||
- [random-int](./src/numbers/random-int.md) — The `random-int` module provides an unbiased, inclusive-range integer sampler. Unlike the common `Math.floor(Math.rando…
|
||||
- [objects](./src/objects/objects.md) — <!-- begin:auto-docs --> _(no submodules)_ - [objects](./src/objects/objects.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-rng](./src/source/crypto-rng.md) — The `crypto-rng` module is the single authoritative binding between `@suaveplan/random` and the platform's cryptographi…
|
||||
- [default-rng](./src/source/default-rng.md) — The `default-rng` module holds the process-wide default `Rng` that every helper in `@suaveplan/random` falls back to wh…
|
||||
- [strings](./src/strings/random-string.md) — <!-- begin:auto-docs --> _(no submodules)_ - [random-string](./src/strings/random-string.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @suaveplan/random
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Pluggable default source (`getDefaultRng`/`setDefaultRng`) — defaults to `cryptoRng()`
|
||||
- Unbiased `randomInt` / `randomFloat`
|
||||
- Strings: `randomString` with 8 charset presets + custom alphabets
|
||||
- IDs: `randomUUID` (v4), `randomUUIDv7`, `randomULID`, `randomNanoid`
|
||||
- Collections: `shuffle`, `shuffleInPlace`, `choice`, `sample`, `weightedChoice`, `weightedSample`
|
||||
- Objects: `randomKey`, `randomValue`, `randomEntry`, `randomSubset`
|
||||
- Games: `coin`, `dice`, `roll`
|
||||
- Continuous distributions: Normal, LogNormal, Exponential, Gamma, Beta, Pareto, Weibull, Cauchy, Logistic
|
||||
- Discrete distributions: Bernoulli, Geometric, Binomial, Poisson
|
||||
- Tagged-union `DistributionConfig` with `createSampler` dispatcher — JSON-serialisable
|
||||
|
||||
Every function accepts an optional `rng?: Rng`; omit to use the crypto-backed default, pass a `SeededRng` for deterministic tests.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { randomUUIDv7, shuffle, sampleDistribution } from "@suaveplan/random";
|
||||
|
||||
const id = randomUUIDv7();
|
||||
const deck = shuffle(["A", "K", "Q", "J"]);
|
||||
const draws = Array.from({ length: 1000 }, () =>
|
||||
sampleDistribution({ type: "normal", mean: 0, stddev: 1 })
|
||||
);
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
- `@suaveplan/random/source` — `cryptoRng`, `getDefaultRng`, `setDefaultRng`, `randomBytes`
|
||||
- `@suaveplan/random/strings` — `randomString`, `CHARSETS`
|
||||
- `@suaveplan/random/ids` — `randomUUID`, `randomUUIDv7`, `randomULID`, `randomNanoid`
|
||||
- `@suaveplan/random/collections` — `shuffle`, `choice`, `sample`, `weightedChoice`, …
|
||||
- `@suaveplan/random/objects` — `randomKey`, `randomValue`, `randomEntry`, `randomSubset`
|
||||
- `@suaveplan/random/game` — `coin`, `dice`, `roll`
|
||||
- `@suaveplan/random/numbers` — `randomInt`, `randomFloat`
|
||||
- `@suaveplan/random/distributions` — all distributions + `DistributionConfig`/`createSampler`
|
||||
- `@suaveplan/random/errors` — `RandomError`, `RANDOM_CODES`
|
||||
|
||||
## Module docs
|
||||
|
||||
See the co-located `.md` next to each implementation.
|
||||
113
packages/foundation/random/random/package.json
Normal file
113
packages/foundation/random/random/package.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"name": "@suaveplan/random",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "High-level CSPRNG-backed randomness: strings, UUID/ULID/Nanoid, collections, objects, game primitives, and a full statistical-distribution suite",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./source": {
|
||||
"import": {
|
||||
"types": "./dist/source/index.d.ts",
|
||||
"default": "./dist/source/index.js"
|
||||
}
|
||||
},
|
||||
"./strings": {
|
||||
"import": {
|
||||
"types": "./dist/strings/random-string.d.ts",
|
||||
"default": "./dist/strings/random-string.js"
|
||||
}
|
||||
},
|
||||
"./ids": {
|
||||
"import": {
|
||||
"types": "./dist/ids/index.d.ts",
|
||||
"default": "./dist/ids/index.js"
|
||||
}
|
||||
},
|
||||
"./collections": {
|
||||
"import": {
|
||||
"types": "./dist/collections/index.d.ts",
|
||||
"default": "./dist/collections/index.js"
|
||||
}
|
||||
},
|
||||
"./objects": {
|
||||
"import": {
|
||||
"types": "./dist/objects/index.d.ts",
|
||||
"default": "./dist/objects/index.js"
|
||||
}
|
||||
},
|
||||
"./game": {
|
||||
"import": {
|
||||
"types": "./dist/game/dice.d.ts",
|
||||
"default": "./dist/game/dice.js"
|
||||
}
|
||||
},
|
||||
"./numbers": {
|
||||
"import": {
|
||||
"types": "./dist/numbers/index.d.ts",
|
||||
"default": "./dist/numbers/index.js"
|
||||
}
|
||||
},
|
||||
"./distributions": {
|
||||
"import": {
|
||||
"types": "./dist/distributions/index.d.ts",
|
||||
"default": "./dist/distributions/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/random-error.d.ts",
|
||||
"default": "./dist/errors/random-error.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@suaveplan/pseudo-random": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# collections/collections
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `collections` module re-presents the five core array-shaped random helpers — `shuffle`, `shuffleInPlace`, `choice`, `sample`, `weightedChoice`, `weightedSample` — with the same ergonomic ‘optional `rng?`' signature used across `@suaveplan/random`. Internally each function delegates to `@suaveplan/pseudo-random`'s unbiased implementation, so you pay no duplication tax but gain the CSPRNG-by-default convenience.
|
||||
|
||||
## Features
|
||||
|
||||
- `shuffle` / `shuveInPlace` — Fisher-Yates, preserves multiset
|
||||
- `choice` — single uniform draw
|
||||
- `sample` — unique, without replacement
|
||||
- `weightedChoice(items, weight, rng?)` — selects an item via a weight function
|
||||
- `weightedSample(items, weight, count, rng?)` — unique-without-replacement weighted draw
|
||||
- Every helper works with no `rng?` argument (uses `getDefaultRng()`)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { shuffle, choice, sample } from "@suaveplan/random/collections";
|
||||
|
||||
const deck = shuffle(["A", "K", "Q", "J"]);
|
||||
const top = choice(deck);
|
||||
const three = sample(deck, 3);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Sfc32Rng } from "@suaveplan/pseudo-random";
|
||||
import { weightedSample } from "@suaveplan/random/collections";
|
||||
|
||||
const items = [
|
||||
{ id: "common", rarity: 70 },
|
||||
{ id: "rare", rarity: 25 },
|
||||
{ id: "epic", rarity: 5 },
|
||||
];
|
||||
const loot = weightedSample(items, (i) => i.rarity, 2, new Sfc32Rng("seed"));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `shuffle<T>(input, rng?): T[]` — new array.
|
||||
- `shuffleInPlace<T>(arr, rng?): T[]` — mutates + returns.
|
||||
- `choice<T>(input, rng?): T` — throws `PseudoRandomError.ARG.EMPTY_ARRAY` on empty input.
|
||||
- `sample<T>(input, count, rng?): T[]` — throws on invalid count.
|
||||
- `weightedChoice<T>(items, weight, rng?): T` — throws `RandomError.ARG.EMPTY` on empty.
|
||||
- `weightedSample<T>(items, weight, count, rng?): T[]` — removes picks from the pool so every returned element is unique.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`weightedSample` maintains parallel `pool` and `weights` arrays and splices out the chosen index each round. For `n` items and `k` draws the cost is O(n·k); there is a theoretically faster reservoir algorithm (Efraimidis–Spirakis) but it is rarely worth the extra complexity at the scales this library targets.
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RandomError } from "../errors/random-error.js";
|
||||
import {
|
||||
choice,
|
||||
sample,
|
||||
shuffle,
|
||||
shuffleInPlace,
|
||||
weightedChoice,
|
||||
weightedSample,
|
||||
} from "./collections.js";
|
||||
|
||||
describe("collections", () => {
|
||||
it("shuffle preserves multiset", { timeout: 5000 }, () => {
|
||||
const rng = new Mulberry32Rng(1);
|
||||
const out = shuffle([1, 2, 3, 4], rng);
|
||||
expect([...out].sort()).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("shuffleInPlace mutates", { timeout: 5000 }, () => {
|
||||
const arr = [1, 2, 3, 4];
|
||||
const ret = shuffleInPlace(arr, new Mulberry32Rng(2));
|
||||
expect(ret).toBe(arr);
|
||||
});
|
||||
|
||||
it("choice returns an element", { timeout: 5000 }, () => {
|
||||
const rng = new Mulberry32Rng(3);
|
||||
expect([1, 2, 3]).toContain(choice([1, 2, 3], rng));
|
||||
});
|
||||
|
||||
it("sample returns distinct count", { timeout: 5000 }, () => {
|
||||
const rng = new Mulberry32Rng(4);
|
||||
const out = sample([1, 2, 3, 4, 5], 3, rng);
|
||||
expect(out.length).toBe(3);
|
||||
expect(new Set(out).size).toBe(3);
|
||||
});
|
||||
|
||||
it("weightedChoice respects weights", { timeout: 5000 }, () => {
|
||||
const rng = new Mulberry32Rng(5);
|
||||
const counts = new Map<string, number>();
|
||||
for (let i = 0; i < 2000; i += 1) {
|
||||
const x = weightedChoice(
|
||||
["a", "b"],
|
||||
(it) => (it === "a" ? 1 : 9),
|
||||
rng
|
||||
);
|
||||
counts.set(x, (counts.get(x) ?? 0) + 1);
|
||||
}
|
||||
const a = counts.get("a") ?? 0;
|
||||
const b = counts.get("b") ?? 0;
|
||||
expect(b).toBeGreaterThan(a * 3);
|
||||
});
|
||||
|
||||
it("weightedChoice throws on empty", { timeout: 5000 }, () => {
|
||||
expect(() => weightedChoice([], () => 1)).toThrow(RandomError);
|
||||
});
|
||||
|
||||
it("weightedSample returns distinct count in size", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rng = new Mulberry32Rng(6);
|
||||
const out = weightedSample([1, 2, 3, 4], () => 1, 3, rng);
|
||||
expect(out.length).toBe(3);
|
||||
expect(new Set(out).size).toBe(3);
|
||||
});
|
||||
|
||||
it("weightedSample throws on bad count", { timeout: 5000 }, () => {
|
||||
expect(() => weightedSample([1], () => 1, -1)).toThrow(RandomError);
|
||||
expect(() => weightedSample([1], () => 1, 5)).toThrow(RandomError);
|
||||
});
|
||||
|
||||
it("all helpers work without explicit rng", { timeout: 5000 }, () => {
|
||||
expect(shuffle([1, 2, 3]).length).toBe(3);
|
||||
expect(sample([1, 2, 3], 2).length).toBe(2);
|
||||
expect([1, 2]).toContain(choice([1, 2]));
|
||||
expect(["a", "b"]).toContain(weightedChoice(["a", "b"], () => 1));
|
||||
expect(weightedSample([1, 2], () => 1, 1).length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
choice as prChoice,
|
||||
sample as prSample,
|
||||
shuffle as prShuffle,
|
||||
shuffleInPlace as prShuffleInPlace,
|
||||
weightedChoice as prWeightedChoice,
|
||||
} from "@suaveplan/pseudo-random";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { RANDOM_CODES, RandomError } from "../errors/random-error.js";
|
||||
import { getDefaultRng } from "../source/default-rng.js";
|
||||
|
||||
/** Shuffled copy of `input`, drawn with `rng` or the default crypto rng. */
|
||||
export function shuffle<T>(input: ReadonlyArray<T>, rng?: Rng): T[] {
|
||||
return prShuffle(input, rng ?? getDefaultRng());
|
||||
}
|
||||
|
||||
/** Mutating Fisher-Yates shuffle, returning the same array. */
|
||||
export function shuffleInPlace<T>(arr: T[], rng?: Rng): T[] {
|
||||
return prShuffleInPlace(arr, rng ?? getDefaultRng());
|
||||
}
|
||||
|
||||
/** Uniform random element of `input`. */
|
||||
export function choice<T>(input: ReadonlyArray<T>, rng?: Rng): T {
|
||||
return prChoice(input, rng ?? getDefaultRng());
|
||||
}
|
||||
|
||||
/** Unique-without-replacement draw of `count` elements. */
|
||||
export function sample<T>(
|
||||
input: ReadonlyArray<T>,
|
||||
count: number,
|
||||
rng?: Rng
|
||||
): T[] {
|
||||
return prSample(input, count, rng ?? getDefaultRng());
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted pick by `weight` function. Returns the selected element.
|
||||
*/
|
||||
export function weightedChoice<T>(
|
||||
items: ReadonlyArray<T>,
|
||||
weight: (item: T, index: number) => number,
|
||||
rng?: Rng
|
||||
): T {
|
||||
if (items.length === 0) {
|
||||
throw new RandomError(
|
||||
RANDOM_CODES["ARG.EMPTY"],
|
||||
"random.weighted_choice.empty"
|
||||
);
|
||||
}
|
||||
const weights = items.map((it, i) => weight(it, i));
|
||||
const idx = prWeightedChoice(weights, rng ?? getDefaultRng());
|
||||
return items[idx] as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted unique-without-replacement sample of `count` items.
|
||||
*/
|
||||
export function weightedSample<T>(
|
||||
items: ReadonlyArray<T>,
|
||||
weight: (item: T, index: number) => number,
|
||||
count: number,
|
||||
rng?: Rng
|
||||
): T[] {
|
||||
if (!Number.isInteger(count) || count < 0) {
|
||||
throw new RandomError(
|
||||
RANDOM_CODES["ARG.INVALID"],
|
||||
"random.weighted_sample.invalid_count",
|
||||
{ context: { count } }
|
||||
);
|
||||
}
|
||||
if (count > items.length) {
|
||||
throw new RandomError(
|
||||
RANDOM_CODES["ARG.OUT_OF_RANGE"],
|
||||
"random.weighted_sample.too_many",
|
||||
{ context: { count, length: items.length } }
|
||||
);
|
||||
}
|
||||
const pool = items.slice();
|
||||
const weights = pool.map((it, i) => weight(it, i));
|
||||
const source = rng ?? getDefaultRng();
|
||||
const out: T[] = [];
|
||||
for (let k = 0; k < count; k += 1) {
|
||||
const idx = prWeightedChoice(weights, source);
|
||||
out.push(pool[idx] as T);
|
||||
pool.splice(idx, 1);
|
||||
weights.splice(idx, 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user