ts_core: a framework-free TypeScript utility package
Some checks failed
palette drift / palette (push) Waiting to run
ts_core tests / test (push) Has been cancelled

`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:
Krzysztof Rudnicki 2026-08-15 15:42:41 +02:00
parent 0dd4229432
commit 41f014e7a7
26 changed files with 2559 additions and 0 deletions

48
.github/workflows/ts-core-tests.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
{"version":3,"file":"rng.js","sourceRoot":"","sources":["../src/rng.ts"],"names":[],"mappings":"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"}

28
ts_core/eslint.config.js Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

80
ts_core/src/clock.test.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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"]
}

View 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
View 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"],
},
});