1/**2 * Seeded RNG family tree.3 *4 * A root seed string derives independent child streams by LABEL, never by5 * sequential draws: child state = cyrb128(root + "\x1f" + labelPath). Adding a6 * new labeled system later cannot perturb any existing stream — that property7 * is load-bearing for stable seeds across app versions and is enforced by8 * tests/rng_test.ts.9 *10 * PRNG: sfc32 (128-bit state, passes PractRand, ~4 int ops/draw, pure 32-bit11 * JS so results are bit-identical across engines).12 */1314const SEP = "\x1f";1516/** Hash a string to four 32-bit words (cyrb128, public domain by bryc). */17export function cyrb128(str: string): [number, number, number, number] {18 let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762;19 for (let i = 0, k: number; i < str.length; i++) {20 k = str.charCodeAt(i);21 h1 = h2 ^ Math.imul(h1 ^ k, 597399067);22 h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);23 h3 = h4 ^ Math.imul(h3 ^ k, 951274213);24 h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);25 }26 h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);27 h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);28 h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);29 h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);30 return [31 (h1 ^ h2 ^ h3 ^ h4) >>> 0,32 (h2 ^ h1) >>> 0,33 (h3 ^ h1) >>> 0,34 (h4 ^ h1) >>> 0,35 ];36}3738export interface Rng {39 /** Uniform in [0, 1). Exact IEEE-754 op — identical across engines. */40 float(): number;41 /** Uniform integer in [minIncl, maxExcl). */42 int(minIncl: number, maxExcl: number): number;43 chance(p: number): boolean;44 pick<T>(items: readonly T[]): T;45 /** Weighted pick; entries with weight <= 0 are never chosen. */46 weighted<T>(entries: readonly (readonly [T, number])[]): T;47 /** Fisher–Yates; returns a copy, input untouched. */48 shuffle<T>(items: readonly T[]): T[];49 /** Derived stream for `${this.path}/${label}`; does NOT advance this one. */50 child(label: string): Rng;51 /** Full label path, e.g. "wfc/retry/1" — for debugging. */52 readonly path: string;53}5455class Sfc32Rng implements Rng {56 private a: number;57 private b: number;58 private c: number;59 private d: number;6061 constructor(62 private readonly rootSeed: string,63 readonly path: string,64 ) {65 const [a, b, c, d] = cyrb128(rootSeed + SEP + path);66 this.a = a;67 this.b = b;68 this.c = c;69 this.d = d;70 // Warm-up: decorrelates streams whose seed material differs slightly.71 for (let i = 0; i < 12; i++) this.next32();72 }7374 private next32(): number {75 const t = (this.a + this.b) | 0;76 this.a = this.b ^ (this.b >>> 9);77 this.b = (this.c + (this.c << 3)) | 0;78 this.c = ((this.c << 21) | (this.c >>> 11)) | 0;79 this.d = (this.d + 1) | 0;80 const r = (t + this.d) | 0;81 this.c = (this.c + r) | 0;82 return r >>> 0;83 }8485 float(): number {86 return this.next32() / 4294967296;87 }8889 int(minIncl: number, maxExcl: number): number {90 return minIncl + Math.floor(this.float() * (maxExcl - minIncl));91 }9293 chance(p: number): boolean {94 return this.float() < p;95 }9697 pick<T>(items: readonly T[]): T {98 if (items.length === 0) {99 throw new Error(`rng.pick on empty array (${this.path})`);100 }101 return items[this.int(0, items.length)];102 }103104 weighted<T>(entries: readonly (readonly [T, number])[]): T {105 let total = 0;106 for (const [, w] of entries) if (w > 0) total += w;107 if (total <= 0) {108 throw new Error(`rng.weighted: no positive weights (${this.path})`);109 }110 let r = this.float() * total;111 for (const [item, w] of entries) {112 if (w <= 0) continue;113 r -= w;114 if (r < 0) return item;115 }116 return entries[entries.length - 1][0];117 }118119 shuffle<T>(items: readonly T[]): T[] {120 const out = items.slice();121 for (let i = out.length - 1; i > 0; i--) {122 const j = this.int(0, i + 1);123 [out[i], out[j]] = [out[j], out[i]];124 }125 return out;126 }127128 child(label: string): Rng {129 return new Sfc32Rng(this.rootSeed, `${this.path}/${label}`);130 }131}132133/** Entry point: one independent stream per (rootSeed, label). */134export function rngFamily(rootSeed: string, label: string): Rng {135 return new Sfc32Rng(rootSeed, label);136}