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
70 lines
2.2 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
|