@irtio/runtime 0.1.0 → 0.2.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/dist/{chunk-3DQHT4CM.js → chunk-FA4IOC2Z.js} +1 -1
- package/dist/{chunk-X5S364FY.js → chunk-GKMD3ICD.js} +888 -31
- package/dist/{contract-BhD88PGb.d.ts → contract-B8QSO0MH.d.ts} +86 -2
- package/dist/index.d.ts +39 -8
- package/dist/index.js +18 -2
- package/dist/room-BfALTh7M.d.ts +431 -0
- package/dist/test/index.d.ts +59 -2
- package/dist/test/index.js +142 -7
- package/dist/worker/index.d.ts +40 -2
- package/dist/worker/index.js +35 -4
- package/package.json +5 -4
- package/dist/room-CBsSCueH.d.ts +0 -246
|
@@ -0,0 +1,431 @@
|
|
|
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
|
+
* Set at `join`: this client's own record(s) — its presence row and whatever entities its own
|
|
52
|
+
* `onJoin` added, owned by it — as they stood right after join, per collection. Consumed once
|
|
53
|
+
* by the very next `flush()`, which strips exactly the ids whose value (and owner) still match
|
|
54
|
+
* this capture from this client's delta only — an id mutated again before that flush (e.g. a
|
|
55
|
+
* same-window `room.setRole`) is left alone, since the join snapshot never saw that value.
|
|
56
|
+
* Every other client's delta is unaffected.
|
|
57
|
+
*/
|
|
58
|
+
pendingJoinAdds: Map<string, ReadonlyMap<string, JoinAddCapture>> | undefined;
|
|
59
|
+
/** Last AOI membership sent to this client, keyed by spatial collection. */
|
|
60
|
+
spatialMembership: Map<string, Set<string>>;
|
|
61
|
+
}
|
|
62
|
+
/** What `join` captured for one of a client's own just-added records, for `flush`'s comparison. */
|
|
63
|
+
interface JoinAddCapture {
|
|
64
|
+
readonly value: unknown;
|
|
65
|
+
readonly owner: string | undefined;
|
|
66
|
+
}
|
|
67
|
+
type GuardResult<T> = {
|
|
68
|
+
readonly ok: true;
|
|
69
|
+
readonly value: T;
|
|
70
|
+
} | {
|
|
71
|
+
readonly ok: false;
|
|
72
|
+
};
|
|
73
|
+
/** One queued inbound frame (tick mode). */
|
|
74
|
+
interface QueuedFrame {
|
|
75
|
+
readonly clientId: string;
|
|
76
|
+
readonly type: number;
|
|
77
|
+
readonly payload: Uint8Array;
|
|
78
|
+
}
|
|
79
|
+
/** What the core modules may do with the physics world (`core/physics.ts` implements it). */
|
|
80
|
+
interface PhysicsApi {
|
|
81
|
+
readonly timestep: number;
|
|
82
|
+
/** Creates bodies for new instances, destroys bodies whose instance is gone. */
|
|
83
|
+
reconcile(): void;
|
|
84
|
+
step(): void;
|
|
85
|
+
/** Body → schema, through the tracked proxies. */
|
|
86
|
+
sync(): void;
|
|
87
|
+
bodyFor(collection: string, id: string): unknown;
|
|
88
|
+
readonly rapier: unknown;
|
|
89
|
+
readonly world: unknown;
|
|
90
|
+
}
|
|
91
|
+
interface LoopApi {
|
|
92
|
+
start(): void;
|
|
93
|
+
stop(): void;
|
|
94
|
+
/** Event mode: an inbound frame or a join re-arms the idle timer. */
|
|
95
|
+
noteActivity(): void;
|
|
96
|
+
/** `room.setTimeout` / `room.setInterval`. Returns a numeric handle. */
|
|
97
|
+
setTimer(ms: number, fn: () => void, repeat: boolean): number;
|
|
98
|
+
clearTimer(handle: number): void;
|
|
99
|
+
/** Drops every room timer (hibernation, stop). */
|
|
100
|
+
clearAllTimers(): void;
|
|
101
|
+
/** Internal one-shot on the host clock (RPC timeouts in event mode). */
|
|
102
|
+
after(ms: number, fn: () => void): void;
|
|
103
|
+
}
|
|
104
|
+
/** What the core modules are allowed to see of `RoomCore`. */
|
|
105
|
+
interface RoomInternals {
|
|
106
|
+
readonly definition: RoomDefinition;
|
|
107
|
+
/** `withBuiltins(definition.schema)` — what every frame and the codec use. */
|
|
108
|
+
readonly ext: AnySchema;
|
|
109
|
+
readonly host: RoomHost;
|
|
110
|
+
readonly roomId: string;
|
|
111
|
+
readonly mode: RoomMode;
|
|
112
|
+
/** The plain state the tracked proxies wrap (what the codec reads). */
|
|
113
|
+
readonly plain: PlainState;
|
|
114
|
+
readonly tracked: Tracked<AnySchema>;
|
|
115
|
+
/** The tracked proxy tree, loosely typed for internal use. */
|
|
116
|
+
readonly anyState: AnyRecord;
|
|
117
|
+
readonly room: Room;
|
|
118
|
+
readonly rng: Mulberry32;
|
|
119
|
+
readonly stats: RoomStats;
|
|
120
|
+
/** Joined clients in join order. */
|
|
121
|
+
readonly clients: Map<string, ClientEntry>;
|
|
122
|
+
readonly loop: LoopApi;
|
|
123
|
+
/** D22: the Rapier world, `undefined` in a room with no `physics:` config. */
|
|
124
|
+
readonly physics: PhysicsApi | undefined;
|
|
125
|
+
tick: number;
|
|
126
|
+
stopped: boolean;
|
|
127
|
+
/** Runs a room handler; a throw is logged and counted, never rethrown. */
|
|
128
|
+
guard<T>(name: string, fn: () => T): T | undefined;
|
|
129
|
+
recordEvent(kind: 'join' | 'leave' | 'write' | 'write-rejected' | 'correct' | 'call' | 'reply' | 'msg' | 'alarm' | 'error', clientId?: string, detail?: string): void;
|
|
130
|
+
/** `guard` that also reports whether the handler threw (the tick loop needs this). */
|
|
131
|
+
tryRun<T>(name: string, fn: () => T): GuardResult<T>;
|
|
132
|
+
log(level: LogLevel, ...args: unknown[]): void;
|
|
133
|
+
/** Sends one framed protocol frame to a connected client and counts it. */
|
|
134
|
+
send(clientId: string, frame: Uint8Array): void;
|
|
135
|
+
ctxFor(clientId: string, reconnecting?: boolean): Ctx;
|
|
136
|
+
/** The client's pending `CORRECT` dirty set, created on demand. */
|
|
137
|
+
correctionFor(clientId: string): DirtySet | undefined;
|
|
138
|
+
/** Flushes the tracked dirty set: corrections first, then one `DELTA` per distinct view. */
|
|
139
|
+
flush(): void;
|
|
140
|
+
/** Invalidates the cached `room.clients` array. */
|
|
141
|
+
invalidateClients(): void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Scheduling: the tick-mode fixed-step loop with catch-up, event-mode idle detection, and
|
|
146
|
+
* `room.setTimeout` / `room.setInterval`.
|
|
147
|
+
*
|
|
148
|
+
* Tick mode: one host timeout re-armed every `1000 / tickRate` ms. Each wake accumulates real
|
|
149
|
+
* elapsed time and runs up to `MAX_CATCHUP` ticks; a bigger backlog is dropped and counted as an
|
|
150
|
+
* overrun (logged once per burst). Room timers are tick-granular: `setTimeout(ms)` fires on the
|
|
151
|
+
* first tick at or after `now + ms`.
|
|
152
|
+
*
|
|
153
|
+
* Event mode: no tick timer. Room timers fire on the host clock, each firing advancing the tick
|
|
154
|
+
* and flushing. After `idleMs` with no inbound frames the host is asked to hibernate exactly once
|
|
155
|
+
* (re-armed by the next frame or join). Connected sockets do not keep an event room awake — a quiz
|
|
156
|
+
* hibernates between rounds with everyone still watching.
|
|
157
|
+
*
|
|
158
|
+
* Both modes hibernate, but "idle" cannot mean the same thing in each. A tick room *simulates* by
|
|
159
|
+
* definition: no inbound frames is its steady state, not its idleness. So a tick room sleeps after
|
|
160
|
+
* `idleMs` with **no connected clients**, which is the only sense in which a simulation nobody is
|
|
161
|
+
* watching is doing nothing worth paying for. `idleMs: 0` opts out in either mode and keeps the
|
|
162
|
+
* room resident (the same meaning it already has for relay rooms in the supervisor).
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
/** Most ticks one wake may run before the backlog is dropped. */
|
|
166
|
+
declare const MAX_CATCHUP = 5;
|
|
167
|
+
/** Consecutive `tick()` throws before the host is told the room crashed. */
|
|
168
|
+
declare const CRASH_AFTER_THROWS = 3;
|
|
169
|
+
declare class Loop implements LoopApi {
|
|
170
|
+
private readonly core;
|
|
171
|
+
readonly inbound: QueuedFrame[];
|
|
172
|
+
private readonly timers;
|
|
173
|
+
private readonly internal;
|
|
174
|
+
private nextTimerId;
|
|
175
|
+
private tickHandle;
|
|
176
|
+
private idleHandle;
|
|
177
|
+
private running;
|
|
178
|
+
private lastWake;
|
|
179
|
+
private accumulator;
|
|
180
|
+
private overrunLogged;
|
|
181
|
+
private consecutiveThrows;
|
|
182
|
+
private lastActivity;
|
|
183
|
+
private slept;
|
|
184
|
+
constructor(core: RoomInternals);
|
|
185
|
+
get intervalMs(): number;
|
|
186
|
+
start(): void;
|
|
187
|
+
stop(): void;
|
|
188
|
+
enqueue(frame: QueuedFrame): void;
|
|
189
|
+
dropFramesFor(clientId: string): void;
|
|
190
|
+
private drainInbound;
|
|
191
|
+
/** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
|
|
192
|
+
applyFrame(f: QueuedFrame): boolean;
|
|
193
|
+
private scheduleTick;
|
|
194
|
+
private onWake;
|
|
195
|
+
/** One tick: inbound → room timers → `tick(state, dt, room)` → physics step → flush. */
|
|
196
|
+
runTick(): void;
|
|
197
|
+
private fireDueTimers;
|
|
198
|
+
/** Applies one frame as its own event: tick++, apply, flush. */
|
|
199
|
+
applyEvent(f: QueuedFrame): boolean;
|
|
200
|
+
noteActivity(): void;
|
|
201
|
+
private armIdle;
|
|
202
|
+
private onIdleCheck;
|
|
203
|
+
setTimer(ms: number, fn: () => void, repeat: boolean): number;
|
|
204
|
+
private armHostTimer;
|
|
205
|
+
clearTimer(handle: number): void;
|
|
206
|
+
clearAllTimers(): void;
|
|
207
|
+
after(ms: number, fn: () => void): void;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* D22 part 1: the Rapier world inside the room.
|
|
212
|
+
*
|
|
213
|
+
* ## Where it sits in a tick
|
|
214
|
+
*
|
|
215
|
+
* inbound frames (intents land) → room timers → `tick()` handler (forces) →
|
|
216
|
+
* **reconcile → step → sync** → flush
|
|
217
|
+
*
|
|
218
|
+
* `reconcile` creates a body for every physics-backed instance that lacks one and destroys the
|
|
219
|
+
* bodies of instances that are gone; `step` advances the world by one fixed timestep; `sync`
|
|
220
|
+
* writes each body's declared channels back into the tracked schema state, so body movement
|
|
221
|
+
* leaves the room as an ordinary `DELTA` (and, for a client-owned body, as an unprompted
|
|
222
|
+
* `CORRECT` — server wins, week-8 semantics, intents replay and positions obey).
|
|
223
|
+
*
|
|
224
|
+
* Everything here iterates **in collection order** and then in instance (Map insertion) order:
|
|
225
|
+
* D34.3 promoted that to a stated guarantee, and body creation order is what decides Rapier's
|
|
226
|
+
* internal handle order, which is what makes the same build reproduce the same world.
|
|
227
|
+
*
|
|
228
|
+
* ## Determinism and the snapshot
|
|
229
|
+
*
|
|
230
|
+
* The world rides inside the hibernation blob (`snapshot.ts` writes the section this module
|
|
231
|
+
* encodes), so wake is atomic: state and world come back from the same bytes. Rapier's own
|
|
232
|
+
* `takeSnapshot()`/`restoreSnapshot()` preserves body handles, but not which entity each handle
|
|
233
|
+
* belongs to — that map is ours, and it is what the section carries alongside the world bytes.
|
|
234
|
+
*
|
|
235
|
+
* ## Async init, in a runtime with no async handlers
|
|
236
|
+
*
|
|
237
|
+
* Rapier's WASM needs `await RAPIER.init()`. The runtime's "handlers are synchronous" rule is not
|
|
238
|
+
* negotiable, so the engine is initialized **before the room is constructed** — by the worker
|
|
239
|
+
* host at bundle-load time, and by `initPhysics()` in the test harness. A `RoomCore` whose
|
|
240
|
+
* definition declares physics and finds no initialized engine throws with that instruction.
|
|
241
|
+
*/
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Loads and initializes `@dimforge/rapier3d-compat` once per process. Idempotent and safe to
|
|
245
|
+
* call concurrently. Hosts call it before constructing a room whose definition declares physics;
|
|
246
|
+
* a room with no physics never pays the ~2.9 MB import.
|
|
247
|
+
*/
|
|
248
|
+
declare function initPhysics(): Promise<RapierModule>;
|
|
249
|
+
/** The initialized engine, or `undefined` when `initPhysics()` has not resolved yet. */
|
|
250
|
+
declare function loadedPhysics(): RapierModule | undefined;
|
|
251
|
+
/** Test seam: forget the loaded engine (never used in production paths). */
|
|
252
|
+
declare function resetPhysicsForTests(): void;
|
|
253
|
+
/** The world plus the entity↔handle map, as it rides inside the hibernation blob. */
|
|
254
|
+
interface PhysicsSection {
|
|
255
|
+
readonly world: Uint8Array;
|
|
256
|
+
/**
|
|
257
|
+
* `[collectionName, id, handle]`, in the order bodies were created. A Rapier `RigidBodyHandle`
|
|
258
|
+
* is the raw 64-bit (index, generation) pair *reinterpreted* as a JS number — handle 9 reads
|
|
259
|
+
* back as the denormal `4.4e-323`, not as `9`. It has to be written as an f64 so the bits
|
|
260
|
+
* survive; `u32` would truncate every handle to 0, which silently aliases every body to
|
|
261
|
+
* whatever was created first (for pachinko, the floor).
|
|
262
|
+
*/
|
|
263
|
+
readonly bodies: readonly (readonly [string, string, number])[];
|
|
264
|
+
}
|
|
265
|
+
declare function encodePhysicsSection(section: PhysicsSection): Uint8Array;
|
|
266
|
+
declare function decodePhysicsSection(bytes: Uint8Array): PhysicsSection;
|
|
267
|
+
interface PhysicsRuntimeOptions {
|
|
268
|
+
/** From a v2 hibernation blob. Absent → a fresh world, and `setup` runs. */
|
|
269
|
+
readonly restore?: PhysicsSection;
|
|
270
|
+
/** Seconds per step when the config does not name one (the tick interval). */
|
|
271
|
+
readonly defaultTimestep: number;
|
|
272
|
+
}
|
|
273
|
+
declare class PhysicsRuntime {
|
|
274
|
+
readonly rapier: RapierModule;
|
|
275
|
+
readonly world: RapierWorld;
|
|
276
|
+
/** `true` when the world was built from scratch and `setup` has to run. */
|
|
277
|
+
readonly rebuilt: boolean;
|
|
278
|
+
private readonly core;
|
|
279
|
+
private readonly config;
|
|
280
|
+
/** Physics-backed collections, in schema (name-sorted) order. */
|
|
281
|
+
private readonly collections;
|
|
282
|
+
private readonly bodies;
|
|
283
|
+
/**
|
|
284
|
+
* Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
|
|
285
|
+
* to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
|
|
286
|
+
* tick it falls asleep, or the schema keeps a phantom residual velocity forever (and a
|
|
287
|
+
* world rebuilt from schema state would wake it with a kick). Found by the drift check.
|
|
288
|
+
*/
|
|
289
|
+
private readonly sleepSynced;
|
|
290
|
+
constructor(core: RoomInternals, rapier: RapierModule, options: PhysicsRuntimeOptions);
|
|
291
|
+
get timestep(): number;
|
|
292
|
+
/** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
|
|
293
|
+
runSetup(room: Room): void;
|
|
294
|
+
free(): void;
|
|
295
|
+
/** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
|
|
296
|
+
bodyFor(collection: string, id: string): RapierRigidBody | undefined;
|
|
297
|
+
private create;
|
|
298
|
+
private applyRecordToBody;
|
|
299
|
+
/**
|
|
300
|
+
* Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
|
|
301
|
+
* tick, right before the step, in collection order then instance order.
|
|
302
|
+
*/
|
|
303
|
+
reconcile(): void;
|
|
304
|
+
step(): void;
|
|
305
|
+
/**
|
|
306
|
+
* Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
|
|
307
|
+
* Values are `Math.fround`ed for f32 fields, so what room code reads is exactly what the wire
|
|
308
|
+
* carries — and so a field that has not really moved does not re-dirty every tick.
|
|
309
|
+
*/
|
|
310
|
+
sync(): void;
|
|
311
|
+
serialize(): PhysicsSection;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* `RoomCore` — the host-agnostic room runtime. It owns the extended schema, the
|
|
316
|
+
* tracked authority state, presence, the loop, and the frame dispatch; everything outside comes
|
|
317
|
+
* through `RoomHost` (`src/contract.ts`). The worker host and the test harness are adapters over
|
|
318
|
+
* exactly this surface and produce byte-identical frames.
|
|
319
|
+
*/
|
|
320
|
+
|
|
321
|
+
declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S> {
|
|
322
|
+
readonly definition: RoomDefinition<S>;
|
|
323
|
+
readonly ext: AnySchema;
|
|
324
|
+
readonly host: RoomHost;
|
|
325
|
+
readonly roomId: string;
|
|
326
|
+
readonly mode: RoomMode;
|
|
327
|
+
readonly plain: PlainState;
|
|
328
|
+
readonly tracked: Tracked<AnySchema>;
|
|
329
|
+
readonly anyState: AnyRecord;
|
|
330
|
+
readonly rng: Mulberry32;
|
|
331
|
+
readonly clients: Map<string, ClientEntry>;
|
|
332
|
+
readonly loop: Loop;
|
|
333
|
+
/** D22: the Rapier world, or `undefined` in a room whose config declares no physics. */
|
|
334
|
+
readonly physics: PhysicsRuntime | undefined;
|
|
335
|
+
readonly stats: RoomStats;
|
|
336
|
+
tick: number;
|
|
337
|
+
stopped: boolean;
|
|
338
|
+
private readonly seed;
|
|
339
|
+
private readonly api;
|
|
340
|
+
private readonly internals;
|
|
341
|
+
private started;
|
|
342
|
+
/** One pending continuation flush at a time; concurrent completions coalesce into it. */
|
|
343
|
+
private continuationFlushPending;
|
|
344
|
+
constructor(definition: RoomDefinition<S>, host: RoomHost, options: RoomCoreOptions);
|
|
345
|
+
/**
|
|
346
|
+
* D22: builds the world, or returns `undefined` for a room with no `physics:` config.
|
|
347
|
+
*
|
|
348
|
+
* A v2 blob restores the world from its own bytes and `setup` does **not** run — the static
|
|
349
|
+
* geometry is already in there. Anything else (a fresh room, a v1 blob written before this
|
|
350
|
+
* room had physics, a migrated snapshot whose world was deliberately dropped) builds a world,
|
|
351
|
+
* runs `setup`, and lets the first tick's `reconcile` rebuild the bodies from schema state:
|
|
352
|
+
* positions and velocities live in schema fields, so the rebuild is faithful to what the state
|
|
353
|
+
* says. Transient contact state — resting contacts, accumulated impulses — is not in the schema
|
|
354
|
+
* and is lost; a stack of boxes may settle again with a small visible jolt.
|
|
355
|
+
*/
|
|
356
|
+
private buildPhysics;
|
|
357
|
+
/** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
|
|
358
|
+
static restore<S2 extends AnySchema>(definition: RoomDefinition<S2>, bytes: Uint8Array, host: RoomHost, options: Omit<RoomCoreOptions, 'restoreFrom'>): RoomCore<S2>;
|
|
359
|
+
get schema(): S;
|
|
360
|
+
get config(): ResolvedRoomConfig<S>;
|
|
361
|
+
get state(): State<S>;
|
|
362
|
+
get room(): Room<S>;
|
|
363
|
+
tryRun<T>(name: string, fn: () => T): GuardResult<T>;
|
|
364
|
+
private readonly events;
|
|
365
|
+
recordEvent(kind: RoomEventKind, clientId?: string, detail?: string): void;
|
|
366
|
+
/** Live JSON view of the room for the dev page / supervisor admin API. */
|
|
367
|
+
inspect(): RoomInspection;
|
|
368
|
+
guard<T>(name: string, fn: () => T): T | undefined;
|
|
369
|
+
log(level: LogLevel, ...args: unknown[]): void;
|
|
370
|
+
start(): void;
|
|
371
|
+
stop(): void;
|
|
372
|
+
/**
|
|
373
|
+
* The hibernation blob's bytes, and **nothing else** — no `onSleep`, no timers cleared, no
|
|
374
|
+
* pending work rejected. A save (D24) is a *copy* of the room; hibernation is the room
|
|
375
|
+
* *leaving*. They want identical bytes and opposite side effects, so the bytes live here and
|
|
376
|
+
* the departure lives in `serialize()`.
|
|
377
|
+
*
|
|
378
|
+
* Conflating the two is not hypothetical: the first cut of `room.save()` routed through
|
|
379
|
+
* `serialize()`, which rejected every pending host call — including the `save()` that had just
|
|
380
|
+
* asked for it. The room waited out its own 10 s deadline for a save that had already been
|
|
381
|
+
* written.
|
|
382
|
+
*/
|
|
383
|
+
snapshot(): Uint8Array;
|
|
384
|
+
serialize(): Uint8Array;
|
|
385
|
+
private get presence();
|
|
386
|
+
private resolveRole;
|
|
387
|
+
join(clientId: string, options?: JoinOptions): JoinResult;
|
|
388
|
+
/** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
|
|
389
|
+
private eventFlush;
|
|
390
|
+
leave(clientId: string, reason: LeaveReason): void;
|
|
391
|
+
markDisconnected(clientId: string): void;
|
|
392
|
+
ctxFor(clientId: string, reconnecting?: boolean): Ctx;
|
|
393
|
+
/**
|
|
394
|
+
* Week 12: the host answering a `room.save()` / `room.kv.*`. The continuation runs here — its
|
|
395
|
+
* own event, between ticks, off the back of a host turn — and the flush afterwards is what
|
|
396
|
+
* makes "state mutated in a continuation is tracked normally" true rather than aspirational.
|
|
397
|
+
*/
|
|
398
|
+
completeHostCall(reqId: number, result: HostCallResult): void;
|
|
399
|
+
/**
|
|
400
|
+
* Flushes whatever a promise continuation wrote, as its own event, once the microtask queue
|
|
401
|
+
* that continuation lives on has drained.
|
|
402
|
+
*
|
|
403
|
+
* The subtlety this exists for: `resolve()` does not run the room's `.then` — it *queues* it,
|
|
404
|
+
* and every promise link between the resolve and the room's callback costs another microtask
|
|
405
|
+
* turn. A single `queueMicrotask(flush)` therefore only ever catches a continuation exactly one
|
|
406
|
+
* link deep, and silently drops the state written by anything the room chained further out.
|
|
407
|
+
* Draining a bounded number of turns first covers the chains rooms actually write, coalesces
|
|
408
|
+
* concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
|
|
409
|
+
* the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
|
|
410
|
+
*/
|
|
411
|
+
private scheduleContinuationFlush;
|
|
412
|
+
/**
|
|
413
|
+
* D26: one durable alarm firing. Same scheduling class as an RPC — a discrete event between
|
|
414
|
+
* ticks — so a tick-mode room never sees a `tick` run half-alarmed, and a handler that re-arms
|
|
415
|
+
* its own name is the supported way to build a repeating timer.
|
|
416
|
+
*/
|
|
417
|
+
fireAlarm(name: string): void;
|
|
418
|
+
correctionFor(clientId: string): DirtySet | undefined;
|
|
419
|
+
invalidateClients(): void;
|
|
420
|
+
send(clientId: string, frame: Uint8Array): void;
|
|
421
|
+
private badFrame;
|
|
422
|
+
receive(clientId: string, frame: Uint8Array): void;
|
|
423
|
+
/**
|
|
424
|
+
* Hands the tracked dirty set out: server-wins corrections first, then per connected client its
|
|
425
|
+
* pending `CORRECT` (before the delta, so it sees the correction and then the broadcast) and
|
|
426
|
+
* its view's `DELTA` — encoded once per distinct view.
|
|
427
|
+
*/
|
|
428
|
+
flush(): void;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
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 };
|
package/dist/test/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { R as RoomCore } from '../room-BfALTh7M.js';
|
|
2
|
+
export { i as initPhysics } from '../room-BfALTh7M.js';
|
|
1
3
|
import { ErrorCodeName, FrameType } from '@irtio/protocol';
|
|
2
|
-
import {
|
|
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
|
/**
|