From f6bb3f19df3e6c74879ca9b410235cb85bc40dbf Mon Sep 17 00:00:00 2001 From: Pabloader Date: Sun, 3 May 2026 15:40:03 +0000 Subject: [PATCH] SeededRandom --- src/common/random.ts | 322 +++++++++++++++++++++++++++++++++++++++++ src/common/rpg/TODO.md | 6 - src/common/utils.ts | 8 + 3 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 src/common/random.ts diff --git a/src/common/random.ts b/src/common/random.ts new file mode 100644 index 0000000..11d57f7 --- /dev/null +++ b/src/common/random.ts @@ -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(iterable: Iterable): T; + choice(iterable: Iterable, k: number): T[]; + choice(iterable: Iterable, 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(items: readonly T[], weights: readonly number[]): T; + weightedChoice(items: readonly T[], weights: readonly number[], k: number): T[]; + weightedChoice(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(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(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); + } +} diff --git a/src/common/rpg/TODO.md b/src/common/rpg/TODO.md index 3ac65cc..85ec16b 100644 --- a/src/common/rpg/TODO.md +++ b/src/common/rpg/TODO.md @@ -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 diff --git a/src/common/utils.ts b/src/common/utils.ts index 8ae716b..cebec80 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -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 = (this: T, ...args: A) => R; export const throttle = function (func: F, ms: number, trailing = false): F {