koota-kit 0.5.0 → 0.6.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0](https://github.com/jbcom/koota-kit/compare/v0.5.0...v0.6.0) (2026-10-10)
4
+
5
+
6
+ ### Features
7
+
8
+ * **schedule:** add createSchedule, a fixed-step system schedule ([bb69bd6](https://github.com/jbcom/koota-kit/commit/bb69bd638e7a3dc6315ede827e4c58820facea3a))
9
+ * **schedule:** add createSchedule, a fixed-step system schedule ([21f6ffb](https://github.com/jbcom/koota-kit/commit/21f6ffbb2a6f4fae819f88197d02e30f50cc98ea))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **schedule:** pause means no steps, and a refused step is never debited ([e2fbfad](https://github.com/jbcom/koota-kit/commit/e2fbfadb363764bc8c43232e19862223ef250ee6))
15
+
3
16
  ## [0.5.0](https://github.com/jbcom/koota-kit/compare/v0.4.0...v0.5.0) (2026-10-10)
4
17
 
5
18
 
package/README.md CHANGED
@@ -137,6 +137,26 @@ defineTrait({ storage: { logs: 0 } }); // TypeError: shared object footg
137
137
  Declare traits once in a leaf module and export each trait from one location.
138
138
  Koota identifies traits by reference, not by a string name.
139
139
 
140
+ ### A fixed-step schedule removes the frame rate from the sim
141
+
142
+ `createSchedule(systems, { step, maxSteps })` returns a `tick(handle, frameDt)`
143
+ for your frame loop. The frame's dt fills an accumulator kept in `handle.scratch`
144
+ (so it dies with the run); each whole `step` advances the clock, then calls every
145
+ system in order with `dt === step`. At most `maxSteps` run per tick and any
146
+ excess is dropped, so one huge dt after a backgrounded tab cannot cause a
147
+ catch-up storm. `tick(handle, 0)` is a valid pause and returns the steps run.
148
+
149
+ ```ts
150
+ import { createSchedule } from "koota-kit";
151
+
152
+ const schedule = createSchedule([stove, mixer, serving], { step: 1 / 60, maxSteps: 6 });
153
+ // once per rendered frame:
154
+ const steps = schedule.tick(sim, frameDt);
155
+ ```
156
+
157
+ Sixty frames of 1/60 s and thirty of 1/30 s run the same sixty steps.
158
+ `schedule.remainder(sim) / schedule.step` is the interpolation alpha.
159
+
140
160
  ### Event logs have one consuming owner
141
161
 
142
162
  `push` appends, `drain` returns every pending event and empties the log, and
@@ -205,6 +225,7 @@ are also available:
205
225
  | `koota-kit/rng` | Seeded streams, named substreams, bounded draws, snapshots |
206
226
  | `koota-kit/seed` | Seed derivation and 128-bit master seeds |
207
227
  | `koota-kit/traits` | Safe trait declaration |
228
+ | `koota-kit/schedule` | Fixed-step system schedule with a step cap |
208
229
  | `koota-kit/eventLog` | World-scoped publish/drain logs |
209
230
  | `koota-kit/react` | `SimWorldProvider` and `useSimWorld` (optional React peer) |
210
231
 
@@ -224,6 +245,8 @@ For task-oriented usage, read the Sourcey guides on
224
245
  - `createMasterSeed` throws when no entropy source exists or the source leaves
225
246
  the buffer all zero.
226
247
  - `advanceClock` accepts finite, non-negative `dt`; `0` is a valid paused tick.
248
+ - `createSchedule` needs a finite `step > 0` and a safe-integer `maxSteps >= 1`;
249
+ a negative, `NaN` or infinite `frameDt` counts as `0`.
227
250
  - `nextInt` requires safe-integer bounds with `maxExclusive > minInclusive` and
228
251
  a range no larger than `2**32`.
229
252
  - `chance` accepts only finite probabilities in `[0, 1]`.
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  // koota-kit — thin koota ECS conventions layer.
3
3
  //
4
- // Five modules, also importable as subpaths:
4
+ // Six modules, also importable as subpaths:
5
5
  // ./world — WorldHandle lifecycle (world + dual-stream rng + clock + scratch)
6
+ // ./schedule — fixed-step system schedule (accumulator in scratch, step cap)
6
7
  // ./rng — dual-layer deterministic seedrandom PRNG and named substreams
7
8
  // with byte-exact snapshot/restore
8
9
  // ./seed — length-prefixed SHA-256 seed derivation and 128-bit master
@@ -12,7 +13,7 @@
12
13
  // ./eventLog — scratch-backed publish/drain event logs with a peek seam for
13
14
  // observers (harnesses, tests, HUDs)
14
15
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.defineTrait = exports.defineEventLog = exports.MASTER_SEED_BYTES = exports.isMasterSeed = exports.deriveSeed = exports.createMasterSeed = exports.substream = exports.snapshotSubstreams = exports.snapshotStream = exports.snapshotLayers = exports.shuffle = exports.restoreSubstreams = exports.restoreStream = exports.restoreLayers = exports.pick = exports.nextU32 = exports.nextInt = exports.nextFloat = exports.getSubstream = exports.createSubstreams = exports.createRng = exports.chance = exports.trait = exports.snapshotWorld = exports.restoreWorldHeader = exports.relation = exports.destroySimWorld = exports.createSimWorld = exports.createActions = exports.advanceClock = void 0;
16
+ exports.defineTrait = exports.defineEventLog = exports.createSchedule = exports.MASTER_SEED_BYTES = exports.isMasterSeed = exports.deriveSeed = exports.createMasterSeed = exports.substream = exports.snapshotSubstreams = exports.snapshotStream = exports.snapshotLayers = exports.shuffle = exports.restoreSubstreams = exports.restoreStream = exports.restoreLayers = exports.pick = exports.nextU32 = exports.nextInt = exports.nextFloat = exports.getSubstream = exports.createSubstreams = exports.createRng = exports.chance = exports.trait = exports.snapshotWorld = exports.restoreWorldHeader = exports.relation = exports.destroySimWorld = exports.createSimWorld = exports.createActions = exports.advanceClock = void 0;
16
17
  var world_js_1 = require("./world.cjs");
17
18
  Object.defineProperty(exports, "advanceClock", { enumerable: true, get: function () { return world_js_1.advanceClock; } });
18
19
  Object.defineProperty(exports, "createActions", { enumerable: true, get: function () { return world_js_1.createActions; } });
@@ -44,6 +45,8 @@ Object.defineProperty(exports, "createMasterSeed", { enumerable: true, get: func
44
45
  Object.defineProperty(exports, "deriveSeed", { enumerable: true, get: function () { return seed_js_1.deriveSeed; } });
45
46
  Object.defineProperty(exports, "isMasterSeed", { enumerable: true, get: function () { return seed_js_1.isMasterSeed; } });
46
47
  Object.defineProperty(exports, "MASTER_SEED_BYTES", { enumerable: true, get: function () { return seed_js_1.MASTER_SEED_BYTES; } });
48
+ var schedule_js_1 = require("./schedule.cjs");
49
+ Object.defineProperty(exports, "createSchedule", { enumerable: true, get: function () { return schedule_js_1.createSchedule; } });
47
50
  var eventLog_js_1 = require("./eventLog.cjs");
48
51
  Object.defineProperty(exports, "defineEventLog", { enumerable: true, get: function () { return eventLog_js_1.defineEventLog; } });
49
52
  var index_js_1 = require("./traits/index.cjs");
@@ -4,6 +4,8 @@ export { chance, createRng, createSubstreams, getSubstream, nextFloat, nextInt,
4
4
  export type { RngLayers, RngLayersSnapshot, RngSeeds, RngStream, RngStreamSnapshot, Substreams, SubstreamsSnapshot, } from "./rng.cjs";
5
5
  export { createMasterSeed, deriveSeed, isMasterSeed, MASTER_SEED_BYTES } from "./seed.cjs";
6
6
  export type { EntropySource, SeedPart } from "./seed.cjs";
7
+ export { createSchedule } from "./schedule.cjs";
8
+ export type { Schedule, ScheduleOptions, System } from "./schedule.cjs";
7
9
  export { defineEventLog } from "./eventLog.cjs";
8
10
  export type { EventLog } from "./eventLog.cjs";
9
11
  export { defineTrait } from "./traits/index.cjs";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAaA,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,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,OAAO,EACP,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,QAAQ,EACR,SAAS,EACT,iBAAiB,EACjB,UAAU,EACV,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1F,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEzD,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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAcA,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,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,OAAO,EACP,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,QAAQ,EACR,SAAS,EACT,iBAAiB,EACjB,UAAU,EACV,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1F,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAEvE,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,131 @@
1
+ "use strict";
2
+ // Fixed-step system schedule — the loop that turns "a frame passed" into
3
+ // "the sim advanced by whole steps".
4
+ //
5
+ // A simulation that integrates the frame's own dt drifts with the frame rate:
6
+ // a 120 Hz phone and a test that advances three seconds in one call disagree,
7
+ // and anything counted in real time (a save every 0.25 s) lands on different
8
+ // ticks. A fixed-step schedule removes the frame rate from the sim: the frame's
9
+ // dt fills an accumulator, and the sim only ever advances by whole `step`s.
10
+ //
11
+ // The loop is generic sim plumbing, so it lives here and not in the game: it
12
+ // knows nothing about React, a renderer, or what a system does. A renderer's
13
+ // frame callback calls `schedule.tick(handle, dt)` and that is the entire
14
+ // integration. The accumulator lives in the handle's `scratch`, so it dies with
15
+ // the run: a new world starts with nothing owed, and two schedules (or two
16
+ // worlds) never share one.
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.createSchedule = createSchedule;
19
+ const world_js_1 = require("./world.cjs");
20
+ let nextScheduleId = 0;
21
+ // Keys are process-wide because schedules are declared once at module scope and
22
+ // used on many handles. An explicit key may be shared on purpose; a generated
23
+ // one must stay private to its schedule.
24
+ const claimedKeys = new Set();
25
+ const generatedKeys = new Set();
26
+ // Float accumulation: 432 frames of 1/144 add up to a hair under 3 s, and
27
+ // without a tolerance the sim would owe — and run — one step fewer than the
28
+ // same time delivered at 60 Hz. A billionth of a step is far below anything a
29
+ // frame clock can resolve, so treating "within it" as whole costs nothing.
30
+ const STEP_TOLERANCE = 1e-9;
31
+ /**
32
+ * Build a fixed-step schedule over `systems`, run in the order given.
33
+ *
34
+ * ```ts
35
+ * const schedule = createSchedule([intro, stove, mixer, serving], {
36
+ * step: 1 / 60,
37
+ * maxSteps: 6,
38
+ * });
39
+ * // once per rendered frame, with the frame's dt:
40
+ * schedule.tick(run, frameDt);
41
+ * ```
42
+ *
43
+ * The systems array is copied; later changes to it do not affect the schedule.
44
+ */
45
+ function createSchedule(systems, options) {
46
+ if (!Array.isArray(systems)) {
47
+ throw new TypeError("createSchedule: systems must be an array of functions.");
48
+ }
49
+ const { step, maxSteps } = options;
50
+ if (!Number.isFinite(step) || step <= 0) {
51
+ throw new RangeError(`createSchedule: step must be finite and greater than zero; received ${step}.`);
52
+ }
53
+ if (!Number.isSafeInteger(maxSteps) || maxSteps < 1) {
54
+ throw new RangeError(`createSchedule: maxSteps must be a safe integer of at least 1; received ${maxSteps}.`);
55
+ }
56
+ if (options.key !== undefined && (typeof options.key !== "string" || options.key === "")) {
57
+ throw new TypeError("createSchedule: key must be a non-empty string when provided.");
58
+ }
59
+ const ordered = Object.freeze([...systems]);
60
+ for (const system of ordered) {
61
+ if (typeof system !== "function") {
62
+ throw new TypeError("createSchedule: every system must be a function.");
63
+ }
64
+ }
65
+ let key;
66
+ if (options.key === undefined) {
67
+ // Skip any key a caller has already named, so a default never lands on one.
68
+ do {
69
+ key = `schedule:${nextScheduleId++}`;
70
+ } while (claimedKeys.has(key));
71
+ generatedKeys.add(key);
72
+ }
73
+ else {
74
+ if (generatedKeys.has(options.key)) {
75
+ throw new TypeError(`createSchedule: key "${options.key}" is already the generated key of another schedule.`);
76
+ }
77
+ key = options.key;
78
+ }
79
+ claimedKeys.add(key);
80
+ const tolerance = step * STEP_TOLERANCE;
81
+ function read(handle) {
82
+ const stored = handle.scratch.get(key);
83
+ if (stored === undefined)
84
+ return 0;
85
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored < 0) {
86
+ throw new TypeError(`createSchedule: scratch key "${key}" already holds something other than a schedule accumulator.`);
87
+ }
88
+ return stored;
89
+ }
90
+ function tick(handle, frameDt) {
91
+ const previous = read(handle);
92
+ // A pause (and any frame dt that counts as 0) runs nothing, even if a
93
+ // failed tick left whole steps owed: those wait for a frame that moves time.
94
+ if (!(Number.isFinite(frameDt) && frameDt > 0))
95
+ return 0;
96
+ const sum = previous + frameDt;
97
+ // Two huge finite frames can sum past the double range; saturate instead of
98
+ // letting Infinity poison the stored accumulator.
99
+ let owed = Number.isFinite(sum) ? sum : Number.MAX_VALUE;
100
+ let ran = 0;
101
+ try {
102
+ while (owed + tolerance >= step && ran < maxSteps) {
103
+ // The clock moves first, so a step it refuses (an exhausted clock) is
104
+ // never debited. After that the step is spent before any system runs:
105
+ // if one throws, this step's time is gone with the tick it caused.
106
+ (0, world_js_1.advanceClock)(handle, step);
107
+ owed = Math.max(0, owed - step);
108
+ ran++;
109
+ for (const system of ordered)
110
+ system(handle, step);
111
+ }
112
+ if (owed + tolerance >= step) {
113
+ // Capped with whole steps still owed: drop them, keep the fraction.
114
+ owed %= step;
115
+ if (owed + tolerance >= step)
116
+ owed = 0;
117
+ }
118
+ }
119
+ finally {
120
+ handle.scratch.set(key, owed);
121
+ }
122
+ return ran;
123
+ }
124
+ return Object.freeze({
125
+ step,
126
+ maxSteps,
127
+ key,
128
+ tick,
129
+ remainder: read,
130
+ });
131
+ }
@@ -0,0 +1,69 @@
1
+ import type { WorldHandle } from "./world.cjs";
2
+ /**
3
+ * One unit of sim work, run once per fixed step.
4
+ *
5
+ * `dt` is always the schedule's `step`, never the frame's dt, so a system can
6
+ * integrate it without knowing the frame rate. Systems that need randomness
7
+ * should draw from a named substream rather than the handle's positional `rng`,
8
+ * so adding a system does not shift another system's draws.
9
+ */
10
+ export type System = (handle: WorldHandle, dt: number) => void;
11
+ export type ScheduleOptions = {
12
+ /** Seconds of sim time one step represents. Finite and greater than zero. */
13
+ step: number;
14
+ /**
15
+ * The most steps a single `tick` may run. A safe integer of at least 1. Time
16
+ * owed beyond this is dropped, not carried, so one huge frame dt (a hidden
17
+ * tab returning after a minute) cannot trigger a catch-up storm.
18
+ */
19
+ maxSteps: number;
20
+ /**
21
+ * The `scratch` key holding this schedule's accumulator, for callers who want
22
+ * to namespace it (`"run:schedule"`). Defaults to a key unique to this
23
+ * schedule. Two schedules given the same key share one accumulator.
24
+ */
25
+ key?: string;
26
+ };
27
+ export type Schedule = {
28
+ readonly step: number;
29
+ readonly maxSteps: number;
30
+ /** The `scratch` key holding this schedule's accumulator on a handle. */
31
+ readonly key: string;
32
+ /**
33
+ * Add `frameDt` seconds to `handle`'s accumulator and run every whole step it
34
+ * now covers, up to `maxSteps`: each step advances the clock by `step`, then
35
+ * calls every system in order with `dt === step`. Returns how many steps ran.
36
+ *
37
+ * A `frameDt` that is negative, `NaN` or infinite counts as `0`, and `0` is a
38
+ * valid pause: no step runs and the clock does not move. Whole steps beyond
39
+ * `maxSteps` are dropped; the sub-step remainder is kept.
40
+ *
41
+ * If a system throws, the steps that had already started are consumed (the
42
+ * clock moved for them) and the rest stay owed, so a retry does not replay
43
+ * time the sim already spent.
44
+ */
45
+ tick: (handle: WorldHandle, frameDt: number) => number;
46
+ /**
47
+ * Seconds currently owed to the next step, in `[0, step)` between ticks.
48
+ * Dividing it by `step` gives the interpolation alpha for a renderer drawing
49
+ * between the previous and current sim state. `0` on a handle that has not
50
+ * ticked.
51
+ */
52
+ remainder: (handle: WorldHandle) => number;
53
+ };
54
+ /**
55
+ * Build a fixed-step schedule over `systems`, run in the order given.
56
+ *
57
+ * ```ts
58
+ * const schedule = createSchedule([intro, stove, mixer, serving], {
59
+ * step: 1 / 60,
60
+ * maxSteps: 6,
61
+ * });
62
+ * // once per rendered frame, with the frame's dt:
63
+ * schedule.tick(run, frameDt);
64
+ * ```
65
+ *
66
+ * The systems array is copied; later changes to it do not affect the schedule.
67
+ */
68
+ export declare function createSchedule(systems: readonly System[], options: ScheduleOptions): Readonly<Schedule>;
69
+ //# sourceMappingURL=schedule.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schedule.d.ts","sourceRoot":"","sources":["../../src/schedule.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG9C;;;;;;;GAOG;AACH,MAAM,MAAM,MAAM,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;AAE/D,MAAM,MAAM,eAAe,GAAG;IAC5B,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,IAAI,EAAE,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IACvD;;;;;OAKG;IACH,SAAS,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,MAAM,CAAC;CAC5C,CAAC;AAeF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,EAAE,eAAe,GACvB,QAAQ,CAAC,QAAQ,CAAC,CA2FpB"}
@@ -4,6 +4,8 @@ export { chance, createRng, createSubstreams, getSubstream, nextFloat, nextInt,
4
4
  export type { RngLayers, RngLayersSnapshot, RngSeeds, RngStream, RngStreamSnapshot, Substreams, SubstreamsSnapshot, } from "./rng.js";
5
5
  export { createMasterSeed, deriveSeed, isMasterSeed, MASTER_SEED_BYTES } from "./seed.js";
6
6
  export type { EntropySource, SeedPart } from "./seed.js";
7
+ export { createSchedule } from "./schedule.js";
8
+ export type { Schedule, ScheduleOptions, System } from "./schedule.js";
7
9
  export { defineEventLog } from "./eventLog.js";
8
10
  export type { EventLog } from "./eventLog.js";
9
11
  export { defineTrait } from "./traits/index.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAaA,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,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,OAAO,EACP,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,QAAQ,EACR,SAAS,EACT,iBAAiB,EACjB,UAAU,EACV,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1F,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEzD,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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAcA,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,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,OAAO,EACP,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,QAAQ,EACR,SAAS,EACT,iBAAiB,EACjB,UAAU,EACV,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1F,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAEvE,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"}
package/dist/esm/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // koota-kit — thin koota ECS conventions layer.
2
2
  //
3
- // Five modules, also importable as subpaths:
3
+ // Six modules, also importable as subpaths:
4
4
  // ./world — WorldHandle lifecycle (world + dual-stream rng + clock + scratch)
5
+ // ./schedule — fixed-step system schedule (accumulator in scratch, step cap)
5
6
  // ./rng — dual-layer deterministic seedrandom PRNG and named substreams
6
7
  // with byte-exact snapshot/restore
7
8
  // ./seed — length-prefixed SHA-256 seed derivation and 128-bit master
@@ -13,5 +14,6 @@
13
14
  export { advanceClock, createActions, createSimWorld, destroySimWorld, relation, restoreWorldHeader, snapshotWorld, trait, } from "./world.js";
14
15
  export { chance, createRng, createSubstreams, getSubstream, nextFloat, nextInt, nextU32, pick, restoreLayers, restoreStream, restoreSubstreams, shuffle, snapshotLayers, snapshotStream, snapshotSubstreams, substream, } from "./rng.js";
15
16
  export { createMasterSeed, deriveSeed, isMasterSeed, MASTER_SEED_BYTES } from "./seed.js";
17
+ export { createSchedule } from "./schedule.js";
16
18
  export { defineEventLog } from "./eventLog.js";
17
19
  export { defineTrait } from "./traits/index.js";
@@ -0,0 +1,69 @@
1
+ import type { WorldHandle } from "./world.js";
2
+ /**
3
+ * One unit of sim work, run once per fixed step.
4
+ *
5
+ * `dt` is always the schedule's `step`, never the frame's dt, so a system can
6
+ * integrate it without knowing the frame rate. Systems that need randomness
7
+ * should draw from a named substream rather than the handle's positional `rng`,
8
+ * so adding a system does not shift another system's draws.
9
+ */
10
+ export type System = (handle: WorldHandle, dt: number) => void;
11
+ export type ScheduleOptions = {
12
+ /** Seconds of sim time one step represents. Finite and greater than zero. */
13
+ step: number;
14
+ /**
15
+ * The most steps a single `tick` may run. A safe integer of at least 1. Time
16
+ * owed beyond this is dropped, not carried, so one huge frame dt (a hidden
17
+ * tab returning after a minute) cannot trigger a catch-up storm.
18
+ */
19
+ maxSteps: number;
20
+ /**
21
+ * The `scratch` key holding this schedule's accumulator, for callers who want
22
+ * to namespace it (`"run:schedule"`). Defaults to a key unique to this
23
+ * schedule. Two schedules given the same key share one accumulator.
24
+ */
25
+ key?: string;
26
+ };
27
+ export type Schedule = {
28
+ readonly step: number;
29
+ readonly maxSteps: number;
30
+ /** The `scratch` key holding this schedule's accumulator on a handle. */
31
+ readonly key: string;
32
+ /**
33
+ * Add `frameDt` seconds to `handle`'s accumulator and run every whole step it
34
+ * now covers, up to `maxSteps`: each step advances the clock by `step`, then
35
+ * calls every system in order with `dt === step`. Returns how many steps ran.
36
+ *
37
+ * A `frameDt` that is negative, `NaN` or infinite counts as `0`, and `0` is a
38
+ * valid pause: no step runs and the clock does not move. Whole steps beyond
39
+ * `maxSteps` are dropped; the sub-step remainder is kept.
40
+ *
41
+ * If a system throws, the steps that had already started are consumed (the
42
+ * clock moved for them) and the rest stay owed, so a retry does not replay
43
+ * time the sim already spent.
44
+ */
45
+ tick: (handle: WorldHandle, frameDt: number) => number;
46
+ /**
47
+ * Seconds currently owed to the next step, in `[0, step)` between ticks.
48
+ * Dividing it by `step` gives the interpolation alpha for a renderer drawing
49
+ * between the previous and current sim state. `0` on a handle that has not
50
+ * ticked.
51
+ */
52
+ remainder: (handle: WorldHandle) => number;
53
+ };
54
+ /**
55
+ * Build a fixed-step schedule over `systems`, run in the order given.
56
+ *
57
+ * ```ts
58
+ * const schedule = createSchedule([intro, stove, mixer, serving], {
59
+ * step: 1 / 60,
60
+ * maxSteps: 6,
61
+ * });
62
+ * // once per rendered frame, with the frame's dt:
63
+ * schedule.tick(run, frameDt);
64
+ * ```
65
+ *
66
+ * The systems array is copied; later changes to it do not affect the schedule.
67
+ */
68
+ export declare function createSchedule(systems: readonly System[], options: ScheduleOptions): Readonly<Schedule>;
69
+ //# sourceMappingURL=schedule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schedule.d.ts","sourceRoot":"","sources":["../../src/schedule.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG9C;;;;;;;GAOG;AACH,MAAM,MAAM,MAAM,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;AAE/D,MAAM,MAAM,eAAe,GAAG;IAC5B,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,IAAI,EAAE,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IACvD;;;;;OAKG;IACH,SAAS,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,MAAM,CAAC;CAC5C,CAAC;AAeF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,EAAE,eAAe,GACvB,QAAQ,CAAC,QAAQ,CAAC,CA2FpB"}
@@ -0,0 +1,128 @@
1
+ // Fixed-step system schedule — the loop that turns "a frame passed" into
2
+ // "the sim advanced by whole steps".
3
+ //
4
+ // A simulation that integrates the frame's own dt drifts with the frame rate:
5
+ // a 120 Hz phone and a test that advances three seconds in one call disagree,
6
+ // and anything counted in real time (a save every 0.25 s) lands on different
7
+ // ticks. A fixed-step schedule removes the frame rate from the sim: the frame's
8
+ // dt fills an accumulator, and the sim only ever advances by whole `step`s.
9
+ //
10
+ // The loop is generic sim plumbing, so it lives here and not in the game: it
11
+ // knows nothing about React, a renderer, or what a system does. A renderer's
12
+ // frame callback calls `schedule.tick(handle, dt)` and that is the entire
13
+ // integration. The accumulator lives in the handle's `scratch`, so it dies with
14
+ // the run: a new world starts with nothing owed, and two schedules (or two
15
+ // worlds) never share one.
16
+ import { advanceClock } from "./world.js";
17
+ let nextScheduleId = 0;
18
+ // Keys are process-wide because schedules are declared once at module scope and
19
+ // used on many handles. An explicit key may be shared on purpose; a generated
20
+ // one must stay private to its schedule.
21
+ const claimedKeys = new Set();
22
+ const generatedKeys = new Set();
23
+ // Float accumulation: 432 frames of 1/144 add up to a hair under 3 s, and
24
+ // without a tolerance the sim would owe — and run — one step fewer than the
25
+ // same time delivered at 60 Hz. A billionth of a step is far below anything a
26
+ // frame clock can resolve, so treating "within it" as whole costs nothing.
27
+ const STEP_TOLERANCE = 1e-9;
28
+ /**
29
+ * Build a fixed-step schedule over `systems`, run in the order given.
30
+ *
31
+ * ```ts
32
+ * const schedule = createSchedule([intro, stove, mixer, serving], {
33
+ * step: 1 / 60,
34
+ * maxSteps: 6,
35
+ * });
36
+ * // once per rendered frame, with the frame's dt:
37
+ * schedule.tick(run, frameDt);
38
+ * ```
39
+ *
40
+ * The systems array is copied; later changes to it do not affect the schedule.
41
+ */
42
+ export function createSchedule(systems, options) {
43
+ if (!Array.isArray(systems)) {
44
+ throw new TypeError("createSchedule: systems must be an array of functions.");
45
+ }
46
+ const { step, maxSteps } = options;
47
+ if (!Number.isFinite(step) || step <= 0) {
48
+ throw new RangeError(`createSchedule: step must be finite and greater than zero; received ${step}.`);
49
+ }
50
+ if (!Number.isSafeInteger(maxSteps) || maxSteps < 1) {
51
+ throw new RangeError(`createSchedule: maxSteps must be a safe integer of at least 1; received ${maxSteps}.`);
52
+ }
53
+ if (options.key !== undefined && (typeof options.key !== "string" || options.key === "")) {
54
+ throw new TypeError("createSchedule: key must be a non-empty string when provided.");
55
+ }
56
+ const ordered = Object.freeze([...systems]);
57
+ for (const system of ordered) {
58
+ if (typeof system !== "function") {
59
+ throw new TypeError("createSchedule: every system must be a function.");
60
+ }
61
+ }
62
+ let key;
63
+ if (options.key === undefined) {
64
+ // Skip any key a caller has already named, so a default never lands on one.
65
+ do {
66
+ key = `schedule:${nextScheduleId++}`;
67
+ } while (claimedKeys.has(key));
68
+ generatedKeys.add(key);
69
+ }
70
+ else {
71
+ if (generatedKeys.has(options.key)) {
72
+ throw new TypeError(`createSchedule: key "${options.key}" is already the generated key of another schedule.`);
73
+ }
74
+ key = options.key;
75
+ }
76
+ claimedKeys.add(key);
77
+ const tolerance = step * STEP_TOLERANCE;
78
+ function read(handle) {
79
+ const stored = handle.scratch.get(key);
80
+ if (stored === undefined)
81
+ return 0;
82
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored < 0) {
83
+ throw new TypeError(`createSchedule: scratch key "${key}" already holds something other than a schedule accumulator.`);
84
+ }
85
+ return stored;
86
+ }
87
+ function tick(handle, frameDt) {
88
+ const previous = read(handle);
89
+ // A pause (and any frame dt that counts as 0) runs nothing, even if a
90
+ // failed tick left whole steps owed: those wait for a frame that moves time.
91
+ if (!(Number.isFinite(frameDt) && frameDt > 0))
92
+ return 0;
93
+ const sum = previous + frameDt;
94
+ // Two huge finite frames can sum past the double range; saturate instead of
95
+ // letting Infinity poison the stored accumulator.
96
+ let owed = Number.isFinite(sum) ? sum : Number.MAX_VALUE;
97
+ let ran = 0;
98
+ try {
99
+ while (owed + tolerance >= step && ran < maxSteps) {
100
+ // The clock moves first, so a step it refuses (an exhausted clock) is
101
+ // never debited. After that the step is spent before any system runs:
102
+ // if one throws, this step's time is gone with the tick it caused.
103
+ advanceClock(handle, step);
104
+ owed = Math.max(0, owed - step);
105
+ ran++;
106
+ for (const system of ordered)
107
+ system(handle, step);
108
+ }
109
+ if (owed + tolerance >= step) {
110
+ // Capped with whole steps still owed: drop them, keep the fraction.
111
+ owed %= step;
112
+ if (owed + tolerance >= step)
113
+ owed = 0;
114
+ }
115
+ }
116
+ finally {
117
+ handle.scratch.set(key, owed);
118
+ }
119
+ return ran;
120
+ }
121
+ return Object.freeze({
122
+ step,
123
+ maxSteps,
124
+ key,
125
+ tick,
126
+ remainder: read,
127
+ });
128
+ }
package/docs/API.md CHANGED
@@ -174,6 +174,63 @@ const Pose = defineTrait(() => ({ position: { x: 0, y: 0 } }));
174
174
  Bare object and array fields are rejected by `SafeSchema` and by the runtime
175
175
  guard.
176
176
 
177
+ ## `schedule`
178
+
179
+ Import from `koota-kit/schedule` or the root.
180
+
181
+ ```ts
182
+ type System = (handle: WorldHandle, dt: number) => void;
183
+
184
+ type ScheduleOptions = {
185
+ step: number; // seconds per step; finite, > 0
186
+ maxSteps: number; // safe integer >= 1: most steps one tick may run
187
+ key?: string; // scratch key for the accumulator; unique by default
188
+ };
189
+
190
+ type Schedule = {
191
+ readonly step: number;
192
+ readonly maxSteps: number;
193
+ readonly key: string;
194
+ tick(handle: WorldHandle, frameDt: number): number;
195
+ remainder(handle: WorldHandle): number;
196
+ };
197
+
198
+ function createSchedule(systems: readonly System[], options: ScheduleOptions): Readonly<Schedule>;
199
+ ```
200
+
201
+ `createSchedule` copies `systems` and validates `step`, `maxSteps`, `key` and
202
+ every system before returning; bad configuration throws `TypeError` or
203
+ `RangeError` naming `createSchedule`.
204
+
205
+ `tick(handle, frameDt)` adds `frameDt` to an accumulator stored in
206
+ `handle.scratch` under `key`, then, while at least one whole `step` is owed and
207
+ fewer than `maxSteps` have run, calls `advanceClock(handle, step)` and then
208
+ every system in order with `dt === step`. It returns the number of steps run.
209
+ Systems read `dt`, never the frame's dt, so the same elapsed time gives the same
210
+ run at any frame rate, provided no single tick owes more than `maxSteps` (the
211
+ excess is dropped, so one long frame and many short ones then differ).
212
+
213
+ - A `frameDt` that is negative, `NaN` or infinite counts as `0`. `0` is a valid
214
+ pause: no step runs and the clock does not move, even if a failed tick left
215
+ whole steps owed; those wait for the next frame that moves time.
216
+ - When `maxSteps` is reached with whole steps still owed, those steps are
217
+ dropped, not carried. The sub-step fraction is kept, so phase is preserved.
218
+ - Owed time saturates at the largest finite double instead of overflowing.
219
+ - A billionth of a step of tolerance absorbs float drift, so 432 frames of
220
+ 1/144 s run the same 180 steps as 180 frames of 1/60 s.
221
+ - The accumulator lives in `scratch`, so it dies with the world and two
222
+ handles never share one. A `key` already holding something other than a
223
+ schedule accumulator throws `TypeError` before the clock moves. Two schedules
224
+ given the same explicit `key` share one accumulator, but an explicit `key`
225
+ may not equal another schedule's generated key, and a generated key skips any
226
+ explicit one.
227
+ - If a system throws, the step it was in is already spent (the clock moved) and
228
+ the steps not yet started stay owed, so a retry does not replay spent time.
229
+
230
+ `remainder(handle)` returns the seconds currently owed, in `[0, step)` between
231
+ ticks (`0` on a handle that has not ticked). `remainder / step` is the
232
+ interpolation alpha for a renderer drawing between sim states.
233
+
177
234
  ## `eventLog`
178
235
 
179
236
  ```ts
@@ -11,6 +11,7 @@ traits ───────────────> koota
11
11
  seed ─────────────────> (leaf: built-in SHA-256)
12
12
  rng ──────────────────> seedrandom + seed
13
13
  world ────────────────> koota + rng
14
+ schedule ─────────────> world (advanceClock) + WorldHandle scratch
14
15
  eventLog ─────────────> WorldHandle scratch
15
16
  react ────────────────> koota/react + react + world (type only)
16
17
  index ────────────────> public re-exports only (never react)
@@ -34,6 +35,12 @@ text) is a stable contract: changing it would change every derived world.
34
35
  `world` owns allocation, teardown, time, and facade-owned persistence. It does
35
36
  not own application entity serialization.
36
37
 
38
+ `schedule` turns a frame's dt into whole fixed steps. Its accumulator lives in
39
+ the handle's scratch map, so time owed dies with the run and two schedules or
40
+ two worlds never share it. It imports no renderer: a frame callback calls
41
+ `tick` and that is the whole integration. The step cap drops excess time rather
42
+ than carrying it, so one huge dt cannot cause a catch-up storm.
43
+
37
44
  `eventLog` uses the handle's scratch map so event lifetime matches world
38
45
  lifetime. It does not use a process-global queue.
39
46
 
@@ -59,6 +66,10 @@ it reaches `react` or `koota/react`.
59
66
  8. A named substream's sequence depends only on its seed and name; creating or
60
67
  consuming another substream never shifts it.
61
68
  9. Only `koota-kit/react` imports React; every other entry stays React-free.
69
+ 10. A schedule's systems only ever see `dt === step`; the same elapsed time
70
+ delivered at any frame rate runs the same steps in the same order, as long
71
+ as no single tick owes more than `maxSteps` (beyond that, the excess is
72
+ dropped by design, so a one-second frame and sixty 1/60 s frames differ).
62
73
 
63
74
  ## Performance choices
64
75
 
@@ -73,8 +84,10 @@ it reaches `react` or `koota/react`.
73
84
  ## Intentional limits
74
85
 
75
86
  - No entity/trait serializer: applications need versioned domain schemas.
76
- - No scheduler or system runner: Koota's queries/actions remain the execution
77
- model.
87
+ - No system graph: `createSchedule` runs an ordered list once per fixed step,
88
+ and nothing more. There is no dependency resolution, parallelism, or
89
+ per-system rate; a system that wants a slower cadence counts steps itself.
90
+ Koota's queries/actions remain the execution model inside each system.
78
91
  - No implicit derived keys: `deriveSeed` hashes only the parts the application
79
92
  passes, so the application still owns stable domain keys.
80
93
  - No multi-consumer queue semantics: publish separate logs when two systems
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koota-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Deterministic Koota simulation conventions: world lifecycle, dual RNG streams, safe object traits, and world-scoped event logs.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -63,6 +63,16 @@
63
63
  "default": "./dist/cjs/traits/index.cjs"
64
64
  }
65
65
  },
66
+ "./schedule": {
67
+ "import": {
68
+ "types": "./dist/esm/schedule.d.ts",
69
+ "default": "./dist/esm/schedule.js"
70
+ },
71
+ "require": {
72
+ "types": "./dist/cjs/schedule.d.cts",
73
+ "default": "./dist/cjs/schedule.cjs"
74
+ }
75
+ },
66
76
  "./eventLog": {
67
77
  "import": {
68
78
  "types": "./dist/esm/eventLog.d.ts",
@@ -188,6 +198,9 @@
188
198
  "traits": [
189
199
  "./dist/esm/traits/index.d.ts"
190
200
  ],
201
+ "schedule": [
202
+ "./dist/esm/schedule.d.ts"
203
+ ],
191
204
  "eventLog": [
192
205
  "./dist/esm/eventLog.d.ts"
193
206
  ],