@irtio/runtime 0.11.0 → 3.0.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-ACC3SDIO.js → chunk-3BCTP4YS.js} +79 -6
- package/dist/{chunk-GYPUNTQN.js → chunk-NPRIYYNM.js} +3011 -1384
- package/dist/{contract-Hk4SkDtW.d.ts → contract-BoCNAPZT.d.ts} +190 -3
- package/dist/index.d.ts +6 -3
- package/dist/index.js +14 -2
- package/dist/{room-0FZBGyke.d.ts → room-CGbiPxi3.d.ts} +334 -12
- package/dist/test/index.d.ts +26 -4
- package/dist/test/index.js +26 -4
- package/dist/worker/index.d.ts +77 -11
- package/dist/worker/index.js +66 -8
- package/package.json +9 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { AttributeOptions, ProfileSnapshot } from '@irtio/protocol';
|
|
1
|
+
import { PlatformNotice, AttributeOptions, ProfileSnapshot } from '@irtio/protocol';
|
|
2
2
|
import { DirtySet, AnySchema, PlainState, Tracked, State } from '@irtio/schema';
|
|
3
3
|
import { RoomDefinition, RoomMode, Room, RewindView, Ctx, MatterModule, MatterEngine, MatterBody, RapierModule, RapierWorld, RapierRigidBody, Rapier2dModule, Rapier2dWorld, Rapier2dRigidBody, ResolvedRoomConfig, LeaveReason } from '@irtio/server';
|
|
4
|
-
import {
|
|
4
|
+
import { p as RoomHost, r as RoomStats, u as RpcTraceEntry, L as LogLevel, k as RoomCoreApi, l as RoomCoreOptions, n as RoomEventKind, y as TimelineRecorderOptions, T as TimelineDump, t as RpcTraceDump, q as RoomInspection, J as JoinOptions, e as JoinResult, d as HostCallResult } from './contract-BoCNAPZT.js';
|
|
5
5
|
|
|
6
6
|
declare class Mulberry32 {
|
|
7
7
|
/** Current internal state (u32). Survives hibernation. */
|
|
@@ -11,13 +11,6 @@ declare class Mulberry32 {
|
|
|
11
11
|
next(): number;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
* The internal seam between `RoomCore` (core/room.ts) and the modules it delegates to
|
|
16
|
-
* (`loop`, `views`, `writes`, `rpc`, `messages`, `room-api`). Nothing here is public API —
|
|
17
|
-
* `src/contract.ts` is. `RoomCore` implements `RoomInternals` structurally, so the modules
|
|
18
|
-
* take it as a parameter and never import `room.ts` (no cycles).
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
14
|
type AnyRecord = Record<string, unknown>;
|
|
22
15
|
/** `${collection}\0${id}` → leaf-path key → the value the runtime wrote for that leaf. */
|
|
23
16
|
type AcceptedWrites = Map<string, Map<string, unknown>>;
|
|
@@ -36,9 +29,25 @@ interface ClientEntry {
|
|
|
36
29
|
* reconnects, exactly like the client id it defaults to.
|
|
37
30
|
*/
|
|
38
31
|
readonly playerId: string;
|
|
32
|
+
/**
|
|
33
|
+
* M8 lane 5: the verified identity subject behind this session, or `undefined` for a key join.
|
|
34
|
+
* Set once at the first join beside `playerId` and kept for the same reason: what a later HELLO
|
|
35
|
+
* on the same seat claims must not be able to change who the seat belongs to. `room.ban` reads
|
|
36
|
+
* it, and reading `undefined` is what makes "a key join cannot be banned durably" a fact of the
|
|
37
|
+
* data rather than a caveat in a doc.
|
|
38
|
+
*/
|
|
39
|
+
readonly subject: string | undefined;
|
|
39
40
|
role: string;
|
|
40
41
|
name: string;
|
|
41
42
|
connected: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* `true` once this session's `WELCOME` snapshot has been encoded for the client, `false` while
|
|
45
|
+
* `join()` is running — including a resume, where the fresh snapshot supersedes anything the
|
|
46
|
+
* prior session held. `room.setRole` reads it: before the snapshot exists there is nothing to
|
|
47
|
+
* catch up, and a catch-up `DELTA` built pre-WELCOME would name record ids of collections the
|
|
48
|
+
* committed role cannot see.
|
|
49
|
+
*/
|
|
50
|
+
welcomed: boolean;
|
|
42
51
|
/** Pending `CORRECT` mask, encoded from current state at the next flush. */
|
|
43
52
|
correction: DirtySet | undefined;
|
|
44
53
|
/** Leaves this client's own accepted `WRITE`s produced during the current flush window. */
|
|
@@ -58,6 +67,13 @@ interface ClientEntry {
|
|
|
58
67
|
* where on the server's tick stream its still-unjudged intents will land.
|
|
59
68
|
*/
|
|
60
69
|
lastAppliedTick: number;
|
|
70
|
+
/**
|
|
71
|
+
* M7 pf3 (g): the room tick this client last joined at. The baseline `room.writerStaleness`
|
|
72
|
+
* measures from before the client's first write lands, so a fresh joiner in a long-running room
|
|
73
|
+
* is 0 ticks stale rather than `room.tick` ticks stale. Reset on every `join`, including a
|
|
74
|
+
* rejoin: a client that has just come back has not had a tick in which to write.
|
|
75
|
+
*/
|
|
76
|
+
joinedTick: number;
|
|
61
77
|
/**
|
|
62
78
|
* Set at `join`: this client's own record(s) — its presence row and whatever entities its own
|
|
63
79
|
* `onJoin` added, owned by it — as they stood right after join, per collection. Consumed once
|
|
@@ -87,6 +103,21 @@ interface QueuedFrame {
|
|
|
87
103
|
readonly type: number;
|
|
88
104
|
readonly payload: Uint8Array;
|
|
89
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The managed registry of bodies that participate in the simulation and never reach the wire.
|
|
108
|
+
*
|
|
109
|
+
* Loosely typed here, as everything on this seam is; `@irtio/server`'s `UnsyncedBodies<Spec, Body>`
|
|
110
|
+
* is the typed surface room code sees. One instance per engine runtime, built in its constructor.
|
|
111
|
+
*/
|
|
112
|
+
interface UnsyncedRegistry {
|
|
113
|
+
create(key: string, spec: unknown): unknown;
|
|
114
|
+
get(key: string): unknown;
|
|
115
|
+
has(key: string): boolean;
|
|
116
|
+
remove(key: string): boolean;
|
|
117
|
+
readonly size: number;
|
|
118
|
+
keys(): IterableIterator<string>;
|
|
119
|
+
[Symbol.iterator](): IterableIterator<readonly [string, unknown]>;
|
|
120
|
+
}
|
|
90
121
|
/** What the core modules may do with the physics world (`core/physics.ts` implements it). */
|
|
91
122
|
interface PhysicsApi {
|
|
92
123
|
/** D45: which of the blessed engines this room is running. */
|
|
@@ -98,6 +129,11 @@ interface PhysicsApi {
|
|
|
98
129
|
/** Body → schema, through the tracked proxies. */
|
|
99
130
|
sync(): void;
|
|
100
131
|
bodyFor(collection: string, id: string): unknown;
|
|
132
|
+
/** Switches one entity's body off (destroying it) or back on (rebuilt on the next reconcile). */
|
|
133
|
+
setSkipped(collection: string, id: string, on: boolean): void;
|
|
134
|
+
isSkipped(collection: string, id: string): boolean;
|
|
135
|
+
/** Bodies the world steps and the runtime owns, that no schema row and no wire byte describes. */
|
|
136
|
+
readonly unsynced: UnsyncedRegistry;
|
|
101
137
|
/**
|
|
102
138
|
* D72: every body this runtime tracks, in the same collection-then-instance order `sync()`
|
|
103
139
|
* walks. Read-only and allocation-free: the pose history calls it once per tick to copy poses
|
|
@@ -125,6 +161,12 @@ interface LoopApi {
|
|
|
125
161
|
clearTimer(handle: number): void;
|
|
126
162
|
/** Drops every room timer (hibernation, stop). */
|
|
127
163
|
clearAllTimers(): void;
|
|
164
|
+
/**
|
|
165
|
+
* The earliest pending room timer's due time on `host.now()`'s clock, or `undefined` when
|
|
166
|
+
* nothing is armed. Read at `serialize()` — before the timers are cleared — so the supervisor
|
|
167
|
+
* can guarantee the room is awake again by then. See `Loop.earliestTimerAt`.
|
|
168
|
+
*/
|
|
169
|
+
earliestTimerAt(): number | undefined;
|
|
128
170
|
/** Internal one-shot on the host clock (RPC timeouts in event mode). */
|
|
129
171
|
after(ms: number, fn: () => void): void;
|
|
130
172
|
}
|
|
@@ -157,6 +199,8 @@ interface RoomInternals {
|
|
|
157
199
|
wallNow(): number;
|
|
158
200
|
/** Runs a room handler; a throw is logged and counted, never rethrown. */
|
|
159
201
|
guard<T>(name: string, fn: () => T): T | undefined;
|
|
202
|
+
/** `room.ban`. Returns the excluded subject, or `undefined` when the client is a key join. */
|
|
203
|
+
banClient(clientId: string, reason?: string, minutes?: number): string | undefined;
|
|
160
204
|
/** D41: record this tick on the authoritative timeline. A no-op while the recorder is unarmed. */
|
|
161
205
|
captureTimeline(): void;
|
|
162
206
|
/**
|
|
@@ -167,6 +211,16 @@ interface RoomInternals {
|
|
|
167
211
|
/** D72: `room.rewind(tick, fn)`. Throws when the room declares no `physics.history`. */
|
|
168
212
|
rewind<T>(tick: number, fn: (past: RewindView) => T): T;
|
|
169
213
|
recordEvent(kind: 'join' | 'leave' | 'write' | 'write-rejected' | 'correct' | 'call' | 'reply' | 'msg' | 'alarm' | 'bus' | 'error', clientId?: string, detail?: string): void;
|
|
214
|
+
/**
|
|
215
|
+
* M7 pf4: file one RPC on the dev trace ring. A no-op in a room that was not started with
|
|
216
|
+
* `traceRpc: true`, which is every room outside `irtio dev` and `testRoom`.
|
|
217
|
+
*/
|
|
218
|
+
recordRpc(entry: Omit<RpcTraceEntry, 'seq'>): void;
|
|
219
|
+
/**
|
|
220
|
+
* M7 pf4: whether a trace ring exists. Callers check this before building anything, so the
|
|
221
|
+
* unarmed path — every production room — costs one boolean read and allocates nothing.
|
|
222
|
+
*/
|
|
223
|
+
readonly rpcTraceArmed: boolean;
|
|
170
224
|
/** `guard` that also reports whether the handler threw (the tick loop needs this). */
|
|
171
225
|
tryRun<T>(name: string, fn: () => T): GuardResult<T>;
|
|
172
226
|
log(level: LogLevel, ...args: unknown[]): void;
|
|
@@ -239,6 +293,14 @@ declare class Loop implements LoopApi {
|
|
|
239
293
|
private consecutiveThrows;
|
|
240
294
|
private lastActivity;
|
|
241
295
|
private slept;
|
|
296
|
+
/**
|
|
297
|
+
* Whether this idle stretch has already spent its one deferral for a nearly-due room timer.
|
|
298
|
+
*
|
|
299
|
+
* Once, deliberately. A `setInterval(100)` in an otherwise silent room must not be able to pin
|
|
300
|
+
* the room resident forever — pending timers are explicitly *not* a reason to stay awake, the
|
|
301
|
+
* supervisor wakes the room at the due time instead. Reset by real activity.
|
|
302
|
+
*/
|
|
303
|
+
private timerDeferred;
|
|
242
304
|
constructor(core: RoomInternals);
|
|
243
305
|
get intervalMs(): number;
|
|
244
306
|
start(): void;
|
|
@@ -246,7 +308,27 @@ declare class Loop implements LoopApi {
|
|
|
246
308
|
enqueue(frame: QueuedFrame): void;
|
|
247
309
|
dropFramesFor(clientId: string): void;
|
|
248
310
|
private drainInbound;
|
|
249
|
-
/**
|
|
311
|
+
/**
|
|
312
|
+
* Applies one queued/immediate frame. Returns `false` on a malformed payload.
|
|
313
|
+
*
|
|
314
|
+
* The `try` is containment, not error handling, and it is deliberately *here* rather than around
|
|
315
|
+
* the whole tick. Room code never throws through this line — `applyWrite` runs its validators
|
|
316
|
+
* under `core.tryRun`, and `handleCall` wraps the RPC handler in a `try` of its own that does
|
|
317
|
+
* the same bookkeeping by hand (`DenyError` answered as a refusal, anything else counted on
|
|
318
|
+
* `core.stats.handlerErrors` and answered `E_INTERNAL`) — so a throw that reaches this frame is by
|
|
319
|
+
* definition ours: a decode or an apply step that met a value it did not expect. Before this,
|
|
320
|
+
* such a throw went out through `runTick`/`applyEvent` uncaught and took the worker down, which
|
|
321
|
+
* meant one crafted frame could end a room (bugs.md: the non-finite `f32`, fixed at the decode
|
|
322
|
+
* in `@irtio/schema`, reached `normalizeValue` from outside `applyWrite`'s own try).
|
|
323
|
+
*
|
|
324
|
+
* Containing it at the frame keeps the blast radius the size of the mistake: the frame is
|
|
325
|
+
* *this* client's, so it is refused as malformed and the caller kicks that connection, and the
|
|
326
|
+
* room — every other player in it — keeps ticking. Wrapping the tick instead would have swallowed
|
|
327
|
+
* the frame's identity along with the throw and left the room running against half-applied
|
|
328
|
+
* state with nobody to attribute it to.
|
|
329
|
+
*
|
|
330
|
+
* Nothing about how a *handler* throw is treated changes: those never arrive here.
|
|
331
|
+
*/
|
|
250
332
|
applyFrame(f: QueuedFrame): boolean;
|
|
251
333
|
private scheduleTick;
|
|
252
334
|
private onWake;
|
|
@@ -261,6 +343,20 @@ declare class Loop implements LoopApi {
|
|
|
261
343
|
setTimer(ms: number, fn: () => void, repeat: boolean): number;
|
|
262
344
|
private armHostTimer;
|
|
263
345
|
clearTimer(handle: number): void;
|
|
346
|
+
/**
|
|
347
|
+
* The earliest pending room timer's due time on `host.now()`'s clock, or `undefined` when the
|
|
348
|
+
* room has none armed.
|
|
349
|
+
*
|
|
350
|
+
* Read at `serialize()`, before `clearAllTimers()` drops the callbacks. The callbacks are
|
|
351
|
+
* closures and cannot be persisted — but the *time* can, and handing it to the supervisor is
|
|
352
|
+
* what turns "your timer is gone" into "your room is awake again by then", which is a contract
|
|
353
|
+
* room code can actually catch up against in `onWake`.
|
|
354
|
+
*
|
|
355
|
+
* Event mode reads the absolute due time stamped on the record at arm time; tick mode derives
|
|
356
|
+
* it from `dueTick`, because there a timer fires on the first tick at or after its deadline and
|
|
357
|
+
* the tick counter is the clock that decides it.
|
|
358
|
+
*/
|
|
359
|
+
earliestTimerAt(): number | undefined;
|
|
264
360
|
clearAllTimers(): void;
|
|
265
361
|
after(ms: number, fn: () => void): void;
|
|
266
362
|
}
|
|
@@ -316,6 +412,35 @@ interface MatterBodyRecord {
|
|
|
316
412
|
}
|
|
317
413
|
interface MatterSection {
|
|
318
414
|
readonly bodies: readonly MatterBodyRecord[];
|
|
415
|
+
/**
|
|
416
|
+
* `${collection}\0${id}` for every entity whose body the room has switched off. Written as a
|
|
417
|
+
* trailing block only when non-empty, so a room that skips nothing emits the bytes it emitted
|
|
418
|
+
* before this existed. See `PhysicsSection.skipped`.
|
|
419
|
+
*/
|
|
420
|
+
readonly skipped?: readonly string[];
|
|
421
|
+
/**
|
|
422
|
+
* The state of every managed unsynced body, by key.
|
|
423
|
+
*
|
|
424
|
+
* Unlike the two Rapier engines, matter2d has no world snapshot to bring the bodies themselves
|
|
425
|
+
* back: the world is rebuilt on every wake and `setup` runs again. So what rides here is what
|
|
426
|
+
* the runtime can reapply to a body `setup` has rebuilt under the same key, which is its pose,
|
|
427
|
+
* its velocity, and whether it was asleep. That is exactly what this section already does for
|
|
428
|
+
* entity-backed matter bodies, and it carries the same consequence: an unsynced body a matter2d
|
|
429
|
+
* room creates *outside* `setup` cannot come back, and the runtime says so by name on the wake
|
|
430
|
+
* that finds it missing.
|
|
431
|
+
*/
|
|
432
|
+
readonly unsynced?: readonly MatterUnsyncedRecord[];
|
|
433
|
+
}
|
|
434
|
+
/** M7 pf3 (a): one unsynced body's restorable state. A `MatterBodyRecord` keyed by string. */
|
|
435
|
+
interface MatterUnsyncedRecord {
|
|
436
|
+
readonly key: string;
|
|
437
|
+
readonly x: number;
|
|
438
|
+
readonly y: number;
|
|
439
|
+
readonly angle: number;
|
|
440
|
+
readonly vx: number;
|
|
441
|
+
readonly vy: number;
|
|
442
|
+
readonly angularVelocity: number;
|
|
443
|
+
readonly sleeping: boolean;
|
|
319
444
|
}
|
|
320
445
|
declare function encodeMatterBodies(section: MatterSection): Uint8Array;
|
|
321
446
|
declare function decodeMatterBodies(bytes: Uint8Array): MatterSection;
|
|
@@ -355,10 +480,36 @@ declare class MatterRuntime {
|
|
|
355
480
|
private readonly sourceRecords;
|
|
356
481
|
/** `collection.field` pairs already warned about a channel a plane cannot hold. */
|
|
357
482
|
private readonly warnedChannels;
|
|
483
|
+
/** See `PhysicsRuntime.skippedKeys`: entities whose body the room has switched off. */
|
|
484
|
+
private readonly skippedKeys;
|
|
485
|
+
private readonly unsyncedBodies;
|
|
486
|
+
/** Saved unsynced state, consumed as `setup` rebuilds each body under the same key. */
|
|
487
|
+
private restoreUnsynced;
|
|
488
|
+
/** One-shot: the first reconcile after a wake reports whatever `setup` did not rebuild. */
|
|
489
|
+
private reportedMissingUnsynced;
|
|
490
|
+
readonly unsynced: UnsyncedRegistry;
|
|
358
491
|
constructor(core: RoomInternals, matter: MatterModule, options: MatterRuntimeOptions);
|
|
359
492
|
get timestep(): number;
|
|
360
493
|
runSetup(room: Room): void;
|
|
361
494
|
free(): void;
|
|
495
|
+
/**
|
|
496
|
+
* bugs.md #100, the matter2d half. Rapier's bindings wake a contact island themselves when a
|
|
497
|
+
* member is removed (`PhysicsRuntime.destroyBody` / `Rapier2dRuntime.destroyBody`); matter.js
|
|
498
|
+
* does not, so a body sleeping against a victim about to be removed is left sleeping and
|
|
499
|
+
* motionless in mid-air once the victim is gone.
|
|
500
|
+
*
|
|
501
|
+
* `enableSleeping` is a property the room's own `setup` sets on `engine` (there is no config
|
|
502
|
+
* flag for it), so this only runs the geometry query when the room has actually turned sleeping
|
|
503
|
+
* on. `Matter.Query.collides` is matter's public narrow-phase check against a body's current
|
|
504
|
+
* vertices, run here against every other body in the world; it costs nothing extra to compute
|
|
505
|
+
* (matter.js does not expose a per-body contact list the way rapier's bindings do) but is bounded
|
|
506
|
+
* by the same room body count that every reconcile already walks. Sensors are skipped, matching
|
|
507
|
+
* the rapier fix: an intersection pair is not an island edge.
|
|
508
|
+
*/
|
|
509
|
+
private wakeContactsOf;
|
|
510
|
+
/** The matter.js half of `PhysicsRuntime.setSkipped`; its comment is the whole argument. */
|
|
511
|
+
setSkipped(collection: string, id: string, on: boolean): void;
|
|
512
|
+
isSkipped(collection: string, id: string): boolean;
|
|
362
513
|
bodyFor(collection: string, id: string): MatterBody | undefined;
|
|
363
514
|
private create;
|
|
364
515
|
/**
|
|
@@ -428,6 +579,27 @@ interface PhysicsSection {
|
|
|
428
579
|
* whatever was created first (for pachinko, the floor).
|
|
429
580
|
*/
|
|
430
581
|
readonly bodies: readonly (readonly [string, string, number])[];
|
|
582
|
+
/**
|
|
583
|
+
* `${collection}\0${id}` for every entity whose body the room has switched off. The set has to
|
|
584
|
+
* survive a wake or a room full of dormant spiders wakes up simulating all of them: the rows
|
|
585
|
+
* come back from the snapshot, `reconcile` sees rows with no body, and builds one for each.
|
|
586
|
+
*
|
|
587
|
+
* It rides as a **trailing block, written only when non-empty**, so a room that skips nothing
|
|
588
|
+
* emits the bytes it emitted before this existed — which is every blob ever written. The
|
|
589
|
+
* decoder reads it only when there is something left after the body map.
|
|
590
|
+
*/
|
|
591
|
+
readonly skipped?: readonly string[];
|
|
592
|
+
/**
|
|
593
|
+
* `[key, handle]` for every managed unsynced body, in creation order.
|
|
594
|
+
*
|
|
595
|
+
* The world snapshot already carries the bodies themselves — they are ordinary Rapier bodies —
|
|
596
|
+
* so what has to ride here is only *which handle is which key*, exactly as `bodies` above does
|
|
597
|
+
* for entity-backed bodies. Losing that map is the leak this feature retires: a woken room with
|
|
598
|
+
* bodies it can no longer name is a room that can never remove them.
|
|
599
|
+
*
|
|
600
|
+
* Written in the same trailing block as {@link skipped}, and only when there is something to say.
|
|
601
|
+
*/
|
|
602
|
+
readonly unsynced?: readonly (readonly [string, number])[];
|
|
431
603
|
}
|
|
432
604
|
declare function encodePhysicsSection(section: PhysicsSection): Uint8Array;
|
|
433
605
|
/**
|
|
@@ -510,11 +682,60 @@ declare class PhysicsRuntime {
|
|
|
510
682
|
private readonly sourceRecords;
|
|
511
683
|
/** `collection.field` pairs already warned about a channel value the engine would not take. */
|
|
512
684
|
private readonly warnedChannels;
|
|
685
|
+
/**
|
|
686
|
+
* Entities whose body the room has switched off. See {@link setSkipped} for what "off" means and
|
|
687
|
+
* why it is spelled as "no body" rather than as "a body that is not stepped".
|
|
688
|
+
*/
|
|
689
|
+
private readonly skippedKeys;
|
|
690
|
+
/**
|
|
691
|
+
* Bodies with no schema row: rope anchors, dead-reckoned proxies, one-off sensors. Keyed by the
|
|
692
|
+
* room's own string rather than by `collection\0id`, because there is no collection and no id.
|
|
693
|
+
*/
|
|
694
|
+
private readonly unsyncedBodies;
|
|
695
|
+
readonly unsynced: UnsyncedRegistry;
|
|
513
696
|
constructor(core: RoomInternals, rapier: RapierModule, options: PhysicsRuntimeOptions);
|
|
514
697
|
get timestep(): number;
|
|
515
698
|
/** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
|
|
516
699
|
runSetup(room: Room): void;
|
|
517
700
|
free(): void;
|
|
701
|
+
/**
|
|
702
|
+
* bugs.md #99: every teardown path goes through here rather than calling `removeRigidBody`
|
|
703
|
+
* directly.
|
|
704
|
+
*
|
|
705
|
+
* A sleeping body is parked in an island wired through the contact graph. Pulling a body out
|
|
706
|
+
* from under one leaves the sleeper resting on nothing — and, in the failure this guards, leaves
|
|
707
|
+
* the island holding a body that no longer exists. Waking the contacting bodies first makes the
|
|
708
|
+
* removal a plain awake-island edit, and it is also what a room wants physically: the crate the
|
|
709
|
+
* floor vanished under should fall.
|
|
710
|
+
*
|
|
711
|
+
* The cost is one narrow-phase lookup per collider of the *victim* — `contactPairsWith` walks
|
|
712
|
+
* only that collider's own contact pairs — against the alternative of waking the world, which
|
|
713
|
+
* would un-sleep every dormant body in the room on every teardown. Sensors are skipped
|
|
714
|
+
* deliberately: an intersection pair is not an island edge, so it cannot be the thing at risk.
|
|
715
|
+
*/
|
|
716
|
+
private destroyBody;
|
|
717
|
+
/**
|
|
718
|
+
* Switches one entity's body off, or back on.
|
|
719
|
+
*
|
|
720
|
+
* Undercity feedback 29: hundreds of dormant-by-design entities want "exists and syncs, but do
|
|
721
|
+
* not step or mirror until I say so". Rapier's own sleeping approximates it and still costs a
|
|
722
|
+
* broadphase entry and a sync slot per row per tick, and a sleeping body wakes on contact,
|
|
723
|
+
* which a dormant spider must not.
|
|
724
|
+
*
|
|
725
|
+
* **Skipped means no body.** The body is destroyed on the way in and rebuilt from the schema
|
|
726
|
+
* row on the way out, on the next `reconcile`. The alternative — keep the body, exclude it from
|
|
727
|
+
* the step — is not something an engine offers per body without also excluding it from
|
|
728
|
+
* collision, and it would leave the cost this exists to remove (a body in the world, in the
|
|
729
|
+
* broadphase, walked by `sync`) exactly where it was. The row is untouched and goes on syncing
|
|
730
|
+
* as ordinary state, which is what the ask asked for; a room that moves a skipped entity by
|
|
731
|
+
* assigning its fields is moving it, and unskipping resumes from wherever the row now says.
|
|
732
|
+
*
|
|
733
|
+
* What is lost across a skip is what a body has and a row does not: contacts, and velocity
|
|
734
|
+
* beyond the f32 the row carries. A skipped entity resumes at rest at its row's pose unless the
|
|
735
|
+
* row's velocity channels say otherwise.
|
|
736
|
+
*/
|
|
737
|
+
setSkipped(collection: string, id: string, on: boolean): void;
|
|
738
|
+
isSkipped(collection: string, id: string): boolean;
|
|
518
739
|
/** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
|
|
519
740
|
bodyFor(collection: string, id: string): RapierRigidBody | undefined;
|
|
520
741
|
private create;
|
|
@@ -628,11 +849,25 @@ declare class Rapier2dRuntime {
|
|
|
628
849
|
private readonly sourceRecords;
|
|
629
850
|
/** `collection.field` pairs already warned about a channel value the engine would not take. */
|
|
630
851
|
private readonly warnedChannels;
|
|
852
|
+
/** See `PhysicsRuntime.skippedKeys`: entities whose body the room has switched off. */
|
|
853
|
+
private readonly skippedKeys;
|
|
854
|
+
/** See `PhysicsRuntime.unsyncedBodies`: bodies the world steps that no schema row describes. */
|
|
855
|
+
private readonly unsyncedBodies;
|
|
856
|
+
readonly unsynced: UnsyncedRegistry;
|
|
631
857
|
constructor(core: RoomInternals, rapier: Rapier2dModule, options: Rapier2dRuntimeOptions);
|
|
632
858
|
get timestep(): number;
|
|
633
859
|
/** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
|
|
634
860
|
runSetup(room: Room): void;
|
|
635
861
|
free(): void;
|
|
862
|
+
/**
|
|
863
|
+
* bugs.md #99, planar half: the teardown door every removal goes through. See
|
|
864
|
+
* `PhysicsRuntime.destroyBody` for the argument — one narrow-phase walk of the victim's own
|
|
865
|
+
* contact pairs, so a body resting on what is about to vanish is awake when it does.
|
|
866
|
+
*/
|
|
867
|
+
private destroyBody;
|
|
868
|
+
/** The planar half of `PhysicsRuntime.setSkipped`; its comment is the whole argument. */
|
|
869
|
+
setSkipped(collection: string, id: string, on: boolean): void;
|
|
870
|
+
isSkipped(collection: string, id: string): boolean;
|
|
636
871
|
/** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
|
|
637
872
|
bodyFor(collection: string, id: string): Rapier2dRigidBody | undefined;
|
|
638
873
|
private create;
|
|
@@ -693,8 +928,15 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
|
|
|
693
928
|
readonly rng: Mulberry32;
|
|
694
929
|
readonly clients: Map<string, ClientEntry>;
|
|
695
930
|
readonly loop: Loop;
|
|
696
|
-
/**
|
|
697
|
-
|
|
931
|
+
/**
|
|
932
|
+
* D22: the Rapier world, or `undefined` in a room whose config declares no physics.
|
|
933
|
+
*
|
|
934
|
+
* Not `readonly`, and the reason is M7 pf3 (a): each builder installs the runtime here *before*
|
|
935
|
+
* it calls `runSetup`, so `room.physics*` is readable from inside `physics.setup`. That is
|
|
936
|
+
* where an unsynced body belongs — it is static geometry with a handle — and it is the only
|
|
937
|
+
* place a matter2d room can put one and have it come back after a wake.
|
|
938
|
+
*/
|
|
939
|
+
physics: PhysicsRuntime | MatterRuntime | Rapier2dRuntime | undefined;
|
|
698
940
|
readonly stats: RoomStats;
|
|
699
941
|
tick: number;
|
|
700
942
|
stopped: boolean;
|
|
@@ -713,6 +955,11 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
|
|
|
713
955
|
* end of every tick, which is the only moment in a tick where that state is settled.
|
|
714
956
|
*/
|
|
715
957
|
private recorder;
|
|
958
|
+
/**
|
|
959
|
+
* M7 pf4: the RPC trace ring, constructed only when `RoomCoreOptions.traceRpc` asked for one.
|
|
960
|
+
* Every cost this lane has is behind this `undefined` — see `core/rpc-trace.ts`.
|
|
961
|
+
*/
|
|
962
|
+
private readonly rpcTraceRing;
|
|
716
963
|
/**
|
|
717
964
|
* D65: the bandwidth ledger, present only when `RoomCoreOptions.profile` asked for one. Every
|
|
718
965
|
* cost the profiler has is behind this `undefined`.
|
|
@@ -734,6 +981,26 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
|
|
|
734
981
|
constructor(definition: RoomDefinition<S>, host: RoomHost, options: RoomCoreOptions);
|
|
735
982
|
private subscribeDeclaredChannels;
|
|
736
983
|
private get busConfig();
|
|
984
|
+
/**
|
|
985
|
+
* M7 D-P2: the platform notice, and the room's veto.
|
|
986
|
+
*
|
|
987
|
+
* Answers whether the supervisor should broadcast. `false` — the veto — comes back for exactly
|
|
988
|
+
* one input: a handler that ran and returned `false`. Every other path answers `true`, and each
|
|
989
|
+
* one is a different way of the room having said nothing:
|
|
990
|
+
*
|
|
991
|
+
* - **No handler.** A room that never wrote this hook. The design's whole point is that its
|
|
992
|
+
* players still hear the warning; a code-less room does not even reach this file.
|
|
993
|
+
* - **A handler that threw.** `tryRun` and not `guard`, so the throw counts toward the crash
|
|
994
|
+
* threshold like any other, and it still does not become a veto. This is the one place the
|
|
995
|
+
* convention differs from `onChat`, where a throw refuses, and the reason is the asymmetry of
|
|
996
|
+
* the two losses: a chat line nobody sees is small; a save-loss warning nobody sees is the
|
|
997
|
+
* feature not existing.
|
|
998
|
+
* - **A stopped room.** Nobody to ask, and for `closing` nobody left to veto for.
|
|
999
|
+
*
|
|
1000
|
+
* The handler may also have written state, posted chat or started an end sequence. All of that
|
|
1001
|
+
* is ordinary handler work and is flushed here the way an alarm's is.
|
|
1002
|
+
*/
|
|
1003
|
+
deliverPlatformNotice(notice: PlatformNotice): boolean;
|
|
737
1004
|
/**
|
|
738
1005
|
* D59: one published message arriving on a channel this room is subscribed to.
|
|
739
1006
|
*
|
|
@@ -796,6 +1063,19 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
|
|
|
796
1063
|
recording(): TimelineDump | undefined;
|
|
797
1064
|
/** D41: called at the end of every tick (and every event-mode flush). No-op when unarmed. */
|
|
798
1065
|
captureTimeline(): void;
|
|
1066
|
+
/**
|
|
1067
|
+
* M7 pf4: file one RPC on the trace. Called from `core/rpc.ts` on every exit path a call has,
|
|
1068
|
+
* and a no-op — not merely a cheap one, an untaken branch — in a room nobody armed.
|
|
1069
|
+
*/
|
|
1070
|
+
recordRpc(entry: Omit<RpcTraceEntry, 'seq'>): void;
|
|
1071
|
+
/**
|
|
1072
|
+
* M7 pf4: whether this room has a trace ring at all. Read **before** an entry is built, so an
|
|
1073
|
+
* unarmed room pays one property read per call rather than building an entry object and
|
|
1074
|
+
* serializing a result for `recordRpc` to drop on the floor.
|
|
1075
|
+
*/
|
|
1076
|
+
get rpcTraceArmed(): boolean;
|
|
1077
|
+
/** M7 pf4: the trace so far, or `undefined` when this room was not armed. */
|
|
1078
|
+
rpcTrace(): RpcTraceDump | undefined;
|
|
799
1079
|
/**
|
|
800
1080
|
* D72: record this tick's body poses, right after `physics.sync()` — the poses the clients are
|
|
801
1081
|
* about to be told about, under the tick number they will be told it under, which is the tick a
|
|
@@ -825,9 +1105,49 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
|
|
|
825
1105
|
* written.
|
|
826
1106
|
*/
|
|
827
1107
|
snapshot(): Uint8Array;
|
|
1108
|
+
/**
|
|
1109
|
+
* The earliest pending room timer's due time at the last `serialize()`, on `host.now()`'s
|
|
1110
|
+
* clock, or `undefined` if nothing was armed.
|
|
1111
|
+
*
|
|
1112
|
+
* `serialize()` is the departure, and it drops every room timer because a callback is a closure
|
|
1113
|
+
* and there is nothing to persist. The *deadline* is persistable, though, and this is where the
|
|
1114
|
+
* worker reads it to hand to the supervisor, which arms a wake for it. The contract room code
|
|
1115
|
+
* gets: your callback does not survive hibernation, but your room is awake again at or shortly
|
|
1116
|
+
* after the time it would have fired — so keep the deadline in state and catch up in `onWake`.
|
|
1117
|
+
*
|
|
1118
|
+
* Captured before `clearAllTimers()`, which is the only ordering that could ever have worked.
|
|
1119
|
+
*/
|
|
1120
|
+
lastSerializeTimerAt: number | undefined;
|
|
828
1121
|
serialize(): Uint8Array;
|
|
829
1122
|
private get presence();
|
|
830
1123
|
private resolveRole;
|
|
1124
|
+
/**
|
|
1125
|
+
* Subjects `room.ban` has excluded, and when each stops being excluded. Values are wall-clock
|
|
1126
|
+
* deadlines from `host.now()`; `Number.POSITIVE_INFINITY` is the default, "until this room
|
|
1127
|
+
* closes".
|
|
1128
|
+
*
|
|
1129
|
+
* In memory and nowhere else, by decision. It is not in `state`, so it is not in a snapshot, so a
|
|
1130
|
+
* hibernation drops it and a wake starts clean — the same call relay audience membership made,
|
|
1131
|
+
* and for the same reason: this is a fact about a sitting, not about a save file. Exclusion that
|
|
1132
|
+
* outlives a wake is what the control plane's `project_bans` is for, and that one is durable
|
|
1133
|
+
* because it is a row in a database rather than a `Map` in a worker.
|
|
1134
|
+
*/
|
|
1135
|
+
private readonly banned;
|
|
1136
|
+
/**
|
|
1137
|
+
* The room's own refusal, before a seat exists. Throws {@link AdmissionError} or returns.
|
|
1138
|
+
*
|
|
1139
|
+
* Order matters and is deliberate: the cooldown is checked first, so a subject the room already
|
|
1140
|
+
* threw out cannot spend the room's `onAdmit` budget on every reconnect attempt.
|
|
1141
|
+
*/
|
|
1142
|
+
private admit;
|
|
1143
|
+
/**
|
|
1144
|
+
* `room.ban`: kick this client and keep its subject out for a while.
|
|
1145
|
+
*
|
|
1146
|
+
* Returns the subject it excluded, or `undefined` when there was nothing durable to exclude —
|
|
1147
|
+
* a key join, whose only name is a client id it can mint again. The kick happens either way,
|
|
1148
|
+
* because a kick is a real thing to do to a session even when the exclusion cannot follow it.
|
|
1149
|
+
*/
|
|
1150
|
+
banClient(clientId: string, reason?: string, minutes?: number): string | undefined;
|
|
831
1151
|
join(clientId: string, options?: JoinOptions): JoinResult;
|
|
832
1152
|
/** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
|
|
833
1153
|
private eventFlush;
|
|
@@ -881,6 +1201,8 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
|
|
|
881
1201
|
stateBytes(): number | undefined;
|
|
882
1202
|
/** D77: how many records are held for players who have left. */
|
|
883
1203
|
holdCount(): number;
|
|
1204
|
+
/** How many `transfer: 'ask'` requests are waiting on an owner's answer right now. */
|
|
1205
|
+
askCount(): number;
|
|
884
1206
|
profile(): ProfileSnapshot | undefined;
|
|
885
1207
|
private badFrame;
|
|
886
1208
|
receive(clientId: string, frame: Uint8Array): void;
|
package/dist/test/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { R as RoomCore } from '../room-
|
|
2
|
-
export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-
|
|
1
|
+
import { R as RoomCore } from '../room-CGbiPxi3.js';
|
|
2
|
+
export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-CGbiPxi3.js';
|
|
3
3
|
import { ErrorCodeName, FrameType } from '@irtio/protocol';
|
|
4
4
|
import { NpcConfig, LeaveReason, Room, RoomDefinition } from '@irtio/server';
|
|
5
|
-
import {
|
|
5
|
+
import { p as RoomHost, L as LogLevel, k as RoomCoreApi, c as HostCall, d as HostCallResult, W as WriteCaps, r as RoomStats } from '../contract-BoCNAPZT.js';
|
|
6
6
|
import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -141,6 +141,12 @@ declare class HarnessHost implements RoomHost {
|
|
|
141
141
|
/** D81: every clip the room wrote, in order: `clipId` -> the blob it was handed. */
|
|
142
142
|
readonly clips: Map<string, Uint8Array<ArrayBufferLike>>;
|
|
143
143
|
private nextClipId;
|
|
144
|
+
/** D81: every recording this room wrote: `recId` -> its chunks in the order they were sent. */
|
|
145
|
+
readonly recordings: Map<string, {
|
|
146
|
+
seq: number;
|
|
147
|
+
bytes: Uint8Array;
|
|
148
|
+
}[]>;
|
|
149
|
+
private nextRecordingId;
|
|
144
150
|
/**
|
|
145
151
|
* D44: the in-process harness has no supervisor, no sockets and therefore no loopback session
|
|
146
152
|
* to open. Recording the request rather than faking a session is the honest option: a test that
|
|
@@ -283,7 +289,12 @@ interface FakeClient<S extends AnySchema = AnySchema> {
|
|
|
283
289
|
/** Sends an arbitrary framed payload (protocol-violation tests). */
|
|
284
290
|
writeRaw(frame: Uint8Array): void;
|
|
285
291
|
/** Client → server RPC. Settles when the `REPLY` is delivered — `h.tick()` first, then `await`. */
|
|
286
|
-
|
|
292
|
+
/**
|
|
293
|
+
* `clientTick` is D72's caller stamp — the tick this client claims it was looking at. Real
|
|
294
|
+
* clients set it from their own state store; a fake one has to be told, and M7 pf4's stamp-lag
|
|
295
|
+
* column has nothing to show without it.
|
|
296
|
+
*/
|
|
297
|
+
call(name: string, params?: AnyRecord, clientTick?: number): Promise<unknown>;
|
|
287
298
|
/** The built-in `requestOwnership` RPC. */
|
|
288
299
|
requestOwnership(entity: string, id: string): Promise<boolean>;
|
|
289
300
|
/** Out-of-band `MSG`. */
|
|
@@ -317,6 +328,11 @@ interface HarnessOptions {
|
|
|
317
328
|
readonly roomId?: string;
|
|
318
329
|
/** Bytes from a previous `serialize()`: the room wakes instead of being created. */
|
|
319
330
|
readonly restoreFrom?: Uint8Array;
|
|
331
|
+
/**
|
|
332
|
+
* M7 pf4: arm this room's RPC trace ring, so `h.core.rpcTrace()` answers something. Off by
|
|
333
|
+
* default, exactly as a deployed room is.
|
|
334
|
+
*/
|
|
335
|
+
readonly traceRpc?: boolean;
|
|
320
336
|
/** D77 / O28: the write-core caps this room runs under. Default: the record cap only. */
|
|
321
337
|
readonly caps?: WriteCaps;
|
|
322
338
|
/** D77: the wall clock hold deadlines are measured against. Default `Date.now`. */
|
|
@@ -333,6 +349,12 @@ interface JoinSpec {
|
|
|
333
349
|
* "a new browser does not" pair testable without a socket.
|
|
334
350
|
*/
|
|
335
351
|
readonly playerId?: string;
|
|
352
|
+
/**
|
|
353
|
+
* The verified subject this join presents, or absent for a key join. Absent is the default,
|
|
354
|
+
* because a harness join presents no credential — which is exactly the case a room with an
|
|
355
|
+
* `onAdmit` that requires identity needs to be tested against.
|
|
356
|
+
*/
|
|
357
|
+
readonly subject?: string;
|
|
336
358
|
}
|
|
337
359
|
interface UntilOptions {
|
|
338
360
|
/** Default 1000. */
|
package/dist/test/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
initPhysics,
|
|
6
6
|
initRapier2d,
|
|
7
7
|
visibleNames
|
|
8
|
-
} from "../chunk-
|
|
8
|
+
} from "../chunk-NPRIYYNM.js";
|
|
9
9
|
|
|
10
10
|
// src/test/clock.ts
|
|
11
11
|
var FakeClock = class {
|
|
@@ -209,6 +209,18 @@ var HarnessHost = class {
|
|
|
209
209
|
this.clips.set(clipId, call.bytes);
|
|
210
210
|
return { ok: true, value: clipId };
|
|
211
211
|
}
|
|
212
|
+
// ---- end M6 lane N ----
|
|
213
|
+
// ---- M6 record packet ----
|
|
214
|
+
case "record-chunk": {
|
|
215
|
+
const recId = call.recId ?? `rec${String(this.nextRecordingId++).padStart(4, "0")}`;
|
|
216
|
+
let chunks = this.recordings.get(recId);
|
|
217
|
+
if (!chunks) {
|
|
218
|
+
chunks = [];
|
|
219
|
+
this.recordings.set(recId, chunks);
|
|
220
|
+
}
|
|
221
|
+
chunks.push({ seq: call.seq, bytes: call.bytes });
|
|
222
|
+
return { ok: true, value: recId };
|
|
223
|
+
}
|
|
212
224
|
}
|
|
213
225
|
}
|
|
214
226
|
// ---- M6 lane N: replays ----
|
|
@@ -216,6 +228,11 @@ var HarnessHost = class {
|
|
|
216
228
|
clips = /* @__PURE__ */ new Map();
|
|
217
229
|
nextClipId = 1;
|
|
218
230
|
// ---- end M6 lane N ----
|
|
231
|
+
// ---- M6 record packet ----
|
|
232
|
+
/** D81: every recording this room wrote: `recId` -> its chunks in the order they were sent. */
|
|
233
|
+
recordings = /* @__PURE__ */ new Map();
|
|
234
|
+
nextRecordingId = 1;
|
|
235
|
+
// ---- end M6 record packet ----
|
|
219
236
|
// -------------------------------------------------------------------------
|
|
220
237
|
// Week 12: durable alarms (D26)
|
|
221
238
|
// -------------------------------------------------------------------------
|
|
@@ -469,7 +486,7 @@ var FakeClientImpl = class {
|
|
|
469
486
|
);
|
|
470
487
|
}
|
|
471
488
|
// ---- end M6 lane K ----
|
|
472
|
-
call(name, params = {}) {
|
|
489
|
+
call(name, params = {}, clientTick) {
|
|
473
490
|
const desc = this.descOf(name);
|
|
474
491
|
const reqId = this.nextReqId++;
|
|
475
492
|
return new Promise((resolve, reject) => {
|
|
@@ -477,7 +494,8 @@ var FakeClientImpl = class {
|
|
|
477
494
|
const payload = encodeCall({
|
|
478
495
|
reqId,
|
|
479
496
|
rpcId: desc.index,
|
|
480
|
-
params: encodeFields(desc.params, params)
|
|
497
|
+
params: encodeFields(desc.params, params),
|
|
498
|
+
...clientTick !== void 0 ? { clientTick } : {}
|
|
481
499
|
});
|
|
482
500
|
this.bridge.toCore(this.id, encodeFrame(FrameType.CALL, payload));
|
|
483
501
|
});
|
|
@@ -729,6 +747,7 @@ var Harness = class {
|
|
|
729
747
|
...options.seed !== void 0 ? { seed: options.seed } : {},
|
|
730
748
|
...options.publicUrl !== void 0 ? { publicUrl: options.publicUrl } : {},
|
|
731
749
|
...options.restoreFrom !== void 0 ? { restoreFrom: options.restoreFrom } : {},
|
|
750
|
+
...options.traceRpc === true ? { traceRpc: true } : {},
|
|
732
751
|
// ---- M6 lane K: stateful relay ----
|
|
733
752
|
...options.caps !== void 0 ? { caps: options.caps } : {},
|
|
734
753
|
...options.wallNow !== void 0 ? { wallNow: options.wallNow } : {}
|
|
@@ -936,8 +955,11 @@ var Harness = class {
|
|
|
936
955
|
role,
|
|
937
956
|
...spec.name !== void 0 ? { name: spec.name } : {},
|
|
938
957
|
// ---- M6 lane K: stateful relay ----
|
|
939
|
-
...spec.playerId !== void 0 ? { playerId: spec.playerId } : {}
|
|
958
|
+
...spec.playerId !== void 0 ? { playerId: spec.playerId } : {},
|
|
940
959
|
// ---- end M6 lane K ----
|
|
960
|
+
// ---- M8 lane 5: moderation ----
|
|
961
|
+
...spec.subject !== void 0 ? { subject: spec.subject } : {}
|
|
962
|
+
// ---- end M8 lane 5 ----
|
|
941
963
|
};
|
|
942
964
|
const client = new FakeClientImpl(this.bridge, id, options);
|
|
943
965
|
this.byId.set(id, client);
|