koota-kit 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/CONTRIBUTING.md +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +215 -0
  5. package/SECURITY.md +25 -0
  6. package/dist/cjs/eventLog.cjs +81 -0
  7. package/dist/cjs/eventLog.d.cts +49 -0
  8. package/dist/cjs/eventLog.d.cts.map +1 -0
  9. package/dist/cjs/index.cjs +36 -0
  10. package/dist/cjs/index.d.cts +9 -0
  11. package/dist/cjs/index.d.cts.map +1 -0
  12. package/dist/cjs/package.json +3 -0
  13. package/dist/cjs/rng.cjs +168 -0
  14. package/dist/cjs/rng.d.cts +86 -0
  15. package/dist/cjs/rng.d.cts.map +1 -0
  16. package/dist/cjs/traits/index.cjs +54 -0
  17. package/dist/cjs/traits/index.d.cts +48 -0
  18. package/dist/cjs/traits/index.d.cts.map +1 -0
  19. package/dist/cjs/world.cjs +111 -0
  20. package/dist/cjs/world.d.cts +83 -0
  21. package/dist/cjs/world.d.cts.map +1 -0
  22. package/dist/esm/eventLog.d.ts +49 -0
  23. package/dist/esm/eventLog.d.ts.map +1 -0
  24. package/dist/esm/eventLog.js +78 -0
  25. package/dist/esm/index.d.ts +9 -0
  26. package/dist/esm/index.d.ts.map +1 -0
  27. package/dist/esm/index.js +14 -0
  28. package/dist/esm/rng.d.ts +86 -0
  29. package/dist/esm/rng.d.ts.map +1 -0
  30. package/dist/esm/rng.js +154 -0
  31. package/dist/esm/traits/index.d.ts +48 -0
  32. package/dist/esm/traits/index.d.ts.map +1 -0
  33. package/dist/esm/traits/index.js +51 -0
  34. package/dist/esm/world.d.ts +83 -0
  35. package/dist/esm/world.d.ts.map +1 -0
  36. package/dist/esm/world.js +101 -0
  37. package/docs/API.md +119 -0
  38. package/docs/ARCHITECTURE.md +57 -0
  39. package/docs/assets/favicon.svg +1 -0
  40. package/docs/assets/koota-kit-hero.webp +0 -0
  41. package/docs/changelog.md +13 -0
  42. package/docs/contributing.md +28 -0
  43. package/examples/basic.mjs +27 -0
  44. package/examples/commonjs.cjs +8 -0
  45. package/package.json +173 -0
