mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
`Clock` and a seeded mulberry32 RNG had each been reimplemented across the game and web repos -- Clock twice (konbini-67, iron-and-anvil, doc comments near-verbatim), the RNG four times. None of it is UI, so no existing shared package owned it: `web_ui` ships `tokens.css` and React components, and a game importing a UI package to get a seeded RNG is exactly the coupling that makes people vendor their own copy instead. The RNG deliberately exports **two** interfaces rather than reconciling them into one. konbini-67 threaded mutable state through free functions; sims3-clone and europe-county-map returned an object of closures. The sequence is the contract here -- these repos have seeded tests and "same seed -> identical world" guarantees, so changing how many times a helper advances the generator silently re-rolls every saved world. A shared package that did that would be worse than the duplication it replaces. The core is shared, so both interfaces yield the same float sequence; that is asserted, not assumed. `rng.test.ts` pins the first five floats for seed 12345, captured from the pre-extraction konbini-67 implementation rather than from this one -- a golden test generated from the code under test only asserts that the code matches itself. It earned its place immediately: the first constants I wrote came from a truncated `toFixed(9)` probe and the test failed, which is precisely the class of silent precision drift it exists to catch. `hotline3d` is not a consumer and should not become one. Its generator uses `%` and `Math.trunc` where mulberry32 uses `>>>`/`>>`; measured, it emits a different sequence, so adopting this package would change its output. 29 tests, 100% branch/function/line/statement coverage, type-aware eslint, and a committed dist/ with a CI freshness check -- the same bar web_ui holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
87 lines
3.4 KiB
JavaScript
87 lines
3.4 KiB
JavaScript
/**
|
|
* Seeded, deterministic PRNG (mulberry32).
|
|
*
|
|
* A given seed must always reproduce the exact same world, so consumers ban
|
|
* `Math.random` by lint outside this file.
|
|
*
|
|
* ## Why there are two interfaces
|
|
*
|
|
* The four repos that had grown their own copy did not agree on shape:
|
|
* konbini-67 used mutable state plus free functions (`nextFloat(rng)`),
|
|
* while sims3-clone and europe-county-map returned an object of closures
|
|
* (`rng.next()` / `rng.float()`). Both are exported here rather than
|
|
* reconciled into one, because the *sequence* is the contract: these repos
|
|
* have seeded tests and "same seed -> identical world" guarantees, and
|
|
* changing how many times a helper advances the generator silently changes
|
|
* every generated world. A shared package that quietly re-rolled saved
|
|
* worlds would be worse than the duplication it replaced.
|
|
*
|
|
* The generator core is identical in both, so a given seed produces the same
|
|
* float sequence whichever interface you use -- verified in the tests.
|
|
*
|
|
* Note `int` is inclusive of both bounds, matching sims3-clone and
|
|
* europe-county-map. konbini-67's local helper took a single *exclusive*
|
|
* max; it maps to `int(rng, 0, max - 1)`.
|
|
*/
|
|
/** Creates mutable generator state from a seed. */
|
|
export const createRng = (seed) => ({ s: seed >>> 0 });
|
|
/**
|
|
* Deterministic float in [0, 1). Advances the generator.
|
|
*
|
|
* This is the mulberry32 core; every other helper here is defined in terms
|
|
* of it, so they all consume exactly one step per call.
|
|
*/
|
|
export const nextFloat = (rng) => {
|
|
rng.s = (rng.s + 0x6d_2b_79_f5) >>> 0;
|
|
let t = rng.s;
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
|
|
};
|
|
/** Deterministic integer in [minInclusive, maxInclusive]. One step. */
|
|
export const nextInt = (rng, minInclusive, maxInclusive) => minInclusive + Math.floor(nextFloat(rng) * (maxInclusive - minInclusive + 1));
|
|
/** True with the given probability in [0, 1]. One step. */
|
|
export const nextChance = (rng, probability) => nextFloat(rng) < probability;
|
|
/**
|
|
* Deterministic pick from a non-empty tuple. One step.
|
|
*
|
|
* Iterates rather than indexing: under `noUncheckedIndexedAccess` an index
|
|
* read is `T | undefined`, and the undefined arm is unreachable here, so it
|
|
* could never be covered by a test.
|
|
*/
|
|
export const pick = (rng, items) => {
|
|
const target = nextInt(rng, 0, items.length - 1);
|
|
let out = items[0];
|
|
let seen = 0;
|
|
for (const item of items) {
|
|
if (seen === target) {
|
|
out = item;
|
|
}
|
|
seen += 1;
|
|
}
|
|
return out;
|
|
};
|
|
/**
|
|
* Creates a {@link SeededRng} over the same mulberry32 core.
|
|
*
|
|
* `pick` throws on an empty array rather than returning `undefined`: the
|
|
* callers all treat their input as non-empty, and a silent `undefined`
|
|
* propagates into generated data where it is far harder to trace.
|
|
*/
|
|
export const createSeededRng = (seed) => {
|
|
const state = createRng(seed);
|
|
const float = () => nextFloat(state);
|
|
return {
|
|
float,
|
|
int: (minInclusive, maxInclusive) => nextInt(state, minInclusive, maxInclusive),
|
|
pick: (items) => {
|
|
const chosen = items[Math.floor(float() * items.length)];
|
|
if (chosen === undefined) {
|
|
throw new Error('pick() on an empty array');
|
|
}
|
|
return chosen;
|
|
},
|
|
chance: (probability) => float() < probability,
|
|
};
|
|
};
|
|
//# sourceMappingURL=rng.js.map
|