Step 3.2: add seeded random number generator

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 18:40:49 -04:00
parent f44789878e
commit 79c63c13b7
5 changed files with 108 additions and 1 deletions

View File

@ -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`. - **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. - **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.

View File

@ -20,7 +20,7 @@
## Phase 3 — Simulation Package ## 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. - [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.3 — Location positions
- [ ] Step 3.4 — Route distance - [ ] Step 3.4 — Route distance
- [ ] Step 3.5 — Travel time - [ ] Step 3.5 — Travel time

View File

@ -1 +1,2 @@
export * from "./clock"; export * from "./clock";
export * from "./rng";

50
packages/sim/src/rng.ts Normal file
View File

@ -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<T>(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;
}

View File

@ -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<number>();
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"));
});
});