@irtio/bots 0.6.0 → 0.8.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +370 -22
  2. package/dist/index.js +483 -118
  3. package/package.json +5 -5
package/dist/index.js CHANGED
@@ -14,7 +14,14 @@ var INVARIANT_NAMES = [
14
14
  "misprediction",
15
15
  "snaps",
16
16
  "disconnects",
17
- "tick-health"
17
+ "tick-health",
18
+ // D70 (M6 lane C): typed peer messages a bot could not read. A shape mismatch between a bot
19
+ // script and the schema was previously a silent nothing-happened; this makes it a red run.
20
+ "typed-message-drops",
21
+ // M6 lane E (D73-c): every party's members share a room, and no room holds more bots than its
22
+ // ticket said it seats. Listed only on a run that queued — a run that built its own room has no
23
+ // parties to be intact, so the row is absent rather than unread. See `partyIntegrityResult`.
24
+ "party-integrity"
18
25
  ];
19
26
  var DEFAULT_THRESHOLDS = {
20
27
  budgetBytesPerSec: 128e3,
@@ -22,7 +29,8 @@ var DEFAULT_THRESHOLDS = {
22
29
  handlerErrorsMax: 0,
23
30
  mispredictionMagnitudeMax: Number.POSITIVE_INFINITY,
24
31
  snapsMax: Number.POSITIVE_INFINITY,
25
- overrunsMax: 0
32
+ overrunsMax: 0,
33
+ typedMessageDropsMax: 0
26
34
  };
27
35
  var widenedSchemas = /* @__PURE__ */ new WeakMap();
28
36
  function widenGrids(ext, slack) {
@@ -264,16 +272,19 @@ function entityValue(frameState, collection, id) {
264
272
  function correlateShots(shots, dump) {
265
273
  const frames = dump.frames;
266
274
  return shots.map((shot) => {
267
- const arrivedAt = shot.sentAt + shot.uplinkMs;
268
- const frame = frames.find((f) => f.at >= arrivedAt);
275
+ const exact = shot.serverTick !== void 0;
276
+ const frame = exact ? frames.find((f) => f.tick === shot.serverTick) : frames.find((f) => f.at >= shot.sentAt + shot.uplinkMs);
277
+ const estimated = !exact && shot.uplinkMs > 0;
278
+ const rewound = shot.rewound;
269
279
  if (frame === void 0) {
270
280
  return {
271
281
  ...shot,
272
- serverTick: void 0,
273
- estimated: shot.uplinkMs > 0,
282
+ serverTick: exact ? shot.serverTick : void 0,
283
+ estimated,
284
+ rewound,
274
285
  authoritative: void 0,
275
286
  missDistance: void 0,
276
- unresolved: frames.length === 0 ? "nothing was recorded, so there is no tick to judge this shot against" : `the shot lands after the last recorded tick (${frames[frames.length - 1]?.tick})`
287
+ unresolved: frames.length === 0 ? "nothing was recorded, so there is no tick to judge this shot against" : exact ? `the room judged this shot at tick ${shot.serverTick}, which is not in the recording` : `the shot lands after the last recorded tick (${frames[frames.length - 1]?.tick})`
277
288
  };
278
289
  }
279
290
  const value = entityValue(frame.state, shot.collection, shot.target);
@@ -281,7 +292,8 @@ function correlateShots(shots, dump) {
281
292
  return {
282
293
  ...shot,
283
294
  serverTick: frame.tick,
284
- estimated: shot.uplinkMs > 0,
295
+ estimated,
296
+ rewound,
285
297
  authoritative: void 0,
286
298
  missDistance: void 0,
287
299
  unresolved: `${shot.collection}.${shot.target} is not in the recording at tick ${frame.tick}`
@@ -303,7 +315,8 @@ function correlateShots(shots, dump) {
303
315
  return {
304
316
  ...shot,
305
317
  serverTick: frame.tick,
306
- estimated: shot.uplinkMs > 0,
318
+ estimated,
319
+ rewound,
307
320
  authoritative,
308
321
  missDistance: void 0,
309
322
  unresolved: missing
@@ -312,7 +325,8 @@ function correlateShots(shots, dump) {
312
325
  return {
313
326
  ...shot,
314
327
  serverTick: frame.tick,
315
- estimated: shot.uplinkMs > 0,
328
+ estimated,
329
+ rewound,
316
330
  authoritative,
317
331
  missDistance: Math.sqrt(sum)
318
332
  };
@@ -465,6 +479,7 @@ import {
465
479
  FrameType as FrameType4,
466
480
  decodeErrorPayload,
467
481
  decodeFrame,
482
+ decodeMsg,
468
483
  decodeReply,
469
484
  decodeWelcome,
470
485
  errorByCode,
@@ -477,6 +492,7 @@ import {
477
492
  applyDelta,
478
493
  decodeDelta as decodeDelta2,
479
494
  decodeDeltaFrom,
495
+ decodeFields,
480
496
  decodeSnapshot,
481
497
  schemaFromCanonical
482
498
  } from "@irtio/schema";
@@ -545,21 +561,24 @@ function makeTrace(startedAt, rings) {
545
561
  }
546
562
 
547
563
  // src/observer.ts
548
- function simulationOnly(ext, delta, predicts) {
564
+ function simulationOnly(ext, delta, kindOf) {
549
565
  let sawSync = false;
550
566
  let sawPredicted = false;
567
+ let sawProxied = false;
551
568
  for (const dc of delta.collections) {
552
569
  const desc = ext.collections.find((c) => c.name === dc.name);
553
570
  const physics = desc?.physics;
554
571
  if (!desc || !physics) return "mixed";
555
572
  for (const op of dc.ops) {
556
573
  if (op.op !== "update") return "mixed";
557
- const predicted = predicts(dc.name, op.id);
574
+ const kind = kindOf(dc.name, op.id);
575
+ const predicted = kind === "predicted";
558
576
  for (const index of op.mask.fields) {
559
577
  const field = desc.fields[index];
560
578
  if (!field) return "mixed";
561
579
  if (physics.bodyFields.has(field.name)) {
562
580
  if (predicted) sawPredicted = true;
581
+ else if (kind === "proxied") sawProxied = true;
563
582
  else sawSync = true;
564
583
  } else if (!predicted && !physics.intents.includes(field.name)) {
565
584
  sawSync = true;
@@ -569,9 +588,11 @@ function simulationOnly(ext, delta, predicts) {
569
588
  }
570
589
  }
571
590
  }
572
- if (sawPredicted && !sawSync) return "predicted";
573
- if (sawSync && !sawPredicted) return "sync";
574
- return "mixed";
591
+ const kinds = Number(sawPredicted) + Number(sawSync) + Number(sawProxied);
592
+ if (kinds !== 1) return "mixed";
593
+ if (sawPredicted) return "predicted";
594
+ if (sawProxied) return "proxied";
595
+ return "sync";
575
596
  }
576
597
  var WriteLog = class {
577
598
  constructor(limit = 4096) {
@@ -654,14 +675,29 @@ var BotObserver = class {
654
675
  bytesOut = 0;
655
676
  corrections = 0;
656
677
  syncCorrections = 0;
678
+ proxiedCorrections = 0;
657
679
  suppressedCorrections = 0;
658
680
  mispredictions = 0;
659
681
  /**
660
- * Does this bot's client currently predict `collection[id]` in a local world? Assigned by
661
- * `spawnBots` once the room exists (`room.prediction`); prediction status is client-local, so
662
- * it cannot be derived from the bytes the observer otherwise sticks to.
682
+ * M6 lane E (D73-b): what this bot's client's local world holds for `collection[id]` right now.
683
+ * Assigned by `spawnBots` once the room exists (`room.prediction`); the local world's shape is
684
+ * client-local, so it cannot be derived from the bytes the observer otherwise sticks to.
685
+ *
686
+ * Unset means "no local world at all", which answers `absent` for everything — the behaviour a
687
+ * bot without physics has always had.
688
+ */
689
+ bodyKind;
690
+ /**
691
+ * M6 lane E (D73-a): the client's own `room.prediction`, when this bot joined with `physics` or
692
+ * `physics2d`. Held rather than copied because `active` flips once the engine has loaded, which
693
+ * happens off the join path — a boolean read at join time would say `false` on every run.
663
694
  */
664
- predictsBody;
695
+ prediction;
696
+ /** D73-a: latched true the first time {@link prediction} reported an active local world. */
697
+ predicted = false;
698
+ /** D73-b: the high-water marks of `prediction.stats.proxies` / `.absent` across the run. */
699
+ proxies = 0;
700
+ absent = 0;
665
701
  mispredictionMagnitude = 0;
666
702
  mispredictionMax = 0;
667
703
  snaps = 0;
@@ -670,6 +706,10 @@ var BotObserver = class {
670
706
  disconnects = 0;
671
707
  peakBytesInPerSec = 0;
672
708
  peakCorrectionsPerSec = 0;
709
+ /** D70: peer-message counters, filled by `inspect`'s MSG case in both directions. */
710
+ messagesSent = 0;
711
+ messagesReceived = 0;
712
+ messagesDropped = 0;
673
713
  /**
674
714
  * Spatial-grid ops actually put to the AOI policy. Zero on a run whose room has no spatial
675
715
  * collection; zero on a run that *does* and would mean the invariant passed vacuously, which is
@@ -705,6 +745,7 @@ var BotObserver = class {
705
745
  bytesOut: this.bytesOut,
706
746
  corrections: this.corrections,
707
747
  syncCorrections: this.syncCorrections,
748
+ proxiedCorrections: this.proxiedCorrections,
708
749
  suppressedCorrections: this.suppressedCorrections,
709
750
  mispredictions: this.mispredictions,
710
751
  mispredictionMagnitude: this.mispredictionMagnitude,
@@ -714,7 +755,13 @@ var BotObserver = class {
714
755
  errors: this.errors,
715
756
  disconnects: this.disconnects,
716
757
  peakBytesInPerSec: this.peakBytesInPerSec,
717
- peakCorrectionsPerSec: this.peakCorrectionsPerSec
758
+ peakCorrectionsPerSec: this.peakCorrectionsPerSec,
759
+ messagesSent: this.messagesSent,
760
+ messagesReceived: this.messagesReceived,
761
+ messagesDropped: this.messagesDropped,
762
+ predicting: this.predicted,
763
+ proxies: this.proxies,
764
+ absent: this.absent
718
765
  };
719
766
  }
720
767
  /** Counts a violation always; keeps an example, rate-limited per invariant when asked. */
@@ -740,6 +787,12 @@ var BotObserver = class {
740
787
  /** The `onFrame` hook. `bytes` is the whole frame, envelope byte included. */
741
788
  onFrame(dir, type, bytes) {
742
789
  const now = Date.now();
790
+ const prediction = this.prediction;
791
+ if (prediction?.active === true) {
792
+ this.predicted = true;
793
+ if (prediction.stats.proxies > this.proxies) this.proxies = prediction.stats.proxies;
794
+ if (prediction.stats.absent > this.absent) this.absent = prediction.stats.absent;
795
+ }
743
796
  let note;
744
797
  if (dir === "in") {
745
798
  this.framesIn++;
@@ -781,6 +834,7 @@ var BotObserver = class {
781
834
  if (dir === "out") {
782
835
  if (type === FrameType4.CALL) this.calls++;
783
836
  if (type === FrameType4.WRITE) this.recordWrites(decodeDelta2(this.ext, payload), now);
837
+ if (type === FrameType4.MSG) return this.onMsg("out", payload);
784
838
  return void 0;
785
839
  }
786
840
  switch (type) {
@@ -799,10 +853,64 @@ var BotObserver = class {
799
853
  return this.onReply(payload);
800
854
  case FrameType4.SCHEMA:
801
855
  return this.onSchema(payload);
856
+ case FrameType4.MSG:
857
+ return this.onMsg("in", payload);
802
858
  default:
803
859
  return void 0;
804
860
  }
805
861
  }
862
+ /**
863
+ * D70: one peer message, in either direction, as a trace note.
864
+ *
865
+ * Before this, `MSG` fell through to `undefined` and a trace showed a frame with a byte count
866
+ * and nothing else — which was tolerable while every message was opaque bytes and is not now
867
+ * that some of them have declared shapes. A raw message notes its target; a typed one notes its
868
+ * name and its value when this bot holds the schema, and its index when it does not.
869
+ *
870
+ * A typed message this bot cannot read is counted as a **drop**, which is what the
871
+ * `typed-message-drops` invariant fails a run on. That is the whole point of the counter: a
872
+ * scenario sending a shape the schema does not describe used to be a message that silently
873
+ * never arrived.
874
+ */
875
+ onMsg(dir, payload) {
876
+ let msg;
877
+ try {
878
+ msg = decodeMsg(payload);
879
+ } catch (err) {
880
+ const why = err instanceof Error ? err.message : String(err);
881
+ if (dir === "in") this.dropTyped(`a MSG frame did not decode: ${why}`);
882
+ return `msg undecodable (${why})`;
883
+ }
884
+ const who = msg.target.kind === "client" ? msg.target.clientId : msg.target.kind === "role" ? `role:${msg.target.role}` : msg.target.kind;
885
+ if (!msg.typed) {
886
+ if (dir === "out") this.messagesSent++;
887
+ else this.messagesReceived++;
888
+ return `msg raw ${who} (${msg.payload.length}B)`;
889
+ }
890
+ const desc = (this.ext.messages ?? [])[msg.typed.index];
891
+ if (!desc) {
892
+ if (dir === "out") this.messagesSent++;
893
+ else this.dropTyped(`no message with index ${msg.typed.index} in this schema`);
894
+ return `msg typed #${msg.typed.index} (undecodable)`;
895
+ }
896
+ let value;
897
+ try {
898
+ value = decodeFields(desc.fields, msg.payload);
899
+ } catch {
900
+ if (dir === "out") this.messagesSent++;
901
+ else this.dropTyped(`${desc.name} did not decode against this schema`);
902
+ return `msg ${desc.name} (undecodable)`;
903
+ }
904
+ if (dir === "out") this.messagesSent++;
905
+ else this.messagesReceived++;
906
+ const arrow = dir === "out" ? "->" : "<-";
907
+ return `msg ${desc.name} ${arrow} ${who} ${JSON.stringify(value)}`;
908
+ }
909
+ /** D70: one typed message this bot could not read — a counter and an invariant violation. */
910
+ dropTyped(why) {
911
+ this.messagesDropped++;
912
+ this.violate("typed-message-drops", why, true);
913
+ }
806
914
  onWelcome(welcome) {
807
915
  this.id = welcome.clientId;
808
916
  this.role = welcome.role;
@@ -848,12 +956,16 @@ var BotObserver = class {
848
956
  const kind = simulationOnly(
849
957
  this.ext,
850
958
  delta,
851
- (c, id) => this.predictsBody ? this.predictsBody(c, id) : false
959
+ (c, id) => this.bodyKind ? this.bodyKind(c, id) : "absent"
852
960
  );
853
961
  if (kind === "sync") {
854
962
  this.syncCorrections++;
855
963
  return `synced ${names}`;
856
964
  }
965
+ if (kind === "proxied") {
966
+ this.proxiedCorrections++;
967
+ return `proxied ${names}`;
968
+ }
857
969
  if (kind === "predicted") {
858
970
  return `judged ${names}${clientTick !== void 0 ? ` (clientTick ${clientTick})` : ""}`;
859
971
  }
@@ -1145,6 +1257,58 @@ function freshValue(rng, desc, ctx) {
1145
1257
 
1146
1258
  // src/report.ts
1147
1259
  import { mergeProfiles } from "@irtio/protocol";
1260
+ function matchmakingSummary(reading) {
1261
+ const waits = [...reading.tickets.map((t) => t.waitedMs)].sort((a, b) => a - b);
1262
+ const rooms = /* @__PURE__ */ new Map();
1263
+ for (const t of reading.tickets) {
1264
+ const room = rooms.get(t.room) ?? { bots: 0, size: t.size };
1265
+ room.bots += 1;
1266
+ room.size = Math.max(room.size, t.size);
1267
+ rooms.set(t.room, room);
1268
+ }
1269
+ const byParty = /* @__PURE__ */ new Map();
1270
+ for (const t of reading.tickets) {
1271
+ if (t.party === void 0) continue;
1272
+ const seen = byParty.get(t.party) ?? /* @__PURE__ */ new Set();
1273
+ seen.add(t.room);
1274
+ byParty.set(t.party, seen);
1275
+ }
1276
+ const splitParties = [...byParty].filter(([, seen]) => seen.size > 1).map(([party, seen]) => ({ party, rooms: [...seen] }));
1277
+ const overfullRooms = [...rooms].filter(([, r]) => r.size > 0 && r.bots > r.size).map(([room, r]) => ({ room, bots: r.bots, size: r.size }));
1278
+ return {
1279
+ tickets: reading.tickets.length,
1280
+ rooms: rooms.size,
1281
+ waitP50Ms: percentile(waits, 50),
1282
+ waitP95Ms: percentile(waits, 95),
1283
+ waitMaxMs: waits[waits.length - 1] ?? 0,
1284
+ timeouts: reading.failures.filter((f) => f.code === "E_NO_MATCH").length,
1285
+ failures: reading.failures.filter((f) => f.code !== "E_NO_MATCH"),
1286
+ backfills: reading.tickets.filter((t) => t.backfill).length,
1287
+ partiesIntact: byParty.size - splitParties.length,
1288
+ parties: byParty.size,
1289
+ overfullRooms,
1290
+ splitParties
1291
+ };
1292
+ }
1293
+ function partyIntegrityResult(reading) {
1294
+ const summary = matchmakingSummary(reading);
1295
+ const violations = summary.splitParties.length + summary.overfullRooms.length;
1296
+ const detail = violations === 0 ? `${summary.parties} part${summary.parties === 1 ? "y" : "ies"} landed whole across ${summary.rooms} room(s), and no room held more bots than its ticket seats` : [
1297
+ ...summary.splitParties.map(
1298
+ (p) => `party ${p.party} was split across ${p.rooms.join(", ")}`
1299
+ ),
1300
+ ...summary.overfullRooms.map(
1301
+ (r) => `room ${r.room} holds ${r.bots} bots on a ticket that seats ${r.size}`
1302
+ )
1303
+ ].join("; ");
1304
+ return {
1305
+ name: "party-integrity",
1306
+ ok: violations === 0,
1307
+ state: violations === 0 ? "ok" : "violation",
1308
+ violations,
1309
+ detail
1310
+ };
1311
+ }
1148
1312
  function percentile(sorted, p) {
1149
1313
  if (sorted.length === 0) return 0;
1150
1314
  const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1));
@@ -1194,9 +1358,12 @@ function detailFor(name, observers, violations, thresholds) {
1194
1358
  case "correction-storm": {
1195
1359
  const corrections = observers.reduce((sum, o) => sum + o.corrections, 0);
1196
1360
  const synced = observers.reduce((sum, o) => sum + o.syncCorrections, 0);
1361
+ const proxied = observers.reduce((sum, o) => sum + o.proxiedCorrections, 0);
1197
1362
  const suppressed = observers.reduce((sum, o) => sum + o.suppressedCorrections, 0);
1198
1363
  const worst = peak(observers, (o) => o.peakCorrectionsPerSec);
1199
- return `${corrections} correction(s), peak ${worst}/s per bot, threshold ${thresholds.correctionsPerSecMax}/s${synced > 0 ? ` (+${synced} body-sync)` : ""}${suppressed > 0 ? ` (+${suppressed} within-epsilon)` : ""}${violations === 0 ? "" : examples(observers, name)}`;
1364
+ return `${corrections} correction(s), peak ${worst}/s per bot, threshold ${thresholds.correctionsPerSecMax}/s${synced > 0 ? ` (+${synced} body-sync)` : ""}${// D73-b: named apart from body-sync, because a proxy correction says something else
1365
+ // the server disagreeing with what this client drew.
1366
+ proxied > 0 ? ` (+${proxied} proxied)` : ""}${suppressed > 0 ? ` (+${suppressed} within-epsilon)` : ""}${violations === 0 ? "" : examples(observers, name)}`;
1200
1367
  }
1201
1368
  case "misprediction": {
1202
1369
  const count = observers.reduce((sum, o) => sum + o.mispredictions, 0);
@@ -1216,7 +1383,14 @@ function detailFor(name, observers, violations, thresholds) {
1216
1383
  const count = observers.reduce((sum, o) => sum + o.disconnects, 0);
1217
1384
  return count === 0 ? "every bot stayed connected" : `${count} disconnect(s)${examples(observers, name)}`;
1218
1385
  }
1386
+ case "typed-message-drops": {
1387
+ const dropped = observers.reduce((sum, o) => sum + o.messagesDropped, 0);
1388
+ const received = observers.reduce((sum, o) => sum + o.messagesReceived, 0);
1389
+ const sent = observers.reduce((sum, o) => sum + o.messagesSent, 0);
1390
+ return `${sent} message(s) sent, ${received} received, ${dropped} dropped, tolerated ${thresholds.typedMessageDropsMax}` + (violations === 0 ? "" : `${examples(observers, name)} \u2014 a dropped typed message means the sender's shape is not one this schema declares`);
1391
+ }
1219
1392
  case "tick-health":
1393
+ case "party-integrity":
1220
1394
  return "";
1221
1395
  }
1222
1396
  }
@@ -1255,10 +1429,19 @@ function buildReport(options) {
1255
1429
  const thresholds = options.thresholds ?? DEFAULT_THRESHOLDS;
1256
1430
  const durationMs = Math.max(1, options.durationMs);
1257
1431
  const seconds = durationMs / 1e3;
1258
- const invariants = INVARIANT_NAMES.map((name) => {
1432
+ const matchmaking = options.matchmaking;
1433
+ const invariants = INVARIANT_NAMES.filter(
1434
+ // D73-c: a run that did not queue has no parties, so the row is absent rather than unread.
1435
+ (name) => name !== "party-integrity" || matchmaking !== void 0
1436
+ ).map((name) => {
1259
1437
  if (name === "tick-health") return tickHealthResult(options.tickHealth, thresholds);
1438
+ if (name === "party-integrity") return partyIntegrityResult(matchmaking);
1260
1439
  const violations = total(observers, name);
1261
- const ok = name === "handler-error" ? violations <= thresholds.handlerErrorsMax : violations === 0;
1440
+ const ok = name === "handler-error" ? violations <= thresholds.handlerErrorsMax : (
1441
+ // D70: a budget too, for the same reason `handler-error` has one — a scenario that
1442
+ // deliberately sends a shape the room does not declare says so with `--typed-drops-max`.
1443
+ name === "typed-message-drops" ? violations <= thresholds.typedMessageDropsMax : violations === 0
1444
+ );
1262
1445
  const state = ok ? "ok" : "violation";
1263
1446
  return {
1264
1447
  name,
@@ -1276,16 +1459,26 @@ function buildReport(options) {
1276
1459
  bytesOut: perBot.reduce((sum, b) => sum + b.bytesOut, 0),
1277
1460
  corrections: perBot.reduce((sum, b) => sum + b.corrections, 0),
1278
1461
  syncCorrections: perBot.reduce((sum, b) => sum + b.syncCorrections, 0),
1462
+ proxiedCorrections: perBot.reduce((sum, b) => sum + b.proxiedCorrections, 0),
1279
1463
  suppressedCorrections: perBot.reduce((sum, b) => sum + b.suppressedCorrections, 0),
1280
1464
  mispredictions: perBot.reduce((sum, b) => sum + b.mispredictions, 0),
1281
1465
  mispredictionMagnitude: perBot.reduce((sum, b) => sum + b.mispredictionMagnitude, 0),
1282
1466
  mispredictionMax: perBot.reduce((max, b) => Math.max(max, b.mispredictionMax), 0),
1283
1467
  snaps: perBot.reduce((sum, b) => sum + b.snaps, 0),
1284
1468
  calls: perBot.reduce((sum, b) => sum + b.calls, 0),
1285
- errors: perBot.reduce((sum, b) => sum + b.errors, 0)
1469
+ errors: perBot.reduce((sum, b) => sum + b.errors, 0),
1470
+ messagesSent: perBot.reduce((sum, b) => sum + b.messagesSent, 0),
1471
+ messagesReceived: perBot.reduce((sum, b) => sum + b.messagesReceived, 0),
1472
+ messagesDropped: perBot.reduce((sum, b) => sum + b.messagesDropped, 0)
1286
1473
  };
1287
1474
  const bots = Math.max(1, perBot.length);
1288
1475
  const convergence = convergenceStats(lags);
1476
+ const predicting = perBot.filter((b) => b.predicting);
1477
+ const prediction = predicting.length === 0 ? void 0 : {
1478
+ bots: predicting.length,
1479
+ proxies: predicting.reduce((sum, b) => sum + b.proxies, 0),
1480
+ absent: predicting.reduce((sum, b) => sum + b.absent, 0)
1481
+ };
1289
1482
  return {
1290
1483
  ok: invariants.every((i) => i.ok),
1291
1484
  bots: perBot.length,
@@ -1300,10 +1493,115 @@ function buildReport(options) {
1300
1493
  convergence,
1301
1494
  convergenceLagMs: convergence?.p50Ms,
1302
1495
  tracePath: options.tracePath,
1303
- ...options.profiles !== void 0 && options.profiles.length > 0 ? { profile: options.profiles.reduce(mergeProfiles) } : {}
1496
+ ...options.profiles !== void 0 && options.profiles.length > 0 ? { profile: options.profiles.reduce(mergeProfiles) } : {},
1497
+ ...prediction !== void 0 ? { prediction } : {},
1498
+ ...options.matchmaking !== void 0 ? { matchmaking: matchmakingSummary(options.matchmaking) } : {}
1304
1499
  };
1305
1500
  }
1306
1501
 
1502
+ // src/match.ts
1503
+ import { MatchError, createParty, findMatch } from "@irtio/client";
1504
+ var DEFAULT_MATCH_TIMEOUT_MS = 2e4;
1505
+ function describe(err) {
1506
+ if (err instanceof MatchError) return { code: err.code, message: err.message };
1507
+ return { code: "E_MATCH_FAILED", message: err instanceof Error ? err.message : String(err) };
1508
+ }
1509
+ async function matchBots(n, options) {
1510
+ if (!Number.isInteger(n) || n < 1) {
1511
+ throw new Error(`irtio bots: matchBots needs at least one bot, got ${String(n)}`);
1512
+ }
1513
+ const groups = /* @__PURE__ */ new Map();
1514
+ for (let i = 0; i < n; i++) {
1515
+ const key = options.party?.(i);
1516
+ if (key === void 0) continue;
1517
+ const members = groups.get(key) ?? [];
1518
+ members.push(i);
1519
+ groups.set(key, members);
1520
+ }
1521
+ const parties = /* @__PURE__ */ new Map();
1522
+ const partyErrors = /* @__PURE__ */ new Map();
1523
+ for (const [key, members] of groups) {
1524
+ if (members.length < 2) continue;
1525
+ try {
1526
+ const minted = await createParty(options.project, {
1527
+ size: members.length,
1528
+ controlUrl: options.controlUrl,
1529
+ ...options.fetch !== void 0 ? { fetch: options.fetch } : {}
1530
+ });
1531
+ parties.set(key, minted.party);
1532
+ } catch (err) {
1533
+ partyErrors.set(key, describe(err));
1534
+ }
1535
+ }
1536
+ const timeoutMs = options.timeoutMs ?? DEFAULT_MATCH_TIMEOUT_MS;
1537
+ const attempts = await Promise.all(
1538
+ Array.from({ length: n }, async (_unused, index) => {
1539
+ const key = options.party?.(index);
1540
+ const party = key !== void 0 && parties.has(key) ? parties.get(key) : void 0;
1541
+ const startedAt = Date.now();
1542
+ const failed = key !== void 0 ? partyErrors.get(key) : void 0;
1543
+ if (failed !== void 0) {
1544
+ return { index, waitedMs: 0, error: failed, ...key !== void 0 ? { party: key } : {} };
1545
+ }
1546
+ const identity = options.identity?.(index);
1547
+ try {
1548
+ const ticket = await findMatch(options.project, {
1549
+ controlUrl: options.controlUrl,
1550
+ timeoutMs,
1551
+ ...options.queue !== void 0 ? { queue: options.queue } : {},
1552
+ ...party !== void 0 ? { party } : {},
1553
+ ...identity !== void 0 ? { identity } : {},
1554
+ ...options.fetch !== void 0 ? { fetch: options.fetch } : {}
1555
+ });
1556
+ return {
1557
+ index,
1558
+ ticket,
1559
+ waitedMs: Date.now() - startedAt,
1560
+ ...key !== void 0 ? { party: key } : {}
1561
+ };
1562
+ } catch (err) {
1563
+ return {
1564
+ index,
1565
+ waitedMs: Date.now() - startedAt,
1566
+ error: describe(err),
1567
+ ...key !== void 0 ? { party: key } : {}
1568
+ };
1569
+ }
1570
+ })
1571
+ );
1572
+ const tickets = [];
1573
+ const failures = [];
1574
+ for (const attempt of attempts) {
1575
+ if (attempt.ticket === void 0) {
1576
+ failures.push({
1577
+ requested: attempt.index,
1578
+ code: attempt.error?.code ?? "E_MATCH_FAILED",
1579
+ message: attempt.error?.message ?? "no ticket and no error",
1580
+ waitedMs: attempt.waitedMs,
1581
+ ...attempt.party !== void 0 ? { party: attempt.party } : {}
1582
+ });
1583
+ continue;
1584
+ }
1585
+ tickets.push({
1586
+ bot: tickets.length,
1587
+ requested: attempt.index,
1588
+ ticket: attempt.ticket,
1589
+ waitedMs: attempt.waitedMs,
1590
+ ...attempt.party !== void 0 ? { party: attempt.party } : {}
1591
+ });
1592
+ }
1593
+ return { tickets, failures, parties };
1594
+ }
1595
+ function roomsOf(tickets) {
1596
+ const rooms = /* @__PURE__ */ new Map();
1597
+ for (const t of tickets) {
1598
+ const bots = rooms.get(t.ticket.room) ?? [];
1599
+ bots.push(t.bot);
1600
+ rooms.set(t.ticket.room, bots);
1601
+ }
1602
+ return rooms;
1603
+ }
1604
+
1307
1605
  // src/scenario.ts
1308
1606
  function defineScenario(scenario) {
1309
1607
  return scenario;
@@ -1547,6 +1845,11 @@ async function spawnBots(n, options = {}) {
1547
1845
  if (!Number.isInteger(n) || n < 1) {
1548
1846
  throw new Error(`irtio bots: spawnBots needs at least one bot, got ${String(n)}`);
1549
1847
  }
1848
+ if (options.physics !== void 0 && options.physics2d !== void 0) {
1849
+ throw new Error(
1850
+ "irtio: joinRoom was given both { physics } and { physics2d }. A room runs one engine and the client predicts with that one, so pass the option matching the engine your room config declares."
1851
+ );
1852
+ }
1550
1853
  const startedAt = Date.now();
1551
1854
  const thresholds = {
1552
1855
  budgetBytesPerSec: options.budgetBytesPerSec ?? DEFAULT_THRESHOLDS.budgetBytesPerSec,
@@ -1554,7 +1857,8 @@ async function spawnBots(n, options = {}) {
1554
1857
  handlerErrorsMax: options.handlerErrorsMax ?? DEFAULT_THRESHOLDS.handlerErrorsMax,
1555
1858
  mispredictionMagnitudeMax: options.mispredictionMagnitudeMax ?? DEFAULT_THRESHOLDS.mispredictionMagnitudeMax,
1556
1859
  snapsMax: options.snapsMax ?? DEFAULT_THRESHOLDS.snapsMax,
1557
- overrunsMax: options.overrunsMax ?? DEFAULT_THRESHOLDS.overrunsMax
1860
+ overrunsMax: options.overrunsMax ?? DEFAULT_THRESHOLDS.overrunsMax,
1861
+ typedMessageDropsMax: options.typedMessageDropsMax ?? DEFAULT_THRESHOLDS.typedMessageDropsMax
1558
1862
  };
1559
1863
  const ext = options.schema ? withBuiltins2(options.schema) : relaySchema;
1560
1864
  const seed = options.seed ?? DEFAULT_SEED;
@@ -1615,12 +1919,16 @@ async function spawnBots(n, options = {}) {
1615
1919
  ...options.flushMs !== void 0 ? { writeIntervalMs: options.flushMs } : {},
1616
1920
  ...options.rpc !== void 0 ? { rpc: options.rpc } : {},
1617
1921
  ...options.physics !== void 0 ? { physics: options.physics } : {},
1922
+ ...options.physics2d !== void 0 ? { physics2d: options.physics2d } : {},
1618
1923
  ...options.profile === true ? { profile: true } : {}
1619
1924
  }) : joinRelay(common);
1620
1925
  const room = await withJoinTimeout(joining, index, joinTimeoutMs, observer, sockets);
1621
1926
  room.on("correct", (correction) => observer.onCorrection(correction));
1622
1927
  const prediction = room.prediction;
1623
- if (prediction) observer.predictsBody = (c, id) => prediction.predicts(c, id);
1928
+ if (prediction) {
1929
+ observer.bodyKind = (c, id) => prediction.predicts(c, id) ? "predicted" : prediction.proxied(c, id) ? "proxied" : "absent";
1930
+ observer.prediction = prediction;
1931
+ }
1624
1932
  observers[index] = observer;
1625
1933
  return new BotImpl(
1626
1934
  index,
@@ -1632,6 +1940,46 @@ async function spawnBots(n, options = {}) {
1632
1940
  shots
1633
1941
  );
1634
1942
  }
1943
+ if (options.match !== void 0) {
1944
+ const matched = await matchBots(n, options.match);
1945
+ if (matched.tickets.length === 0) {
1946
+ const first2 = matched.failures[0];
1947
+ throw new Error(
1948
+ `irtio bots: no bot got a ticket from the ${options.match.queue ?? "default"} queue at ${options.match.controlUrl}, so there is no room to run in` + (first2 !== void 0 ? ` (${first2.code}: ${first2.message})` : "")
1949
+ );
1950
+ }
1951
+ const joined = await Promise.allSettled(
1952
+ matched.tickets.map((t) => joinOne(t.bot, t.ticket.room))
1953
+ );
1954
+ const failure = joined.find((r) => r.status === "rejected");
1955
+ if (failure !== void 0) {
1956
+ for (const settled of joined) {
1957
+ if (settled.status === "fulfilled") settled.value.room.leave();
1958
+ }
1959
+ throw failure.reason;
1960
+ }
1961
+ for (const settled of joined) {
1962
+ if (settled.status === "fulfilled") bots.push(settled.value);
1963
+ }
1964
+ return finishRun(bots, {
1965
+ roomId: matched.tickets[0]?.ticket.room ?? "",
1966
+ matches: matched.tickets,
1967
+ rooms: roomsOf(matched.tickets),
1968
+ matchFailures: matched.failures,
1969
+ matchmaking: {
1970
+ tickets: matched.tickets.map((t) => ({
1971
+ bot: t.bot,
1972
+ room: t.ticket.room,
1973
+ queue: t.ticket.queue,
1974
+ size: t.ticket.size,
1975
+ backfill: t.ticket.backfill,
1976
+ waitedMs: t.waitedMs,
1977
+ ...t.party !== void 0 ? { party: t.party } : {}
1978
+ })),
1979
+ failures: matched.failures.map((f) => ({ requested: f.requested, code: f.code }))
1980
+ }
1981
+ });
1982
+ }
1635
1983
  const first = await joinOne(0, options.room ?? "");
1636
1984
  bots.push(first);
1637
1985
  const roomId = first.room.id;
@@ -1651,102 +1999,115 @@ async function spawnBots(n, options = {}) {
1651
1999
  if (settled.status === "fulfilled") bots.push(settled.value);
1652
2000
  }
1653
2001
  }
1654
- const scriptErrors = [];
1655
- let firstError;
1656
- const script = options.script;
1657
- const scripts = bots.map(async (bot) => {
1658
- if (!script) {
1659
- await bot.until(() => bot.stopped, { label: "the run to end", timeoutMs: 24 * 36e5 });
1660
- return;
1661
- }
1662
- try {
1663
- await script(bot);
1664
- } catch (error) {
1665
- scriptErrors.push({ bot: bot.index, error });
1666
- firstError ??= error;
1667
- }
2002
+ return finishRun(bots, {
2003
+ roomId,
2004
+ matches: [],
2005
+ rooms: /* @__PURE__ */ new Map([[roomId, bots.map((b) => b.index)]]),
2006
+ matchFailures: []
1668
2007
  });
1669
- const finished = Promise.all(scripts).then(() => void 0);
1670
- let endedBy;
1671
- let deadline;
1672
- if (options.durationMs !== void 0) {
1673
- deadline = setTimeout(() => {
1674
- endedBy ??= "duration";
1675
- for (const bot of bots) bot.stop();
1676
- }, options.durationMs);
1677
- }
1678
- let goneSince = 0;
1679
- let goneTimer;
1680
- if (roomGoneGraceMs > 0) {
1681
- goneTimer = setInterval(() => {
1682
- const down = observers.every(
1683
- (o) => o.lastStatus === "reconnecting" || o.lastStatus === "closed"
1684
- );
1685
- if (!down) {
1686
- goneSince = 0;
2008
+ function finishRun(bots2, placement) {
2009
+ const roomId2 = placement.roomId;
2010
+ const scriptErrors = [];
2011
+ let firstError;
2012
+ const script = options.script;
2013
+ const scripts = bots2.map(async (bot) => {
2014
+ if (!script) {
2015
+ await bot.until(() => bot.stopped, { label: "the run to end", timeoutMs: 24 * 36e5 });
1687
2016
  return;
1688
2017
  }
1689
- goneSince ||= Date.now();
1690
- if (Date.now() - goneSince < roomGoneGraceMs) return;
1691
- endedBy ??= "room-gone";
1692
- for (const bot of bots) bot.stop();
1693
- }, ROOM_GONE_POLL_MS);
1694
- goneTimer.unref?.();
1695
- }
1696
- void finished.then(() => {
1697
- endedBy ??= "scripts";
1698
- if (deadline) clearTimeout(deadline);
1699
- if (goneTimer) clearInterval(goneTimer);
1700
- });
1701
- let stoppedAt;
1702
- let tickHealth;
1703
- const rings = () => observers.map((o) => o.ring);
1704
- let profiles;
1705
- const runner = {
1706
- bots,
1707
- roomId,
1708
- trace: makeTrace(startedAt, rings),
1709
- scriptErrors,
1710
- shots,
1711
- conditions,
1712
- get endedBy() {
1713
- return endedBy;
1714
- },
1715
- [Symbol.iterator]: () => bots[Symbol.iterator](),
1716
- async done() {
1717
- await finished;
1718
- if (firstError !== void 0) throw firstError;
1719
- },
1720
- recordTickHealth(reading) {
1721
- tickHealth = reading;
1722
- },
1723
- report() {
1724
- if (options.profile === true) {
1725
- const live = bots.map((bot) => bot.room.profile?.total()).filter((p) => p !== void 0);
1726
- if (live.length > 0) profiles = live;
2018
+ try {
2019
+ await script(bot);
2020
+ } catch (error) {
2021
+ scriptErrors.push({ bot: bot.index, error });
2022
+ firstError ??= error;
1727
2023
  }
1728
- return buildReport({
1729
- observers,
1730
- roomId,
1731
- durationMs: (stoppedAt ?? Date.now()) - startedAt,
1732
- lags,
1733
- thresholds,
1734
- tickHealth,
1735
- ...profiles !== void 0 ? { profiles } : {}
1736
- });
1737
- },
1738
- async stop() {
2024
+ });
2025
+ const finished = Promise.all(scripts).then(() => void 0);
2026
+ let endedBy;
2027
+ let deadline;
2028
+ if (options.durationMs !== void 0) {
2029
+ deadline = setTimeout(() => {
2030
+ endedBy ??= "duration";
2031
+ for (const bot of bots2) bot.stop();
2032
+ }, options.durationMs);
2033
+ }
2034
+ let goneSince = 0;
2035
+ let goneTimer;
2036
+ if (roomGoneGraceMs > 0) {
2037
+ goneTimer = setInterval(() => {
2038
+ const down = observers.every(
2039
+ (o) => o.lastStatus === "reconnecting" || o.lastStatus === "closed"
2040
+ );
2041
+ if (!down) {
2042
+ goneSince = 0;
2043
+ return;
2044
+ }
2045
+ goneSince ||= Date.now();
2046
+ if (Date.now() - goneSince < roomGoneGraceMs) return;
2047
+ endedBy ??= "room-gone";
2048
+ for (const bot of bots2) bot.stop();
2049
+ }, ROOM_GONE_POLL_MS);
2050
+ goneTimer.unref?.();
2051
+ }
2052
+ void finished.then(() => {
2053
+ endedBy ??= "scripts";
1739
2054
  if (deadline) clearTimeout(deadline);
1740
2055
  if (goneTimer) clearInterval(goneTimer);
1741
- for (const bot of bots) bot.stop();
1742
- await finished;
1743
- stoppedAt ??= Date.now();
1744
- for (const observer of observers) observer.stopping = true;
1745
- for (const bot of bots) bot.room.leave();
1746
- return this.report();
1747
- }
1748
- };
1749
- return runner;
2056
+ });
2057
+ let stoppedAt;
2058
+ let tickHealth;
2059
+ const rings = () => observers.map((o) => o.ring);
2060
+ let profiles;
2061
+ const runner = {
2062
+ bots: bots2,
2063
+ roomId: roomId2,
2064
+ matches: placement.matches,
2065
+ rooms: placement.rooms,
2066
+ matchFailures: placement.matchFailures,
2067
+ trace: makeTrace(startedAt, rings),
2068
+ scriptErrors,
2069
+ shots,
2070
+ conditions,
2071
+ get endedBy() {
2072
+ return endedBy;
2073
+ },
2074
+ [Symbol.iterator]: () => bots2[Symbol.iterator](),
2075
+ async done() {
2076
+ await finished;
2077
+ if (firstError !== void 0) throw firstError;
2078
+ },
2079
+ recordTickHealth(reading) {
2080
+ tickHealth = reading;
2081
+ },
2082
+ report() {
2083
+ if (options.profile === true) {
2084
+ const live = bots2.map((bot) => bot.room.profile?.total()).filter((p) => p !== void 0);
2085
+ if (live.length > 0) profiles = live;
2086
+ }
2087
+ return buildReport({
2088
+ observers,
2089
+ roomId: roomId2,
2090
+ durationMs: (stoppedAt ?? Date.now()) - startedAt,
2091
+ lags,
2092
+ thresholds,
2093
+ tickHealth,
2094
+ ...placement.matchmaking !== void 0 ? { matchmaking: placement.matchmaking } : {},
2095
+ ...profiles !== void 0 ? { profiles } : {}
2096
+ });
2097
+ },
2098
+ async stop() {
2099
+ if (deadline) clearTimeout(deadline);
2100
+ if (goneTimer) clearInterval(goneTimer);
2101
+ for (const bot of bots2) bot.stop();
2102
+ await finished;
2103
+ stoppedAt ??= Date.now();
2104
+ for (const observer of observers) observer.stopping = true;
2105
+ for (const bot of bots2) bot.room.leave();
2106
+ return this.report();
2107
+ }
2108
+ };
2109
+ return runner;
2110
+ }
1750
2111
  }
1751
2112
 
1752
2113
  // src/timeline.ts
@@ -1882,6 +2243,7 @@ function makeTimeline(dump) {
1882
2243
  export {
1883
2244
  BotObserver,
1884
2245
  DEFAULT_JOIN_TIMEOUT_MS,
2246
+ DEFAULT_MATCH_TIMEOUT_MS,
1885
2247
  DEFAULT_REORDER_MS,
1886
2248
  DEFAULT_ROOM_GONE_GRACE_MS,
1887
2249
  DEFAULT_SEED,
@@ -1911,10 +2273,13 @@ export {
1911
2273
  makeRng,
1912
2274
  makeTimeline,
1913
2275
  makeTrace,
2276
+ matchBots,
2277
+ matchmakingSummary,
1914
2278
  newConditionCounters,
1915
2279
  nextValue,
1916
2280
  randomScript,
1917
2281
  relayEchoScript,
2282
+ roomsOf,
1918
2283
  snapshotVisibilityLeaks,
1919
2284
  spawnBots
1920
2285
  };