/** * Component 7 — Seeded RNG. * * Every random decision in the game derives from one stored seed, so any game is exactly * replayable. See architecture/components.md §2 A.7. * * IMPORTANT: never call Math.random() anywhere in the engine. A single ambient random call * silently breaks replay, and the failure is invisible until someone tries to reproduce a bug. */ /** An RNG is a value, not a global. Thread it through explicitly. */ export type Rng = { /** Raw 32-bit unsigned draw. */ next(): number; /** Uniform integer in [0, bound). Throws if bound < 1. */ nextInt(bound: number): number; /** A single twelve-sided die roll, 1..12 (§4.3, §4.4, §7). */ d12(): number; /** Fisher-Yates. Returns a new array; does not mutate the input. */ shuffle(items: readonly T[]): T[]; /** The current internal state, so a game can be snapshotted mid-play. */ getState(): number; }; /** * mulberry32 — small, fast, and good enough for a board game. Chosen because its entire state * is one 32-bit integer, which makes snapshotting and restoring an in-progress game trivial. */ export function createRng(seed: number): Rng { let state = seed >>> 0; const next = (): number => { state = (state + 0x6d2b79f5) >>> 0; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return (t ^ (t >>> 14)) >>> 0; }; const nextInt = (bound: number): number => { if (!Number.isInteger(bound) || bound < 1) { throw new Error(`nextInt bound must be a positive integer, got ${bound}`); } // Rejection sampling, so the distribution stays uniform rather than modulo-biased. const limit = Math.floor(0x100000000 / bound) * bound; let draw = next(); while (draw >= limit) draw = next(); return draw % bound; }; return { next, nextInt, d12: () => nextInt(12) + 1, shuffle: (items: readonly T[]): T[] => { const out = items.slice(); for (let i = out.length - 1; i > 0; i--) { const j = nextInt(i + 1); const a = out[i]!; const b = out[j]!; out[i] = b; out[j] = a; } return out; }, getState: () => state, }; }