@irtio/runtime 0.5.1 → 0.6.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, 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-BjMsoJIV.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,14 @@ interface PhysicsApi {
92
102
  /** Body → schema, through the tracked proxies. */
93
103
  sync(): void;
94
104
  bodyFor(collection: string, id: string): unknown;
105
+ /** rapier3d only. */
95
106
  readonly rapier: unknown;
107
+ /** rapier3d only. */
96
108
  readonly world: unknown;
109
+ /** matter2d only: the `matter-js` namespace. */
110
+ readonly matter: unknown;
111
+ /** matter2d only: the live `Matter.Engine`. */
112
+ readonly matterEngine: unknown;
97
113
  }
98
114
  interface LoopApi {
99
115
  start(): void;
@@ -133,7 +149,9 @@ interface RoomInternals {
133
149
  stopped: boolean;
134
150
  /** Runs a room handler; a throw is logged and counted, never rethrown. */
135
151
  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;
152
+ /** D41: record this tick on the authoritative timeline. A no-op while the recorder is unarmed. */
153
+ captureTimeline(): void;
154
+ recordEvent(kind: 'join' | 'leave' | 'write' | 'write-rejected' | 'correct' | 'call' | 'reply' | 'msg' | 'alarm' | 'bus' | 'error', clientId?: string, detail?: string): void;
137
155
  /** `guard` that also reports whether the handler threw (the tick loop needs this). */
138
156
  tryRun<T>(name: string, fn: () => T): GuardResult<T>;
139
157
  log(level: LogLevel, ...args: unknown[]): void;
@@ -144,6 +162,13 @@ interface RoomInternals {
144
162
  correctionFor(clientId: string): DirtySet | undefined;
145
163
  /** Flushes the tracked dirty set: corrections first, then one `DELTA` per distinct view. */
146
164
  flush(): void;
165
+ /**
166
+ * Schedules an event-mode flush after the current microtask chain settles; a no-op in tick
167
+ * mode (the next tick flushes anyway) and while one is already pending. This is the
168
+ * "own event, then flush" step the host-call seam runs after `completeHostCall`, exposed so a
169
+ * promise the room API rejects *locally* (bug #48) settles in the same scheduling class.
170
+ */
171
+ scheduleContinuationFlush(): void;
147
172
  /** Invalidates the cached `room.clients` array. */
148
173
  invalidateClients(): void;
149
174
  }
@@ -225,6 +250,101 @@ declare class Loop implements LoopApi {
225
250
  after(ms: number, fn: () => void): void;
226
251
  }
227
252
 
253
+ /**
254
+ * D45 part 1: the matter.js world inside the room.
255
+ *
256
+ * It sits in exactly the place `core/physics.ts` describes — reconcile, step, sync, between the
257
+ * `tick()` handler and the flush — and writes the same schema channels. What differs is everything
258
+ * underneath, and two differences are load-bearing enough to be worth stating here.
259
+ *
260
+ * ## There is no engine snapshot
261
+ *
262
+ * Rapier has `world.takeSnapshot()` / `restoreSnapshot()`, and a woken Rapier room is byte-for-byte
263
+ * the world that went to sleep, contacts included. matter.js has no equivalent: its world is plain
264
+ * JavaScript objects, and the honest options were to serialize the object graph ourselves or to
265
+ * rebuild the world and reapply per-body state. This module does the second. `setup` therefore runs
266
+ * on **every** matter2d wake, not only on a rebuild, and what is not restored is the solver's
267
+ * transient state: resting contacts, accumulated impulses, and the sleep timers behind them. A
268
+ * settled pile may re-settle with a small jolt. The measurement in `docs/m3-part5-report.md` says
269
+ * how big that jolt is on the fixture that ships with this.
270
+ *
271
+ * ## The units are matter.js's, not translated
272
+ *
273
+ * `gravity` goes into `engine.gravity` verbatim (matter's own default is `{ x: 0, y: 1 }` with
274
+ * `scale: 0.001`, and y is **down**), and `body.velocity` is matter's per-step displacement rather
275
+ * than a per-second velocity. Translating either would mean irtio inventing a unit system on top of
276
+ * an engine it promised to bless rather than abstract, and every matter.js tutorial would then be
277
+ * subtly wrong inside a room. The mapping onto the wire channels is in `@irtio/schema`
278
+ * (`channelOf2d`), and it is the only translation there is.
279
+ */
280
+
281
+ /**
282
+ * Loads `matter-js` once per process. Pure JavaScript, so there is no `init()` to await and no
283
+ * WASM instance to keep unique — but the load is still async (it is an `import()`), and handlers
284
+ * are synchronous, so it happens before the room is constructed exactly as Rapier's does.
285
+ */
286
+ declare function initMatter(): Promise<MatterModule>;
287
+ declare function loadedMatter(): MatterModule | undefined;
288
+ /** Test seam: forget the loaded engine. */
289
+ declare function resetMatterForTests(): void;
290
+ /** One body's restorable state. Everything a rebuild needs and nothing the world-builder gives. */
291
+ interface MatterBodyRecord {
292
+ readonly collection: string;
293
+ readonly id: string;
294
+ readonly x: number;
295
+ readonly y: number;
296
+ readonly angle: number;
297
+ readonly vx: number;
298
+ readonly vy: number;
299
+ readonly angularVelocity: number;
300
+ readonly sleeping: boolean;
301
+ }
302
+ interface MatterSection {
303
+ readonly bodies: readonly MatterBodyRecord[];
304
+ }
305
+ declare function encodeMatterBodies(section: MatterSection): Uint8Array;
306
+ declare function decodeMatterBodies(bytes: Uint8Array): MatterSection;
307
+ interface MatterRuntimeOptions {
308
+ readonly restore?: MatterSection;
309
+ readonly defaultTimestep: number;
310
+ }
311
+ declare class MatterRuntime {
312
+ readonly engineKind: "matter2d";
313
+ /** rapier3d only; present so both runtimes satisfy one internal shape. */
314
+ readonly rapier: undefined;
315
+ readonly world: undefined;
316
+ readonly matter: MatterModule;
317
+ readonly engine: MatterEngine;
318
+ /** Alias under the name `PhysicsApi` uses, so `room.physics2d` reads through one field. */
319
+ get matterEngine(): MatterEngine;
320
+ /** `true` when the blob carried no world at all: the caller logs it. */
321
+ readonly rebuilt: boolean;
322
+ /**
323
+ * Always `true`. Unlike Rapier's, a matter2d world is never restored as a world — only as
324
+ * per-body state on a world the builder made — so `setup` has to run every time.
325
+ */
326
+ readonly needsSetup = true;
327
+ private readonly core;
328
+ private readonly config;
329
+ private readonly collections;
330
+ private readonly bodies;
331
+ private readonly restore;
332
+ private readonly stepMs;
333
+ private readonly sleepSynced;
334
+ constructor(core: RoomInternals, matter: MatterModule, options: MatterRuntimeOptions);
335
+ get timestep(): number;
336
+ runSetup(room: Room): void;
337
+ free(): void;
338
+ bodyFor(collection: string, id: string): MatterBody | undefined;
339
+ private create;
340
+ private applyState;
341
+ private applyRecordToBody;
342
+ reconcile(): void;
343
+ step(): void;
344
+ sync(): void;
345
+ serialize(): MatterSection;
346
+ }
347
+
228
348
  /**
229
349
  * D22 part 1: the Rapier world inside the room.
230
350
  *
@@ -281,6 +401,23 @@ interface PhysicsSection {
281
401
  readonly bodies: readonly (readonly [string, string, number])[];
282
402
  }
283
403
  declare function encodePhysicsSection(section: PhysicsSection): Uint8Array;
404
+ /**
405
+ * D45: which engine wrote a v2 physics section.
406
+ *
407
+ * The discriminant is not a new leading byte, because a new leading byte would change every blob
408
+ * ever written. It is a **zero-length world**: a Rapier section starts with the varint length of
409
+ * `world.takeSnapshot()`, which is never zero, so `0` is a value no existing blob can hold. A
410
+ * matter2d section writes that zero, then a one-byte engine tag, then its own payload. Every
411
+ * pre-D45 blob is therefore byte-identical and reads as Rapier without a version bump, which is
412
+ * what the recommendation in the plan asked for and why format 3 was not needed.
413
+ */
414
+ type PhysicsEngineTag = 'rapier3d' | 'matter2d';
415
+ /** Reads the engine out of an encoded section without decoding the rest of it. */
416
+ declare function physicsSectionEngine(bytes: Uint8Array): PhysicsEngineTag;
417
+ /** Wraps a matter2d body-state payload in the discriminated envelope. */
418
+ declare function encodeMatterSectionEnvelope(payload: Uint8Array): Uint8Array;
419
+ /** The payload inside a matter2d envelope. Throws if the section is a Rapier one. */
420
+ declare function decodeMatterSectionEnvelope(bytes: Uint8Array): Uint8Array;
284
421
  declare function decodePhysicsSection(bytes: Uint8Array): PhysicsSection;
285
422
  interface PhysicsRuntimeOptions {
286
423
  /** From a v2 hibernation blob. Absent → a fresh world, and `setup` runs. */
@@ -289,10 +426,16 @@ interface PhysicsRuntimeOptions {
289
426
  readonly defaultTimestep: number;
290
427
  }
291
428
  declare class PhysicsRuntime {
429
+ readonly engineKind: "rapier3d";
430
+ /** matter2d only; present so both runtimes satisfy one internal shape. */
431
+ readonly matter: undefined;
432
+ readonly matterEngine: undefined;
292
433
  readonly rapier: RapierModule;
293
434
  readonly world: RapierWorld;
294
435
  /** `true` when the world was built from scratch and `setup` has to run. */
295
436
  readonly rebuilt: boolean;
437
+ /** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
438
+ get needsSetup(): boolean;
296
439
  private readonly core;
297
440
  private readonly config;
298
441
  /** Physics-backed collections, in schema (name-sorted) order. */
@@ -351,7 +494,7 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
351
494
  readonly clients: Map<string, ClientEntry>;
352
495
  readonly loop: Loop;
353
496
  /** D22: the Rapier world, or `undefined` in a room whose config declares no physics. */
354
- readonly physics: PhysicsRuntime | undefined;
497
+ readonly physics: PhysicsRuntime | MatterRuntime | undefined;
355
498
  readonly stats: RoomStats;
356
499
  tick: number;
357
500
  stopped: boolean;
@@ -362,6 +505,17 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
362
505
  * into a timeout in an unrelated assertion ten seconds later. Unset in production.
363
506
  */
364
507
  onHandlerError: ((name: string, err: unknown) => void) | undefined;
508
+ /**
509
+ * D41: the recorded authoritative timeline. Armed by `startRecording()` and off otherwise, so a
510
+ * room nobody asked to record pays nothing. A room that was asked captures its own state at the
511
+ * end of every tick, which is the only moment in a tick where that state is settled.
512
+ */
513
+ private recorder;
514
+ /**
515
+ * D65: the bandwidth ledger, present only when `RoomCoreOptions.profile` asked for one. Every
516
+ * cost the profiler has is behind this `undefined`.
517
+ */
518
+ private readonly ledger;
365
519
  private readonly seed;
366
520
  private readonly api;
367
521
  private readonly internals;
@@ -369,6 +523,26 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
369
523
  /** One pending continuation flush at a time; concurrent completions coalesce into it. */
370
524
  private continuationFlushPending;
371
525
  constructor(definition: RoomDefinition<S>, host: RoomHost, options: RoomCoreOptions);
526
+ private subscribeDeclaredChannels;
527
+ private get busConfig();
528
+ /**
529
+ * D59: one published message arriving on a channel this room is subscribed to.
530
+ *
531
+ * Same scheduling class as an alarm or an RPC — a discrete event between ticks — so a tick-mode
532
+ * room never sees a `tick` run half-delivered. `from` is supervisor-stamped, so the handler may
533
+ * trust it as far as it trusts its own project.
534
+ */
535
+ deliverBusEvent(channel: string, from: string, payload: string): void;
536
+ /**
537
+ * D59: one directed `room.bus.send` arriving. At-least-once, so this can run twice for one send;
538
+ * that is the receiver's problem to be idempotent about and the docs say so.
539
+ *
540
+ * A throw propagates through `guard` and counts toward the crash threshold exactly as any other
541
+ * handler throw does. That is deliberate, and it is why the supervisor bounds redelivery: the
542
+ * two behaviours together would otherwise let one poisonous message close a room on every wake,
543
+ * forever.
544
+ */
545
+ deliverBusMessage(from: string, payload: string): void;
372
546
  /**
373
547
  * D22: builds the world, or returns `undefined` for a room with no `physics:` config.
374
548
  *
@@ -381,6 +555,13 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
381
555
  * and is lost; a stack of boxes may settle again with a small visible jolt.
382
556
  */
383
557
  private buildPhysics;
558
+ /**
559
+ * D45: the matter2d half. It differs from Rapier's in one structural way — there is no engine
560
+ * snapshot to restore, so the world is always **built** and per-body state is reapplied on top
561
+ * of it. `setup` therefore runs on every wake, and only the "there was no world at all" case is
562
+ * worth logging.
563
+ */
564
+ private buildMatter;
384
565
  /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
385
566
  static restore<S2 extends AnySchema>(definition: RoomDefinition<S2>, bytes: Uint8Array, host: RoomHost, options: Omit<RoomCoreOptions, 'restoreFrom'>): RoomCore<S2>;
386
567
  get schema(): S;
@@ -390,6 +571,16 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
390
571
  tryRun<T>(name: string, fn: () => T): GuardResult<T>;
391
572
  private readonly events;
392
573
  recordEvent(kind: RoomEventKind, clientId?: string, detail?: string): void;
574
+ /**
575
+ * D41: begin (or restart) recording the authoritative timeline. Calling it again clears what
576
+ * was recorded, so two scenario runs against one long-lived dev server do not read each
577
+ * other's ticks.
578
+ */
579
+ startRecording(options?: TimelineRecorderOptions): void;
580
+ /** D41: what has been recorded so far, or `undefined` when nobody armed the recorder. */
581
+ recording(): TimelineDump | undefined;
582
+ /** D41: called at the end of every tick (and every event-mode flush). No-op when unarmed. */
583
+ captureTimeline(): void;
393
584
  /** Live JSON view of the room for the dev page / supervisor admin API. */
394
585
  inspect(): RoomInspection;
395
586
  guard<T>(name: string, fn: () => T): T | undefined;
@@ -434,8 +625,12 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
434
625
  * Draining a bounded number of turns first covers the chains rooms actually write, coalesces
435
626
  * concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
436
627
  * the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
628
+ *
629
+ * Public on `RoomInternals` (bug #48) so the room API's local rejections — a float score, an
630
+ * empty bus target, a call to a client that is not connected — flush the state their `.catch()`
631
+ * writes, instead of leaving it for whatever frame happens to arrive next.
437
632
  */
438
- private scheduleContinuationFlush;
633
+ scheduleContinuationFlush(): void;
439
634
  /**
440
635
  * D26: one durable alarm firing. Same scheduling class as an RPC — a discrete event between
441
636
  * ticks — so a tick-mode room never sees a `tick` run half-alarmed, and a handler that re-arms
@@ -444,7 +639,19 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
444
639
  fireAlarm(name: string): void;
445
640
  correctionFor(clientId: string): DirtySet | undefined;
446
641
  invalidateClients(): void;
447
- send(clientId: string, frame: Uint8Array): void;
642
+ /**
643
+ * D65: `hint` is profiling context and nothing else — the payload object a per-view frame was
644
+ * built from (so one encode is walked once and replayed per recipient) and the AOI ids whose
645
+ * ops are visibility churn. Ignored entirely when this room is not profiling, which is why it
646
+ * is an optional argument rather than a second method.
647
+ */
648
+ send(clientId: string, frame: Uint8Array, hint?: AttributeOptions): void;
649
+ /**
650
+ * D65: the ledger so far. The room's own view, so it counts what `send()` sent and what
651
+ * `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
652
+ * `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
653
+ */
654
+ profile(): ProfileSnapshot | undefined;
448
655
  private badFrame;
449
656
  receive(clientId: string, frame: Uint8Array): void;
450
657
  /**
@@ -455,4 +662,4 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
455
662
  flush(): void;
456
663
  }
457
664
 
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 };
665
+ 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-CfnEjlcg.js';
2
+ export { k as initMatter, l as initPhysics } from '../room-CfnEjlcg.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-BjMsoJIV.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-K42HA75G.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.board} ${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
  };