@irtio/runtime 0.1.0 → 0.3.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.
@@ -0,0 +1,458 @@
1
+ import { DirtySet, AnySchema, PlainState, Tracked, State } from '@irtio/schema';
2
+ import { RoomDefinition, RoomMode, Room, Ctx, RapierModule, RapierWorld, RapierRigidBody, ResolvedRoomConfig, LeaveReason } from '@irtio/server';
3
+ import { h as RoomHost, j as RoomStats, L as LogLevel, R as RoomCoreApi, d as RoomCoreOptions, f as RoomEventKind, i as RoomInspection, J as JoinOptions, c as JoinResult, b as HostCallResult } from './contract-B8QSO0MH.js';
4
+
5
+ /**
6
+ * `room.random()` — mulberry32 over a u32 seed. Deterministic, tiny, and serializable: the
7
+ * generator's whole state is one u32, so `serialize()`/`restore()` round-trip it exactly.
8
+ */
9
+ declare class Mulberry32 {
10
+ /** Current internal state (u32). Survives hibernation. */
11
+ state: number;
12
+ constructor(seed: number);
13
+ /** Next float in `[0, 1)`. */
14
+ next(): number;
15
+ }
16
+
17
+ /**
18
+ * The internal seam between `RoomCore` (core/room.ts) and the modules it delegates to
19
+ * (`loop`, `views`, `writes`, `rpc`, `messages`, `room-api`). Nothing here is public API —
20
+ * `src/contract.ts` is. `RoomCore` implements `RoomInternals` structurally, so the modules
21
+ * take it as a parameter and never import `room.ts` (no cycles).
22
+ */
23
+
24
+ type AnyRecord = Record<string, unknown>;
25
+ /** `${collection}\0${id}` → leaf-path key → the value the runtime wrote for that leaf. */
26
+ type AcceptedWrites = Map<string, Map<string, unknown>>;
27
+ /** One joined client. Presence is the wire truth; this is the runtime's bookkeeping. */
28
+ interface ClientEntry {
29
+ readonly clientId: string;
30
+ /**
31
+ * D25: the identity `ctx.playerId` reports. Set once at the first join and kept across
32
+ * reconnects, exactly like the client id it defaults to.
33
+ */
34
+ readonly playerId: string;
35
+ role: string;
36
+ name: string;
37
+ connected: boolean;
38
+ /** Pending `CORRECT` mask, encoded from current state at the next flush. */
39
+ correction: DirtySet | undefined;
40
+ /** Leaves this client's own accepted `WRITE`s produced during the current flush window. */
41
+ readonly accepted: AcceptedWrites;
42
+ /**
43
+ * The tick stamped on the last `WRITE` this client sent (the client-local write counter in the
44
+ * delta header — week 8, D19), `0` before its first write. Every `CORRECT` sent to this client
45
+ * carries it as `clientTick`: "your writes through this tick are reflected in these values".
46
+ * An unprompted correction (a server-wins overwrite with no triggering write) echoes it
47
+ * unchanged — for a client that never wrote, that is `0`.
48
+ */
49
+ lastClientTick: number;
50
+ /**
51
+ * The room tick `lastClientTick`'s write was applied at, `0` before this client's first write.
52
+ * Sent alongside it on every `CORRECT` (bug 1). The two are a pair — one number on the
53
+ * client's stamp clock, one on the room's — and the client uses the gap between them to learn
54
+ * where on the server's tick stream its still-unjudged intents will land.
55
+ */
56
+ lastAppliedTick: number;
57
+ /**
58
+ * Set at `join`: this client's own record(s) — its presence row and whatever entities its own
59
+ * `onJoin` added, owned by it — as they stood right after join, per collection. Consumed once
60
+ * by the very next `flush()`, which strips exactly the ids whose value (and owner) still match
61
+ * this capture from this client's delta only — an id mutated again before that flush (e.g. a
62
+ * same-window `room.setRole`) is left alone, since the join snapshot never saw that value.
63
+ * Every other client's delta is unaffected.
64
+ */
65
+ pendingJoinAdds: Map<string, ReadonlyMap<string, JoinAddCapture>> | undefined;
66
+ /** Last AOI membership sent to this client, keyed by spatial collection. */
67
+ spatialMembership: Map<string, Set<string>>;
68
+ }
69
+ /** What `join` captured for one of a client's own just-added records, for `flush`'s comparison. */
70
+ interface JoinAddCapture {
71
+ readonly value: unknown;
72
+ readonly owner: string | undefined;
73
+ }
74
+ type GuardResult<T> = {
75
+ readonly ok: true;
76
+ readonly value: T;
77
+ } | {
78
+ readonly ok: false;
79
+ };
80
+ /** One queued inbound frame (tick mode). */
81
+ interface QueuedFrame {
82
+ readonly clientId: string;
83
+ readonly type: number;
84
+ readonly payload: Uint8Array;
85
+ }
86
+ /** What the core modules may do with the physics world (`core/physics.ts` implements it). */
87
+ interface PhysicsApi {
88
+ readonly timestep: number;
89
+ /** Creates bodies for new instances, destroys bodies whose instance is gone. */
90
+ reconcile(): void;
91
+ step(): void;
92
+ /** Body → schema, through the tracked proxies. */
93
+ sync(): void;
94
+ bodyFor(collection: string, id: string): unknown;
95
+ readonly rapier: unknown;
96
+ readonly world: unknown;
97
+ }
98
+ interface LoopApi {
99
+ start(): void;
100
+ stop(): void;
101
+ /** Event mode: an inbound frame or a join re-arms the idle timer. */
102
+ noteActivity(): void;
103
+ /** `room.setTimeout` / `room.setInterval`. Returns a numeric handle. */
104
+ setTimer(ms: number, fn: () => void, repeat: boolean): number;
105
+ clearTimer(handle: number): void;
106
+ /** Drops every room timer (hibernation, stop). */
107
+ clearAllTimers(): void;
108
+ /** Internal one-shot on the host clock (RPC timeouts in event mode). */
109
+ after(ms: number, fn: () => void): void;
110
+ }
111
+ /** What the core modules are allowed to see of `RoomCore`. */
112
+ interface RoomInternals {
113
+ readonly definition: RoomDefinition;
114
+ /** `withBuiltins(definition.schema)` — what every frame and the codec use. */
115
+ readonly ext: AnySchema;
116
+ readonly host: RoomHost;
117
+ readonly roomId: string;
118
+ readonly mode: RoomMode;
119
+ /** The plain state the tracked proxies wrap (what the codec reads). */
120
+ readonly plain: PlainState;
121
+ readonly tracked: Tracked<AnySchema>;
122
+ /** The tracked proxy tree, loosely typed for internal use. */
123
+ readonly anyState: AnyRecord;
124
+ readonly room: Room;
125
+ readonly rng: Mulberry32;
126
+ readonly stats: RoomStats;
127
+ /** Joined clients in join order. */
128
+ readonly clients: Map<string, ClientEntry>;
129
+ readonly loop: LoopApi;
130
+ /** D22: the Rapier world, `undefined` in a room with no `physics:` config. */
131
+ readonly physics: PhysicsApi | undefined;
132
+ tick: number;
133
+ stopped: boolean;
134
+ /** Runs a room handler; a throw is logged and counted, never rethrown. */
135
+ guard<T>(name: string, fn: () => T): T | undefined;
136
+ recordEvent(kind: 'join' | 'leave' | 'write' | 'write-rejected' | 'correct' | 'call' | 'reply' | 'msg' | 'alarm' | 'error', clientId?: string, detail?: string): void;
137
+ /** `guard` that also reports whether the handler threw (the tick loop needs this). */
138
+ tryRun<T>(name: string, fn: () => T): GuardResult<T>;
139
+ log(level: LogLevel, ...args: unknown[]): void;
140
+ /** Sends one framed protocol frame to a connected client and counts it. */
141
+ send(clientId: string, frame: Uint8Array): void;
142
+ ctxFor(clientId: string, reconnecting?: boolean): Ctx;
143
+ /** The client's pending `CORRECT` dirty set, created on demand. */
144
+ correctionFor(clientId: string): DirtySet | undefined;
145
+ /** Flushes the tracked dirty set: corrections first, then one `DELTA` per distinct view. */
146
+ flush(): void;
147
+ /** Invalidates the cached `room.clients` array. */
148
+ invalidateClients(): void;
149
+ }
150
+
151
+ /**
152
+ * Scheduling: the tick-mode fixed-step loop with catch-up, event-mode idle detection, and
153
+ * `room.setTimeout` / `room.setInterval`.
154
+ *
155
+ * Tick mode: one host timeout re-armed every `1000 / tickRate` ms. Each wake accumulates real
156
+ * elapsed time and runs up to `MAX_CATCHUP` ticks; a bigger backlog is dropped and counted as an
157
+ * overrun (logged once per burst). Room timers are tick-granular: `setTimeout(ms)` fires on the
158
+ * first tick at or after `now + ms`.
159
+ *
160
+ * Event mode: no tick timer. Room timers fire on the host clock, each firing advancing the tick
161
+ * and flushing. After `idleMs` with no inbound frames the host is asked to hibernate exactly once
162
+ * (re-armed by the next frame or join). Connected sockets do not keep an event room awake — a quiz
163
+ * hibernates between rounds with everyone still watching.
164
+ *
165
+ * Both modes hibernate, but "idle" cannot mean the same thing in each. A tick room *simulates* by
166
+ * definition: no inbound frames is its steady state, not its idleness. So a tick room sleeps after
167
+ * `idleMs` with **no connected clients**, which is the only sense in which a simulation nobody is
168
+ * watching is doing nothing worth paying for. `idleMs: 0` opts out in either mode and keeps the
169
+ * room resident (the same meaning it already has for relay rooms in the supervisor).
170
+ */
171
+
172
+ /** Most ticks one wake may run before the backlog is dropped. */
173
+ declare const MAX_CATCHUP = 5;
174
+ /**
175
+ * Consecutive ticks in which a guarded stage threw before the host is told the room crashed.
176
+ *
177
+ * Any of them counts, not just `tick()` (bug 2). A room that cannot step its physics is not
178
+ * running — it is a socket that answers with a frozen world — and the runtime is the only thing
179
+ * that can tell the difference, because it is the only thing that sees the throw. The specific
180
+ * way this was found is worth keeping: one Rapier getter called from a room handler at the wrong
181
+ * moment leaves the rigid-body set borrowed in WASM, and *every* Rapier call after it throws.
182
+ * Nothing in JS can un-poison that world, so the honest response is to stop claiming to be up:
183
+ * crashing hands the room to the supervisor, which restarts it from its snapshot with a fresh
184
+ * world, and gives up out loud if the same thing happens again (`maxRestartsPerMin`).
185
+ */
186
+ declare const CRASH_AFTER_THROWS = 3;
187
+ declare class Loop implements LoopApi {
188
+ private readonly core;
189
+ readonly inbound: QueuedFrame[];
190
+ private readonly timers;
191
+ private readonly internal;
192
+ private nextTimerId;
193
+ private tickHandle;
194
+ private idleHandle;
195
+ private running;
196
+ private lastWake;
197
+ private accumulator;
198
+ private overrunLogged;
199
+ private consecutiveThrows;
200
+ private lastActivity;
201
+ private slept;
202
+ constructor(core: RoomInternals);
203
+ get intervalMs(): number;
204
+ start(): void;
205
+ stop(): void;
206
+ enqueue(frame: QueuedFrame): void;
207
+ dropFramesFor(clientId: string): void;
208
+ private drainInbound;
209
+ /** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
210
+ applyFrame(f: QueuedFrame): boolean;
211
+ private scheduleTick;
212
+ private onWake;
213
+ /** One tick: inbound → room timers → `tick(state, dt, room)` → physics step → flush. */
214
+ runTick(): void;
215
+ private fireDueTimers;
216
+ /** Applies one frame as its own event: tick++, apply, flush. */
217
+ applyEvent(f: QueuedFrame): boolean;
218
+ noteActivity(): void;
219
+ private armIdle;
220
+ private onIdleCheck;
221
+ setTimer(ms: number, fn: () => void, repeat: boolean): number;
222
+ private armHostTimer;
223
+ clearTimer(handle: number): void;
224
+ clearAllTimers(): void;
225
+ after(ms: number, fn: () => void): void;
226
+ }
227
+
228
+ /**
229
+ * D22 part 1: the Rapier world inside the room.
230
+ *
231
+ * ## Where it sits in a tick
232
+ *
233
+ * inbound frames (intents land) → room timers → `tick()` handler (forces) →
234
+ * **reconcile → step → sync** → flush
235
+ *
236
+ * `reconcile` creates a body for every physics-backed instance that lacks one and destroys the
237
+ * bodies of instances that are gone; `step` advances the world by one fixed timestep; `sync`
238
+ * writes each body's declared channels back into the tracked schema state, so body movement
239
+ * leaves the room as an ordinary `DELTA` (and, for a client-owned body, as an unprompted
240
+ * `CORRECT` — server wins, week-8 semantics, intents replay and positions obey).
241
+ *
242
+ * Everything here iterates **in collection order** and then in instance (Map insertion) order:
243
+ * D34.3 promoted that to a stated guarantee, and body creation order is what decides Rapier's
244
+ * internal handle order, which is what makes the same build reproduce the same world.
245
+ *
246
+ * ## Determinism and the snapshot
247
+ *
248
+ * The world rides inside the hibernation blob (`snapshot.ts` writes the section this module
249
+ * encodes), so wake is atomic: state and world come back from the same bytes. Rapier's own
250
+ * `takeSnapshot()`/`restoreSnapshot()` preserves body handles, but not which entity each handle
251
+ * belongs to — that map is ours, and it is what the section carries alongside the world bytes.
252
+ *
253
+ * ## Async init, in a runtime with no async handlers
254
+ *
255
+ * Rapier's WASM needs `await RAPIER.init()`. The runtime's "handlers are synchronous" rule is not
256
+ * negotiable, so the engine is initialized **before the room is constructed** — by the worker
257
+ * host at bundle-load time, and by `initPhysics()` in the test harness. A `RoomCore` whose
258
+ * definition declares physics and finds no initialized engine throws with that instruction.
259
+ */
260
+
261
+ /**
262
+ * Loads and initializes `@dimforge/rapier3d-compat` once per process. Idempotent and safe to
263
+ * call concurrently. Hosts call it before constructing a room whose definition declares physics;
264
+ * a room with no physics never pays the ~2.9 MB import.
265
+ */
266
+ declare function initPhysics(): Promise<RapierModule>;
267
+ /** The initialized engine, or `undefined` when `initPhysics()` has not resolved yet. */
268
+ declare function loadedPhysics(): RapierModule | undefined;
269
+ /** Test seam: forget the loaded engine (never used in production paths). */
270
+ declare function resetPhysicsForTests(): void;
271
+ /** The world plus the entity↔handle map, as it rides inside the hibernation blob. */
272
+ interface PhysicsSection {
273
+ readonly world: Uint8Array;
274
+ /**
275
+ * `[collectionName, id, handle]`, in the order bodies were created. A Rapier `RigidBodyHandle`
276
+ * is the raw 64-bit (index, generation) pair *reinterpreted* as a JS number — handle 9 reads
277
+ * back as the denormal `4.4e-323`, not as `9`. It has to be written as an f64 so the bits
278
+ * survive; `u32` would truncate every handle to 0, which silently aliases every body to
279
+ * whatever was created first (for pachinko, the floor).
280
+ */
281
+ readonly bodies: readonly (readonly [string, string, number])[];
282
+ }
283
+ declare function encodePhysicsSection(section: PhysicsSection): Uint8Array;
284
+ declare function decodePhysicsSection(bytes: Uint8Array): PhysicsSection;
285
+ interface PhysicsRuntimeOptions {
286
+ /** From a v2 hibernation blob. Absent → a fresh world, and `setup` runs. */
287
+ readonly restore?: PhysicsSection;
288
+ /** Seconds per step when the config does not name one (the tick interval). */
289
+ readonly defaultTimestep: number;
290
+ }
291
+ declare class PhysicsRuntime {
292
+ readonly rapier: RapierModule;
293
+ readonly world: RapierWorld;
294
+ /** `true` when the world was built from scratch and `setup` has to run. */
295
+ readonly rebuilt: boolean;
296
+ private readonly core;
297
+ private readonly config;
298
+ /** Physics-backed collections, in schema (name-sorted) order. */
299
+ private readonly collections;
300
+ private readonly bodies;
301
+ /** Collections already warned about the friction-killing 2D lock recipe (bug 6). */
302
+ private readonly warnedPlanar;
303
+ /**
304
+ * Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
305
+ * to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
306
+ * tick it falls asleep, or the schema keeps a phantom residual velocity forever (and a
307
+ * world rebuilt from schema state would wake it with a kick). Found by the drift check.
308
+ */
309
+ private readonly sleepSynced;
310
+ constructor(core: RoomInternals, rapier: RapierModule, options: PhysicsRuntimeOptions);
311
+ get timestep(): number;
312
+ /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
313
+ runSetup(room: Room): void;
314
+ free(): void;
315
+ /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
316
+ bodyFor(collection: string, id: string): RapierRigidBody | undefined;
317
+ private create;
318
+ private applyRecordToBody;
319
+ /**
320
+ * Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
321
+ * tick, right before the step, in collection order then instance order.
322
+ */
323
+ reconcile(): void;
324
+ step(): void;
325
+ /**
326
+ * Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
327
+ * Values are `Math.fround`ed for f32 fields, so what room code reads is exactly what the wire
328
+ * carries — and so a field that has not really moved does not re-dirty every tick.
329
+ */
330
+ sync(): void;
331
+ serialize(): PhysicsSection;
332
+ }
333
+
334
+ /**
335
+ * `RoomCore` — the host-agnostic room runtime. It owns the extended schema, the
336
+ * tracked authority state, presence, the loop, and the frame dispatch; everything outside comes
337
+ * through `RoomHost` (`src/contract.ts`). The worker host and the test harness are adapters over
338
+ * exactly this surface and produce byte-identical frames.
339
+ */
340
+
341
+ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S> {
342
+ readonly definition: RoomDefinition<S>;
343
+ readonly ext: AnySchema;
344
+ readonly host: RoomHost;
345
+ readonly roomId: string;
346
+ readonly mode: RoomMode;
347
+ readonly plain: PlainState;
348
+ readonly tracked: Tracked<AnySchema>;
349
+ readonly anyState: AnyRecord;
350
+ readonly rng: Mulberry32;
351
+ readonly clients: Map<string, ClientEntry>;
352
+ readonly loop: Loop;
353
+ /** D22: the Rapier world, or `undefined` in a room whose config declares no physics. */
354
+ readonly physics: PhysicsRuntime | undefined;
355
+ readonly stats: RoomStats;
356
+ tick: number;
357
+ stopped: boolean;
358
+ /**
359
+ * Called for every handler throw `tryRun` swallows, before it is logged. A test harness sets
360
+ * this so a room that breaks fails the test that broke it (bug 2): the runtime's job is to
361
+ * keep the room up in production, but under test that same guarding turns a broken handler
362
+ * into a timeout in an unrelated assertion ten seconds later. Unset in production.
363
+ */
364
+ onHandlerError: ((name: string, err: unknown) => void) | undefined;
365
+ private readonly seed;
366
+ private readonly api;
367
+ private readonly internals;
368
+ private started;
369
+ /** One pending continuation flush at a time; concurrent completions coalesce into it. */
370
+ private continuationFlushPending;
371
+ constructor(definition: RoomDefinition<S>, host: RoomHost, options: RoomCoreOptions);
372
+ /**
373
+ * D22: builds the world, or returns `undefined` for a room with no `physics:` config.
374
+ *
375
+ * A v2 blob restores the world from its own bytes and `setup` does **not** run — the static
376
+ * geometry is already in there. Anything else (a fresh room, a v1 blob written before this
377
+ * room had physics, a migrated snapshot whose world was deliberately dropped) builds a world,
378
+ * runs `setup`, and lets the first tick's `reconcile` rebuild the bodies from schema state:
379
+ * positions and velocities live in schema fields, so the rebuild is faithful to what the state
380
+ * says. Transient contact state — resting contacts, accumulated impulses — is not in the schema
381
+ * and is lost; a stack of boxes may settle again with a small visible jolt.
382
+ */
383
+ private buildPhysics;
384
+ /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
385
+ static restore<S2 extends AnySchema>(definition: RoomDefinition<S2>, bytes: Uint8Array, host: RoomHost, options: Omit<RoomCoreOptions, 'restoreFrom'>): RoomCore<S2>;
386
+ get schema(): S;
387
+ get config(): ResolvedRoomConfig<S>;
388
+ get state(): State<S>;
389
+ get room(): Room<S>;
390
+ tryRun<T>(name: string, fn: () => T): GuardResult<T>;
391
+ private readonly events;
392
+ recordEvent(kind: RoomEventKind, clientId?: string, detail?: string): void;
393
+ /** Live JSON view of the room for the dev page / supervisor admin API. */
394
+ inspect(): RoomInspection;
395
+ guard<T>(name: string, fn: () => T): T | undefined;
396
+ log(level: LogLevel, ...args: unknown[]): void;
397
+ start(): void;
398
+ stop(): void;
399
+ /**
400
+ * The hibernation blob's bytes, and **nothing else** — no `onSleep`, no timers cleared, no
401
+ * pending work rejected. A save (D24) is a *copy* of the room; hibernation is the room
402
+ * *leaving*. They want identical bytes and opposite side effects, so the bytes live here and
403
+ * the departure lives in `serialize()`.
404
+ *
405
+ * Conflating the two is not hypothetical: the first cut of `room.save()` routed through
406
+ * `serialize()`, which rejected every pending host call — including the `save()` that had just
407
+ * asked for it. The room waited out its own 10 s deadline for a save that had already been
408
+ * written.
409
+ */
410
+ snapshot(): Uint8Array;
411
+ serialize(): Uint8Array;
412
+ private get presence();
413
+ private resolveRole;
414
+ join(clientId: string, options?: JoinOptions): JoinResult;
415
+ /** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
416
+ private eventFlush;
417
+ leave(clientId: string, reason: LeaveReason): void;
418
+ markDisconnected(clientId: string): void;
419
+ ctxFor(clientId: string, reconnecting?: boolean): Ctx;
420
+ /**
421
+ * Week 12: the host answering a `room.save()` / `room.kv.*`. The continuation runs here — its
422
+ * own event, between ticks, off the back of a host turn — and the flush afterwards is what
423
+ * makes "state mutated in a continuation is tracked normally" true rather than aspirational.
424
+ */
425
+ completeHostCall(reqId: number, result: HostCallResult): void;
426
+ /**
427
+ * Flushes whatever a promise continuation wrote, as its own event, once the microtask queue
428
+ * that continuation lives on has drained.
429
+ *
430
+ * The subtlety this exists for: `resolve()` does not run the room's `.then` — it *queues* it,
431
+ * and every promise link between the resolve and the room's callback costs another microtask
432
+ * turn. A single `queueMicrotask(flush)` therefore only ever catches a continuation exactly one
433
+ * link deep, and silently drops the state written by anything the room chained further out.
434
+ * Draining a bounded number of turns first covers the chains rooms actually write, coalesces
435
+ * concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
436
+ * the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
437
+ */
438
+ private scheduleContinuationFlush;
439
+ /**
440
+ * D26: one durable alarm firing. Same scheduling class as an RPC — a discrete event between
441
+ * ticks — so a tick-mode room never sees a `tick` run half-alarmed, and a handler that re-arms
442
+ * its own name is the supported way to build a repeating timer.
443
+ */
444
+ fireAlarm(name: string): void;
445
+ correctionFor(clientId: string): DirtySet | undefined;
446
+ invalidateClients(): void;
447
+ send(clientId: string, frame: Uint8Array): void;
448
+ private badFrame;
449
+ receive(clientId: string, frame: Uint8Array): void;
450
+ /**
451
+ * Hands the tracked dirty set out: server-wins corrections first, then per connected client its
452
+ * pending `CORRECT` (before the delta, so it sees the correction and then the broadcast) and
453
+ * its view's `DELTA` — encoded once per distinct view.
454
+ */
455
+ flush(): void;
456
+ }
457
+
458
+ export { CRASH_AFTER_THROWS as C, MAX_CATCHUP as M, type PhysicsSection as P, RoomCore as R, Mulberry32 as a, decodePhysicsSection as d, encodePhysicsSection as e, initPhysics as i, loadedPhysics as l, resetPhysicsForTests as r };
@@ -1,8 +1,9 @@
1
+ import { R as RoomCore } from '../room-9ZQoy9yi.js';
2
+ export { i as initPhysics } from '../room-9ZQoy9yi.js';
1
3
  import { ErrorCodeName, FrameType } from '@irtio/protocol';