@@ -0,0 +1,78 @@
1
+ // Scratch-backed event logs — the publish/drain seam between sim systems.
2
+ //
3
+ // A fixed-timestep sim needs one system to tell another that something HAPPENED,
4
+ // not merely that some state now differs. The distinction is load-bearing: a
5
+ // system cannot infer an event from state a lower priority band has already
6
+ // consumed, so "a demon was struck this tick" has to be published as a fact
7
+ // rather than reconstructed from health that has already changed.
8
+ //
9
+ // Every such log is the same shape: an array under a well-known scratch key,
10
+ // appended by the owner, drained by exactly one consumer. Consuming systems
11
+ // drain (the log must empty, or a cue plays twice); OBSERVERS — a dev harness, a
12
+ // test, a HUD — must peek instead, because a reader that consumes what a consumer
13
+ // needs is a race, and whichever runs first wins.
14
+ //
15
+ // That distinction is why this is a package module rather than a snippet. It was
16
+ // re-derived ELEVEN times in one consuming game, and three of those sites had no
17
+ // peek at all until an observer silently stole records from the audio bridge and
18
+ // the missing sound was misdiagnosed as a missing swing. The pattern is not hard;
19
+ // getting it wrong is quiet, and it costs a debugging session every time.
20
+ /**
21
+ * Declare an event log under `key`.
22
+ *
23
+ * The array is created lazily on first use and lives in `handle.scratch`, so it
24
+ * lifecycles with the world: a new run starts with empty logs and cannot inherit
25
+ * the last one's events. That is not a detail — a log at module scope survives
26
+ * the world that produced it, and stale events replay into a fresh run.
27
+ *
28
+ * Keys should be namespaced by system (`"combat:hits"`, `"voices:events"`) for
29
+ * the same reason trait names are: scratch is one flat map shared by every
30
+ * system in the world. Empty keys are rejected, as is a collision with an
31
+ * existing non-array scratch value.
32
+ */
33
+ export function defineEventLog(key) {
34
+ if (typeof key !== "string" || key.trim().length === 0) {
35
+ throw new TypeError("defineEventLog: key must be a non-empty string.");
36
+ }
37
+ const existingFor = (handle) => {
38
+ const existing = handle.scratch.get(key);
39
+ if (existing === undefined)
40
+ return undefined;
41
+ if (!Array.isArray(existing)) {
42
+ throw new TypeError(`Event log "${key}" cannot use its scratch key because it already holds a non-array value.`);
43
+ }
44
+ return existing;
45
+ };
46
+ const arrayFor = (handle) => {
47
+ const existing = existingFor(handle);
48
+ if (existing)
49
+ return existing;
50
+ const log = [];
51
+ handle.scratch.set(key, log);
52
+ return log;
53
+ };
54
+ return {
55
+ key,
56
+ push: (handle, event) => {
57
+ arrayFor(handle).push(event);
58
+ },
59
+ drain: (handle) => {
60
+ const log = existingFor(handle);
61
+ if (!log)
62
+ return [];
63
+ // Copy, then truncate IN PLACE rather than replacing the array: another
64
+ // system may already hold this reference, and swapping in a new array
65
+ // would leave that holder appending into an orphan nobody drains.
66
+ const out = log.slice();
67
+ log.length = 0;
68
+ return out;
69
+ },
70
+ peek: (handle) => existingFor(handle)?.slice() ?? [],
71
+ clear: (handle) => {
72
+ const log = existingFor(handle);
73
+ if (log)
74
+ log.length = 0;
75
+ },
76
+ size: (handle) => existingFor(handle)?.length ?? 0,
77
+ };
78
+ }
@@ -0,0 +1,9 @@
1
+ export { advanceClock, createActions, createSimWorld, destroySimWorld, relation, restoreWorldHeader, snapshotWorld, trait, } from "./world.js";
2
+ export type { Entity, World, WorldHandle, WorldSnapshot } from "./world.js";
3
+ export { chance, createRng, nextFloat, nextInt, nextU32, restoreLayers, restoreStream, snapshotLayers, snapshotStream, } from "./rng.js";
4
+ export type { RngLayers, RngLayersSnapshot, RngSeeds, RngStream, RngStreamSnapshot, } from "./rng.js";
5
+ export { defineEventLog } from "./eventLog.js";
6
+ export type { EventLog } from "./eventLog.js";
7
+ export { defineTrait } from "./traits/index.js";
8
+ export type { SafeSchema } from "./traits/index.js";
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAWA,OAAO,EACL,YAAY,EACZ,aAAa,EACb,cAAc,EACd,eAAe,EACf,QAAQ,EACR,kBAAkB,EAClB,aAAa,EACb,KAAK,GACN,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE5E,OAAO,EACL,MAAM,EACN,SAAS,EACT,SAAS,EACT,OAAO,EACP,OAAO,EACP,aAAa,EACb,aAAa,EACb,cAAc,EACd,cAAc,GACf,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,QAAQ,EACR,SAAS,EACT,iBAAiB,GAClB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,14 @@
1
+ // koota-kit — thin koota ECS conventions layer.
2
+ //
3
+ // Four modules, also importable as subpaths:
4
+ // ./world — WorldHandle lifecycle (world + dual-stream rng + clock + scratch)
5
+ // ./rng — dual-layer deterministic seedrandom PRNG with byte-exact
6
+ // snapshot/restore
7
+ // ./traits — defineTrait AoS-aliasing guard (leaf module, zero sibling
8
+ // imports)
9
+ // ./eventLog — scratch-backed publish/drain event logs with a peek seam for
10
+ // observers (harnesses, tests, HUDs)
11
+ export { advanceClock, createActions, createSimWorld, destroySimWorld, relation, restoreWorldHeader, snapshotWorld, trait, } from "./world.js";
12
+ export { chance, createRng, nextFloat, nextInt, nextU32, restoreLayers, restoreStream, snapshotLayers, snapshotStream, } from "./rng.js";
13
+ export { defineEventLog } from "./eventLog.js";
14
+ export { defineTrait } from "./traits/index.js";
@@ -0,0 +1,86 @@
1
+ import seedrandom from "seedrandom";
2
+ type State = seedrandom.State.Arc4;
3
+ type Stateful = seedrandom.StatefulPRNG<State>;
4
+ export type RngStream = {
5
+ prng: Stateful;
6
+ };
7
+ export type RngLayers = {
8
+ gen: RngStream;
9
+ events: RngStream;
10
+ };
11
+ export type RngSeeds = {
12
+ /** World-generation seed (immutable, defines the world's identity). */
13
+ readonly gen: string | number;
14
+ /** Per-playthrough events seed (changes per cycle for new playthroughs of the same world). */
15
+ readonly events: string | number;
16
+ };
17
+ /**
18
+ * Create both RNG layers from their seeds. Called once at world creation
19
+ * (see `createSimWorld` in `./world`) — `gen` and `events` are independent
20
+ * seedrandom instances from this point on, so consuming one never perturbs
21
+ * the other's sequence.
22
+ */
23
+ export declare function createRng(seeds: RngSeeds): RngLayers;
24
+ /**
25
+ * Draw the next raw unsigned 32-bit integer from `stream` (range
26
+ * `[0, 2**32)`). The lowest-level draw primitive — `nextFloat`, `nextInt`,
27
+ * and `chance` are all expressed in terms of `quick()`, not this function;
28
+ * reach for `nextU32` directly only when you need the full 32 bits of
29
+ * entropy (e.g. seeding another PRNG, hashing).
30
+ */
31
+ export declare function nextU32(stream: RngStream): number;
32
+ /**
33
+ * Draw the next float from `stream` in the half-open range `[0, 1)`. The
34
+ * primitive every other draw helper in this module (`nextInt`, `chance`)
35
+ * is built on.
36
+ */
37
+ export declare function nextFloat(stream: RngStream): number;
38
+ /**
39
+ * Draw a random integer from `stream` in the half-open range
40
+ * `[minInclusive, maxExclusive)`. Bounds must be safe integers, the range must
41
+ * be non-empty and no larger than `2**32`, and valid calls consume exactly one
42
+ * `nextFloat` draw. Invalid calls do not consume the stream.
43
+ */
44
+ export declare function nextInt(stream: RngStream, minInclusive: number, maxExclusive: number): number;
45
+ /**
46
+ * Roll a boolean weighted by finite probability `p` in `[0, 1]` (`0` never
47
+ * true, `1` always true). Valid calls consume exactly one `nextFloat` draw
48
+ * from `stream`, so calling `chance` where a system might otherwise skip a
49
+ * draw entirely will shift every later draw on that stream — keep draw counts
50
+ * deterministic across codepaths that must replay identically.
51
+ */
52
+ export declare function chance(stream: RngStream, p: number): boolean;
53
+ export type RngStreamSnapshot = {
54
+ state: State;
55
+ };
56
+ export type RngLayersSnapshot = {
57
+ gen: RngStreamSnapshot;
58
+ events: RngStreamSnapshot;
59
+ };
60
+ /**
61
+ * Capture `stream`'s internal seedrandom state as a plain, JSON-serializable
62
+ * object (deep-cloned, so later draws on `stream` cannot mutate the
63
+ * snapshot). Restoring it with `restoreStream` reproduces every subsequent
64
+ * draw byte-exact.
65
+ */
66
+ export declare function snapshotStream(stream: RngStream): RngStreamSnapshot;
67
+ /**
68
+ * Snapshot both RNG layers at once. See `snapshotStream` — this just applies
69
+ * it to `gen` and `events` independently and returns both.
70
+ */
71
+ export declare function snapshotLayers(rng: RngLayers): RngLayersSnapshot;
72
+ /**
73
+ * Rebuild an `RngStream` from a snapshot taken by `snapshotStream`. The next
74
+ * draw on the returned stream reproduces the exact next draw the snapshotted
75
+ * stream would have produced — the seed string passed to `seedrandom` here
76
+ * is irrelevant because `{ state: snap.state }` fully overrides it. Malformed
77
+ * persisted state is rejected before seedrandom sees it.
78
+ */
79
+ export declare function restoreStream(snap: RngStreamSnapshot): RngStream;
80
+ /**
81
+ * Rebuild both RNG layers from a snapshot taken by `snapshotLayers`. See
82
+ * `restoreStream` — applied independently to `gen` and `events`.
83
+ */
84
+ export declare function restoreLayers(snap: RngLayersSnapshot): RngLayers;
85
+ export {};
86
+ //# sourceMappingURL=rng.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rng.d.ts","sourceRoot":"","sources":["../../src/rng.ts"],"names":[],"mappings":"AAqBA,OAAO,UAAU,MAAM,YAAY,CAAC;AAEpC,KAAK,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC;AACnC,KAAK,QAAQ,GAAG,UAAU,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;AAE/C,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,QAAQ,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,GAAG,EAAE,SAAS,CAAC;IACf,MAAM,EAAE,SAAS,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,uEAAuE;IACvE,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,8FAA8F;IAC9F,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;CAClC,CAAC;AAgBF;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,QAAQ,GAAG,SAAS,CAUpD;AAED;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAEjD;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAEnD;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAc7F;AAED;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAK5D;AAED,MAAM,MAAM,iBAAiB,GAAG;IAAE,KAAK,EAAE,KAAK,CAAA;CAAE,CAAC;AACjD,MAAM,MAAM,iBAAiB,GAAG;IAAE,GAAG,EAAE,iBAAiB,CAAC;IAAC,MAAM,EAAE,iBAAiB,CAAA;CAAE,CAAC;AAEtF;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,SAAS,GAAG,iBAAiB,CAGnE;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,SAAS,GAAG,iBAAiB,CAEhE;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,iBAAiB,GAAG,SAAS,CAqBhE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,iBAAiB,GAAG,SAAS,CAKhE"}
@@ -0,0 +1,154 @@
1
+ // Dual-layer deterministic PRNG (seedrandom-backed).
2
+ //
3
+ // `gen` — worldgen / layouts / spawn locations / map composition / one-time
4
+ // seeded decisions. Replays a world reliably from the gen seed alone, even
5
+ // when play diverges. Snapshot it once at world creation and you can
6
+ // re-derive the same world forever.
7
+ //
8
+ // `events` — per-tick runtime decisions (weather rolls, event triggers, mood
9
+ // drift, combat dice). Diverges based on play history. Snapshot/restore
10
+ // independently so a worldgen replay doesn't carry play state with it.
11
+ //
12
+ // Why two streams: a single stream couples worldgen to play, so seeding a
13
+ // "same map, fresh playthrough" scenario requires you to either re-derive
14
+ // worldgen mid-restore (fragile) or snapshot the stream at the exact tick
15
+ // worldgen finished (brittle to system ordering). Two streams: snapshot gen
16
+ // once, never touch it again; snapshot events on demand. Each layer's
17
+ // determinism contract is independent.
18
+ //
19
+ // Forbidden in sim code: `Math.random()`. Use `nextFloat(rng.gen)` or
20
+ // `nextFloat(rng.events)` depending on the layer.
21
+ import seedrandom from "seedrandom";
22
+ function assertSeed(seed, name) {
23
+ if (typeof seed !== "string" && typeof seed !== "number") {
24
+ throw new TypeError(`createRng: seeds.${name} must be a string or finite number.`);
25
+ }
26
+ if (typeof seed === "number" && !Number.isFinite(seed)) {
27
+ throw new RangeError(`createRng: seeds.${name} must be finite; received ${String(seed)}.`);
28
+ }
29
+ }
30
+ function makeStream(seed) {
31
+ const prng = seedrandom(String(seed), { state: true });
32
+ return { prng };
33
+ }
34
+ /**
35
+ * Create both RNG layers from their seeds. Called once at world creation
36
+ * (see `createSimWorld` in `./world`) — `gen` and `events` are independent
37
+ * seedrandom instances from this point on, so consuming one never perturbs
38
+ * the other's sequence.
39
+ */
40
+ export function createRng(seeds) {
41
+ if (typeof seeds !== "object" || seeds === null) {
42
+ throw new TypeError("createRng: seeds must be an object with gen and events values.");
43
+ }
44
+ assertSeed(seeds.gen, "gen");
45
+ assertSeed(seeds.events, "events");
46
+ return {
47
+ gen: makeStream(seeds.gen),
48
+ events: makeStream(seeds.events),
49
+ };
50
+ }
51
+ /**
52
+ * Draw the next raw unsigned 32-bit integer from `stream` (range
53
+ * `[0, 2**32)`). The lowest-level draw primitive — `nextFloat`, `nextInt`,
54
+ * and `chance` are all expressed in terms of `quick()`, not this function;
55
+ * reach for `nextU32` directly only when you need the full 32 bits of
56
+ * entropy (e.g. seeding another PRNG, hashing).
57
+ */
58
+ export function nextU32(stream) {
59
+ return stream.prng.int32() >>> 0;
60
+ }
61
+ /**
62
+ * Draw the next float from `stream` in the half-open range `[0, 1)`. The
63
+ * primitive every other draw helper in this module (`nextInt`, `chance`)
64
+ * is built on.
65
+ */
66
+ export function nextFloat(stream) {
67
+ return stream.prng.quick();
68
+ }
69
+ /**
70
+ * Draw a random integer from `stream` in the half-open range
71
+ * `[minInclusive, maxExclusive)`. Bounds must be safe integers, the range must
72
+ * be non-empty and no larger than `2**32`, and valid calls consume exactly one
73
+ * `nextFloat` draw. Invalid calls do not consume the stream.
74
+ */
75
+ export function nextInt(stream, minInclusive, maxExclusive) {
76
+ if (!Number.isSafeInteger(minInclusive) || !Number.isSafeInteger(maxExclusive)) {
77
+ throw new TypeError("nextInt: bounds must be safe integers.");
78
+ }
79
+ if (maxExclusive <= minInclusive) {
80
+ throw new RangeError(`nextInt: maxExclusive (${maxExclusive}) must be greater than minInclusive (${minInclusive}).`);
81
+ }
82
+ if (maxExclusive - minInclusive > 2 ** 32) {
83
+ throw new RangeError("nextInt: the requested range cannot exceed 2**32 values.");
84
+ }
85
+ const span = maxExclusive - minInclusive;
86
+ return minInclusive + Math.floor(nextFloat(stream) * span);
87
+ }
88
+ /**
89
+ * Roll a boolean weighted by finite probability `p` in `[0, 1]` (`0` never
90
+ * true, `1` always true). Valid calls consume exactly one `nextFloat` draw
91
+ * from `stream`, so calling `chance` where a system might otherwise skip a
92
+ * draw entirely will shift every later draw on that stream — keep draw counts
93
+ * deterministic across codepaths that must replay identically.
94
+ */
95
+ export function chance(stream, p) {
96
+ if (!Number.isFinite(p) || p < 0 || p > 1) {
97
+ throw new RangeError(`chance: probability must be a finite number in [0, 1]; received ${p}.`);
98
+ }
99
+ return nextFloat(stream) < p;
100
+ }
101
+ /**
102
+ * Capture `stream`'s internal seedrandom state as a plain, JSON-serializable
103
+ * object (deep-cloned, so later draws on `stream` cannot mutate the
104
+ * snapshot). Restoring it with `restoreStream` reproduces every subsequent
105
+ * draw byte-exact.
106
+ */
107
+ export function snapshotStream(stream) {
108
+ const state = stream.prng.state();
109
+ return { state: { i: state.i, j: state.j, S: [...state.S] } };
110
+ }
111
+ /**
112
+ * Snapshot both RNG layers at once. See `snapshotStream` — this just applies
113
+ * it to `gen` and `events` independently and returns both.
114
+ */
115
+ export function snapshotLayers(rng) {
116
+ return { gen: snapshotStream(rng.gen), events: snapshotStream(rng.events) };
117
+ }
118
+ /**
119
+ * Rebuild an `RngStream` from a snapshot taken by `snapshotStream`. The next
120
+ * draw on the returned stream reproduces the exact next draw the snapshotted
121
+ * stream would have produced — the seed string passed to `seedrandom` here
122
+ * is irrelevant because `{ state: snap.state }` fully overrides it. Malformed
123
+ * persisted state is rejected before seedrandom sees it.
124
+ */
125
+ export function restoreStream(snap) {
126
+ const state = snap?.state;
127
+ if (!state ||
128
+ !Number.isInteger(state.i) ||
129
+ state.i < 0 ||
130
+ state.i > 255 ||
131
+ !Number.isInteger(state.j) ||
132
+ state.j < 0 ||
133
+ state.j > 255 ||
134
+ !Array.isArray(state.S) ||
135
+ state.S.length !== 256 ||
136
+ state.S.some((value) => !Number.isInteger(value) || value < 0 || value > 255) ||
137
+ new Set(state.S).size !== 256) {
138
+ throw new TypeError("restoreStream: snapshot must contain a valid seedrandom ARC4 state.");
139
+ }
140
+ // Clone the permutation so the same immutable snapshot can safely seed more
141
+ // than one replay, even if seedrandom changes its internal copy behavior.
142
+ const prng = seedrandom("", { state: { i: state.i, j: state.j, S: [...state.S] } });
143
+ return { prng };
144
+ }
145
+ /**
146
+ * Rebuild both RNG layers from a snapshot taken by `snapshotLayers`. See
147
+ * `restoreStream` — applied independently to `gen` and `events`.
148
+ */
149
+ export function restoreLayers(snap) {
150
+ if (typeof snap !== "object" || snap === null) {
151
+ throw new TypeError("restoreLayers: snapshot must contain gen and events stream states.");
152
+ }
153
+ return { gen: restoreStream(snap.gen), events: restoreStream(snap.events) };
154
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `src/traits/` — trait-authoring conventions, kept in a LEAF sub-package.
3
+ *
4
+ * Packaging pattern (from medieval-hexagon-gameboard): trait declarations
5
+ * live in a leaf sub-package with ZERO sibling imports — this file imports
6
+ * only `koota` (the npm dep) and pure types, never runtime values from
7
+ * sibling modules like ./world or ./rng. That is what prevents the
8
+ * koota ↔ world-facade ↔ game-scenario top-level evaluation cycle that a
9
+ * flat layout only dodges accidentally. Games adopting this package should
10
+ * follow the same rule: declare traits in their own leaf module, exported
11
+ * from exactly one location so trait reference-identity holds regardless of
12
+ * which subpath a consumer imports from.
13
+ *
14
+ * The AoS-aliasing footgun this module guards against (timber-town's
15
+ * documented convention): object-valued fields in a SoA schema must use the
16
+ * AoS-factory pattern (`() => ({...})`) so each entity gets its own object
17
+ * instance — koota's SoA pattern would otherwise alias state across
18
+ * entities (one shared object literal mutated by every entity that has the
19
+ * trait).
20
+ *
21
+ * @module
22
+ */
23
+ import type { Norm, Schema, Trait } from "koota";
24
+ /**
25
+ * Compile-time guard: any object-valued field in a SoA schema must be an
26
+ * AoS factory (`() => ({...})`), never a bare object literal. Fields that
27
+ * are functions, primitives, null, or undefined pass through unchanged;
28
+ * bare object/array literals map to `never`, producing a type error at the
29
+ * call site.
30
+ */
31
+ export type SafeSchema<S> = S extends (...args: never[]) => unknown ? S : {
32
+ [K in keyof S]: S[K] extends (...args: never[]) => unknown ? S[K] : S[K] extends object ? never : S[K];
33
+ };
34
+ /**
35
+ * Thin wrapper over koota's `trait()` that enforces the AoS-factory
36
+ * convention for object-valued fields, at both type level (see SafeSchema)
37
+ * and runtime (throws with an explanation instead of silently aliasing).
38
+ *
39
+ * Accepted forms:
40
+ * defineTrait({ count: 0, label: "x" }) // SoA, primitives
41
+ * defineTrait({ pos: () => ({ x: 0, y: 0 }) }) // SoA, per-field factory
42
+ * defineTrait(() => ({ pos: { x: 0, y: 0 } })) // whole-trait AoS factory
43
+ *
44
+ * Rejected (the footgun):
45
+ * defineTrait({ pos: { x: 0, y: 0 } }) // shared literal → aliased
46
+ */
47
+ export declare function defineTrait<S extends Schema>(schema: S & SafeSchema<S>): Trait<Norm<S>>;
48
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/traits/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAGjD;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,GAC/D,CAAC,GACD;KACG,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,GACtD,CAAC,CAAC,CAAC,CAAC,GACJ,CAAC,CAAC,CAAC,CAAC,SAAS,MAAM,GACjB,KAAK,GACL,CAAC,CAAC,CAAC,CAAC;CACX,CAAC;AAEN;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAgBvF"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `src/traits/` — trait-authoring conventions, kept in a LEAF sub-package.
3
+ *
4
+ * Packaging pattern (from medieval-hexagon-gameboard): trait declarations
5
+ * live in a leaf sub-package with ZERO sibling imports — this file imports
6
+ * only `koota` (the npm dep) and pure types, never runtime values from
7
+ * sibling modules like ./world or ./rng. That is what prevents the
8
+ * koota ↔ world-facade ↔ game-scenario top-level evaluation cycle that a
9
+ * flat layout only dodges accidentally. Games adopting this package should
10
+ * follow the same rule: declare traits in their own leaf module, exported
11
+ * from exactly one location so trait reference-identity holds regardless of
12
+ * which subpath a consumer imports from.
13
+ *
14
+ * The AoS-aliasing footgun this module guards against (timber-town's
15
+ * documented convention): object-valued fields in a SoA schema must use the
16
+ * AoS-factory pattern (`() => ({...})`) so each entity gets its own object
17
+ * instance — koota's SoA pattern would otherwise alias state across
18
+ * entities (one shared object literal mutated by every entity that has the
19
+ * trait).
20
+ *
21
+ * @module
22
+ */
23
+ import { trait } from "koota";
24
+ /**
25
+ * Thin wrapper over koota's `trait()` that enforces the AoS-factory
26
+ * convention for object-valued fields, at both type level (see SafeSchema)
27
+ * and runtime (throws with an explanation instead of silently aliasing).
28
+ *
29
+ * Accepted forms:
30
+ * defineTrait({ count: 0, label: "x" }) // SoA, primitives
31
+ * defineTrait({ pos: () => ({ x: 0, y: 0 }) }) // SoA, per-field factory
32
+ * defineTrait(() => ({ pos: { x: 0, y: 0 } })) // whole-trait AoS factory
33
+ *
34
+ * Rejected (the footgun):
35
+ * defineTrait({ pos: { x: 0, y: 0 } }) // shared literal → aliased
36
+ */
37
+ export function defineTrait(schema) {
38
+ if (typeof schema !== "function") {
39
+ for (const [key, value] of Object.entries(schema)) {
40
+ if (value !== null && typeof value === "object") {
41
+ throw new TypeError(`defineTrait: field "${key}" is a bare object literal. koota's SoA layout ` +
42
+ `would share this single instance across every entity holding the trait ` +
43
+ `(state aliasing). Use an AoS factory instead: ${key}: () => ({...}).`);
44
+ }
45
+ }
46
+ }
47
+ // Pin Koota's normalization generic to the caller's schema. Passing the
48
+ // guard intersection through inference makes Koota 0.6.x normalize
49
+ // `S & SafeSchema<S>` and produces a narrower, incompatible Trait type.
50
+ return trait(schema);
51
+ }
@@ -0,0 +1,83 @@
1
+ import type { World } from "koota";
2
+ import { createActions, relation, trait } from "koota";
3
+ import type { RngLayers, RngLayersSnapshot, RngSeeds } from "./rng.js";
4
+ export type { Entity } from "koota";
5
+ export type { World };
6
+ export { createActions, relation, trait };
7
+ export type WorldHandle = {
8
+ readonly world: World;
9
+ rng: RngLayers;
10
+ /**
11
+ * The immutable seeds this world was created from. Stored so systems
12
+ * can derive deterministic per-entity seeds (e.g. noise keyed on an
13
+ * entity's stable grid coordinates) WITHOUT lazily consuming the
14
+ * rng.gen stream in query-iteration order — koota entity ids are not
15
+ * reset between worlds, so iteration-order rng draws are
16
+ * non-deterministic across two runs in one process. Derive from
17
+ * `seeds.gen`, never a lazy draw.
18
+ */
19
+ readonly seeds: Readonly<RngSeeds>;
20
+ clock: {
21
+ tickIndex: number;
22
+ simSeconds: number;
23
+ };
24
+ /**
25
+ * Per-system scratch caches owned by the WorldHandle (so they lifecycle with
26
+ * the world, not at module scope). Systems read/write via well-known keys.
27
+ * Examples: per-region noise fns, pathfinding grids, behavior-tree instances.
28
+ */
29
+ readonly scratch: Map<string, unknown>;
30
+ };
31
+ /**
32
+ * Create a fresh `WorldHandle`: a new koota world, both RNG layers seeded
33
+ * from `seeds`, a zeroed clock, and an empty scratch map. This is the one
34
+ * entry point for standing up a sim — call it once per world (per game
35
+ * session, per test, per worldgen run) rather than constructing the koota
36
+ * world and RNG separately, so the facade's invariants (immutable `seeds`,
37
+ * clock starting at zero, scratch starting empty) always hold.
38
+ */
39
+ export declare function createSimWorld(seeds: RngSeeds): WorldHandle;
40
+ /**
41
+ * Tear down a simulation handle. This clears facade-owned scratch state and
42
+ * releases Koota's process-global world id. The operation is idempotent, which
43
+ * makes it safe to call from cleanup paths that may run more than once.
44
+ */
45
+ export declare function destroySimWorld(handle: WorldHandle): void;
46
+ /**
47
+ * Advance `handle`'s clock by one fixed-timestep tick: increments
48
+ * `tickIndex` by 1 and accumulates `dt` seconds into `simSeconds`. Call once
49
+ * per sim tick, before running that tick's systems, so `clock` always
50
+ * reflects "how far the sim has progressed" rather than "how far it will
51
+ * have progressed after this tick's work."
52
+ */
53
+ export declare function advanceClock(handle: WorldHandle, dt: number): void;
54
+ /**
55
+ * The serializable "header" of a world: both RNG layers' byte-exact state
56
+ * plus the clock. Deliberately excludes entity/component state — koota
57
+ * entities are the caller's data to serialize however their game needs
58
+ * (full ECS dump, delta log, etc.); this snapshot only covers what the
59
+ * facade itself owns.
60
+ */
61
+ export type WorldSnapshot = {
62
+ rng: RngLayersSnapshot;
63
+ clock: {
64
+ tickIndex: number;
65
+ simSeconds: number;
66
+ };
67
+ };
68
+ /**
69
+ * Capture `handle`'s RNG state and clock into a plain, JSON-serializable
70
+ * `WorldSnapshot`. Pair with `restoreWorldHeader` for save/load — restoring
71
+ * a snapshot reproduces every subsequent RNG draw on both layers byte-exact
72
+ * and resumes the clock from the exact tick/second it was captured at.
73
+ */
74
+ export declare function snapshotWorld(handle: WorldHandle): WorldSnapshot;
75
+ /**
76
+ * Restore `handle`'s RNG layers and clock from a `WorldSnapshot` taken by
77
+ * `snapshotWorld`. Mutates `handle` in place (replaces `handle.rng` and
78
+ * `handle.clock` with freshly rebuilt values) — entity/component state is
79
+ * untouched, since the facade never captured it in the first place; restore
80
+ * that separately using whatever serialization your game chose.
81
+ */
82
+ export declare function restoreWorldHeader(handle: WorldHandle, snap: WorldSnapshot): void;
83
+ //# sourceMappingURL=world.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"world.d.ts","sourceRoot":"","sources":["../../src/world.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AACnC,OAAO,EAAE,aAAa,EAAe,QAAQ,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AACpE,OAAO,KAAK,EAAE,SAAS,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAGvE,YAAY,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AACpC,YAAY,EAAE,KAAK,EAAE,CAAC;AACtB,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;AAE1C,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,GAAG,EAAE,SAAS,CAAC;IACf;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACnC,KAAK,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IACjD;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,GAAG,WAAW,CAY3D;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,CAKzD;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI,CAiBlE;AAED;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,iBAAiB,CAAC;IACvB,KAAK,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;CAClD,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,aAAa,CAKhE;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,aAAa,GAAG,IAAI,CAkBjF"}
@@ -0,0 +1,101 @@
1
+ // World facade. Wraps koota's createWorld + trait helper so the rest of a
2
+ // game's codebase imports from here and lifecycle/determinism conventions stay
3
+ // behind one boundary.
4
+ //
5
+ // Games declare their traits with `defineTrait` (see ./traits) and drive the
6
+ // sim through the WorldHandle: world creation, trait re-export, tick clock,
7
+ // dual-layer RNG plumbing.
8
+ import { createActions, createWorld, relation, trait } from "koota";
9
+ import { createRng, restoreLayers, snapshotLayers } from "./rng.js";
10
+ export { createActions, relation, trait };
11
+ /**
12
+ * Create a fresh `WorldHandle`: a new koota world, both RNG layers seeded
13
+ * from `seeds`, a zeroed clock, and an empty scratch map. This is the one
14
+ * entry point for standing up a sim — call it once per world (per game
15
+ * session, per test, per worldgen run) rather than constructing the koota
16
+ * world and RNG separately, so the facade's invariants (immutable `seeds`,
17
+ * clock starting at zero, scratch starting empty) always hold.
18
+ */
19
+ export function createSimWorld(seeds) {
20
+ // Validate before allocating a Koota world: invalid input must not consume a
21
+ // process-global world id or leave a world that the caller cannot destroy.
22
+ const rng = createRng(seeds);
23
+ const immutableSeeds = Object.freeze({ gen: seeds.gen, events: seeds.events });
24
+ return {
25
+ world: createWorld(),
26
+ rng,
27
+ seeds: immutableSeeds,
28
+ clock: { tickIndex: 0, simSeconds: 0 },
29
+ scratch: new Map(),
30
+ };
31
+ }
32
+ /**
33
+ * Tear down a simulation handle. This clears facade-owned scratch state and
34
+ * releases Koota's process-global world id. The operation is idempotent, which
35
+ * makes it safe to call from cleanup paths that may run more than once.
36
+ */
37
+ export function destroySimWorld(handle) {
38
+ handle.scratch.clear();
39
+ if (handle.world.isInitialized) {
40
+ handle.world.destroy();
41
+ }
42
+ }
43
+ /**
44
+ * Advance `handle`'s clock by one fixed-timestep tick: increments
45
+ * `tickIndex` by 1 and accumulates `dt` seconds into `simSeconds`. Call once
46
+ * per sim tick, before running that tick's systems, so `clock` always
47
+ * reflects "how far the sim has progressed" rather than "how far it will
48
+ * have progressed after this tick's work."
49
+ */
50
+ export function advanceClock(handle, dt) {
51
+ if (!Number.isFinite(dt) || dt < 0) {
52
+ throw new RangeError(`advanceClock: dt must be a finite, non-negative number; received ${dt}.`);
53
+ }
54
+ if (!Number.isSafeInteger(handle.clock.tickIndex) || handle.clock.tickIndex < 0) {
55
+ throw new RangeError("advanceClock: clock.tickIndex must be a non-negative safe integer.");
56
+ }
57
+ if (!Number.isFinite(handle.clock.simSeconds) || handle.clock.simSeconds < 0) {
58
+ throw new RangeError("advanceClock: clock.simSeconds must be finite and non-negative.");
59
+ }
60
+ const tickIndex = handle.clock.tickIndex + 1;
61
+ const simSeconds = handle.clock.simSeconds + dt;
62
+ if (!Number.isSafeInteger(tickIndex) || !Number.isFinite(simSeconds)) {
63
+ throw new RangeError("advanceClock: advancing the clock would exceed its numeric range.");
64
+ }
65
+ handle.clock.tickIndex = tickIndex;
66
+ handle.clock.simSeconds = simSeconds;
67
+ }
68
+ /**
69
+ * Capture `handle`'s RNG state and clock into a plain, JSON-serializable
70
+ * `WorldSnapshot`. Pair with `restoreWorldHeader` for save/load — restoring
71
+ * a snapshot reproduces every subsequent RNG draw on both layers byte-exact
72
+ * and resumes the clock from the exact tick/second it was captured at.
73
+ */
74
+ export function snapshotWorld(handle) {
75
+ return {
76
+ rng: snapshotLayers(handle.rng),
77
+ clock: { tickIndex: handle.clock.tickIndex, simSeconds: handle.clock.simSeconds },
78
+ };
79
+ }
80
+ /**
81
+ * Restore `handle`'s RNG layers and clock from a `WorldSnapshot` taken by
82
+ * `snapshotWorld`. Mutates `handle` in place (replaces `handle.rng` and
83
+ * `handle.clock` with freshly rebuilt values) — entity/component state is
84
+ * untouched, since the facade never captured it in the first place; restore
85
+ * that separately using whatever serialization your game chose.
86
+ */
87
+ export function restoreWorldHeader(handle, snap) {
88
+ const clock = snap?.clock;
89
+ if (!clock ||
90
+ !Number.isSafeInteger(clock.tickIndex) ||
91
+ clock.tickIndex < 0 ||
92
+ !Number.isFinite(clock.simSeconds) ||
93
+ clock.simSeconds < 0) {
94
+ throw new TypeError("restoreWorldHeader: snapshot.clock must contain a non-negative safe integer tickIndex and finite, non-negative simSeconds.");
95
+ }
96
+ // Build and validate every replacement before mutating the handle so a bad
97
+ // persisted snapshot cannot leave it half-restored.
98
+ const rng = restoreLayers(snap.rng);
99
+ handle.rng = rng;
100
+ handle.clock = { tickIndex: clock.tickIndex, simSeconds: clock.simSeconds };
101
+ }