@irtio/runtime 0.5.2 → 0.7.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,6 +1,7 @@
1
+ import { AttributeOptions, ProfileSnapshot } from '@irtio/protocol';
1
2
  import { DirtySet, AnySchema, PlainState, Tracked, State } from '@irtio/schema';
2
- import { RoomDefinition, RoomMode, Room, Ctx, RapierModule, RapierWorld, RapierRigidBody, ResolvedRoomConfig, LeaveReason } from '@irtio/server';
3
- import { h as RoomHost, j as RoomStats, L as LogLevel, R as RoomCoreApi, d as RoomCoreOptions, f as RoomEventKind, i as RoomInspection, J as JoinOptions, c as JoinResult, b as HostCallResult } from './contract-B8QSO0MH.js';
3
+ import { RoomDefinition, RoomMode, Room, RewindView, Ctx, MatterModule, MatterEngine, MatterBody, RapierModule, RapierWorld, RapierRigidBody, ResolvedRoomConfig, LeaveReason } from '@irtio/server';
4
+ import { i as RoomHost, k as RoomStats, L as LogLevel, R as RoomCoreApi, e as RoomCoreOptions, g as RoomEventKind, n as TimelineRecorderOptions, T as TimelineDump, j as RoomInspection, J as JoinOptions, d as JoinResult, c as HostCallResult } from './contract-DwxqjeWP.js';
4
5
 
5
6
  /**
6
7
  * `room.random()` — mulberry32 over a u32 seed. Deterministic, tiny, and serializable: the
@@ -27,6 +28,13 @@ type AcceptedWrites = Map<string, Map<string, unknown>>;
27
28
  /** One joined client. Presence is the wire truth; this is the runtime's bookkeeping. */
