1
0
Fork 0

SeededRandom

This commit is contained in:
Pabloader 2026-05-03 15:40:03 +00:00
parent e951317ddd
commit f6bb3f19df
3 changed files with 330 additions and 6 deletions

322
src/common/random.ts Normal file
View File

@ -0,0 +1,322 @@
/**
* SeededRandom deterministic PRNG for games and procedural generation.
*
* Algorithm: xoshiro128** (Blackman & Vigna, 2018)
* Period: 2^128 1
* Quality: passes PractRand, BigCrush; excellent for game use
* State: 4 × uint32 compact, fast, fully serialisable
*
* Seeding: SplitMix32 is used to expand any integer or string seed into
* the four-word initial state, avoiding the all-zero trap.
*
* Usage:
* const rng = new SeededRandom('world-seed');
* const roomRng = rng.clone(); // independent sub-stream
* const x = roomRng.randInt(0, 100);
* const saved = rng.getState(); // snapshot
* rng.setState(saved); // restore
*/
import { stringHash } from "./utils";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Four-word xoshiro128** state. Serialisable to / from JSON. */
export type RNGState = [number, number, number, number];
// ---------------------------------------------------------------------------
// Helpers (module-private)
// ---------------------------------------------------------------------------
/** Left-rotate a uint32. */
function rotl(x: number, k: number): number {
return ((x << k) | (x >>> (32 - k))) >>> 0;
}
/**
* SplitMix32 used only during seeding to expand a single uint32 into the
* four independent state words required by xoshiro128**.
*/
function splitMix32Step(h: number): [number, number] {
h = (h + 0x9e3779b9) >>> 0;
let v = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0;
v = Math.imul(v ^ (v >>> 13), 0xc2b2ae35) >>> 0;
return [(v ^ (v >>> 16)) >>> 0, h];
}
/** Expand a single integer (or hashed string) into a valid xoshiro128 state. */
function buildState(seed: number | string): RNGState {
let h = typeof seed === 'string' ? stringHash(seed) : seed >>> 0;
let s0: number, s1: number, s2: number, s3: number;
[s0, h] = splitMix32Step(h);
[s1, h] = splitMix32Step(h);
[s2, h] = splitMix32Step(h);
[s3] = splitMix32Step(h);
// Guard: xoshiro128** must never start in the all-zero state.
if ((s0 | s1 | s2 | s3) === 0) s0 = 1;
return [s0, s1, s2, s3];
}
// ---------------------------------------------------------------------------
// Main class
// ---------------------------------------------------------------------------
export class SeededRandom {
private s: RNGState;
/**
* @param seed A number, string, or a previously saved RNGState array.
* Strings are hashed deterministically (same string same sequence).
*/
constructor(seed: number | string | RNGState = Date.now()) {
this.s = Array.isArray(seed)
? ([...seed] as RNGState)
: buildState(seed);
}
// -------------------------------------------------------------------------
// Core generator
// -------------------------------------------------------------------------
/** xoshiro128** — advances state, returns a raw uint32. */
private next(): number {
const [s0, s1, s2, s3] = this.s;
const result = Math.imul(rotl(Math.imul(s1, 5) >>> 0, 7), 9) >>> 0;
const t = (s1 << 9) >>> 0;
this.s[2] = (s2 ^ s0) >>> 0;
this.s[3] = (s3 ^ s1) >>> 0;
this.s[1] = (s1 ^ this.s[2]) >>> 0;
this.s[0] = (s0 ^ this.s[3]) >>> 0;
this.s[2] = (this.s[2] ^ t) >>> 0;
this.s[3] = rotl(this.s[3], 11);
return result;
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/**
* Uniform float in [0, 1).
* Uses 32 bits of randomness 2^32 distinct values spaced by ~2.3 × 10^10.
*/
random(): number {
return this.next() / 0x1_0000_0000;
}
/**
* Uniform integer in [min, max).
* Uses rejection sampling to eliminate modulo bias.
*
* @param min Inclusive lower bound (default 0).
* @param max Exclusive upper bound (required).
*/
randInt(min: number, max: number): number;
randInt(max: number): number;
randInt(minOrMax: number, max?: number): number {
const [min, hi] = max === undefined ? [0, minOrMax] : [minOrMax, max];
if (!Number.isInteger(min) || !Number.isInteger(hi)) {
throw new RangeError(`randInt: bounds must be integers, got (${min}, ${hi})`);
}
if (min >= hi) {
throw new RangeError(`randInt: min (${min}) must be strictly less than max (${hi})`);
}
const range = hi - min;
// Rejection threshold that makes every output equally likely.
// threshold = (2^32 mod range), computed without overflow as:
const threshold = (0x1_0000_0000 - range) % range >>> 0;
let v: number;
do { v = this.next(); } while (v < threshold);
return min + (v % range);
}
/** Uniform boolean — exactly 50 % probability. */
randBool(): boolean {
return (this.next() & 1) === 1;
}
/**
* Uniform random element from any iterable.
* Arrays are read in O(1); other iterables are materialised first.
*
* @param iterable Source collection.
* @param k When provided, returns k distinct elements sampled
* **without replacement** using a partial Fisher-Yates
* sweep O(k) time, O(n) space for the working copy.
*
* @throws RangeError if the collection is empty, k < 1, or k > collection size.
*/
choice<T>(iterable: Iterable<T>): T;
choice<T>(iterable: Iterable<T>, k: number): T[];
choice<T>(iterable: Iterable<T>, k?: number): T | T[] {
const arr = [...iterable];
if (arr.length === 0) return [];
if (k === undefined) {
return arr[this.randInt(0, arr.length)];
}
if (k === 0) {
return [];
}
if (!Number.isInteger(k) || k < 0) {
throw new RangeError(`choice: k must be a positive integer, got ${k}`);
}
if (k > arr.length) {
throw new RangeError(`choice: k (${k}) exceeds collection size (${arr.length})`);
}
// Partial Fisher-Yates — only iterate the first k swaps.
// The selected items accumulate at the front of the working copy.
for (let i = 0; i < k; i++) {
const j = this.randInt(i, arr.length);
const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
}
return arr.slice(0, k);
}
/**
* Weighted random choice weights need not be normalised.
*
* Runs in O(n) per draw. For hot paths with a large, static item list
* consider building an alias table (Vose's method) instead.
*
* @param items Source array.
* @param weights Parallel weight array; must be non-negative with total > 0.
* @param k When provided, returns k elements sampled independently
* **with replacement** each draw is an independent weighted
* roll, so the same item can appear multiple times.
*
* @throws RangeError if arrays have different lengths, contain negative
* weights, the total weight is zero, or k < 1.
*/
weightedChoice<T>(items: readonly T[], weights: readonly number[]): T;
weightedChoice<T>(items: readonly T[], weights: readonly number[], k: number): T[];
weightedChoice<T>(items: readonly T[], weights: readonly number[], k?: number): T | T[] {
if (items.length === 0) {
throw new RangeError('weightedChoice: items is empty');
}
if (items.length !== weights.length) {
throw new RangeError(
`weightedChoice: items.length (${items.length}) !== weights.length (${weights.length})`,
);
}
if (k === 0) {
return [];
}
if (k !== undefined && (!Number.isInteger(k) || k < 1)) {
throw new RangeError(`weightedChoice: k must be a positive integer, got ${k}`);
}
let total = 0;
for (let i = 0; i < weights.length; i++) {
if (weights[i] < 0) throw new RangeError(`weightedChoice: negative weight at index ${i}`);
total += weights[i];
}
if (total <= 0) throw new RangeError('weightedChoice: total weight must be > 0');
const drawOne = (): T => {
let r = this.random() * total;
for (let i = 0; i < items.length; i++) {
r -= weights[i];
if (r < 0) return items[i];
}
// Float rounding safety net — return last item with non-zero weight.
for (let i = items.length - 1; i >= 0; i--) {
if (weights[i] > 0) return items[i];
}
return items[items.length - 1];
};
if (k === undefined) return drawOne();
const result: T[] = new Array(k);
for (let i = 0; i < k; i++) result[i] = drawOne();
return result;
}
/**
* Fisher-Yates shuffle mutates the array in place.
* Returns the same array for chaining.
*/
shuffle<T>(arr: T[]): T[] {
for (let i = arr.length - 1; i > 0; i--) {
const j = this.randInt(0, i + 1);
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
return arr;
}
/**
* Returns a new shuffled copy of the input.
* The original is not modified.
*/
toShuffled<T>(arr: readonly T[]): T[] {
return this.shuffle([...arr]);
}
// -------------------------------------------------------------------------
// State management
// -------------------------------------------------------------------------
/**
* Snapshot the current generator state.
* The returned value is a plain array safe to JSON.stringify / store.
*/
getState(): RNGState {
return [...this.s] as RNGState;
}
/**
* Restore the generator to a previously snapshotted state.
* After this call, the sequence is identical to what it was at the snapshot.
*/
setState(state: RNGState): void {
if (!Array.isArray(state) || state.length !== 4) {
throw new TypeError('setState: expected an array of 4 uint32s');
}
this.s = [...state] as RNGState;
}
/**
* Create an independent child RNG for a sub-resource (room, NPC, particle
* system, ).
*
* The child starts from the *current* state. The parent then advances by
* one step so parent and child immediately diverge neither will reproduce
* the other's sequence.
*
* Typical pattern:
* const worldRng = new SeededRandom('seed-42');
* const dungeonRng = worldRng.clone(); // independent dungeon stream
* const npcRng = worldRng.clone(); // independent NPC stream
*/
clone(): SeededRandom {
const child = new SeededRandom(this.getState());
this.next(); // diverge parent so the two streams never overlap
return child;
}
// -------------------------------------------------------------------------
// Serialisation
// -------------------------------------------------------------------------
toJSON(): { state: RNGState } {
return { state: this.getState() };
}
static fromJSON(json: { state: RNGState }): SeededRandom {
return new SeededRandom(json.state);
}
}

View File

@ -8,9 +8,3 @@ the attacker/source that CombatSystem folds into the final damage value.
### Crit / variance
RNG layer on top of damage calculation (crit chance, crit multiplier, random range).
### Rendering
- Different display systems
- [x] TextDisplay
- [ ] BrickDisplay
- [ ] Canvas

View File

@ -76,6 +76,14 @@ export const intHash = (seed: number, ...parts: number[]) => {
return h1;
};
export const sinHash = (...data: number[]) => data.reduce((hash, n) => Math.sin((hash * 123.12 + n) * 756.12), 0) / 2 + 0.5;
/** FNV-1a 32-bit hash — turns a string into a uint32 seed. */
export const stringHash = (s: string): number => {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0;
}
return h;
}
type F<T, A extends unknown[], R> = (this: T, ...args: A) => R;
export const throttle = function <T, A extends unknown[], R>(func: F<T, A, R>, ms: number, trailing = false): F<T, A, R> {