172 lines
5.3 KiB
JavaScript
172 lines
5.3 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|