@irtio/testing 3.0.0 → 3.1.1

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.
@@ -86,6 +86,7 @@ function clockScheduler(clock) {
86
86
  }
87
87
 
88
88
  // src/harness.ts
89
+ import { spawnBots } from "@irtio/bots";
89
90
  import { INTERNAL_SESSION, joinRoom } from "@irtio/client";
90
91
  import {
91
92
  ErrorCode,
@@ -259,6 +260,7 @@ function frameNameOf(frame) {
259
260
  async function microtasks(turns = MICROTASK_TURNS) {
260
261
  for (let i = 0; i < turns; i++) await Promise.resolve();
261
262
  }
263
+ var NPC_TRACE_LIMIT = 16;
262
264
  function detailOf(detail) {
263
265
  return detail ? { detail } : {};
264
266
  }
@@ -282,6 +284,8 @@ var TestHarness = class {
282
284
  /** Client ids a frame was dropped for because nobody knows them, each warned about once. */
283
285
  unknownSendWarned = /* @__PURE__ */ new Set();
284
286
  clientList = [];
287
+ /** D44: live NPC sessions by client id. */
288
+ npcSessions = /* @__PURE__ */ new Map();
285
289
  roleById = /* @__PURE__ */ new Map();
286
290
  roleOverrides = /* @__PURE__ */ new Map();
287
291
  traceLog = [];
@@ -340,6 +344,8 @@ var TestHarness = class {
340
344
  }
341
345
  this.holdOrDrop(clientId, frame);
342
346
  };
347
+ this.host.onSpawnNpc = (clientId, config) => this.spawnNpc(clientId, config);
348
+ this.host.onDespawnNpc = (clientId) => this.despawnNpc(clientId);
343
349
  this.coreRef = new RoomCore(this.definition, this.host, {
344
350
  roomId: this.roomId,
345
351
  ...options.seed !== void 0 ? { seed: options.seed } : {},
@@ -437,13 +443,20 @@ var TestHarness = class {
437
443
  // -------------------------------------------------------------------------
438
444
  // The link
439
445
  // -------------------------------------------------------------------------
440
- /** A `Transport` bound to one `t.join()`; reconnects call `connect` again with the same spec. */
441
- 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) {
442
454
  return {
443
455
  connect: () => {
444
456
  const link = {
445
457
  socket: void 0,
446
- clientId: `c${this.nextClientId++}`,
458
+ clientId: identity?.clientId ?? `c${this.nextClientId++}`,
459
+ npc: identity?.npc ?? false,
447
460
  role: "",
448
461
  joined: false,
449
462
  connected: true,
@@ -463,6 +476,105 @@ var TestHarness = class {
463
476
  }
464
477
  };
465
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
+ }
466
578
  /**
467
579
  * Bug #49: a frame for a client with no link. Held when the core has the client and counts it
468
580
  * connected, which is exactly the window between `RoomCore.join` running `onJoin` and
@@ -635,7 +747,8 @@ var TestHarness = class {
635
747
  result = this.coreRef.join(link.clientId, {
636
748
  ...role !== void 0 ? { role } : {},
637
749
  ...name !== void 0 ? { name } : {},
638
- ...reconnecting ? { reconnecting: true } : {}
750
+ ...reconnecting ? { reconnecting: true } : {},
751
+ ...link.npc ? { npc: true } : {}
639
752
  });
640
753
  } catch (err) {
641
754
  failure = err;
@@ -1218,6 +1331,11 @@ var TestHarness = class {
1218
1331
  }
1219
1332
  stop() {
1220
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
+ }
1221
1339
  for (const client of this.clientList) client.leave();
1222
1340
  for (const link of this.links) link.socket.hangUp();
1223
1341
  this.coreRef.stop();
package/dist/index.d.ts CHANGED
@@ -407,6 +407,8 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
407
407
  /** Client ids a frame was dropped for because nobody knows them, each warned about once. */
408
408
  private readonly unknownSendWarned;
409
409
  private readonly clientList;
410
+ /** D44: live NPC sessions by client id. */
411
+ private readonly npcSessions;
410
412
  private readonly roleById;
411
413
  private readonly roleOverrides;
412
414
  private readonly traceLog;
@@ -468,8 +470,35 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
468
470
  /** M7 pf4: the RPC trace, or `undefined` unless `traceRpc: true` armed one. */
469
471
  get rpcTrace(): RpcTraceDump | undefined;
470
472
  private enterCore;
471
- /** 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
+ */
472
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;
473
502
  /**
474
503
  * Bug #49: a frame for a client with no link. Held when the core has the client and counts it
475
504
  * connected, which is exactly the window between `RoomCore.join` running `onJoin` and
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  toHaveRejected,
13
13
  toStayUnderBandwidth,
14
14
  toStayWithinPrediction
15
- } from "./chunk-3W5KPX3I.js";
15
+ } from "./chunk-47O3HZBE.js";
16
16
 
17
17
  // src/relay-harness.ts
18
18
  import { joinRelay } from "@irtio/client";
package/dist/matchers.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  toHaveRejected,
6
6
  toStayUnderBandwidth,
7
7
  toStayWithinPrediction
8
- } from "./chunk-3W5KPX3I.js";
8
+ } from "./chunk-47O3HZBE.js";
9
9
 
10
10
  // src/matchers.ts
11
11
  import { expect } from "vitest";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/testing",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "irtio's blessed test API: testRoom() in-process rooms with real client semantics, plus Vitest matchers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,11 +28,12 @@
28
28
  "dist"
29
29
  ],
30
30
  "dependencies": {
31
- "@irtio/client": "3.0.0",
32
- "@irtio/protocol": "3.0.0",
33
- "@irtio/runtime": "3.0.0",
34
- "@irtio/schema": "3.0.0",
35
- "@irtio/server": "3.0.0"
31
+ "@irtio/bots": "3.1.1",
32
+ "@irtio/client": "3.1.1",
33
+ "@irtio/protocol": "3.1.1",
34
+ "@irtio/runtime": "3.1.1",
35
+ "@irtio/schema": "3.1.1",
36
+ "@irtio/server": "3.1.1"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "vitest": ">=3"