@irtio/runtime 1.0.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-YWDRE77H.js → chunk-3BCTP4YS.js} +79 -6
- package/dist/{chunk-LT46QFLW.js → chunk-NPRIYYNM.js} +1951 -1009
- package/dist/{contract-UEKTud1Z.d.ts → contract-BoCNAPZT.d.ts} +146 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +14 -2
- package/dist/{room-uhj3b-8E.d.ts → room-CGbiPxi3.d.ts} +311 -11
- package/dist/test/index.d.ts +20 -4
- package/dist/test/index.js +9 -4
- package/dist/worker/index.d.ts +41 -9
- package/dist/worker/index.js +34 -7
- package/package.json +9 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
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`.
|
|
@@ -816,6 +1063,19 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
|
|
|
816
1063
|
recording(): TimelineDump | undefined;
|
|
817
1064
|
/** D41: called at the end of every tick (and every event-mode flush). No-op when unarmed. */
|
|
818
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;
|
|
819
1079
|
/**
|
|
820
1080
|
* D72: record this tick's body poses, right after `physics.sync()` — the poses the clients are
|
|
821
1081
|
* about to be told about, under the tick number they will be told it under, which is the tick a
|
|
@@ -845,9 +1105,49 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
|
|
|
845
1105
|
* written.
|
|
846
1106
|
*/
|
|
847
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;
|
|
848
1121
|
serialize(): Uint8Array;
|
|
849
1122
|
private get presence();
|
|
850
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;
|
|
851
1151
|
join(clientId: string, options?: JoinOptions): JoinResult;
|
|
852
1152
|
/** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
|
|
853
1153
|
private eventFlush;
|
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
|
/**
|
|
@@ -289,7 +289,12 @@ interface FakeClient<S extends AnySchema = AnySchema> {
|
|
|
289
289
|
/** Sends an arbitrary framed payload (protocol-violation tests). */
|
|
290
290
|
writeRaw(frame: Uint8Array): void;
|
|
291
291
|
/** Client → server RPC. Settles when the `REPLY` is delivered — `h.tick()` first, then `await`. */
|
|
292
|
-
|
|
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>;
|
|
293
298
|
/** The built-in `requestOwnership` RPC. */
|
|
294
299
|
requestOwnership(entity: string, id: string): Promise<boolean>;
|
|
295
300
|
/** Out-of-band `MSG`. */
|
|
@@ -323,6 +328,11 @@ interface HarnessOptions {
|
|
|
323
328
|
readonly roomId?: string;
|
|
324
329
|
/** Bytes from a previous `serialize()`: the room wakes instead of being created. */
|
|
325
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;
|
|
326
336
|
/** D77 / O28: the write-core caps this room runs under. Default: the record cap only. */
|
|
327
337
|
readonly caps?: WriteCaps;
|
|
328
338
|
/** D77: the wall clock hold deadlines are measured against. Default `Date.now`. */
|
|
@@ -339,6 +349,12 @@ interface JoinSpec {
|
|
|
339
349
|
* "a new browser does not" pair testable without a socket.
|
|
340
350
|
*/
|
|
341
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;
|
|
342
358
|
}
|
|
343
359
|
interface UntilOptions {
|
|
344
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 {
|
|
@@ -486,7 +486,7 @@ var FakeClientImpl = class {
|
|
|
486
486
|
);
|
|
487
487
|
}
|
|
488
488
|
// ---- end M6 lane K ----
|
|
489
|
-
call(name, params = {}) {
|
|
489
|
+
call(name, params = {}, clientTick) {
|
|
490
490
|
const desc = this.descOf(name);
|
|
491
491
|
const reqId = this.nextReqId++;
|
|
492
492
|
return new Promise((resolve, reject) => {
|
|
@@ -494,7 +494,8 @@ var FakeClientImpl = class {
|
|
|
494
494
|
const payload = encodeCall({
|
|
495
495
|
reqId,
|
|
496
496
|
rpcId: desc.index,
|
|
497
|
-
params: encodeFields(desc.params, params)
|
|
497
|
+
params: encodeFields(desc.params, params),
|
|
498
|
+
...clientTick !== void 0 ? { clientTick } : {}
|
|
498
499
|
});
|
|
499
500
|
this.bridge.toCore(this.id, encodeFrame(FrameType.CALL, payload));
|
|
500
501
|
});
|
|
@@ -746,6 +747,7 @@ var Harness = class {
|
|
|
746
747
|
...options.seed !== void 0 ? { seed: options.seed } : {},
|
|
747
748
|
...options.publicUrl !== void 0 ? { publicUrl: options.publicUrl } : {},
|
|
748
749
|
...options.restoreFrom !== void 0 ? { restoreFrom: options.restoreFrom } : {},
|
|
750
|
+
...options.traceRpc === true ? { traceRpc: true } : {},
|
|
749
751
|
// ---- M6 lane K: stateful relay ----
|
|
750
752
|
...options.caps !== void 0 ? { caps: options.caps } : {},
|
|
751
753
|
...options.wallNow !== void 0 ? { wallNow: options.wallNow } : {}
|
|
@@ -953,8 +955,11 @@ var Harness = class {
|
|
|
953
955
|
role,
|
|
954
956
|
...spec.name !== void 0 ? { name: spec.name } : {},
|
|
955
957
|
// ---- M6 lane K: stateful relay ----
|
|
956
|
-
...spec.playerId !== void 0 ? { playerId: spec.playerId } : {}
|
|
958
|
+
...spec.playerId !== void 0 ? { playerId: spec.playerId } : {},
|
|
957
959
|
// ---- end M6 lane K ----
|
|
960
|
+
// ---- M8 lane 5: moderation ----
|
|
961
|
+
...spec.subject !== void 0 ? { subject: spec.subject } : {}
|
|
962
|
+
// ---- end M8 lane 5 ----
|
|
958
963
|
};
|
|
959
964
|
const client = new FakeClientImpl(this.bridge, id, options);
|
|
960
965
|
this.byId.set(id, client);
|