2
- import { f as RoomHost, L as LogLevel, h as RoomStats } from '../contract-BhD88PGb.js';
4
+ import { h as RoomHost, L as LogLevel, R as RoomCoreApi, a as HostCall, b as HostCallResult, j as RoomStats } from '../contract-B8QSO0MH.js';
3
5
  import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
4
6
  import { LeaveReason, Room, RoomDefinition } from '@irtio/server';
5
- import { R as RoomCore } from '../room-CBsSCueH.js';
6
7
 
7
8
  /**
8
9
  * The harness clock: a deterministic fake `now()` plus an ordered timer queue. `RoomCore` only
@@ -30,6 +31,18 @@ declare class FakeClock {
30
31
  * `HarnessHost` — the `RoomHost` the harness gives `RoomCore`. Everything the host is told is
31
32
  * recorded so a test can assert on it: logs, kicks, `close()`, `sleep()`, `crashed()`, and how
32
33
  * many frames/bytes went out (per client and in total).
34
+ *
35
+ * Week 12 adds the two host capabilities a room can now reach for, both modelled the way the
36
+ * real supervisor models them:
37
+ *
38
+ * - **Host calls** (`room.save()`, `room.kv.*`) are answered on a fake-clock timer, never
39
+ * synchronously. `hostCallDelayMs` defaults to 0, and even 0 goes through `clock.setTimeout` —
40
+ * which is the point: it makes "the continuation is its own event" the same structural fact in
41
+ * the harness that it is in the worker.
42
+ * - **Alarms** are *host* state, exactly as they are in the supervisor: `setAlarm` records a due
43
+ * time here and nothing goes near the hibernation blob. `fireDue()` runs what is due, in name
44
+ * order, and a test simulating hibernation builds a new core while keeping this host's
45
+ * `armedAlarms` — which is precisely what the supervisor does across a wake.
33
46
  */