28
29
  interface ClientEntry {
29
30
  readonly clientId: string;
31
+ /**
32
+ * D44: this session is a scripted NPC, spawned by room code rather than dialled from outside.
33
+ * It changes exactly two things: the idle clock does not count it (a room full of NPCs and no
34
+ * players hibernates like an empty one), and metrics report it separately. Everything else —
35
+ * `maxClients`, validation, ownership, presence — treats it as the client session it is.
36
+ */
37
+ readonly npc: boolean;
30
38
  /**
31
39
  * D25: the identity `ctx.playerId` reports. Set once at the first join and kept across
32
40
  * reconnects, exactly like the client id it defaults to.
@@ -85,6 +93,8 @@ interface QueuedFrame {
85
93
  }
86
94
  /** What the core modules may do with the physics world (`core/physics.ts` implements it). */
87
95
  interface PhysicsApi {
96
+ /** D45: which of the two blessed engines this room is running. */
97
+ readonly engineKind: 'rapier3d' | 'matter2d';
88
98
  readonly timestep: number;
89
99
  /** Creates bodies for new instances, destroys bodies whose instance is gone. */
90
100
  reconcile(): void;
@@ -92,8 +102,22 @@ interface PhysicsApi {
92
102
  /** Body → schema, through the tracked proxies. */
93
103
  sync(): void;
94
104
  bodyFor(collection: string, id: string): unknown;
105
+ /**
106
+ * D72: every body this runtime tracks, in the same collection-then-instance order `sync()`
107
+ * walks. Read-only and allocation-free: the pose history calls it once per tick to copy poses
108
+ * out, and the rewind scratch calls it to keep its doubles in step with the live world. It is
109
+ * the only thing outside the engine runtimes that sees a live body other than through
110
+ * `bodyFor`, and it never hands out the map itself.
111
+ */
112
+ eachTrackedBody(fn: (collection: string, id: string, body: unknown) => void): void;
113
+ /** rapier3d only. */
95
114
  readonly rapier: unknown;
115
+ /** rapier3d only. */
96
116
  readonly world: unknown;
117
+ /** matter2d only: the `matter-js` namespace. */
118
+ readonly matter: unknown;
119
+ /** matter2d only: the live `Matter.Engine`. */
120
+ readonly matterEngine: unknown;
97
121
  }
98
122
  interface LoopApi {
99
123
  start(): void;
@@ -133,7 +157,16 @@ interface RoomInternals {
133
157
  stopped: boolean;
134
158
  /** Runs a room handler; a throw is logged and counted, never rethrown. */
135
159
  guard<T>(name: string, fn: () => T): T | undefined;
136
- recordEvent(kind: 'join' | 'leave' | 'write' | 'write-rejected' | 'correct' | 'call' | 'reply' | 'msg' | 'alarm' | 'error', clientId?: string, detail?: string): void;
160
+ /** D41: record this tick on the authoritative timeline. A no-op while the recorder is unarmed. */
161
+ captureTimeline(): void;
162
+ /**
163
+ * D72: record this tick's body poses. A no-op — and, more to the point, a call that never
164
+ * reaches the engine runtime at all — in a room whose config declares no `physics.history`.
165
+ */
166
+ captureHistory(): void;
167
+ /** D72: `room.rewind(tick, fn)`. Throws when the room declares no `physics.history`. */
168
+ rewind<T>(tick: number, fn: (past: RewindView) => T): T;
169
+ recordEvent(kind: 'join' | 'leave' | 'write' | 'write-rejected' | 'correct' | 'call' | 'reply' | 'msg' | 'alarm' | 'bus' | 'error', clientId?: string, detail?: string): void;
137
170
  /** `guard` that also reports whether the handler threw (the tick loop needs this). */
138
171
  tryRun<T>(name: string, fn: () => T): GuardResult<T>;
139
172
  log(level: LogLevel, ...args: unknown[]): void;
@@ -144,6 +177,13 @@ interface RoomInternals {
144
177
  correctionFor(clientId: string): DirtySet | undefined;
145
178
  /** Flushes the tracked dirty set: corrections first, then one `DELTA` per distinct view. */
146
179
  flush(): void;
180
+ /**
181
+ * Schedules an event-mode flush after the current microtask chain settles; a no-op in tick
182
+ * mode (the next tick flushes anyway) and while one is already pending. This is the
183
+ * "own event, then flush" step the host-call seam runs after `completeHostCall`, exposed so a
184
+ * promise the room API rejects *locally* (bug #48) settles in the same scheduling class.
185
+ */
186
+ scheduleContinuationFlush(): void;
147
187
  /** Invalidates the cached `room.clients` array. */
148
188
  invalidateClients(): void;
149
189
  }
@@ -225,6 +265,106 @@ declare class Loop implements LoopApi {
225
265
  after(ms: number, fn: () => void): void;
226
266
  }
227
267
 
268
+ /**
269
+ * D45 part 1: the matter.js world inside the room.
270
+ *
271
+ * It sits in exactly the place `core/physics.ts` describes — reconcile, step, sync, between the
272
+ * `tick()` handler and the flush — and writes the same schema channels. What differs is everything
273
+ * underneath, and two differences are load-bearing enough to be worth stating here.
274
+ *
275
+ * ## There is no engine snapshot
276
+ *
277
+ * Rapier has `world.takeSnapshot()` / `restoreSnapshot()`, and a woken Rapier room is byte-for-byte
278
+ * the world that went to sleep, contacts included. matter.js has no equivalent: its world is plain
279
+ * JavaScript objects, and the honest options were to serialize the object graph ourselves or to
280
+ * rebuild the world and reapply per-body state. This module does the second. `setup` therefore runs
281
+ * on **every** matter2d wake, not only on a rebuild, and what is not restored is the solver's
282
+ * transient state: resting contacts, accumulated impulses, and the sleep timers behind them. A
283
+ * settled pile may re-settle with a small jolt. The measurement in `docs/m3-part5-report.md` says
284
+ * how big that jolt is on the fixture that ships with this.
285
+ *
286
+ * ## The units are matter.js's, not translated
287
+ *
288
+ * `gravity` goes into `engine.gravity` verbatim (matter's own default is `{ x: 0, y: 1 }` with
289
+ * `scale: 0.001`, and y is **down**), and `body.velocity` is matter's per-step displacement rather
290
+ * than a per-second velocity. Translating either would mean irtio inventing a unit system on top of
291
+ * an engine it promised to bless rather than abstract, and every matter.js tutorial would then be
292
+ * subtly wrong inside a room. The mapping onto the wire channels is in `@irtio/schema`
293
+ * (`channelOf2d`), and it is the only translation there is.
294
+ */
295
+
296
+ /**
297
+ * Loads `matter-js` once per process. Pure JavaScript, so there is no `init()` to await and no
298
+ * WASM instance to keep unique — but the load is still async (it is an `import()`), and handlers
299
+ * are synchronous, so it happens before the room is constructed exactly as Rapier's does.
300
+ */
301
+ declare function initMatter(): Promise<MatterModule>;
302
+ declare function loadedMatter(): MatterModule | undefined;
303
+ /** Test seam: forget the loaded engine. */
304
+ declare function resetMatterForTests(): void;
305
+ /** One body's restorable state. Everything a rebuild needs and nothing the world-builder gives. */
306
+ interface MatterBodyRecord {
307
+ readonly collection: string;
308
+ readonly id: string;
309
+ readonly x: number;
310
+ readonly y: number;
311
+ readonly angle: number;
312
+ readonly vx: number;
313
+ readonly vy: number;
314
+ readonly angularVelocity: number;
315
+ readonly sleeping: boolean;
316
+ }
317
+ interface MatterSection {
318
+ readonly bodies: readonly MatterBodyRecord[];
319
+ }
320
+ declare function encodeMatterBodies(section: MatterSection): Uint8Array;
321
+ declare function decodeMatterBodies(bytes: Uint8Array): MatterSection;
322
+ interface MatterRuntimeOptions {
323
+ readonly restore?: MatterSection;
324
+ readonly defaultTimestep: number;
325
+ }
326
+ declare class MatterRuntime {
327
+ readonly engineKind: "matter2d";
328
+ /** rapier3d only; present so both runtimes satisfy one internal shape. */
329
+ readonly rapier: undefined;
330
+ readonly world: undefined;
331
+ readonly matter: MatterModule;
332
+ readonly engine: MatterEngine;
333
+ /** Alias under the name `PhysicsApi` uses, so `room.physics2d` reads through one field. */
334
+ get matterEngine(): MatterEngine;
335
+ /** `true` when the blob carried no world at all: the caller logs it. */
336
+ readonly rebuilt: boolean;
337
+ /**
338
+ * Always `true`. Unlike Rapier's, a matter2d world is never restored as a world — only as
339
+ * per-body state on a world the builder made — so `setup` has to run every time.
340
+ */
341
+ readonly needsSetup = true;
342
+ private readonly core;
343
+ private readonly config;
344
+ private readonly collections;
345
+ private readonly bodies;
346
+ private readonly restore;
347
+ private readonly stepMs;
348
+ private readonly sleepSynced;
349
+ constructor(core: RoomInternals, matter: MatterModule, options: MatterRuntimeOptions);
350
+ get timestep(): number;
351
+ runSetup(room: Room): void;
352
+ free(): void;
353
+ bodyFor(collection: string, id: string): MatterBody | undefined;
354
+ private create;
355
+ /**
356
+ * D72: every tracked body, in the order `sync()` walks them. The matter half of the same
357
+ * read-only accessor `PhysicsRuntime` carries; see its comment for what calls it and when.
358
+ */
359
+ eachTrackedBody(fn: (collection: string, id: string, body: MatterBody) => void): void;
360
+ private applyState;
361
+ private applyRecordToBody;
362
+ reconcile(): void;
363
+ step(): void;
364
+ sync(): void;
365
+ serialize(): MatterSection;
366
+ }
367
+
228
368
  /**
229
369
  * D22 part 1: the Rapier world inside the room.
230
370
  *
@@ -281,6 +421,23 @@ interface PhysicsSection {
281
421
  readonly bodies: readonly (readonly [string, string, number])[];
282
422
  }
283
423
  declare function encodePhysicsSection(section: PhysicsSection): Uint8Array;
424
+ /**
425
+ * D45: which engine wrote a v2 physics section.
426
+ *
427
+ * The discriminant is not a new leading byte, because a new leading byte would change every blob
428
+ * ever written. It is a **zero-length world**: a Rapier section starts with the varint length of
429
+ * `world.takeSnapshot()`, which is never zero, so `0` is a value no existing blob can hold. A
430
+ * matter2d section writes that zero, then a one-byte engine tag, then its own payload. Every
431
+ * pre-D45 blob is therefore byte-identical and reads as Rapier without a version bump, which is
432
+ * what the recommendation in the plan asked for and why format 3 was not needed.
433
+ */
434
+ type PhysicsEngineTag = 'rapier3d' | 'matter2d';
435
+ /** Reads the engine out of an encoded section without decoding the rest of it. */
436
+ declare function physicsSectionEngine(bytes: Uint8Array): PhysicsEngineTag;
437
+ /** Wraps a matter2d body-state payload in the discriminated envelope. */
438
+ declare function encodeMatterSectionEnvelope(payload: Uint8Array): Uint8Array;
439
+ /** The payload inside a matter2d envelope. Throws if the section is a Rapier one. */
440
+ declare function decodeMatterSectionEnvelope(bytes: Uint8Array): Uint8Array;
284
441
  declare function decodePhysicsSection(bytes: Uint8Array): PhysicsSection;
285
442
  interface PhysicsRuntimeOptions {
286
443
  /** From a v2 hibernation blob. Absent → a fresh world, and `setup` runs. */
@@ -289,10 +446,16 @@ interface PhysicsRuntimeOptions {
289
446
  readonly defaultTimestep: number;
290
447
  }
291
448
  declare class PhysicsRuntime {
449
+ readonly engineKind: "rapier3d";
450
+ /** matter2d only; present so both runtimes satisfy one internal shape. */
451
+ readonly matter: undefined;
452
+ readonly matterEngine: undefined;
292
453
  readonly rapier: RapierModule;
293
454
  readonly world: RapierWorld;
294
455
  /** `true` when the world was built from scratch and `setup` has to run. */
295
456
  readonly rebuilt: boolean;
457
+ /** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
458
+ get needsSetup(): boolean;
296
459
  private readonly core;
297
460
  private readonly config;
298
461
  /** Physics-backed collections, in schema (name-sorted) order. */
@@ -315,6 +478,13 @@ declare class PhysicsRuntime {
315
478
  /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
316
479
  bodyFor(collection: string, id: string): RapierRigidBody | undefined;
317
480
  private create;
481
+ /**
482
+ * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
483
+ * order). Read-only: it hands out the live bodies and nothing else, and the map stays private.
484
+ * The pose history calls this once per tick and the rewind scratch calls it per rewind; a room
485
+ * that declares no `physics.history` never calls it at all.
486
+ */
487
+ eachTrackedBody(fn: (collection: string, id: string, body: RapierRigidBody) => void): void;
318
488
  private applyRecordToBody;
319
489
  /**
320
490
  * Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
@@ -351,7 +521,7 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
351
521
  readonly clients: Map<string, ClientEntry>;
352
522
  readonly loop: Loop;
353
523
  /** D22: the Rapier world, or `undefined` in a room whose config declares no physics. */
354
- readonly physics: PhysicsRuntime | undefined;
524
+ readonly physics: PhysicsRuntime | MatterRuntime | undefined;
355
525
  readonly stats: RoomStats;
356
526
  tick: number;
357
527
  stopped: boolean;
@@ -362,6 +532,24 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
362
532
  * into a timeout in an unrelated assertion ten seconds later. Unset in production.
363
533
  */
364
534
  onHandlerError: ((name: string, err: unknown) => void) | undefined;
535
+ /**
536
+ * D41: the recorded authoritative timeline. Armed by `startRecording()` and off otherwise, so a
537
+ * room nobody asked to record pays nothing. A room that was asked captures its own state at the
538
+ * end of every tick, which is the only moment in a tick where that state is settled.
539
+ */
540
+ private recorder;
541
+ /**
542
+ * D65: the bandwidth ledger, present only when `RoomCoreOptions.profile` asked for one. Every
543
+ * cost the profiler has is behind this `undefined`.
544
+ */
545
+ private readonly ledger;
546
+ /**
547
+ * D72: the pose history and the rewind scratch, present only when the room's physics config
548
+ * declares `history`. Every cost this lane has is behind this `undefined`, and it is
549
+ * deliberately not in the hibernation blob: a woken room starts with an empty buffer and fills
550
+ * it again over its next `history` ticks.
551
+ */
552
+ private readonly rewindState;
365
553
  private readonly seed;
366
554
  private readonly api;
367
555
  private readonly internals;
@@ -369,6 +557,26 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
369
557
  /** One pending continuation flush at a time; concurrent completions coalesce into it. */
370
558
  private continuationFlushPending;
371
559
  constructor(definition: RoomDefinition<S>, host: RoomHost, options: RoomCoreOptions);
560
+ private subscribeDeclaredChannels;
561
+ private get busConfig();
562
+ /**
563
+ * D59: one published message arriving on a channel this room is subscribed to.
564
+ *
565
+ * Same scheduling class as an alarm or an RPC — a discrete event between ticks — so a tick-mode
566
+ * room never sees a `tick` run half-delivered. `from` is supervisor-stamped, so the handler may
567
+ * trust it as far as it trusts its own project.
568
+ */
569
+ deliverBusEvent(channel: string, from: string, payload: string): void;
570
+ /**
571
+ * D59: one directed `room.bus.send` arriving. At-least-once, so this can run twice for one send;
572
+ * that is the receiver's problem to be idempotent about and the docs say so.
573
+ *
574
+ * A throw propagates through `guard` and counts toward the crash threshold exactly as any other
575
+ * handler throw does. That is deliberate, and it is why the supervisor bounds redelivery: the
576
+ * two behaviours together would otherwise let one poisonous message close a room on every wake,
577
+ * forever.
578
+ */
579
+ deliverBusMessage(from: string, payload: string): void;
372
580
  /**
373
581
  * D22: builds the world, or returns `undefined` for a room with no `physics:` config.
374
582
  *
@@ -381,6 +589,13 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
381
589
  * and is lost; a stack of boxes may settle again with a small visible jolt.
382
590
  */
383
591
  private buildPhysics;
592
+ /**
593
+ * D45: the matter2d half. It differs from Rapier's in one structural way — there is no engine
594
+ * snapshot to restore, so the world is always **built** and per-body state is reapplied on top
595
+ * of it. `setup` therefore runs on every wake, and only the "there was no world at all" case is
596
+ * worth logging.
597
+ */
598
+ private buildMatter;
384
599
  /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
385
600
  static restore<S2 extends AnySchema>(definition: RoomDefinition<S2>, bytes: Uint8Array, host: RoomHost, options: Omit<RoomCoreOptions, 'restoreFrom'>): RoomCore<S2>;
386
601
  get schema(): S;
@@ -390,6 +605,27 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
390
605
  tryRun<T>(name: string, fn: () => T): GuardResult<T>;
391
606
  private readonly events;
392
607
  recordEvent(kind: RoomEventKind, clientId?: string, detail?: string): void;
608
+ /**
609
+ * D41: begin (or restart) recording the authoritative timeline. Calling it again clears what
610
+ * was recorded, so two scenario runs against one long-lived dev server do not read each
611
+ * other's ticks.
612
+ */
613
+ startRecording(options?: TimelineRecorderOptions): void;
614
+ /** D41: what has been recorded so far, or `undefined` when nobody armed the recorder. */
615
+ recording(): TimelineDump | undefined;
616
+ /** D41: called at the end of every tick (and every event-mode flush). No-op when unarmed. */
617
+ captureTimeline(): void;
618
+ /**
619
+ * D72: record this tick's body poses, right after `physics.sync()` — the poses the clients are
620
+ * about to be told about, under the tick number they will be told it under, which is the tick a
621
+ * client later stamps its `CALL` with.
622
+ */
623
+ captureHistory(): void;
624
+ /**
625
+ * D72: `room.rewind(tick, fn)`. The live world is not touched and nothing is re-simulated; `fn`
626
+ * queries a scratch world holding every tracked body at its pose at `tick`.
627
+ */
628
+ rewind<T>(tick: number, fn: (past: RewindView) => T): T;
393
629
  /** Live JSON view of the room for the dev page / supervisor admin API. */
394
630
  inspect(): RoomInspection;
395
631
  guard<T>(name: string, fn: () => T): T | undefined;
@@ -434,8 +670,12 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
434
670
  * Draining a bounded number of turns first covers the chains rooms actually write, coalesces
435
671
  * concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
436
672
  * the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
673
+ *
674
+ * Public on `RoomInternals` (bug #48) so the room API's local rejections — a float score, an
675
+ * empty bus target, a call to a client that is not connected — flush the state their `.catch()`
676
+ * writes, instead of leaving it for whatever frame happens to arrive next.
437
677
  */
438
- private scheduleContinuationFlush;
678
+ scheduleContinuationFlush(): void;
439
679
  /**
440
680
  * D26: one durable alarm firing. Same scheduling class as an RPC — a discrete event between
441
681
  * ticks — so a tick-mode room never sees a `tick` run half-alarmed, and a handler that re-arms
@@ -444,7 +684,19 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
444
684
  fireAlarm(name: string): void;
445
685
  correctionFor(clientId: string): DirtySet | undefined;
446
686
  invalidateClients(): void;
447
- send(clientId: string, frame: Uint8Array): void;
687
+ /**
688
+ * D65: `hint` is profiling context and nothing else — the payload object a per-view frame was
689
+ * built from (so one encode is walked once and replayed per recipient) and the AOI ids whose
690
+ * ops are visibility churn. Ignored entirely when this room is not profiling, which is why it
691
+ * is an optional argument rather than a second method.
692
+ */
693
+ send(clientId: string, frame: Uint8Array, hint?: AttributeOptions): void;
694
+ /**
695
+ * D65: the ledger so far. The room's own view, so it counts what `send()` sent and what
696
+ * `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
697
+ * `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
698
+ */
699
+ profile(): ProfileSnapshot | undefined;
448
700
  private badFrame;
449
701
  receive(clientId: string, frame: Uint8Array): void;
450
702
  /**
@@ -455,4 +707,4 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
455
707
  flush(): void;
456
708
  }
457
709
 
458
- export { CRASH_AFTER_THROWS as C, MAX_CATCHUP as M, type PhysicsSection as P, RoomCore as R, Mulberry32 as a, decodePhysicsSection as d, encodePhysicsSection as e, initPhysics as i, loadedPhysics as l, resetPhysicsForTests as r };
710
+ export { CRASH_AFTER_THROWS as C, MAX_CATCHUP as M, type PhysicsEngineTag as P, RoomCore as R, type MatterBodyRecord as a, type MatterSection as b, Mulberry32 as c, type PhysicsSection as d, decodeMatterBodies as e, decodeMatterSectionEnvelope as f, decodePhysicsSection as g, encodeMatterBodies as h, encodeMatterSectionEnvelope as i, encodePhysicsSection as j, initMatter as k, initPhysics as l, loadedMatter as m, loadedPhysics as n, resetPhysicsForTests as o, physicsSectionEngine as p, resetMatterForTests as r };
@@ -1,9 +1,9 @@
1
- import { R as RoomCore } from '../room-9ZQoy9yi.js';
2
- export { i as initPhysics } from '../room-9ZQoy9yi.js';
1
+ import { R as RoomCore } from '../room-CoQDczh2.js';
2
+ export { k as initMatter, l as initPhysics } from '../room-CoQDczh2.js';
3
3
  import { ErrorCodeName, FrameType } from '@irtio/protocol';
4
- import { h as RoomHost, L as LogLevel, R as RoomCoreApi, a as HostCall, b as HostCallResult, j as RoomStats } from '../contract-B8QSO0MH.js';
4
+ import { NpcConfig, LeaveReason, Room, RoomDefinition } from '@irtio/server';
5
+ import { i as RoomHost, L as LogLevel, R as RoomCoreApi, b as HostCall, c as HostCallResult, k as RoomStats } from '../contract-DwxqjeWP.js';
5
6
  import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
6
- import { LeaveReason, Room, RoomDefinition } from '@irtio/server';
7
7
 
8
8
  /**
9
9
  * The harness clock: a deterministic fake `now()` plus an ordered timer queue. `RoomCore` only
@@ -90,6 +90,23 @@ declare class HarnessHost implements RoomHost {
90
90
  hostCallDelayMs: number;
91
91
  /** The in-memory player KV, keyed the way the real table's composite primary key is. */
92
92
  readonly kv: Map<string, string>;
93
+ /** D53: `"<board> <playerId>"` -> the best score submitted so far. */
94
+ readonly scores: Map<string, number>;
95
+ /** D63: every `room.ratings.report` the room asked for, in order. Recorded, never applied. */
96
+ readonly ratingReports: {
97
+ queue: string;
98
+ results: {
99
+ playerId: string;
100
+ place: number;
101
+ }[];
102
+ }[];
103
+ /** D63: every `room.ratings.set`, in order. */
104
+ readonly ratingSets: {
105
+ queue: string;
106
+ playerId: string;
107
+ rating: number;
108
+ deviation?: number;
109
+ }[];
93
110
  /** Save generations this host minted: `saveId` -> the bytes it was handed. */
94
111
  readonly saves: Map<string, Uint8Array<ArrayBufferLike>>;
95
112
  /** What `room.save()` serializes. The harness sets it to `() => core.snapshot()` — the bytes
@@ -121,6 +138,36 @@ declare class HarnessHost implements RoomHost {
121
138
  crashed(reason: string): void;
122
139
  hostCall(reqId: number, call: HostCall): void;
123
140
  private runHostCall;
141
+ /**
142
+ * D44: the in-process harness has no supervisor, no sockets and therefore no loopback session
143
+ * to open. Recording the request rather than faking a session is the honest option: a test that
144
+ * wants to see an NPC *play* needs a real tenant (`packages/supervisor/test/npc.test.ts`), and
145
+ * one that wants to see a room ask for one reads these.
146
+ */
147
+ readonly spawnedNpcs: {
148
+ clientId: string;
149
+ config: NpcConfig;
150
+ }[];
151
+ readonly despawnedNpcs: string[];
152
+ spawnNpc(clientId: string, config: NpcConfig): void;
153
+ despawnNpc(clientId: string): void;
154
+ /** Every `room.bus.send` the room asked for, in order. */
155
+ readonly busSends: {
156
+ roomId: string;
157
+ payload: string;
158
+ }[];
159
+ /** Every `room.bus.publish`, in order. */
160
+ readonly busPublishes: {
161
+ channel: string;
162
+ payload: string;
163
+ }[];
164
+ /** The channels this room is currently subscribed to, in subscription order. */
165
+ readonly busSubscriptions: Set<string>;
166
+ /** D63-e: the harness has no room list to report on, so it records the value and stops. */
167
+ backfillOpen: boolean | undefined;
168
+ setBackfill(open: boolean): void;
169
+ busPublish(channel: string, payload: string): void;
170
+ busSubscribe(channel: string, subscribed: boolean): void;
124
171
  setAlarm(name: string, atMs: number | undefined): void;
125
172
  /**
126
173
  * Fires every alarm due at or before `now`, in **name order** (D34 determinism), removing each
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  RoomCore,
3
3
  createVisibilityPolicy,
4
+ initMatter,
4
5
  initPhysics,
5
6
  visibleNames
6
- } from "../chunk-5ZDQAAFJ.js";
7
+ } from "../chunk-EXPFVRD4.js";
7
8
 
8
9
  // src/test/clock.ts
9
10
  var FakeClock = class {
@@ -79,6 +80,12 @@ var HarnessHost = class {
79
80
  hostCallDelayMs = 0;
80
81
  /** The in-memory player KV, keyed the way the real table's composite primary key is. */
81
82
  kv = /* @__PURE__ */ new Map();
83
+ /** D53: `"<board> <playerId>"` -> the best score submitted so far. */
84
+ scores = /* @__PURE__ */ new Map();
85
+ /** D63: every `room.ratings.report` the room asked for, in order. Recorded, never applied. */
86
+ ratingReports = [];
87
+ /** D63: every `room.ratings.set`, in order. */
88
+ ratingSets = [];
82
89
  /** Save generations this host minted: `saveId` -> the bytes it was handed. */
83
90
  saves = /* @__PURE__ */ new Map();
84
91
  /** What `room.save()` serializes. The harness sets it to `() => core.snapshot()` — the bytes
@@ -169,11 +176,72 @@ var HarnessHost = class {
169
176
  case "kvDelete":
170
177
  this.kv.delete(kvKey(call.playerId, call.key));
171
178
  return { ok: true };
179
+ case "busSend": {
180
+ this.busSends.push({ roomId: call.roomId, payload: call.payload });
181
+ return { ok: true };
182
+ }
183
+ case "ratingReport": {
184
+ this.ratingReports.push({
185
+ queue: call.queue,
186
+ results: call.results.map((r) => ({ ...r }))
187
+ });
188
+ return { ok: true };
189
+ }
190
+ case "ratingSet": {
191
+ this.ratingSets.push({
192
+ queue: call.queue,
193
+ playerId: call.playerId,
194
+ rating: call.rating,
195
+ ...call.deviation !== void 0 ? { deviation: call.deviation } : {}
196
+ });
197
+ return { ok: true };
198
+ }
199
+ case "lbSubmit": {
200
+ const key = call.bucket === void 0 ? `${call.board} ${call.playerId}` : `${call.board} ${call.bucket} ${call.playerId}`;
201
+ const existing = this.scores.get(key);
202
+ if (existing === void 0 || call.score > existing) this.scores.set(key, call.score);
203
+ return { ok: true };
204
+ }
172
205
  }
173
206
  }
174
207
  // -------------------------------------------------------------------------
175
208
  // Week 12: durable alarms (D26)
176
209
  // -------------------------------------------------------------------------
210
+ /**
211
+ * D44: the in-process harness has no supervisor, no sockets and therefore no loopback session
212
+ * to open. Recording the request rather than faking a session is the honest option: a test that
213
+ * wants to see an NPC *play* needs a real tenant (`packages/supervisor/test/npc.test.ts`), and
214
+ * one that wants to see a room ask for one reads these.
215
+ */
216
+ spawnedNpcs = [];
217
+ despawnedNpcs = [];
218
+ spawnNpc(clientId, config) {
219
+ this.spawnedNpcs.push({ clientId, config });
220
+ }
221
+ despawnNpc(clientId) {
222
+ this.despawnedNpcs.push(clientId);
223
+ }
224
+ // -------------------------------------------------------------------------
225
+ // D59: the bus
226
+ // -------------------------------------------------------------------------
227
+ /** Every `room.bus.send` the room asked for, in order. */
228
+ busSends = [];
229
+ /** Every `room.bus.publish`, in order. */
230
+ busPublishes = [];
231
+ /** The channels this room is currently subscribed to, in subscription order. */
232
+ busSubscriptions = /* @__PURE__ */ new Set();
233
+ /** D63-e: the harness has no room list to report on, so it records the value and stops. */
234
+ backfillOpen;
235
+ setBackfill(open) {
236
+ this.backfillOpen = open;
237
+ }
238
+ busPublish(channel, payload) {
239
+ this.busPublishes.push({ channel, payload });
240
+ }
241
+ busSubscribe(channel, subscribed) {
242
+ if (subscribed) this.busSubscriptions.add(channel);
243
+ else this.busSubscriptions.delete(channel);
244
+ }
177
245
  setAlarm(name, atMs) {
178
246
  if (atMs === void 0) this.armedAlarms.delete(name);
179
247
  else this.armedAlarms.set(name, atMs);
@@ -905,5 +973,6 @@ export {
905
973
  HarnessHost,
906
974
  createRoomHarness,
907
975
  frameTypeName,
976
+ initMatter,
908
977
  initPhysics
909
978
  };