diff --git a/DECISIONS.md b/DECISIONS.md index 7f4e0ae..6ebac1c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -82,3 +82,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Times are Unix-millisecond numbers.** A real time is a wall-clock instant; a game time is the instant of the in-game date, so `new Date(gameTime)` gives the in-game date and can be passed straight to `astronomy-engine`. - **Every sim function takes the content it needs as arguments** (here the `GameConfig`), rather than importing the default bundle, so tests can pass custom fixtures. + +## Step 3.2 + +- **Seeds are 32-bit unsigned integers.** The PRNG is mulberry32; child seeds are an FNV-1a hash of the label, started from the parent seed, plus a final mixing step. +- **`createRng` returns an object with internal state.** It is still deterministic: the same seed always gives the same sequence. Each use (a board, a name) creates its own generator from a derived seed, so draws never depend on call order elsewhere. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 6485c39..c021c48 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -20,7 +20,7 @@ ## Phase 3 — Simulation Package - [x] **Step 3.1 — Game clock conversion.** `src/clock.ts`: `realToGame` / `gameToReal` using the config epoch and compression; times are Unix-ms numbers. -- [ ] Step 3.2 — Seeded random number generator +- [x] **Step 3.2 — Seeded RNG.** `src/rng.ts`: `createRng(seed)` (mulberry32) with `next`, `int`, `pick`; `deriveSeed(parent, label)` for child seeds. - [ ] Step 3.3 — Location positions - [ ] Step 3.4 — Route distance - [ ] Step 3.5 — Travel time diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 83adf15..984edf2 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -1 +1,2 @@ export * from "./clock"; +export * from "./rng"; diff --git a/packages/sim/src/rng.ts b/packages/sim/src/rng.ts new file mode 100644 index 0000000..b9c5001 --- /dev/null +++ b/packages/sim/src/rng.ts @@ -0,0 +1,50 @@ +/** A 32-bit unsigned integer seed. */ +export type Seed = number; + +export interface Rng { + /** Float in [0, 1). */ + next(): number; + /** Integer in [min, max], inclusive. */ + int(min: number, max: number): number; + /** A uniformly chosen element. Throws on an empty list. */ + pick(list: readonly T[]): T; +} + +/** Seeded PRNG (mulberry32). The same seed always yields the same sequence. */ +export function createRng(seed: Seed): Rng { + let state = seed >>> 0; + + const next = () => { + 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) / 4294967296; + }; + + return { + next, + int(min, max) { + return min + Math.floor(next() * (max - min + 1)); + }, + pick(list) { + if (list.length === 0) throw new Error("Cannot pick from an empty list"); + return list[Math.floor(next() * list.length)]!; + }, + }; +} + +/** Derives an independent child seed from a parent seed and a label (FNV-1a, then a final mix). */ +export function deriveSeed(parent: Seed, label: string): Seed { + let hash = 0x811c9dc5 ^ (parent >>> 0); + for (let i = 0; i < label.length; i++) { + hash ^= label.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + hash ^= hash >>> 16; + hash = Math.imul(hash, 0x85ebca6b); + hash ^= hash >>> 13; + hash = Math.imul(hash, 0xc2b2ae35); + hash ^= hash >>> 16; + return hash >>> 0; +} diff --git a/packages/sim/test/rng.test.ts b/packages/sim/test/rng.test.ts new file mode 100644 index 0000000..38f47dc --- /dev/null +++ b/packages/sim/test/rng.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { createRng, deriveSeed } from "../src/index"; + +const sequence = (seed: number, length = 1000) => { + const rng = createRng(seed); + return Array.from({ length }, () => rng.next()); +}; + +describe("seeded RNG", () => { + it("produces the same 1,000 values for the same seed", () => { + expect(sequence(42)).toEqual(sequence(42)); + }); + + it("produces different sequences for different seeds", () => { + expect(sequence(42)).not.toEqual(sequence(43)); + }); + + it("keeps next() in [0, 1)", () => { + for (const value of sequence(7, 10_000)) { + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); + + it("keeps int() within inclusive bounds and reaches both ends", () => { + const rng = createRng(1); + const seen = new Set(); + for (let i = 0; i < 1000; i++) { + const value = rng.int(1, 5); + expect(value).toBeGreaterThanOrEqual(1); + expect(value).toBeLessThanOrEqual(5); + seen.add(value); + } + expect([...seen].sort()).toEqual([1, 2, 3, 4, 5]); + }); + + it("picks only list elements and throws on an empty list", () => { + const rng = createRng(3); + const list = ["a", "b", "c"]; + for (let i = 0; i < 100; i++) { + expect(list).toContain(rng.pick(list)); + } + expect(() => rng.pick([])).toThrow(); + }); + + it("derives different child seeds for different labels", () => { + expect(deriveSeed(42, "board")).not.toBe(deriveSeed(42, "names")); + expect(deriveSeed(42, "board")).not.toBe(deriveSeed(43, "board")); + expect(deriveSeed(42, "board")).toBe(deriveSeed(42, "board")); + }); +});