34
47
 
35
48
  interface KickRecord {
@@ -45,6 +58,11 @@ interface SendCounts {
45
58
  frames: number;
46
59
  bytes: number;
47
60
  }
61
+ /** One `hostCall` the room made, in order, for assertions. */
62
+ interface HostCallRecord {
63
+ readonly reqId: number;
64
+ readonly call: HostCall;
65
+ }
48
66
  declare class HarnessHost implements RoomHost {
49
67
  private readonly clock;
50
68
  readonly logs: LogRecord[];
@@ -61,6 +79,32 @@ declare class HarnessHost implements RoomHost {
61
79
  readonly sentByClient: Map<string, SendCounts>;
62
80
  /** Set by the harness: where an outbound frame goes. */
63
81
  onSend: (clientId: string, frame: Uint8Array) => void;
82
+ /**
83
+ * The core this host answers. Set by whoever built the pair — the host has to call back into
84
+ * `completeHostCall`/`fireAlarm`, which runs in the same direction `receive()` does.
85
+ */
86
+ core: RoomCoreApi | undefined;
87
+ /** Every `room.save()` / `room.kv.*` the room asked for, in order. */
88
+ readonly hostCalls: HostCallRecord[];
89
+ /** Fake ms between a host call and its answer. 0 still means "a later event", not "inline". */
90
+ hostCallDelayMs: number;
91
+ /** The in-memory player KV, keyed the way the real table's composite primary key is. */
92
+ readonly kv: Map<string, string>;
93
+ /** Save generations this host minted: `saveId` -> the bytes it was handed. */
94
+ readonly saves: Map<string, Uint8Array<ArrayBufferLike>>;
95
+ /** What `room.save()` serializes. The harness sets it to `() => core.snapshot()` — the bytes
96
+ * without hibernation's side effects, exactly as the supervisor asks for them. */
97
+ serializeForSave: (() => Uint8Array) | undefined;
98
+ /**
99
+ * Override the host-call backend. Return `undefined` to fall through to the built-in in-memory
100
+ * one; return a result to answer it yourself — how the limit and outage tests are written.
101
+ */
102
+ handleHostCall: ((call: HostCall) => HostCallResult | undefined) | undefined;
103
+ private nextSaveId;
104
+ /** D26: armed alarms, `name` -> due time on the fake clock. Host state, never room state. */
105
+ readonly armedAlarms: Map<string, number>;
106
+ /** Every alarm that fired, in order, as `name@dueMs`. */
107
+ readonly firedAlarms: string[];
64
108
  constructor(clock: FakeClock);
65
109
  /** `true` once the runtime has asked to hibernate at least once. */
66
110
  get slept(): boolean;
@@ -75,6 +119,17 @@ declare class HarnessHost implements RoomHost {
75
119
  sleep(): void;
76
120
  log(level: LogLevel, args: unknown[]): void;
77
121
  crashed(reason: string): void;
122
+ hostCall(reqId: number, call: HostCall): void;
123
+ private runHostCall;
124
+ setAlarm(name: string, atMs: number | undefined): void;
125
+ /**
126
+ * Fires every alarm due at or before `now`, in **name order** (D34 determinism), removing each
127
+ * before it runs so a handler that re-arms its own name arms the *next* one rather than having
128
+ * this pass cancel it straight back out.
129
+ */
130
+ fireDue(now?: number): string[];
131
+ /** The earliest due time across armed alarms — what the supervisor reports to control. */
132
+ get dueAlarmAt(): number | undefined;
78
133
  /** Logged messages of one level, flattened to strings (handy in assertions). */
79
134
  logsOf(level: LogLevel): string[];
80
135
  }
@@ -109,6 +164,8 @@ interface VisibilityLeak {
109
164
  /** Ids involved (`['']` for a singleton). */
110
165
  readonly ids: readonly string[];
111
166
  readonly tick: number;
167
+ /** Spatial diagnostics when available. */
168
+ readonly detail?: string;
112
169
  }
113
170
 
114
171
  /**