orbit/js/utils/Rng.js

172 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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];
}
/** FisherYates. 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 (BoxMuller, 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;
}
}