/** * Deterministic, seedable PRNG for procedural generation. * * Rules of the road (see docs/PROJECT_NOTES.md → "World model"): * - same seed + same call sequence ⇒ same results, always; * - every derived stream is LABELLED (e.g. 'system:S000123'), so * generating system A before or after system B never changes what * either one draws. That's the property that lets us generate the * whole galaxy's *roster* up front but defer each system's actual * contents until the player arrives — lazy and eager generation * produce identical results. * * Pure ES module, no Phaser — runs in the browser and in Node * (dev/*.test.mjs). */ // xmur3 — 32-bit string hash (the "mur" family). function xmur3(str) { let h = 1779033703 ^ str.length; for (let i = 0; i < str.length; i++) { h = Math.imul(h ^ str.charCodeAt(i), 3432918353); h = (h << 13) | (h >>> 19); } return () => { h = (h ^ (h >>> 16)) >>> 0; h = Math.imul(h, 2246822507); h = (h ^ (h >>> 13)) >>> 0; h = Math.imul(h, 3266489909); return (h ^ (h >>> 16)) >>> 0; }; } function hashSeed(seed) { return xmur3(String(seed))(); } // mulberry32 — tiny, fast 32-bit generator. Plenty for worldgen; // not for anything cryptographic. function mulberry32(state) { let a = state >>> 0; return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export class Rng { /** @param {string|number} seed — anything; it's hashed to a 32-bit state */ constructor(seed) { if (seed === undefined || seed === null || String(seed).length === 0) { throw new Error('Rng requires a non-empty seed'); } this.seed = String(seed); this._gen = mulberry32(hashSeed(this.seed)); this._spareNormal = null; } /** @returns {number} uniform float in [0, 1) */ next() { return this._gen(); } /** @returns {number} uniform float in [min, max) */ range(min, max) { return min + this.next() * (max - min); } /** @returns {number} uniform integer in [min, max], inclusive */ int(min, max) { return Math.floor(this.next() * (max - min + 1)) + min; } /** @returns {boolean} true with probability p */ chance(p) { return this.next() < Math.min(1, Math.max(0, p ?? 0)); } /** @returns {*} a random element of the array (undefined if empty) */ pick(arr) { if (!Array.isArray(arr) || arr.length === 0) return undefined; return arr[Math.floor(this.next() * arr.length)]; } /** * Weighted pick. Entries: { a: 30, b: 70 } or [[ 'a', 30 ], [ 'b', 70 ]]. * @returns {*} a key, or `fallback` if every weight is 0 */ weighted(entries, fallback = undefined) { const pairs = Array.isArray(entries) ? entries : Object.entries(entries); if (pairs.length === 0) return fallback; const total = pairs.reduce((sum, [, w]) => sum + (w > 0 ? w : 0), 0); if (total <= 0) return fallback; let u = this.next() * total; for (const [key, w] of pairs) { if (w <= 0) continue; if (u < w) return key; u -= w; } return pairs[pairs.length - 1][0]; } /** Fisher–Yates. Returns a NEW array; the input is untouched. */ shuffle(arr) { const out = Array.from(arr); for (let i = out.length - 1; i > 0; i--) { const j = Math.floor(this.next() * (i + 1)); [out[i], out[j]] = [out[j], out[i]]; } return out; } /** Standard normal (Box–Muller, with a cached spare), scaled to mean/sd. */ normal(mean = 0, sd = 1) { if (this._spareNormal !== null) { const spare = this._spareNormal; this._spareNormal = null; return mean + sd * spare; } let u = 0, v = 0, s = 0; do { u = 2 * this.next() - 1; v = 2 * this.next() - 1; s = u * u + v * v; } while (s >= 1 || s === 0); const m = Math.sqrt((-2 * Math.log(s)) / s); this._spareNormal = v * m; return mean + sd * (u * m); } /** * A child stream derived from THIS seed plus labels. Independent of the * parent's state: drawing from the parent afterwards does not change it, * and two forks with the same labels always agree. */ fork(...labels) { return Rng.derive(this.seed, ...labels); } /** * Derive an independent stream from a seed + purpose labels, e.g. * Rng.derive(galaxySeed, 'system', 'S000123') * Same arguments ⇒ same stream, always. */ static derive(seed, ...labels) { const key = [seed, ...labels].map((x) => String(x)).join('\u0000'); return new Rng(key); } /** * A fresh, NON-deterministic seed string (for "roll a new galaxy"). * Deliberately NOT one of the PRNG streams — this is the human's * free will picking a point in seed space. */ static randomSeedString(len = 8) { const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; let out = ''; const cryptoObj = globalThis.crypto; if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') { const buf = new Uint32Array(len); cryptoObj.getRandomValues(buf); for (let i = 0; i < len; i++) out += alphabet[buf[i] % alphabet.length]; } else { for (let i = 0; i < len; i++) { out += alphabet[Math.floor(Math.random() * alphabet.length)]; } } return out; } }