@irtio/testing 1.0.0 → 3.1.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.
@@ -71,6 +71,9 @@ function singletonIsDefault(desc, value) {
71
71
  return deepEqual(value, defaultRecord(desc));
72
72
  }
73
73
 
74
+ // src/contract.ts
75
+ var DEFAULT_AUTO_PUMP_TICKS = 200;
76
+
74
77
  // src/scheduler.ts
75
78
  function clockScheduler(clock) {
76
79
  return {
@@ -83,7 +86,8 @@ function clockScheduler(clock) {
83
86
  }
84
87
 
85
88
  // src/harness.ts
86
- import { joinRoom } from "@irtio/client";
89
+ import { spawnBots } from "@irtio/bots";
90
+ import { INTERNAL_SESSION, joinRoom } from "@irtio/client";
87
91
  import {
88
92
  ErrorCode,
89
93
  FrameType,
@@ -154,6 +158,84 @@ var InProcessSocket = class {
154
158
  }
155
159
  };
156
160
 
161
+ // src/physics.ts
162
+ function vec(v) {
163
+ return { x: v.x, y: v.y, z: v.z ?? 0 };
164
+ }
165
+ function createTestPhysics(core) {
166
+ const physicsOf = () => {
167
+ const p = core.physics;
168
+ if (!p) {
169
+ throw new Error(
170
+ "t.physics: this room declares no `physics` config, so there is no world to read or push. Add one to the room definition, or drop the t.physics call."
171
+ );
172
+ }
173
+ return p;
174
+ };
175
+ const bodyOf = (collection, id) => {
176
+ const p = physicsOf();
177
+ const body = p.bodyFor(collection, id);
178
+ if (body === void 0 || body === null) {
179
+ throw new Error(
180
+ `t.physics: no body for ${collection}[${id}]. Either the id is not in that collection, or the collection is not body-backed (the schema needs a \`physics: { body: \u2026 }\` on it).`
181
+ );
182
+ }
183
+ return body;
184
+ };
185
+ return {
186
+ get engine() {
187
+ return core.physics?.engineKind;
188
+ },
189
+ position(collection, id) {
190
+ const body = bodyOf(collection, id);
191
+ if (physicsOf().engineKind === "matter2d") return vec(body.position);
192
+ return vec(body.translation());
193
+ },
194
+ velocity(collection, id) {
195
+ const body = bodyOf(collection, id);
196
+ if (physicsOf().engineKind === "matter2d") return vec(body.velocity);
197
+ return vec(body.linvel());
198
+ },
199
+ teleport(collection, id, to) {
200
+ const p = physicsOf();
201
+ const body = bodyOf(collection, id);
202
+ if (p.engineKind === "matter2d") {
203
+ matterOf(p).Body.setPosition(body, { x: to.x, y: to.y });
204
+ return;
205
+ }
206
+ body.setTranslation(
207
+ p.engineKind === "rapier3d" ? { x: to.x, y: to.y, z: to.z ?? 0 } : { x: to.x, y: to.y },
208
+ true
209
+ );
210
+ },
211
+ impulse(collection, id, by) {
212
+ const p = physicsOf();
213
+ const body = bodyOf(collection, id);
214
+ if (p.engineKind === "matter2d") {
215
+ const m = body;
216
+ matterOf(p).Body.setVelocity(body, {
217
+ x: m.velocity.x + by.x / m.mass,
218
+ y: m.velocity.y + by.y / m.mass
219
+ });
220
+ return;
221
+ }
222
+ body.applyImpulse(
223
+ p.engineKind === "rapier3d" ? { x: by.x, y: by.y, z: by.z ?? 0 } : { x: by.x, y: by.y },
224
+ true
225
+ );
226
+ }
227
+ };
228
+ }
229
+ function matterOf(p) {
230
+ const matter = p.matter;
231
+ if (!matter) {
232
+ throw new Error(
233
+ "t.physics: this room runs matter2d but the matter-js namespace is not loaded. Await `initMatter()` in a `beforeAll` before building the room."
234
+ );
235
+ }
236
+ return matter;
237
+ }
238
+
157
239
  // src/harness.ts
158
240
  var TEST_URL = "ws://localhost:7070";
159
241
  var TEST_KEY = "test";
@@ -178,6 +260,7 @@ function frameNameOf(frame) {
178
260
  async function microtasks(turns = MICROTASK_TURNS) {
179
261
  for (let i = 0; i < turns; i++) await Promise.resolve();
180
262
  }
263
+ var NPC_TRACE_LIMIT = 16;
181
264
  function detailOf(detail) {
182
265
  return detail ? { detail } : {};
183
266
  }
@@ -201,11 +284,20 @@ var TestHarness = class {
201
284
  /** Client ids a frame was dropped for because nobody knows them, each warned about once. */
202
285
  unknownSendWarned = /* @__PURE__ */ new Set();
203
286
  clientList = [];
287
+ /** D44: live NPC sessions by client id. */
288
+ npcSessions = /* @__PURE__ */ new Map();
204
289
  roleById = /* @__PURE__ */ new Map();
205
290
  roleOverrides = /* @__PURE__ */ new Map();
206
291
  traceLog = [];
207
292
  frameLeaks = [];
208
- spatialSeen = /* @__PURE__ */ new Map();
293
+ /**
294
+ * Record ids this harness has actually delivered to a client, per collection. Started as the
295
+ * join snapshot's ids and kept current by `checkFrame`. It was a spatial-grid-only ledger; every
296
+ * collection is tracked now, because a `room.setRole` demotion catches the client up with
297
+ * `remove` ops for a collection its new role cannot see, and those ids are exactly the ones it
298
+ * was legitimately sent before.
299
+ */
300
+ deliveredSeen = /* @__PURE__ */ new Map();
209
301
  rejectionLog = [];
210
302
  /** Every handler throw the runtime caught, in order (bug 2). */
211
303
  handlerErrorLog = [];
@@ -227,6 +319,11 @@ var TestHarness = class {
227
319
  writeIntervalMs;
228
320
  latency;
229
321
  failOnHandlerError;
322
+ /** M7 pf4 (f): auto-pump settings, resolved once. */
323
+ autoPump;
324
+ autoPumpTicks;
325
+ /** M7 pf4 (g): built once, lazily reading `core.physics` on every call. */
326
+ physicsRef;
230
327
  stopped = false;
231
328
  constructor(definition, options) {
232
329
  this.definition = configure(definition, options);
@@ -234,6 +331,8 @@ var TestHarness = class {
234
331
  this.latency = options.latency;
235
332
  this.writeIntervalMs = options.writeIntervalMs;
236
333
  this.failOnHandlerError = options.failOnHandlerError ?? true;
334
+ this.autoPump = options.autoPump ?? true;
335
+ this.autoPumpTicks = options.autoPumpTicks ?? DEFAULT_AUTO_PUMP_TICKS;
237
336
  this.rng = new Mulberry32(options.seed ?? 1);
238
337
  this.scheduler = clockScheduler(this.clock);
239
338
  this.host = new HarnessHost(this.clock);
@@ -245,11 +344,17 @@ var TestHarness = class {
245
344
  }
246
345
  this.holdOrDrop(clientId, frame);
247
346
  };
347
+ this.host.onSpawnNpc = (clientId, config) => this.spawnNpc(clientId, config);
348
+ this.host.onDespawnNpc = (clientId) => this.despawnNpc(clientId);
248
349
  this.coreRef = new RoomCore(this.definition, this.host, {
249
350
  roomId: this.roomId,
250
351
  ...options.seed !== void 0 ? { seed: options.seed } : {},
251
- ...options.profile === true ? { profile: true } : {}
352
+ ...options.profile === true ? { profile: true } : {},
353
+ ...options.traceRpc === true ? { traceRpc: true } : {}
252
354
  });
355
+ this.physicsRef = createTestPhysics(
356
+ this.coreRef
357
+ );
253
358
  this.host.core = this.coreRef;
254
359
  this.host.serializeForSave = () => this.coreRef.snapshot();
255
360
  this.coreRef.onHandlerError = (handler, err) => {
@@ -308,6 +413,14 @@ var TestHarness = class {
308
413
  get profile() {
309
414
  return this.coreRef.profile();
310
415
  }
416
+ /** M7 pf4 (g): the narrow physics handle. Always present; its methods refuse with a reason. */
417
+ get physics() {
418
+ return this.physicsRef;
419
+ }
420
+ /** M7 pf4: the RPC trace, or `undefined` unless `traceRpc: true` armed one. */
421
+ get rpcTrace() {
422
+ return this.coreRef.rpcTrace();
423
+ }
311
424
  // -------------------------------------------------------------------------
312
425
  // The core is never re-entered
313
426
  // -------------------------------------------------------------------------
@@ -330,13 +443,20 @@ var TestHarness = class {
330
443
  // -------------------------------------------------------------------------
331
444
  // The link
332
445
  // -------------------------------------------------------------------------
333
- /** A `Transport` bound to one `t.join()`; reconnects call `connect` again with the same spec. */
334
- transportFor(spec) {
446
+ /**
447
+ * A `Transport` bound to one `t.join()`; reconnects call `connect` again with the same spec.
448
+ *
449
+ * `identity` is how an NPC gets in: the runtime mints its client id (`npc-<room>-<n>`) before the
450
+ * host is asked to open the session, and every reconnect this transport makes has to come back as
451
+ * that same id with the same `npc` flag, exactly as the supervisor's loopback does.
452
+ */
453
+ transportFor(spec, identity) {
335
454
  return {
336
455
  connect: () => {
337
456
  const link = {
338
457
  socket: void 0,
339
- clientId: `c${this.nextClientId++}`,
458
+ clientId: identity?.clientId ?? `c${this.nextClientId++}`,
459
+ npc: identity?.npc ?? false,
340
460
  role: "",
341
461
  joined: false,
342
462
  connected: true,
@@ -356,6 +476,105 @@ var TestHarness = class {
356
476
  }
357
477
  };
358
478
  }
479
+ // -------------------------------------------------------------------------
480
+ // D44: NPCs
481
+ // -------------------------------------------------------------------------
482
+ /**
483
+ * `room.spawnNPC`, in process. The runtime has already validated the config and minted the id;
484
+ * what is left is the supervisor's half — find the script in the definition's `npcs` map and put
485
+ * a real client behind it.
486
+ *
487
+ * Everything here rides `this.clock`: the bot joins through `transportFor`, so its frames are the
488
+ * same fake-clock timers a player's are, and `scheduler` makes `npc.wait` / `npc.until` fake time
489
+ * too. So an NPC spawned inside a tick becomes visible on the next `t.tick()` / `t.run()` /
490
+ * `t.settle`, and never makes progress behind the test's back.
491
+ *
492
+ * Throwing is deliberate: the caller is `room.spawnNPC` inside a handler, which the runtime runs
493
+ * under `tryRun`, so the sentence lands in `t.handlerErrors` and (by default) fails the test at
494
+ * the clock call that ran the handler. Recording it and carrying on is what this used to do, and
495
+ * it is how "my NPC never showed up" became a silent test.
496
+ */
497
+ spawnNpc(clientId, config) {
498
+ if (this.stopped) return;
499
+ if (this.npcSessions.has(clientId)) {
500
+ this.host.log("warn", [`irtio: npc ${clientId} is already running; spawn ignored`]);
501
+ return;
502
+ }
503
+ const scripts = this.definition.config.npcs;
504
+ const script = scripts?.[config.brain.script];
505
+ if (!script) {
506
+ throw new Error(
507
+ `testRoom: no npc script named ${JSON.stringify(config.brain.script)} in this room's npcs map`
508
+ );
509
+ }
510
+ const name = config.name ?? config.brain.script;
511
+ const entry = {
512
+ clientId,
513
+ script: config.brain.script,
514
+ runner: void 0,
515
+ stopped: false
516
+ };
517
+ this.npcSessions.set(clientId, entry);
518
+ this.activity++;
519
+ void spawnBots(1, {
520
+ schema: this.definition.schema,
521
+ room: this.roomId,
522
+ url: TEST_URL,
523
+ key: TEST_KEY,
524
+ name,
525
+ ...config.role !== void 0 ? { role: config.role } : {},
526
+ ...config.seed !== void 0 ? { seed: config.seed } : {},
527
+ traceLimit: NPC_TRACE_LIMIT,
528
+ // Both of these are real timers in `@irtio/bots`, and a fake-clock harness must not own one:
529
+ // the join is bounded by the test's own clock calls, and "every bot disconnected" is what
530
+ // `t.stop()` deliberately does.
531
+ joinTimeoutMs: 0,
532
+ roomGoneGraceMs: 0,
533
+ transport: this.transportFor({}, { clientId, npc: true }),
534
+ scheduler: this.scheduler,
535
+ script: (bot) => script(bot)
536
+ }).then(
537
+ (runner) => {
538
+ this.activity++;
539
+ entry.runner = runner;
540
+ if (entry.stopped) void this.stopNpc(entry);
541
+ else void runner.done().catch((err) => this.noteNpcError(entry, err));
542
+ },
543
+ (err) => {
544
+ this.npcSessions.delete(clientId);
545
+ this.noteNpcError(entry, err);
546
+ }
547
+ );
548
+ }
549
+ despawnNpc(clientId) {
550
+ const entry = this.npcSessions.get(clientId);
551
+ if (!entry) return;
552
+ this.npcSessions.delete(clientId);
553
+ entry.stopped = true;
554
+ if (entry.runner) void this.stopNpc(entry);
555
+ }
556
+ /** Stops one NPC's runner: its script winds down, then it leaves the room like any client. */
557
+ async stopNpc(entry) {
558
+ const runner = entry.runner;
559
+ entry.runner = void 0;
560
+ if (!runner) return;
561
+ this.activity++;
562
+ try {
563
+ await runner.stop();
564
+ } catch (err) {
565
+ this.noteNpcError(entry, err);
566
+ }
567
+ this.activity++;
568
+ }
569
+ /** An NPC failure, filed where a handler throw is filed — see `reportHandlerErrors`. */
570
+ noteNpcError(entry, err) {
571
+ this.handlerErrorLog.push({
572
+ handler: `npcs.${entry.script} (${entry.clientId})`,
573
+ message: err instanceof Error ? err.message : String(err),
574
+ tick: this.coreRef.tick
575
+ });
576
+ this.activity++;
577
+ }
359
578
  /**
360
579
  * Bug #49: a frame for a client with no link. Held when the core has the client and counts it
361
580
  * connected, which is exactly the window between `RoomCore.join` running `onJoin` and
@@ -528,7 +747,8 @@ var TestHarness = class {
528
747
  result = this.coreRef.join(link.clientId, {
529
748
  ...role !== void 0 ? { role } : {},
530
749
  ...name !== void 0 ? { name } : {},
531
- ...reconnecting ? { reconnecting: true } : {}
750
+ ...reconnecting ? { reconnecting: true } : {},
751
+ ...link.npc ? { npc: true } : {}
532
752
  });
533
753
  } catch (err) {
534
754
  failure = err;
@@ -555,11 +775,11 @@ var TestHarness = class {
555
775
  const decodedJoin = decodeSnapshot(this.ext, joined.snapshot).state;
556
776
  const seen = /* @__PURE__ */ new Map();
557
777
  for (const desc of this.ext.collections) {
558
- if (desc.visibility !== "spatial-grid") continue;
778
+ if (desc.kind !== "entity") continue;
559
779
  const collection = decodedJoin[desc.name];
560
- seen.set(desc.name, new Set(collection.ids()));
780
+ seen.set(desc.name, new Set(collection ? collection.ids() : []));
561
781
  }
562
- this.spatialSeen.set(link.clientId, seen);
782
+ this.deliveredSeen.set(link.clientId, seen);
563
783
  this.roleById.set(link.clientId, joined.role);
564
784
  if (reconnecting) {
565
785
  this.reconnectCounts.set(link.clientId, (this.reconnectCounts.get(link.clientId) ?? 0) + 1);
@@ -607,6 +827,36 @@ var TestHarness = class {
607
827
  this.record("out", link.clientId, type, frame.length);
608
828
  this.schedule(link, "out", type, () => link.socket.deliver(frame));
609
829
  }
830
+ /**
831
+ * The room's own role for this client **right now**, not the one its WELCOME carried.
832
+ *
833
+ * `room.setRole` commits `entry.role` before it hands the catch-up `DELTA` to `core.send`, so by
834
+ * the time that frame reaches `onSend` the authoritative role is already the new one. Judging it
835
+ * against the WELCOME-cached role called every legitimate promotion catch-up a leak. Reading the
836
+ * core here keeps `link.role`/`roleById` truthful too, so `roleFor` and the view half of
837
+ * `checkVisibility` follow a mid-session promotion without a separate hook.
838
+ */
839
+ currentRole(link) {
840
+ const role = this.coreRef.clients.get(link.clientId)?.role;
841
+ if (role === void 0 || role === link.role) return link.role;
842
+ link.role = role;
843
+ this.roleById.set(link.clientId, role);
844
+ return role;
845
+ }
846
+ /** The live "already delivered" id set for one client and collection, created on demand. */
847
+ deliveredIds(clientId, collection) {
848
+ let byCollection = this.deliveredSeen.get(clientId);
849
+ if (!byCollection) {
850
+ byCollection = /* @__PURE__ */ new Map();
851
+ this.deliveredSeen.set(clientId, byCollection);
852
+ }
853
+ let ids = byCollection.get(collection);
854
+ if (!ids) {
855
+ ids = /* @__PURE__ */ new Set();
856
+ byCollection.set(collection, ids);
857
+ }
858
+ return ids;
859
+ }
610
860
  /** A `DELTA`/`CORRECT` naming a collection this client's role may not see is a leak. */
611
861
  checkFrame(link, frame) {
612
862
  let delta;
@@ -615,27 +865,31 @@ var TestHarness = class {
615
865
  } catch {
616
866
  return;
617
867
  }
618
- const keep = visibleNames(this.ext, link.role);
619
- const policy = createVisibilityPolicy(this.ext, this.plain, link.clientId, link.role);
868
+ const role = this.currentRole(link);
869
+ const keep = visibleNames(this.ext, role);
870
+ const policy = createVisibilityPolicy(this.ext, this.plain, link.clientId, role);
620
871
  for (const dc of delta.collections) {
621
872
  const desc = this.ext.collection(dc.name);
622
- const seen = this.spatialSeen.get(link.clientId)?.get(dc.name) ?? /* @__PURE__ */ new Set();
873
+ const seen = this.deliveredIds(link.clientId, dc.name);
623
874
  const leaked = dc.ops.filter((op) => {
875
+ if (op.op === "remove") {
876
+ if (keep.has(dc.name)) return desc.visibility === "spatial-grid" && !seen.has(op.id);
877
+ return !seen.has(op.id);
878
+ }
624
879
  if (!keep.has(dc.name)) return true;
625
880
  if (desc.visibility !== "spatial-grid") return false;
626
- if (op.op === "remove") return !seen.has(op.id);
627
881
  return !policy.maySeeEntity(desc, op.id);
628
882
  });
629
- if (desc.visibility === "spatial-grid") {
630
- for (const op of dc.ops) {
631
- if (op.op === "remove") seen.delete(op.id);
632
- else if (policy.maySeeEntity(desc, op.id)) seen.add(op.id);
883
+ for (const op of dc.ops) {
884
+ if (op.op === "remove") seen.delete(op.id);
885
+ else if (desc.visibility !== "spatial-grid" || policy.maySeeEntity(desc, op.id)) {
886
+ seen.add(op.id);
633
887
  }
634
888
  }
635
889
  if (leaked.length === 0) continue;
636
890
  this.frameLeaks.push({
637
891
  clientId: link.clientId,
638
- role: link.role,
892
+ role,
639
893
  collection: dc.name,
640
894
  kind: "frame",
641
895
  ids: leaked.map((o) => o.id),
@@ -755,6 +1009,70 @@ var TestHarness = class {
755
1009
  }
756
1010
  return this.joinOne(a ?? {});
757
1011
  }
1012
+ /**
1013
+ * M7 pf4 (f): a `Promise`-shaped thing that advances the fake clock while it is being awaited.
1014
+ *
1015
+ * Lazy on purpose. The pump starts on the first `then` — that is, when somebody awaits the call
1016
+ * — and not when the call is made, so a test can still hold the promise, assert on the state
1017
+ * before the room has answered, and drive the clock itself. Eager pumping would have taken that
1018
+ * away, and it is the one thing the manual boilerplate was good for.
1019
+ *
1020
+ * The cap is named in the rejection because a room that never replies is a bug in the room, and
1021
+ * a harness that spun on it forever would report the wrong thing in the wrong place.
1022
+ *
1023
+ * `label` names the operation being awaited (`client.call.start()`, `client.requestOwnership()`,
1024
+ * `client.leave()`). Every awaitable the `TestClient` exposes comes through here, so a hang has
1025
+ * to say which one hung; the first report of this was a bare vitest timeout on a
1026
+ * `requestOwnership` that the harness had never pumped at all.
1027
+ */
1028
+ pumped(inner, label) {
1029
+ if (!this.autoPump) return inner;
1030
+ let settled;
1031
+ inner.then(
1032
+ (value) => {
1033
+ settled = { ok: true, value };
1034
+ },
1035
+ (error) => {
1036
+ settled = { ok: false, error };
1037
+ }
1038
+ );
1039
+ const cap = this.autoPumpTicks;
1040
+ const drive = async () => {
1041
+ const step = this.mode === "tick" ? this.intervalMs : 1;
1042
+ await this.settle();
1043
+ for (let i = 0; i < cap && settled === void 0; i++) {
1044
+ this.clock.advance(step);
1045
+ await this.settle();
1046
+ }
1047
+ if (settled === void 0) {
1048
+ throw new Error(
1049
+ `testRoom: ${label} never settled \u2014 ${cap} ticks of auto-pumping (${cap * step} ms of fake time) and the room has not answered. Either the handler never returns, or this needs testRoom({ autoPumpTicks }) raised, or autoPump: false and your own clock.`
1050
+ );
1051
+ }
1052
+ if (settled.ok) return settled.value;
1053
+ throw settled.error;
1054
+ };
1055
+ let started;
1056
+ const run = () => started ??= drive();
1057
+ const thenable = {
1058
+ // biome-ignore lint/suspicious/noThenProperty: a lazy thenable is exactly the point — a Promise subclass runs its executor eagerly, which is the property the auto-pump must not have.
1059
+ then: (onOk, onErr) => run().then(onOk, onErr),
1060
+ catch: (onErr) => run().catch(onErr),
1061
+ finally: (onDone) => run().finally(onDone)
1062
+ };
1063
+ return thenable;
1064
+ }
1065
+ /**
1066
+ * M7 pf4 (e): fire the ping now. See `TestRoom.pingNow` for why a test would want to.
1067
+ */
1068
+ async pingNow(clientId) {
1069
+ for (const client of this.clientList) {
1070
+ if (clientId !== void 0 && client.id !== clientId) continue;
1071
+ const session = client[INTERNAL_SESSION];
1072
+ session?.pingNow();
1073
+ }
1074
+ await this.settle();
1075
+ }
758
1076
  async joinOne(spec) {
759
1077
  const options = {
760
1078
  url: TEST_URL,
@@ -777,7 +1095,30 @@ var TestHarness = class {
777
1095
  marginMs: (latency?.rttMs ?? 0) + (latency?.jitterMs ?? 0)
778
1096
  });
779
1097
  const client = Object.create(room);
1098
+ const call = new Proxy(
1099
+ {},
1100
+ {
1101
+ get: (_target, prop) => {
1102
+ if (typeof prop !== "string") return void 0;
1103
+ const method = room.call[prop];
1104
+ if (typeof method !== "function") return method;
1105
+ return (params) => this.pumped(
1106
+ method(params),
1107
+ `client.call.${prop}()`
1108
+ );
1109
+ }
1110
+ }
1111
+ );
780
1112
  Object.defineProperties(client, {
1113
+ call: { get: () => call, enumerable: true },
1114
+ requestOwnership: {
1115
+ value: (entity, id) => this.pumped(room.requestOwnership(entity, id), "client.requestOwnership()"),
1116
+ enumerable: true
1117
+ },
1118
+ leave: {
1119
+ value: () => this.pumped(room.leave(), "client.leave()"),
1120
+ enumerable: true
1121
+ },
781
1122
  id: { get: () => room.me, enumerable: true },
782
1123
  roomId: { get: () => room.id, enumerable: true },
783
1124
  view: { get: () => room.state, enumerable: true },
@@ -990,6 +1331,11 @@ var TestHarness = class {
990
1331
  }
991
1332
  stop() {
992
1333
  this.stopped = true;
1334
+ for (const entry of [...this.npcSessions.values()]) {
1335
+ this.npcSessions.delete(entry.clientId);
1336
+ entry.stopped = true;
1337
+ void this.stopNpc(entry);
1338
+ }
993
1339
  for (const client of this.clientList) client.leave();
994
1340
  for (const link of this.links) link.socket.hangUp();
995
1341
  this.coreRef.stop();
@@ -1127,6 +1473,7 @@ var matchers = {
1127
1473
  export {
1128
1474
  materialize,
1129
1475
  divergence,
1476
+ DEFAULT_AUTO_PUMP_TICKS,
1130
1477
  InProcessSocket,
1131
1478
  clockScheduler,
1132
1479
  TestHarness,
package/dist/index.d.ts CHANGED
@@ -1,34 +1,56 @@
1
1
  import { RoleOf, AnySchema, State, PlainState } from '@irtio/schema';
2
2
  import { RoomDefinition } from '@irtio/server';
3
+ import * as _irtio_runtime from '@irtio/runtime';
4
+ import { RoomCore, RpcTraceDump } from '@irtio/runtime';
3
5
  import { Room, ClientState, RelayRoom, Scheduler } from '@irtio/client';
4
6
  import { ProfileSnapshot } from '@irtio/protocol';
5
- import { RoomCore } from '@irtio/runtime';
6
7
  import { TraceEntry, HarnessHost, VisibilityLeak, FakeClock } from '@irtio/runtime/test';
7
8
  export { TraceEntry, VisibilityLeak, initMatter, initPhysics, initRapier2d } from '@irtio/runtime/test';
8
9
  export { M as MatcherResult, P as PredictionBounds, T as TRACE_TAIL, m as matchers, t as toHaveConverged, a as toHaveNoVisibilityLeaks, b as toHaveRejected, c as toStayUnderBandwidth, d as toStayWithinPrediction } from './assertions-Cj1Sejhj.js';
9
10
 
10
11
  /**
11
- * `@irtio/testing`'s public contract: what `testRoom` hands back, and the two option bags.
12
+ * M7 pf4 (g): `t.physics` — a narrow, engine-neutral handle on the room's live world.
12
13
  *
13
- * The shapes here are the surface the canonical usage example relies on (the docblock in
14
- * `index.ts`). Two of them differ from the shipped client and the difference is deliberate:
14
+ * Deliberately four things and no more: read a body's position, read its velocity, teleport it,
15
+ * push it. It is **not** a general mid-tick mutation escape hatch and it hands out no raw world
16
+ * handle, because the moment it does, every test that reaches through it is written against one
17
+ * engine's API and the harness has quietly become a Rapier test harness. A test that genuinely
18
+ * needs the world still has `t.core.physics` — one level down, and obviously so.
15
19
  *
16
- * - **`client.id` is the *client* id.** The example writes `expect([a.id, b.id]).toContain(...)` against an
17
- * owner, so `a.id` has to be `room.me`. `@irtio/client`'s `Room.id` is the *room code*, so a
18
- * `TestClient` shadows it and re-exposes the room code as `client.roomId`.
19
- * - **`client.view` is `room.state`.** The example reads `a.view.cards.get('c1')`; the shipped client calls
20
- * that `room.state`. Both names point at the same object.
20
+ * The vectors are 3D-shaped with `z` optional. A 2D engine ignores `z` on the way in and reports
21
+ * `0` on the way out; that is one shape for callers instead of a union they have to narrow, and
22
+ * the alternative (a `z` that silently means something in one engine) is worse than a zero.
21
23
  *
22
- * Two more things live here beyond that example:
23
- *
24
- * - **Reconnection.** `TestClient.drop()` severs a client's in-process transport the way a real
25
- * socket drop would, so the shipped client's own backoff runs on the harness's fake clock —
26
- * `await t.run(300)` is enough to see it reconnect. `TestClient.reconnects` counts how many
27
- * times it has.
28
- * - **Relay.** `testRelay()` is the sibling of `testRoom()` for relay rooms: no schema, no
29
- * state, no RPCs — presence and a raw message channel over the same in-process, fake-clock link.
24
+ * Everything refuses by *saying what is wrong*: a room with no physics, a collection with no
25
+ * body-backed entity, an id that is not there. A test that quietly did nothing would be the worst
26
+ * outcome here, since the assertion after it would fail somewhere else entirely.
30
27
  */
31
28
 
29
+ /** A position or a velocity. `z` is `0` on a 2D engine. */
30
+ interface TestVector {
31
+ readonly x: number;
32
+ readonly y: number;
33
+ readonly z: number;
34
+ }
35
+ /** What you may pass in. `z` is ignored by the 2D engines. */
36
+ interface TestVectorInput {
37
+ readonly x: number;
38
+ readonly y: number;
39
+ readonly z?: number;
40
+ }
41
+ interface TestPhysics {
42
+ /** Which engine this room runs, or `undefined` when it declares no physics at all. */
43
+ readonly engine: 'rapier3d' | 'matter2d' | 'rapier2d' | undefined;
44
+ /** The body's position. Throws when the room has no physics or the body is not there. */
45
+ position(collection: string, id: string): TestVector;
46
+ /** The body's linear velocity. */
47
+ velocity(collection: string, id: string): TestVector;
48
+ /** Moves the body, waking it. The schema catches up on the room's next `sync()` — one tick. */
49
+ teleport(collection: string, id: string, to: TestVectorInput): void;
50
+ /** Applies a linear impulse at the body's centre of mass, waking it. */
51
+ impulse(collection: string, id: string, by: TestVectorInput): void;
52
+ }
53
+
32
54
  /**
33
55
  * Network simulation for the in-process link. Delays are applied on the **fake clock**, so a
34
56
  * `rttMs: 100` room still runs at full speed — it just needs 100 ms of `t.run()` to see a reply.
@@ -46,6 +68,8 @@ interface LatencySpec {
46
68
  */
47
69
  readonly loss?: number;
48
70
  }
71
+ /** M7 pf4 (f): how many ticks `await client.call.*()` will pump before it gives up. */
72
+ declare const DEFAULT_AUTO_PUMP_TICKS = 200;
49
73
  interface TestRoomOptions {
50
74
  /** Overrides the room definition's mode. The definition is **not** re-validated. */
51
75
  readonly mode?: 'tick' | 'event';
@@ -75,6 +99,31 @@ interface TestRoomOptions {
75
99
  * costs nothing — the room constructs no ledger and walks no frame.
76
100
  */
77
101
  readonly profile?: boolean;
102
+ /**
103
+ * M7 pf4 (f): while an `await`ed `client.call.*()` reply is pending, advance the fake clock
104
+ * automatically so the reply can arrive. Default `true`.
105
+ *
106
+ * Without it the boilerplate every RPC test carries is `const p = a.call.x(); await t.tick();
107
+ * await p;` — three lines that say nothing about the test, and one of which is easy to forget in
108
+ * a way that hangs. With it, `await a.call.x()` does what it looks like it does.
109
+ *
110
+ * The pump is lazy: it starts when the returned promise is first awaited, never at the call. So
111
+ * a test that wants to assert on state *between* sending the call and the room answering still
112
+ * can — hold the promise, assert, then advance the clock yourself. `autoPump: false` turns it
113
+ * off entirely for a test whose subject is that ordering.
114
+ */
115
+ readonly autoPump?: boolean;
116
+ /**
117
+ * M7 pf4 (f): how many ticks the auto-pump will advance before giving up, naming this cap in
118
+ * the rejection. Default {@link DEFAULT_AUTO_PUMP_TICKS}. A room that never replies is a bug in
119
+ * the room, and a test that spins forever on one reports the wrong thing.
120
+ */
121
+ readonly autoPumpTicks?: number;
122
+ /**
123
+ * M7 pf4: run the room with an RPC trace ring, readable as `t.rpcTrace`. Off by default, and
124
+ * off costs nothing — the same knob `irtio dev --trace-rpc` sets.
125
+ */
126
+ readonly traceRpc?: boolean;
78
127
  }
79
128
  interface TestJoinSpec<Role extends string = string> {
80
129
  readonly role?: Role;
@@ -228,6 +277,29 @@ interface TestRoom<S extends AnySchema> {
228
277
  bytes: number;
229
278
  perTick: number;
230
279
  }[];
280
+ /**
281
+ * M7 pf4 (g): a narrow handle on the room's live physics world — read a body's position and
282
+ * velocity, teleport it, push it. Deliberately not a general mutation hatch and deliberately no
283
+ * raw world handle; `t.core.physics` is one level down for a test that really needs it.
284
+ */
285
+ readonly physics: TestPhysics;
286
+ /**
287
+ * M7 pf4: the room's RPC trace, or `undefined` unless `testRoom({ traceRpc: true })` armed one.
288
+ * `undefined` and "empty" are different readings, exactly as `profile` is.
289
+ */
290
+ readonly rpcTrace: _irtio_runtime.RpcTraceDump | undefined;
291
+ /**
292
+ * M7 pf4 (e): fire each client's 2 s ping now, without waiting for its timer.
293
+ *
294
+ * A client's idea of the server's tick — and therefore the stamp it puts on its `CALL`s — is
295
+ * refreshed by a `DELTA` or by the `PONG` a ping draws. In a **quiet** room only the ping does
296
+ * it, so stamp staleness saws between zero and one ping interval and a test of it lands
297
+ * wherever the phase happens to put it. This pins the phase: `await t.pingNow()` puts every
298
+ * client at lag zero, and `await t.run(n)` afterwards puts them at a lag you chose.
299
+ *
300
+ * Pass a client id to ping just that one.
301
+ */
302
+ pingNow(clientId?: string): Promise<void>;
231
303
  /** Every client leaves and the room stops. */
232
304
  stop(): void;
233
305
  }
@@ -335,11 +407,20 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
335
407
  /** Client ids a frame was dropped for because nobody knows them, each warned about once. */
336
408
  private readonly unknownSendWarned;
337
409
  private readonly clientList;
410
+ /** D44: live NPC sessions by client id. */
411
+ private readonly npcSessions;
338
412
  private readonly roleById;
339
413
  private readonly roleOverrides;
340
414
  private readonly traceLog;
341
415
  private readonly frameLeaks;
342
- private readonly spatialSeen;
416
+ /**
417
+ * Record ids this harness has actually delivered to a client, per collection. Started as the
418
+ * join snapshot's ids and kept current by `checkFrame`. It was a spatial-grid-only ledger; every
419
+ * collection is tracked now, because a `room.setRole` demotion catches the client up with
420
+ * `remove` ops for a collection its new role cannot see, and those ids are exactly the ones it
421
+ * was legitimately sent before.
422
+ */
423
+ private readonly deliveredSeen;
343
424
  private readonly rejectionLog;
344
425
  /** Every handler throw the runtime caught, in order (bug 2). */
345
426
  private readonly handlerErrorLog;
@@ -361,6 +442,11 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
361
442
  private readonly writeIntervalMs;
362
443
  private readonly latency;
363
444
  private readonly failOnHandlerError;
445
+ /** M7 pf4 (f): auto-pump settings, resolved once. */
446
+ private readonly autoPump;
447
+ private readonly autoPumpTicks;
448
+ /** M7 pf4 (g): built once, lazily reading `core.physics` on every call. */
449
+ private readonly physicsRef;
364
450
  private stopped;
365
451
  constructor(definition: RoomDefinition<S>, options: TestRoomOptions);
366
452
  get core(): RoomCore<S>;
@@ -379,9 +465,40 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
379
465
  get ext(): AnySchema;
380
466
  /** D65: the room's bandwidth ledger, or `undefined` unless `profile: true` asked for one. */
381
467
  get profile(): ProfileSnapshot | undefined;
468
+ /** M7 pf4 (g): the narrow physics handle. Always present; its methods refuse with a reason. */
469
+ get physics(): TestPhysics;
470
+ /** M7 pf4: the RPC trace, or `undefined` unless `traceRpc: true` armed one. */
471
+ get rpcTrace(): RpcTraceDump | undefined;
382
472
  private enterCore;
383
- /** A `Transport` bound to one `t.join()`; reconnects call `connect` again with the same spec. */
473
+ /**
474
+ * A `Transport` bound to one `t.join()`; reconnects call `connect` again with the same spec.
475
+ *
476
+ * `identity` is how an NPC gets in: the runtime mints its client id (`npc-<room>-<n>`) before the
477
+ * host is asked to open the session, and every reconnect this transport makes has to come back as
478
+ * that same id with the same `npc` flag, exactly as the supervisor's loopback does.
479
+ */
384
480
  private transportFor;
481
+ /**
482
+ * `room.spawnNPC`, in process. The runtime has already validated the config and minted the id;
483
+ * what is left is the supervisor's half — find the script in the definition's `npcs` map and put
484
+ * a real client behind it.
485
+ *
486
+ * Everything here rides `this.clock`: the bot joins through `transportFor`, so its frames are the
487
+ * same fake-clock timers a player's are, and `scheduler` makes `npc.wait` / `npc.until` fake time
488
+ * too. So an NPC spawned inside a tick becomes visible on the next `t.tick()` / `t.run()` /
489
+ * `t.settle`, and never makes progress behind the test's back.
490
+ *
491
+ * Throwing is deliberate: the caller is `room.spawnNPC` inside a handler, which the runtime runs
492
+ * under `tryRun`, so the sentence lands in `t.handlerErrors` and (by default) fails the test at
493
+ * the clock call that ran the handler. Recording it and carrying on is what this used to do, and
494
+ * it is how "my NPC never showed up" became a silent test.
495
+ */
496
+ private spawnNpc;
497
+ private despawnNpc;
498
+ /** Stops one NPC's runner: its script winds down, then it leaves the room like any client. */
499
+ private stopNpc;
500
+ /** An NPC failure, filed where a handler throw is filed — see `reportHandlerErrors`. */
501
+ private noteNpcError;
385
502
  /**
386
503
  * Bug #49: a frame for a client with no link. Held when the core has the client and counts it
387
504
  * connected, which is exactly the window between `RoomCore.join` running `onJoin` and
@@ -423,6 +540,18 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
423
540
  private handleHello;
424
541
  private fail;
425
542
  private toClient;
543
+ /**
544
+ * The room's own role for this client **right now**, not the one its WELCOME carried.
545
+ *
546
+ * `room.setRole` commits `entry.role` before it hands the catch-up `DELTA` to `core.send`, so by
547
+ * the time that frame reaches `onSend` the authoritative role is already the new one. Judging it
548
+ * against the WELCOME-cached role called every legitimate promotion catch-up a leak. Reading the
549
+ * core here keeps `link.role`/`roleById` truthful too, so `roleFor` and the view half of
550
+ * `checkVisibility` follow a mid-session promotion without a separate hook.
551
+ */
552
+ private currentRole;
553
+ /** The live "already delivered" id set for one client and collection, created on demand. */
554
+ private deliveredIds;
426
555
  /** A `DELTA`/`CORRECT` naming a collection this client's role may not see is a leak. */
427
556
  private checkFrame;
428
557
  private noteReply;
@@ -452,6 +581,27 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
452
581
  join<Role extends string = RoleOf<S> & string>(spec?: TestJoinSpec<Role> & {
453
582
  role?: RoleOf<S> & string;
454
583
  }): Promise<TestClient<S, Role>>;
584
+ /**
585
+ * M7 pf4 (f): a `Promise`-shaped thing that advances the fake clock while it is being awaited.
586
+ *
587
+ * Lazy on purpose. The pump starts on the first `then` — that is, when somebody awaits the call
588
+ * — and not when the call is made, so a test can still hold the promise, assert on the state
589
+ * before the room has answered, and drive the clock itself. Eager pumping would have taken that
590
+ * away, and it is the one thing the manual boilerplate was good for.
591
+ *
592
+ * The cap is named in the rejection because a room that never replies is a bug in the room, and
593
+ * a harness that spun on it forever would report the wrong thing in the wrong place.
594
+ *
595
+ * `label` names the operation being awaited (`client.call.start()`, `client.requestOwnership()`,
596
+ * `client.leave()`). Every awaitable the `TestClient` exposes comes through here, so a hang has
597
+ * to say which one hung; the first report of this was a bare vitest timeout on a
598
+ * `requestOwnership` that the harness had never pumped at all.
599
+ */
600
+ private pumped;
601
+ /**
602
+ * M7 pf4 (e): fire the ping now. See `TestRoom.pingNow` for why a test would want to.
603
+ */
604
+ pingNow(clientId?: string): Promise<void>;
455
605
  private joinOne;
456
606
  /**
457
607
  * Drives the clock until `joinRoom` settles. A zero-latency join lands inside the first
@@ -648,4 +798,4 @@ declare function testRoom<S extends AnySchema>(definition: RoomDefinition<S>, op
648
798
  /** Starts a schema-less relay room in-process and returns the test handle. See the module docblock. */
649
799
  declare function testRelay(options?: TestRelayOptions): Promise<TestRelay>;
650
800
 
651
- export { type HandlerError, type LatencySpec, type PredictionStats, type RejectedCall, type TestClient, TestHarness, type TestJoinSpec, type TestRelay, type TestRelayClient, TestRelayHarness, type TestRelayJoinSpec, type TestRelayOptions, type TestRoom, type TestRoomOptions, type UntilOptions, clockScheduler, divergence, materialize, testRelay, testRoom };
801
+ export { DEFAULT_AUTO_PUMP_TICKS, type HandlerError, type LatencySpec, type PredictionStats, type RejectedCall, type TestClient, TestHarness, type TestJoinSpec, type TestPhysics, type TestRelay, type TestRelayClient, TestRelayHarness, type TestRelayJoinSpec, type TestRelayOptions, type TestRoom, type TestRoomOptions, type TestVector, type TestVectorInput, type UntilOptions, clockScheduler, divergence, materialize, testRelay, testRoom };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ DEFAULT_AUTO_PUMP_TICKS,
2
3
  InProcessSocket,
3
4
  TRACE_TAIL,
4
5
  TestHarness,
@@ -11,7 +12,7 @@ import {
11
12
  toHaveRejected,
12
13
  toStayUnderBandwidth,
13
14
  toStayWithinPrediction
14
- } from "./chunk-YMZ3FZYM.js";
15
+ } from "./chunk-47O3HZBE.js";
15
16
 
16
17
  // src/relay-harness.ts
17
18
  import { joinRelay } from "@irtio/client";
@@ -458,6 +459,7 @@ async function testRelay(options = {}) {
458
459
  return new TestRelayHarness(options);
459
460
  }
460
461
  export {
462
+ DEFAULT_AUTO_PUMP_TICKS,
461
463
  TRACE_TAIL,
462
464
  TestHarness,
463
465
  TestRelayHarness,
package/dist/matchers.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  toHaveRejected,
6
6
  toStayUnderBandwidth,
7
7
  toStayWithinPrediction
8
- } from "./chunk-YMZ3FZYM.js";
8
+ } from "./chunk-47O3HZBE.js";
9
9
 
10
10
  // src/matchers.ts
11
11
  import { expect } from "vitest";
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@irtio/testing",
3
- "version": "1.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "irtio's blessed test API: testRoom() in-process rooms with real client semantics, plus Vitest matchers",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/alex-irt/irtio.git",
9
+ "directory": "packages/testing"
10
+ },
6
11
  "publishConfig": {
7
12
  "access": "public"
8
13
  },
@@ -23,11 +28,12 @@
23
28
  "dist"
24
29
  ],
25
30
  "dependencies": {
26
- "@irtio/client": "1.0.0",
27
- "@irtio/protocol": "1.0.0",
28
- "@irtio/runtime": "1.0.0",
29
- "@irtio/schema": "1.0.0",
30
- "@irtio/server": "1.0.0"
31
+ "@irtio/bots": "3.1.0",
32
+ "@irtio/client": "3.1.0",
33
+ "@irtio/protocol": "3.1.0",
34
+ "@irtio/runtime": "3.1.0",
35
+ "@irtio/schema": "3.1.0",
36
+ "@irtio/server": "3.1.0"
31
37
  },
32
38
  "peerDependencies": {
33
39
  "vitest": ">=3"