Files
station-master/src/engine/rng.ts
T
Jesse.MarkowitzandClaude Fable 5.1 04ca74c365 v0.8.5 — housekeeping from the audit, and the playtest line retired
The third release from the audit; nothing a player sees changes. CHANGELOG has the detail.

The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that
existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every
line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused
declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were
dead bot functions from rejected candidates the round said it had deleted. The documents no
longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2),
model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted);
the README's account of bot flags now matches the bot's. Five playtest saves committed in
`docs/` against the repository's own rule are in the ignored `playtests/`.

What the audit found and did not fix is written down as TODO #112-#117, each with its reason.
#112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 —
`/api/save` hands a seat the seed mid-game — waits on a conversation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:02:33 -04:00

70 lines
2.2 KiB
TypeScript

/**
* 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<T>(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: <T,>(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,
};
}