@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.
@@ -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 { n as RoomHost, p as RoomStats, L as LogLevel, i as RoomCoreApi, j as RoomCoreOptions, l as RoomEventKind, s as TimelineRecorderOptions, T as TimelineDump, o as RoomInspection, J as JoinOptions, e as JoinResult, d as HostCallResult } from './contract-UEKTud1Z.js';
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
- /** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
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
- /** D22: the Rapier world, or `undefined` in a room whose config declares no physics. */
697
- readonly physics: PhysicsRuntime | MatterRuntime | Rapier2dRuntime | undefined;
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;
@@ -1,8 +1,8 @@
1
- import { R as RoomCore } from '../room-uhj3b-8E.js';
2
- export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-uhj3b-8E.js';
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 { n as RoomHost, L as LogLevel, i as RoomCoreApi, c as HostCall, d as HostCallResult, W as WriteCaps, p as RoomStats } from '../contract-UEKTud1Z.js';
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
- call(name: string, params?: AnyRecord): Promise<unknown>;
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. */
@@ -5,7 +5,7 @@ import {
5
5
  initPhysics,
6
6
  initRapier2d,
7
7
  visibleNames
8
- } from "../chunk-LT46QFLW.js";
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);