mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 17:47:24 +01:00
ts_core: a framework-free TypeScript utility package
`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
This commit is contained in:
parent
0dd4229432
commit
41f014e7a7
48
.github/workflows/ts-core-tests.yml
vendored
Normal file
48
.github/workflows/ts-core-tests.yml
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
name: ts_core tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['ts_core/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['ts_core/**']
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ts_core
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Run tests with the 100% coverage gate
|
||||
run: pnpm coverage
|
||||
|
||||
# The tag ships a prebuilt dist/ because Vite does not transpile
|
||||
# dependencies. A committed dist/ that does not match src/ would ship
|
||||
# stale code to every consumer with no error anywhere, so rebuild and
|
||||
# fail if the result differs from what is checked in.
|
||||
- name: dist/ is in sync with src/
|
||||
run: |
|
||||
pnpm build
|
||||
if ! git diff --exit-code -- dist; then
|
||||
echo "::error::dist/ is stale — run 'pnpm build' in ts_core and commit the result."
|
||||
exit 1
|
||||
fi
|
||||
9
ts_core/.gitignore
vendored
Normal file
9
ts_core/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
# dist/ is deliberately NOT ignored here: consumers pull this package as a git
|
||||
# dependency and Vite does not transpile dependencies, so the build must be
|
||||
# committed in the tag. See README.md. The repo root ignores `dist/` globally,
|
||||
# hence the negation below.
|
||||
node_modules/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
!dist/
|
||||
!dist/**
|
||||
77
ts_core/README.md
Normal file
77
ts_core/README.md
Normal file
@ -0,0 +1,77 @@
|
||||
# `@kuhyx/ts-core`
|
||||
|
||||
Framework-free TypeScript utilities shared across kuhy's games and web apps.
|
||||
|
||||
Deliberately **no React and no CSS** — those live in
|
||||
[`@kuhyx/web-ui`](../web_ui). A game that needs a seeded RNG should not have
|
||||
to install a UI package to get one; that coupling is what makes people vendor
|
||||
their own copy instead, which is how these ended up duplicated four ways.
|
||||
|
||||
## Install
|
||||
|
||||
**pnpm only.** npm cannot install a subdirectory of a git repo — it ignores
|
||||
the `&path:` key, looks for `package.json` at the repo root and fails with
|
||||
ENOENT. Every consumer of this package must be on pnpm.
|
||||
|
||||
```jsonc
|
||||
// package.json
|
||||
"dependencies": {
|
||||
"@kuhyx/ts-core": "github:kuhyx/utils#ts_core-v0.1.0&path:/ts_core"
|
||||
}
|
||||
```
|
||||
|
||||
## What it ships
|
||||
|
||||
| Export | Notes |
|
||||
| --- | --- |
|
||||
| `Clock`, `createRealClock`, `realClock`, `createManualClock`, `ManualClock` | Injectable time source, so real-time mechanics are driven by a scripted clock in tests instead of racing the wall clock. |
|
||||
| `Rng`, `createRng`, `nextFloat`, `nextInt`, `nextChance`, `pick` | Seeded mulberry32, free-function style over mutable state. |
|
||||
| `SeededRng`, `createSeededRng` | The same core, closure style. |
|
||||
|
||||
### Why the RNG has two interfaces
|
||||
|
||||
The repos that had grown their own copy did not agree on shape: konbini-67
|
||||
used mutable state plus free functions (`nextFloat(rng)`); sims3-clone and
|
||||
europe-county-map returned an object of closures (`rng.next()` / `rng.float()`).
|
||||
|
||||
Both ship here rather than being reconciled into one, because **the sequence
|
||||
is the contract**. 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 replaced.
|
||||
|
||||
The generator core is identical in both, so a given seed produces the same
|
||||
float sequence whichever interface you use — asserted in the tests.
|
||||
|
||||
`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
|
||||
`nextInt(rng, 0, max - 1)`.
|
||||
|
||||
### The golden-sequence test
|
||||
|
||||
`rng.test.ts` pins the first five floats for seed `12345`, captured from the
|
||||
**pre-extraction** konbini-67 implementation. If that test fails, the shared
|
||||
core has stopped reproducing the sequence every consumer's saved seeds depend
|
||||
on. Regenerating those constants to make a failure go away defeats the entire
|
||||
point of the test — fix the code instead.
|
||||
|
||||
### `hotline3d` is deliberately not a consumer
|
||||
|
||||
`hotline3d/hotline-kernel/src/core/rng.ts` looks like the same generator but
|
||||
uses `%` and `Math.trunc` where mulberry32 uses `>>>`/`>>`. Measured, it
|
||||
produces a **different sequence** — so adopting this package would change its
|
||||
generated output. Left alone on purpose.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm test # vitest
|
||||
pnpm coverage # 100% branches/functions/lines/statements, enforced
|
||||
pnpm lint # tsc --noEmit + eslint (type-aware, strict)
|
||||
pnpm build # tsc -> dist/ (ESM + .d.ts), committed to the tag
|
||||
```
|
||||
|
||||
`dist/` is committed, like `web_ui`'s: Vite does not transpile dependencies,
|
||||
so a consumer would otherwise need per-repo bundler config. CI fails if the
|
||||
committed `dist/` does not match a fresh build.
|
||||
33
ts_core/dist/clock.d.ts
vendored
Normal file
33
ts_core/dist/clock.d.ts
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Injectable time source.
|
||||
*
|
||||
* All elapsed-time reads flow through this interface instead of calling
|
||||
* `performance.now()`/`Date.now()` directly (banned by lint everywhere except
|
||||
* this file), so real-time mechanics can be driven by a scripted clock in
|
||||
* tests rather than racing the wall clock.
|
||||
*/
|
||||
export interface Clock {
|
||||
/** Current time in milliseconds. Not comparable across different clocks. */
|
||||
now: () => number;
|
||||
}
|
||||
/** A {@link Clock} backed by the browser's monotonic performance timer. */
|
||||
export declare const createRealClock: () => Clock;
|
||||
/**
|
||||
* The one real clock instance UI code should pass around.
|
||||
*
|
||||
* A single shared instance (rather than calling {@link createRealClock} per
|
||||
* component) keeps its identity stable across renders, which matters for
|
||||
* hooks that take it as an effect dependency -- a fresh object each render
|
||||
* re-fires the effect every time.
|
||||
*/
|
||||
export declare const realClock: Clock;
|
||||
/** A {@link Clock} test double whose time only moves when told to. */
|
||||
export interface ManualClock extends Clock {
|
||||
/** Moves the clock forward by the given number of milliseconds. */
|
||||
advance: (deltaMs: number) => void;
|
||||
/** Jumps the clock to an absolute time in milliseconds. */
|
||||
set: (timeMs: number) => void;
|
||||
}
|
||||
/** Creates a {@link ManualClock} starting at `startMs` (default 0). */
|
||||
export declare const createManualClock: (startMs?: number) => ManualClock;
|
||||
//# sourceMappingURL=clock.d.ts.map
|
||||
1
ts_core/dist/clock.d.ts.map
vendored
Normal file
1
ts_core/dist/clock.d.ts.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,KAAK;IACpB,4EAA4E;IAC5E,GAAG,EAAE,MAAM,MAAM,CAAA;CAClB;AAED,2EAA2E;AAC3E,eAAO,MAAM,eAAe,QAAO,KAEjC,CAAA;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,SAAS,EAAE,KAAyB,CAAA;AAEjD,sEAAsE;AACtE,MAAM,WAAW,WAAY,SAAQ,KAAK;IACxC,mEAAmE;IACnE,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;IAElC,2DAA2D;IAC3D,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;CAC9B;AAED,uEAAuE;AACvE,eAAO,MAAM,iBAAiB,GAAI,gBAAW,KAAG,WAW/C,CAAA"}
|
||||
27
ts_core/dist/clock.js
vendored
Normal file
27
ts_core/dist/clock.js
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
/** A {@link Clock} backed by the browser's monotonic performance timer. */
|
||||
export const createRealClock = () => ({
|
||||
now: () => performance.now(),
|
||||
});
|
||||
/**
|
||||
* The one real clock instance UI code should pass around.
|
||||
*
|
||||
* A single shared instance (rather than calling {@link createRealClock} per
|
||||
* component) keeps its identity stable across renders, which matters for
|
||||
* hooks that take it as an effect dependency -- a fresh object each render
|
||||
* re-fires the effect every time.
|
||||
*/
|
||||
export const realClock = createRealClock();
|
||||
/** Creates a {@link ManualClock} starting at `startMs` (default 0). */
|
||||
export const createManualClock = (startMs = 0) => {
|
||||
let current = startMs;
|
||||
return {
|
||||
now: () => current,
|
||||
advance: (deltaMs) => {
|
||||
current += deltaMs;
|
||||
},
|
||||
set: (timeMs) => {
|
||||
current = timeMs;
|
||||
},
|
||||
};
|
||||
};
|
||||
//# sourceMappingURL=clock.js.map
|
||||
1
ts_core/dist/clock.js.map
vendored
Normal file
1
ts_core/dist/clock.js.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"clock.js","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAaA,2EAA2E;AAC3E,MAAM,CAAC,MAAM,eAAe,GAAG,GAAU,EAAE,CAAC,CAAC;IAC3C,GAAG,EAAE,GAAW,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE;CACrC,CAAC,CAAA;AAEF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,SAAS,GAAU,eAAe,EAAE,CAAA;AAWjD,uEAAuE;AACvE,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,OAAO,GAAG,CAAC,EAAe,EAAE;IAC5D,IAAI,OAAO,GAAG,OAAO,CAAA;IACrB,OAAO;QACL,GAAG,EAAE,GAAW,EAAE,CAAC,OAAO;QAC1B,OAAO,EAAE,CAAC,OAAe,EAAQ,EAAE;YACjC,OAAO,IAAI,OAAO,CAAA;QACpB,CAAC;QACD,GAAG,EAAE,CAAC,MAAc,EAAQ,EAAE;YAC5B,OAAO,GAAG,MAAM,CAAA;QAClB,CAAC;KACF,CAAA;AACH,CAAC,CAAA"}
|
||||
11
ts_core/dist/index.d.ts
vendored
Normal file
11
ts_core/dist/index.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Framework-free TypeScript utilities shared across kuhy's games and web apps.
|
||||
*
|
||||
* Deliberately no React and no CSS: those live in `@kuhyx/web-ui`. A game
|
||||
* that needs a seeded RNG should not have to install a UI package to get one
|
||||
* -- that is the coupling that makes people vendor their own copy instead,
|
||||
* which is how these ended up duplicated four ways to begin with.
|
||||
*/
|
||||
export { createManualClock, createRealClock, realClock, type Clock, type ManualClock, } from './clock.ts';
|
||||
export { createRng, createSeededRng, nextChance, nextFloat, nextInt, pick, type Rng, type SeededRng, } from './rng.ts';
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
1
ts_core/dist/index.d.ts.map
vendored
Normal file
1
ts_core/dist/index.d.ts.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,SAAS,EACT,KAAK,KAAK,EACV,KAAK,WAAW,GACjB,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,SAAS,EACT,eAAe,EACf,UAAU,EACV,SAAS,EACT,OAAO,EACP,IAAI,EACJ,KAAK,GAAG,EACR,KAAK,SAAS,GACf,MAAM,UAAU,CAAA"}
|
||||
11
ts_core/dist/index.js
vendored
Normal file
11
ts_core/dist/index.js
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Framework-free TypeScript utilities shared across kuhy's games and web apps.
|
||||
*
|
||||
* Deliberately no React and no CSS: those live in `@kuhyx/web-ui`. A game
|
||||
* that needs a seeded RNG should not have to install a UI package to get one
|
||||
* -- that is the coupling that makes people vendor their own copy instead,
|
||||
* which is how these ended up duplicated four ways to begin with.
|
||||
*/
|
||||
export { createManualClock, createRealClock, realClock, } from "./clock.js";
|
||||
export { createRng, createSeededRng, nextChance, nextFloat, nextInt, pick, } from "./rng.js";
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
ts_core/dist/index.js.map
vendored
Normal file
1
ts_core/dist/index.js.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,SAAS,GAGV,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,SAAS,EACT,eAAe,EACf,UAAU,EACV,SAAS,EACT,OAAO,EACP,IAAI,GAGL,MAAM,UAAU,CAAA"}
|
||||
74
ts_core/dist/rng.d.ts
vendored
Normal file
74
ts_core/dist/rng.d.ts
vendored
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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)`.
|
||||
*/
|
||||
/** Mutable state for a mulberry32 generator. */
|
||||
export interface Rng {
|
||||
/** Internal 32-bit state. Advanced by every draw. */
|
||||
s: number;
|
||||
}
|
||||
/** Creates mutable generator state from a seed. */
|
||||
export declare const createRng: (seed: number) => Rng;
|
||||
/**
|
||||
* 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 declare const nextFloat: (rng: Rng) => number;
|
||||
/** Deterministic integer in [minInclusive, maxInclusive]. One step. */
|
||||
export declare const nextInt: (rng: Rng, minInclusive: number, maxInclusive: number) => number;
|
||||
/** True with the given probability in [0, 1]. One step. */
|
||||
export declare const nextChance: (rng: Rng, probability: number) => boolean;
|
||||
/**
|
||||
* 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 declare const pick: <T>(rng: Rng, items: readonly [T, ...T[]]) => T;
|
||||
/**
|
||||
* The closure-style interface, for consumers that pass an `Rng` object
|
||||
* around rather than threading mutable state through free functions.
|
||||
*/
|
||||
export interface SeededRng {
|
||||
/** Uniform float in [0, 1). */
|
||||
float: () => number;
|
||||
/** Uniform integer in [minInclusive, maxInclusive]. */
|
||||
int: (minInclusive: number, maxInclusive: number) => number;
|
||||
/** Uniform element of a non-empty array. */
|
||||
pick: <T>(items: readonly T[]) => T;
|
||||
/** True with the given probability in [0, 1]. */
|
||||
chance: (probability: number) => boolean;
|
||||
}
|
||||
/**
|
||||
* 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 declare const createSeededRng: (seed: number) => SeededRng;
|
||||
//# sourceMappingURL=rng.d.ts.map
|
||||
1
ts_core/dist/rng.d.ts.map
vendored
Normal file
1
ts_core/dist/rng.d.ts.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"rng.d.ts","sourceRoot":"","sources":["../src/rng.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,gDAAgD;AAChD,MAAM,WAAW,GAAG;IAClB,qDAAqD;IACrD,CAAC,EAAE,MAAM,CAAA;CACV;AAED,mDAAmD;AACnD,eAAO,MAAM,SAAS,GAAI,MAAM,MAAM,KAAG,GAA0B,CAAA;AAEnE;;;;;GAKG;AACH,eAAO,MAAM,SAAS,GAAI,KAAK,GAAG,KAAG,MAMpC,CAAA;AAED,uEAAuE;AACvE,eAAO,MAAM,OAAO,GAClB,KAAK,GAAG,EACR,cAAc,MAAM,EACpB,cAAc,MAAM,KACnB,MAC4E,CAAA;AAE/E,2DAA2D;AAC3D,eAAO,MAAM,UAAU,GAAI,KAAK,GAAG,EAAE,aAAa,MAAM,KAAG,OAC7B,CAAA;AAE9B;;;;;;GAMG;AACH,eAAO,MAAM,IAAI,GAAI,CAAC,EAAE,KAAK,GAAG,EAAE,OAAO,SAAS,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,KAAG,CAW/D,CAAA;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,+BAA+B;IAC/B,KAAK,EAAE,MAAM,MAAM,CAAA;IACnB,uDAAuD;IACvD,GAAG,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,MAAM,CAAA;IAC3D,4CAA4C;IAC5C,IAAI,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,SAAS,CAAC,EAAE,KAAK,CAAC,CAAA;IACnC,iDAAiD;IACjD,MAAM,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAA;CACzC;AAED;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,SAgB9C,CAAA"}
|
||||
87
ts_core/dist/rng.js
vendored
Normal file
87
ts_core/dist/rng.js
vendored
Normal file
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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
|
||||
1
ts_core/dist/rng.js.map
vendored
Normal file
1
ts_core/dist/rng.js.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"rng.js","sourceRoot":"","sources":["../src/rng.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAQH,mDAAmD;AACnD,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,IAAY,EAAO,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,KAAK,CAAC,EAAE,CAAC,CAAA;AAEnE;;;;;GAKG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,GAAQ,EAAU,EAAE;IAC5C,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,aAAa,CAAC,KAAK,CAAC,CAAA;IACrC,IAAI,CAAC,GAAG,GAAG,CAAC,CAAC,CAAA;IACb,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAA;IACpC,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,CAAA;IACzC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,aAAa,CAAA;AACjD,CAAC,CAAA;AAED,uEAAuE;AACvE,MAAM,CAAC,MAAM,OAAO,GAAG,CACrB,GAAQ,EACR,YAAoB,EACpB,YAAoB,EACZ,EAAE,CACV,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,YAAY,GAAG,YAAY,GAAG,CAAC,CAAC,CAAC,CAAA;AAE/E,2DAA2D;AAC3D,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,GAAQ,EAAE,WAAmB,EAAW,EAAE,CACnE,SAAS,CAAC,GAAG,CAAC,GAAG,WAAW,CAAA;AAE9B;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,CAAI,GAAQ,EAAE,KAA2B,EAAK,EAAE;IAClE,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IAChD,IAAI,GAAG,GAAM,KAAK,CAAC,CAAC,CAAC,CAAA;IACrB,IAAI,IAAI,GAAG,CAAC,CAAA;IACZ,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;YACpB,GAAG,GAAG,IAAI,CAAA;QACZ,CAAC;QACD,IAAI,IAAI,CAAC,CAAA;IACX,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC,CAAA;AAiBD;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,IAAY,EAAa,EAAE;IACzD,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,CAAC,CAAA;IAC7B,MAAM,KAAK,GAAG,GAAW,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,CAAA;IAC5C,OAAO;QACL,KAAK;QACL,GAAG,EAAE,CAAC,YAAoB,EAAE,YAAoB,EAAU,EAAE,CAC1D,OAAO,CAAC,KAAK,EAAE,YAAY,EAAE,YAAY,CAAC;QAC5C,IAAI,EAAE,CAAI,KAAmB,EAAK,EAAE;YAClC,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAA;YACxD,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;gBACzB,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAA;YAC7C,CAAC;YACD,OAAO,MAAM,CAAA;QACf,CAAC;QACD,MAAM,EAAE,CAAC,WAAmB,EAAW,EAAE,CAAC,KAAK,EAAE,GAAG,WAAW;KAChE,CAAA;AACH,CAAC,CAAA"}
|
||||
28
ts_core/eslint.config.js
Normal file
28
ts_core/eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
// Type-aware flat config, mirroring web_ui's so the two shared TS packages
|
||||
// hold an identical bar.
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "coverage", "eslint.config.js"] },
|
||||
{ linterOptions: { reportUnusedDisableDirectives: "error" } },
|
||||
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.lint.json"],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Every exported symbol crosses a package boundary, so an explicit
|
||||
// return type is documentation, not ceremony.
|
||||
"@typescript-eslint/explicit-function-return-type": "error",
|
||||
},
|
||||
},
|
||||
);
|
||||
39
ts_core/package.json
Normal file
39
ts_core/package.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@kuhyx/ts-core",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"description": "Framework-free TypeScript utilities shared across kuhy's games and web apps: an injectable Clock and a seeded mulberry32 RNG. Deliberately no React and no CSS -- see @kuhyx/web-ui for the UI layer.",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"lint": "tsc --noEmit -p tsconfig.lint.json && eslint .",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^10.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
1603
ts_core/pnpm-lock.yaml
Normal file
1603
ts_core/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
80
ts_core/src/clock.test.ts
Normal file
80
ts_core/src/clock.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
createManualClock,
|
||||
createRealClock,
|
||||
realClock,
|
||||
type Clock,
|
||||
} from './clock.ts'
|
||||
|
||||
describe('createRealClock', () => {
|
||||
it('reads from performance.now()', () => {
|
||||
const spy = vi.spyOn(performance, 'now').mockReturnValue(1234)
|
||||
expect(createRealClock().now()).toBe(1234)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('advances as real time passes', () => {
|
||||
const clock = createRealClock()
|
||||
const first = clock.now()
|
||||
expect(clock.now()).toBeGreaterThanOrEqual(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('realClock', () => {
|
||||
it('is a stable shared instance', () => {
|
||||
// The whole reason this export exists: a fresh object per render would
|
||||
// re-fire every effect that takes the clock as a dependency.
|
||||
expect(realClock).toBe(realClock)
|
||||
expect(realClock).not.toBe(createRealClock())
|
||||
})
|
||||
|
||||
it('satisfies the Clock interface', () => {
|
||||
const asClock: Clock = realClock
|
||||
expect(typeof asClock.now()).toBe('number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createManualClock', () => {
|
||||
it('starts at 0 by default', () => {
|
||||
expect(createManualClock().now()).toBe(0)
|
||||
})
|
||||
|
||||
it('starts at the given time', () => {
|
||||
expect(createManualClock(500).now()).toBe(500)
|
||||
})
|
||||
|
||||
it('only moves when told to', () => {
|
||||
const clock = createManualClock(100)
|
||||
expect(clock.now()).toBe(100)
|
||||
expect(clock.now()).toBe(100)
|
||||
clock.advance(50)
|
||||
expect(clock.now()).toBe(150)
|
||||
})
|
||||
|
||||
it('accumulates repeated advances', () => {
|
||||
const clock = createManualClock()
|
||||
clock.advance(10)
|
||||
clock.advance(15)
|
||||
expect(clock.now()).toBe(25)
|
||||
})
|
||||
|
||||
it('moves backwards on a negative advance', () => {
|
||||
const clock = createManualClock(100)
|
||||
clock.advance(-40)
|
||||
expect(clock.now()).toBe(60)
|
||||
})
|
||||
|
||||
it('jumps to an absolute time with set()', () => {
|
||||
const clock = createManualClock(100)
|
||||
clock.set(7)
|
||||
expect(clock.now()).toBe(7)
|
||||
})
|
||||
|
||||
it('keeps two instances independent', () => {
|
||||
const a = createManualClock()
|
||||
const b = createManualClock()
|
||||
a.advance(99)
|
||||
expect(b.now()).toBe(0)
|
||||
})
|
||||
})
|
||||
50
ts_core/src/clock.ts
Normal file
50
ts_core/src/clock.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Injectable time source.
|
||||
*
|
||||
* All elapsed-time reads flow through this interface instead of calling
|
||||
* `performance.now()`/`Date.now()` directly (banned by lint everywhere except
|
||||
* this file), so real-time mechanics can be driven by a scripted clock in
|
||||
* tests rather than racing the wall clock.
|
||||
*/
|
||||
export interface Clock {
|
||||
/** Current time in milliseconds. Not comparable across different clocks. */
|
||||
now: () => number
|
||||
}
|
||||
|
||||
/** A {@link Clock} backed by the browser's monotonic performance timer. */
|
||||
export const createRealClock = (): Clock => ({
|
||||
now: (): number => performance.now(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The one real clock instance UI code should pass around.
|
||||
*
|
||||
* A single shared instance (rather than calling {@link createRealClock} per
|
||||
* component) keeps its identity stable across renders, which matters for
|
||||
* hooks that take it as an effect dependency -- a fresh object each render
|
||||
* re-fires the effect every time.
|
||||
*/
|
||||
export const realClock: Clock = createRealClock()
|
||||
|
||||
/** A {@link Clock} test double whose time only moves when told to. */
|
||||
export interface ManualClock extends Clock {
|
||||
/** Moves the clock forward by the given number of milliseconds. */
|
||||
advance: (deltaMs: number) => void
|
||||
|
||||
/** Jumps the clock to an absolute time in milliseconds. */
|
||||
set: (timeMs: number) => void
|
||||
}
|
||||
|
||||
/** Creates a {@link ManualClock} starting at `startMs` (default 0). */
|
||||
export const createManualClock = (startMs = 0): ManualClock => {
|
||||
let current = startMs
|
||||
return {
|
||||
now: (): number => current,
|
||||
advance: (deltaMs: number): void => {
|
||||
current += deltaMs
|
||||
},
|
||||
set: (timeMs: number): void => {
|
||||
current = timeMs
|
||||
},
|
||||
}
|
||||
}
|
||||
25
ts_core/src/index.ts
Normal file
25
ts_core/src/index.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Framework-free TypeScript utilities shared across kuhy's games and web apps.
|
||||
*
|
||||
* Deliberately no React and no CSS: those live in `@kuhyx/web-ui`. A game
|
||||
* that needs a seeded RNG should not have to install a UI package to get one
|
||||
* -- that is the coupling that makes people vendor their own copy instead,
|
||||
* which is how these ended up duplicated four ways to begin with.
|
||||
*/
|
||||
export {
|
||||
createManualClock,
|
||||
createRealClock,
|
||||
realClock,
|
||||
type Clock,
|
||||
type ManualClock,
|
||||
} from './clock.ts'
|
||||
export {
|
||||
createRng,
|
||||
createSeededRng,
|
||||
nextChance,
|
||||
nextFloat,
|
||||
nextInt,
|
||||
pick,
|
||||
type Rng,
|
||||
type SeededRng,
|
||||
} from './rng.ts'
|
||||
157
ts_core/src/rng.test.ts
Normal file
157
ts_core/src/rng.test.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createRng,
|
||||
createSeededRng,
|
||||
nextChance,
|
||||
nextFloat,
|
||||
nextInt,
|
||||
pick,
|
||||
} from './rng.ts'
|
||||
|
||||
/**
|
||||
* The golden sequence for seed 12345, captured from the *existing* konbini-67
|
||||
* and sims3-clone implementations before extraction.
|
||||
*
|
||||
* This is the load-bearing test in the package. These repos guarantee "same
|
||||
* seed -> identical world"; if the shared core ever stops reproducing this
|
||||
* exact sequence, every saved seed in every consumer silently generates a
|
||||
* different world. Regenerating these numbers to make a failure go away
|
||||
* would defeat the entire point of the test.
|
||||
*/
|
||||
const GOLDEN_12345 = [
|
||||
0.979_728_267_760_947_3, 0.306_752_264_499_664_3, 0.484_205_421_525_985,
|
||||
0.817_934_412_509_203, 0.509_428_369_347_006_1,
|
||||
]
|
||||
|
||||
describe('mulberry32 core', () => {
|
||||
it('reproduces the pre-extraction sequence for a known seed', () => {
|
||||
const rng = createRng(12_345)
|
||||
const seen = Array.from({ length: 5 }, () => nextFloat(rng))
|
||||
expect(seen).toEqual(GOLDEN_12345)
|
||||
})
|
||||
|
||||
it('gives both interfaces the same sequence', () => {
|
||||
const free = createRng(999)
|
||||
const closure = createSeededRng(999)
|
||||
const a = Array.from({ length: 8 }, () => nextFloat(free))
|
||||
const b = Array.from({ length: 8 }, () => closure.float())
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
|
||||
it('is reproducible: same seed, same draws', () => {
|
||||
const a = createRng(7)
|
||||
const b = createRng(7)
|
||||
expect(nextFloat(a)).toBe(nextFloat(b))
|
||||
})
|
||||
|
||||
it('differs between seeds', () => {
|
||||
expect(nextFloat(createRng(1))).not.toBe(nextFloat(createRng(2)))
|
||||
})
|
||||
|
||||
it('stays within [0, 1) across many draws', () => {
|
||||
const rng = createRng(42)
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
const value = nextFloat(rng)
|
||||
expect(value).toBeGreaterThanOrEqual(0)
|
||||
expect(value).toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles a seed of 0 and a negative seed', () => {
|
||||
expect(nextFloat(createRng(0))).toBeGreaterThanOrEqual(0)
|
||||
expect(nextFloat(createRng(-1))).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextInt', () => {
|
||||
it('is inclusive of both bounds', () => {
|
||||
const rng = createRng(3)
|
||||
const seen = new Set<number>()
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
seen.add(nextInt(rng, 1, 3))
|
||||
}
|
||||
expect([...seen].sort()).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('returns the only value when min equals max', () => {
|
||||
expect(nextInt(createRng(5), 4, 4)).toBe(4)
|
||||
})
|
||||
|
||||
it('consumes exactly one step, like a raw float draw', () => {
|
||||
const a = createRng(11)
|
||||
const b = createRng(11)
|
||||
nextInt(a, 0, 9)
|
||||
nextFloat(b)
|
||||
expect(nextFloat(a)).toBe(nextFloat(b))
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextChance', () => {
|
||||
it('is always true at probability 1 and never at 0', () => {
|
||||
const rng = createRng(8)
|
||||
expect(nextChance(rng, 1)).toBe(true)
|
||||
expect(nextChance(rng, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('lands near the requested probability over many draws', () => {
|
||||
const rng = createRng(2024)
|
||||
let hits = 0
|
||||
for (let i = 0; i < 4000; i += 1) {
|
||||
if (nextChance(rng, 0.25)) {
|
||||
hits += 1
|
||||
}
|
||||
}
|
||||
expect(hits / 4000).toBeGreaterThan(0.2)
|
||||
expect(hits / 4000).toBeLessThan(0.3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick', () => {
|
||||
it('returns the single element of a one-item tuple', () => {
|
||||
expect(pick(createRng(1), ['only'])).toBe('only')
|
||||
})
|
||||
|
||||
it('eventually returns every element', () => {
|
||||
const rng = createRng(4)
|
||||
const seen = new Set<string>()
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
seen.add(pick(rng, ['a', 'b', 'c']))
|
||||
}
|
||||
expect([...seen].sort()).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('is deterministic for a given seed', () => {
|
||||
const items: readonly [string, ...string[]] = ['w', 'x', 'y', 'z']
|
||||
expect(pick(createRng(77), items)).toBe(pick(createRng(77), items))
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSeededRng', () => {
|
||||
it('picks uniformly and deterministically', () => {
|
||||
expect(createSeededRng(5).pick(['p', 'q', 'r'])).toBe(
|
||||
createSeededRng(5).pick(['p', 'q', 'r']),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws rather than returning undefined on an empty array', () => {
|
||||
expect(() => createSeededRng(1).pick([])).toThrow('pick() on an empty array')
|
||||
})
|
||||
|
||||
it('matches europe-county-map: pick indexes by floor(float * len)', () => {
|
||||
// The old local implementation indexed directly off the float rather than
|
||||
// going through int(). Same step count, and algebraically identical for
|
||||
// 0..len-1 -- pinned here so a future "simplification" to int() cannot
|
||||
// silently shift every pick by an off-by-one.
|
||||
const probe = createRng(31)
|
||||
const expectedIndex = Math.floor(nextFloat(probe) * 5)
|
||||
const items = ['a', 'b', 'c', 'd', 'e']
|
||||
expect(createSeededRng(31).pick(items)).toBe(items[expectedIndex])
|
||||
})
|
||||
|
||||
it('exposes int and chance over the same stream', () => {
|
||||
const rng = createSeededRng(64)
|
||||
expect(rng.int(10, 10)).toBe(10)
|
||||
expect(typeof rng.chance(0.5)).toBe('boolean')
|
||||
})
|
||||
})
|
||||
120
ts_core/src/rng.ts
Normal file
120
ts_core/src/rng.ts
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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)`.
|
||||
*/
|
||||
|
||||
/** Mutable state for a mulberry32 generator. */
|
||||
export interface Rng {
|
||||
/** Internal 32-bit state. Advanced by every draw. */
|
||||
s: number
|
||||
}
|
||||
|
||||
/** Creates mutable generator state from a seed. */
|
||||
export const createRng = (seed: number): Rng => ({ 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): number => {
|
||||
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: Rng,
|
||||
minInclusive: number,
|
||||
maxInclusive: number,
|
||||
): number =>
|
||||
minInclusive + Math.floor(nextFloat(rng) * (maxInclusive - minInclusive + 1))
|
||||
|
||||
/** True with the given probability in [0, 1]. One step. */
|
||||
export const nextChance = (rng: Rng, probability: number): boolean =>
|
||||
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 = <T>(rng: Rng, items: readonly [T, ...T[]]): T => {
|
||||
const target = nextInt(rng, 0, items.length - 1)
|
||||
let out: T = items[0]
|
||||
let seen = 0
|
||||
for (const item of items) {
|
||||
if (seen === target) {
|
||||
out = item
|
||||
}
|
||||
seen += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The closure-style interface, for consumers that pass an `Rng` object
|
||||
* around rather than threading mutable state through free functions.
|
||||
*/
|
||||
export interface SeededRng {
|
||||
/** Uniform float in [0, 1). */
|
||||
float: () => number
|
||||
/** Uniform integer in [minInclusive, maxInclusive]. */
|
||||
int: (minInclusive: number, maxInclusive: number) => number
|
||||
/** Uniform element of a non-empty array. */
|
||||
pick: <T>(items: readonly T[]) => T
|
||||
/** True with the given probability in [0, 1]. */
|
||||
chance: (probability: number) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: number): SeededRng => {
|
||||
const state = createRng(seed)
|
||||
const float = (): number => nextFloat(state)
|
||||
return {
|
||||
float,
|
||||
int: (minInclusive: number, maxInclusive: number): number =>
|
||||
nextInt(state, minInclusive, maxInclusive),
|
||||
pick: <T>(items: readonly T[]): T => {
|
||||
const chosen = items[Math.floor(float() * items.length)]
|
||||
if (chosen === undefined) {
|
||||
throw new Error('pick() on an empty array')
|
||||
}
|
||||
return chosen
|
||||
},
|
||||
chance: (probability: number): boolean => float() < probability,
|
||||
}
|
||||
}
|
||||
42
ts_core/tsconfig.json
Normal file
42
ts_core/tsconfig.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
// DOM is here only for `performance.now()` in clock.ts -- the package
|
||||
// ships no DOM *code*. Without it the build fails while the lint config
|
||||
// (which adds @types/node) passes, so the two must not be trusted to
|
||||
// agree by accident.
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
/* Strictness: everything on, matching the consumers so a file that
|
||||
compiles here compiles there. */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
|
||||
/* Ships a prebuilt dist/ (ESM + .d.ts) because consumers use Vite, which
|
||||
does not transpile dependencies. */
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
13
ts_core/tsconfig.lint.json
Normal file
13
ts_core/tsconfig.lint.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
// tsconfig.json excludes tests so they never land in the published dist/.
|
||||
// Type-aware linting still needs them in a project, so this config widens
|
||||
// the include for eslint and `tsc --noEmit` only. It emits nothing.
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": ".",
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src", "*.config.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
19
ts_core/vitest.config.ts
Normal file
19
ts_core/vitest.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
exclude: ["src/index.ts", "src/**/*.d.ts"],
|
||||
include: ["src/**/*.ts"],
|
||||
provider: "v8",
|
||||
// Same bar as design_system and web_ui: a shared layer several repos
|
||||
// depend on does not get to be the least-tested code in the tree.
|
||||
thresholds: { branches: 100, functions: 100, lines: 100, statements: 100 },
|
||||
},
|
||||
// No jsdom: this package is framework-free. `performance.now()` exists in
|
||||
// plain node, which is the only browser-ish API it touches.
|
||||
environment: "node",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user