@irtio/bots 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +917 -63
  2. package/dist/index.js +1114 -144
  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) {
@@ -110,29 +118,390 @@ function frameVisibilityLeaks(ext, role, type, payload, spatial) {
110
118
  return deltaVisibilityLeaks(ext, role, decodeDelta(ext, payload), spatial);
111
119
  }
112
120
 
121
+ // src/conditions.ts
122
+ import { FrameType as FrameType2 } from "@irtio/protocol";
123
+ var STATE_FRAMES = /* @__PURE__ */ new Set([
124
+ FrameType2.WRITE,
125
+ FrameType2.DELTA,
126
+ FrameType2.CORRECT,
127
+ FrameType2.MSG
128
+ ]);
129
+ var DUPLICATE_GAP_MS = 1;
130
+ var DEFAULT_REORDER_MS = 50;
131
+ function newConditionCounters() {
132
+ return {
133
+ droppedOut: 0,
134
+ droppedIn: 0,
135
+ duplicatedOut: 0,
136
+ duplicatedIn: 0,
137
+ reorderedOut: 0,
138
+ reorderedIn: 0,
139
+ delayed: 0
140
+ };
141
+ }
142
+ function hasConditions(c) {
143
+ if (!c) return false;
144
+ return (c.rttMs ?? 0) > 0 || (c.jitterMs ?? 0) > 0 || (c.loss ?? 0) > 0 || (c.duplicate ?? 0) > 0 || (c.reorder ?? 0) > 0;
145
+ }
146
+ function describeConditions(c) {
147
+ const parts = [];
148
+ if ((c.rttMs ?? 0) > 0) parts.push(`rtt ${c.rttMs} ms`);
149
+ if ((c.jitterMs ?? 0) > 0) parts.push(`jitter ${c.jitterMs} ms`);
150
+ if ((c.loss ?? 0) > 0) parts.push(`loss ${pct(c.loss ?? 0)}`);
151
+ if ((c.duplicate ?? 0) > 0) parts.push(`duplicate ${pct(c.duplicate ?? 0)}`);
152
+ if ((c.reorder ?? 0) > 0) {
153
+ parts.push(`reorder ${pct(c.reorder ?? 0)} within ${c.reorderMs ?? DEFAULT_REORDER_MS} ms`);
154
+ }
155
+ return parts.length === 0 ? "none" : parts.join(", ");
156
+ }
157
+ function pct(v) {
158
+ return `${Math.round(v * 1e3) / 10}%`;
159
+ }
160
+ var realTimers = {
161
+ now: () => Date.now(),
162
+ setTimeout(fn, ms) {
163
+ const timer = setTimeout(fn, ms);
164
+ timer.unref?.();
165
+ return () => clearTimeout(timer);
166
+ }
167
+ };
168
+ function conditionedTransport(base, conditions, rng, options = {}) {
169
+ const counters = options.counters ?? newConditionCounters();
170
+ const timers = options.timers ?? realTimers;
171
+ const rttMs = Math.max(0, conditions.rttMs ?? 0);
172
+ const jitterMs = Math.max(0, conditions.jitterMs ?? 0);
173
+ const loss = clamp01(conditions.loss ?? 0);
174
+ const duplicate = clamp01(conditions.duplicate ?? 0);
175
+ const reorder = clamp01(conditions.reorder ?? 0);
176
+ const reorderMs = Math.max(0, conditions.reorderMs ?? DEFAULT_REORDER_MS);
177
+ return {
178
+ connect(url) {
179
+ const socket = base.connect(url);
180
+ const cancels = /* @__PURE__ */ new Set();
181
+ let closed = false;
182
+ let nextIn = 0;
183
+ let nextOut = 0;
184
+ function schedule(delayMs, run) {
185
+ if (closed) return;
186
+ counters.delayed++;
187
+ let cancel = () => void 0;
188
+ const fire = () => {
189
+ cancels.delete(cancel);
190
+ if (!closed) run();
191
+ };
192
+ if (delayMs <= 0) {
193
+ cancel = timers.setTimeout(fire, 0);
194
+ } else {
195
+ cancel = timers.setTimeout(fire, delayMs);
196
+ }
197
+ cancels.add(cancel);
198
+ }
199
+ function deliver(dir, bytes, run) {
200
+ const type = bytes.length > 0 ? bytes[0] ?? -1 : -1;
201
+ const stateful = STATE_FRAMES.has(type);
202
+ if (stateful && loss > 0 && rng.next() < loss) {
203
+ if (dir === "in") counters.droppedIn++;
204
+ else counters.droppedOut++;
205
+ return;
206
+ }
207
+ const now = timers.now();
208
+ let at = now + rttMs / 2 + (jitterMs > 0 ? rng.next() * jitterMs : 0);
209
+ const reordered = stateful && reorder > 0 && rng.next() < reorder;
210
+ if (reordered) {
211
+ at += reorderMs > 0 ? rng.next() * reorderMs : 0;
212
+ if (dir === "in") counters.reorderedIn++;
213
+ else counters.reorderedOut++;
214
+ } else {
215
+ const floor = dir === "in" ? nextIn : nextOut;
216
+ at = Math.max(at, floor);
217
+ if (dir === "in") nextIn = at;
218
+ else nextOut = at;
219
+ }
220
+ schedule(at - now, run);
221
+ if (stateful && duplicate > 0 && rng.next() < duplicate) {
222
+ if (dir === "in") counters.duplicatedIn++;
223
+ else counters.duplicatedOut++;
224
+ schedule(at - now + DUPLICATE_GAP_MS, run);
225
+ }
226
+ }
227
+ const wrapper = {
228
+ onopen: null,
229
+ onmessage: null,
230
+ onclose: null,
231
+ onerror: null,
232
+ send(bytes) {
233
+ const copy = bytes.slice();
234
+ deliver("out", copy, () => socket.send(copy));
235
+ },
236
+ close() {
237
+ closed = true;
238
+ for (const cancel of cancels) cancel();
239
+ cancels.clear();
240
+ socket.close();
241
+ }
242
+ };
243
+ socket.onopen = () => wrapper.onopen?.();
244
+ socket.onmessage = (bytes) => {
245
+ const copy = bytes.slice();
246
+ deliver("in", copy, () => wrapper.onmessage?.(copy));
247
+ };
248
+ socket.onclose = (info) => {
249
+ closed = true;
250
+ for (const cancel of cancels) cancel();
251
+ cancels.clear();
252
+ wrapper.onclose?.(info);
253
+ };
254
+ socket.onerror = (error) => wrapper.onerror?.(error);
255
+ return wrapper;
256
+ }
257
+ };
258
+ }
259
+ function clamp01(v) {
260
+ return Math.min(1, Math.max(0, v));
261
+ }
262
+
263
+ // src/hits.ts
264
+ function entityValue(frameState, collection, id) {
265
+ const table = frameState[collection];
266
+ if (typeof table !== "object" || table === null) return void 0;
267
+ const record = table[id];
268
+ if (typeof record !== "object" || record === null) return void 0;
269
+ const value = record.value;
270
+ return typeof value === "object" && value !== null ? value : void 0;
271
+ }
272
+ function correlateShots(shots, dump) {
273
+ const frames = dump.frames;
274
+ return shots.map((shot) => {
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;
279
+ if (frame === void 0) {
280
+ return {
281
+ ...shot,
282
+ serverTick: exact ? shot.serverTick : void 0,
283
+ estimated,
284
+ rewound,
285
+ authoritative: void 0,
286
+ missDistance: void 0,
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})`
288
+ };
289
+ }
290
+ const value = entityValue(frame.state, shot.collection, shot.target);
291
+ if (value === void 0) {
292
+ return {
293
+ ...shot,
294
+ serverTick: frame.tick,
295
+ estimated,
296
+ rewound,
297
+ authoritative: void 0,
298
+ missDistance: void 0,
299
+ unresolved: `${shot.collection}.${shot.target} is not in the recording at tick ${frame.tick}`
300
+ };
301
+ }
302
+ const authoritative = {};
303
+ let sum = 0;
304
+ let missing;
305
+ for (const [field, aimed] of Object.entries(shot.aim)) {
306
+ const actual = value[field];
307
+ if (typeof actual !== "number") {
308
+ missing ??= `${shot.collection}.${shot.target}.${field} is not a number on the timeline`;
309
+ continue;
310
+ }
311
+ authoritative[field] = actual;
312
+ sum += (actual - aimed) ** 2;
313
+ }
314
+ if (missing !== void 0) {
315
+ return {
316
+ ...shot,
317
+ serverTick: frame.tick,
318
+ estimated,
319
+ rewound,
320
+ authoritative,
321
+ missDistance: void 0,
322
+ unresolved: missing
323
+ };
324
+ }
325
+ return {
326
+ ...shot,
327
+ serverTick: frame.tick,
328
+ estimated,
329
+ rewound,
330
+ authoritative,
331
+ missDistance: Math.sqrt(sum)
332
+ };
333
+ });
334
+ }
335
+
336
+ // src/truth.ts
337
+ function captureClientState(schema, state) {
338
+ const view = state ?? {};
339
+ const out = {};
340
+ for (const desc of schema.collections) {
341
+ const raw = view[desc.name];
342
+ if (raw === void 0) continue;
343
+ const collection = asCollection(raw);
344
+ if (collection) {
345
+ const records = {};
346
+ for (const id of collection.ids()) {
347
+ const value = collection.get(id);
348
+ if (value === void 0) continue;
349
+ records[id] = { owner: collection.ownerOf(id), value: plain(value) };
350
+ }
351
+ out[desc.name] = records;
352
+ } else {
353
+ out[desc.name] = plain(raw);
354
+ }
355
+ }
356
+ return out;
357
+ }
358
+ function asCollection(value) {
359
+ return value !== null && typeof value === "object" && typeof value.ownerOf === "function" ? value : void 0;
360
+ }
361
+ function plain(value) {
362
+ if (value === null || typeof value !== "object") return value;
363
+ const out = {};
364
+ for (const [k, v] of Object.entries(value)) out[k] = plain(v);
365
+ return out;
366
+ }
367
+ function diffAgainstSave(clients, saveState, meta) {
368
+ const bots = [];
369
+ const all = [];
370
+ let compared = 0;
371
+ for (const client of clients) {
372
+ const differences = [];
373
+ let seen = 0;
374
+ for (const [collection, held] of Object.entries(client.state)) {
375
+ const authoritative = saveState[collection];
376
+ if (isTable(held)) {
377
+ const table = isTable(authoritative) ? authoritative : {};
378
+ for (const [id, record] of Object.entries(held)) {
379
+ seen++;
380
+ const mine = record;
381
+ const theirs = table[id];
382
+ if (theirs === void 0) {
383
+ differences.push({
384
+ bot: client.bot,
385
+ collection,
386
+ id,
387
+ client: mine.value,
388
+ save: void 0,
389
+ detail: `bot ${client.bot} holds ${collection}.${id}, which the save does not have. A client holding an entity authority does not is a desync, not a visibility gap.`
390
+ });
391
+ continue;
392
+ }
393
+ if (mine.owner !== theirs.owner) {
394
+ differences.push({
395
+ bot: client.bot,
396
+ collection,
397
+ id,
398
+ field: "owner",
399
+ client: mine.owner,
400
+ save: theirs.owner,
401
+ detail: `bot ${client.bot} has ${collection}.${id} owned by ${String(mine.owner)}, the save says ${String(theirs.owner)}`
402
+ });
403
+ }
404
+ compareValues(client.bot, collection, id, mine.value, theirs.value, differences);
405
+ }
406
+ } else {
407
+ seen++;
408
+ compareValues(client.bot, collection, void 0, held, authoritative, differences);
409
+ }
410
+ }
411
+ compared += seen;
412
+ bots.push({ bot: client.bot, compared: seen, differences, ok: differences.length === 0 });
413
+ all.push(...differences);
414
+ }
415
+ return {
416
+ ok: all.length === 0,
417
+ bots,
418
+ compared,
419
+ differences: all,
420
+ saveTick: meta.saveTick,
421
+ saveVersion: meta.saveVersion
422
+ };
423
+ }
424
+ function isTable(value) {
425
+ return typeof value === "object" && value !== null && !Array.isArray(value);
426
+ }
427
+ function compareValues(bot, collection, id, client, save, out) {
428
+ const where = id === void 0 ? collection : `${collection}.${id}`;
429
+ if (!isTable(client)) {
430
+ if (!same(client, save)) {
431
+ out.push({
432
+ bot,
433
+ collection,
434
+ ...id !== void 0 ? { id } : {},
435
+ client,
436
+ save,
437
+ detail: `bot ${bot} has ${where} = ${show(client)}, the save has ${show(save)}`
438
+ });
439
+ }
440
+ return;
441
+ }
442
+ const theirs = isTable(save) ? save : void 0;
443
+ for (const [field, value] of Object.entries(client)) {
444
+ const actual = theirs?.[field];
445
+ if (same(value, actual)) continue;
446
+ out.push({
447
+ bot,
448
+ collection,
449
+ ...id !== void 0 ? { id } : {},
450
+ field,
451
+ client: value,
452
+ save: actual,
453
+ detail: `bot ${bot} has ${where}.${field} = ${show(value)}, the save has ${show(actual)}`
454
+ });
455
+ }
456
+ }
457
+ function same(a, b) {
458
+ if (a === b) return true;
459
+ if (typeof a === "number" && typeof b === "number") {
460
+ return Number.isNaN(a) && Number.isNaN(b);
461
+ }
462
+ if (isTable(a) && isTable(b)) {
463
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
464
+ for (const k of keys) if (!same(a[k], b[k])) return false;
465
+ return true;
466
+ }
467
+ return false;
468
+ }
469
+ function show(value) {
470
+ if (value === void 0) return "nothing";
471
+ if (typeof value === "number") return String(Math.round(value * 1e3) / 1e3);
472
+ if (typeof value === "string") return JSON.stringify(value);
473
+ if (isTable(value)) return JSON.stringify(value);
474
+ return String(value);
475
+ }
476
+
113
477
  // src/observer.ts
114
478
  import {
115
- FrameType as FrameType3,
479
+ FrameType as FrameType4,
116
480
  decodeErrorPayload,
117
481
  decodeFrame,
482
+ decodeMsg,
118
483
  decodeReply,
119
484
  decodeWelcome,
120
485
  errorByCode,
121
- readCorrectClientTick
486
+ readCorrectClientTick,
487
+ readSchemaPayload,
488
+ withBuiltins
122
489
  } from "@irtio/protocol";
123
490
  import {
124
491
  ByteReader,
125
492
  applyDelta,
126
493
  decodeDelta as decodeDelta2,
127
494
  decodeDeltaFrom,
128
- decodeSnapshot
495
+ decodeFields,
496
+ decodeSnapshot,
497
+ schemaFromCanonical
129
498
  } from "@irtio/schema";
130
499
 
131
500
  // src/trace.ts
132
501
  import { writeFile } from "fs/promises";
133
- import { FrameType as FrameType2 } from "@irtio/protocol";
502
+ import { FrameType as FrameType3 } from "@irtio/protocol";
134
503
  var FRAME_NAMES = new Map(
135
- Object.entries(FrameType2).map(([name, value]) => [value, name])
504
+ Object.entries(FrameType3).map(([name, value]) => [value, name])
136
505
  );
137
506
  function frameName(type) {
138
507
  return FRAME_NAMES.get(type) ?? `UNKNOWN(${type})`;
@@ -192,27 +561,38 @@ function makeTrace(startedAt, rings) {
192
561
  }
193
562
 
194
563
  // src/observer.ts
195
- function simulationOnly(ext, delta, predicts) {
564
+ function simulationOnly(ext, delta, kindOf) {
196
565
  let sawSync = false;
197
566
  let sawPredicted = false;
567
+ let sawProxied = false;
198
568
  for (const dc of delta.collections) {
199
569
  const desc = ext.collections.find((c) => c.name === dc.name);
200
570
  const physics = desc?.physics;
201
571
  if (!desc || !physics) return "mixed";
202
572
  for (const op of dc.ops) {
203
573
  if (op.op !== "update") return "mixed";
204
- const predicted = predicts(dc.name, op.id);
574
+ const kind = kindOf(dc.name, op.id);
575
+ const predicted = kind === "predicted";
205
576
  for (const index of op.mask.fields) {
206
577
  const field = desc.fields[index];
207
- if (!field || !physics.bodyFields.has(field.name)) return "mixed";
208
- if (predicted) sawPredicted = true;
209
- else sawSync = true;
578
+ if (!field) return "mixed";
579
+ if (physics.bodyFields.has(field.name)) {
580
+ if (predicted) sawPredicted = true;
581
+ else if (kind === "proxied") sawProxied = true;
582
+ else sawSync = true;
583
+ } else if (!predicted && !physics.intents.includes(field.name)) {
584
+ sawSync = true;
585
+ } else {
586
+ return "mixed";
587
+ }
210
588
  }
211
589
  }
212
590
  }
213
- if (sawPredicted && !sawSync) return "predicted";
214
- if (sawSync && !sawPredicted) return "sync";
215
- 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";
216
596
  }
217
597
  var WriteLog = class {
218
598
  constructor(limit = 4096) {
@@ -262,11 +642,22 @@ var BotObserver = class {
262
642
  constructor(options) {
263
643
  this.options = options;
264
644
  this.ring = new TraceRing(options.traceLimit);
645
+ this.ext = options.ext;
265
646
  }
266
647
  options;
267
648
  ring;
268
649
  violations = /* @__PURE__ */ new Map();
269
650
  counts = /* @__PURE__ */ new Map();
651
+ /**
652
+ * Bug #28: the decode extension, *mutable*. It starts as `options.ext` (the schema the run was
653
+ * spawned with) and is rebuilt in place when an inbound `SCHEMA` frame (13) lands — a D50
654
+ * additive `migrate` swaps the wire mid-session, the real client rebuilds its own decoders
655
+ * (`session.swapSchema`), and an observer still holding the v1 descriptor underruns on the v2
656
+ * resync WELCOME and fails `schema-validity` on a swap that was perfect.
657
+ */
658
+ ext;
659
+ /** How many `SCHEMA` frames rebuilt {@link ext}. For tests and the trace, like the client's. */
660
+ schemaSwaps = 0;
270
661
  id = "";
271
662
  role = "";
272
663
  roomId = "";
@@ -284,14 +675,29 @@ var BotObserver = class {
284
675
  bytesOut = 0;
285
676
  corrections = 0;
286
677
  syncCorrections = 0;
678
+ proxiedCorrections = 0;
287
679
  suppressedCorrections = 0;
288
680
  mispredictions = 0;
289
681
  /**
290
- * Does this bot's client currently predict `collection[id]` in a local world? Assigned by
291
- * `spawnBots` once the room exists (`room.prediction`); prediction status is client-local, so
292
- * 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.
293
694
  */
294
- 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;
295
701
  mispredictionMagnitude = 0;
296
702
  mispredictionMax = 0;
297
703
  snaps = 0;
@@ -300,6 +706,10 @@ var BotObserver = class {
300
706
  disconnects = 0;
301
707
  peakBytesInPerSec = 0;
302
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;
303
713
  /**
304
714
  * Spatial-grid ops actually put to the AOI policy. Zero on a run whose room has no spatial
305
715
  * collection; zero on a run that *does* and would mean the invariant passed vacuously, which is
@@ -335,6 +745,7 @@ var BotObserver = class {
335
745
  bytesOut: this.bytesOut,
336
746
  corrections: this.corrections,
337
747
  syncCorrections: this.syncCorrections,
748
+ proxiedCorrections: this.proxiedCorrections,
338
749
  suppressedCorrections: this.suppressedCorrections,
339
750
  mispredictions: this.mispredictions,
340
751
  mispredictionMagnitude: this.mispredictionMagnitude,
@@ -344,7 +755,13 @@ var BotObserver = class {
344
755
  errors: this.errors,
345
756
  disconnects: this.disconnects,
346
757
  peakBytesInPerSec: this.peakBytesInPerSec,
347
- 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
348
765
  };
349
766
  }
350
767
  /** Counts a violation always; keeps an example, rate-limited per invariant when asked. */
@@ -370,6 +787,12 @@ var BotObserver = class {
370
787
  /** The `onFrame` hook. `bytes` is the whole frame, envelope byte included. */
371
788
  onFrame(dir, type, bytes) {
372
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
+ }
373
796
  let note;
374
797
  if (dir === "in") {
375
798
  this.framesIn++;
@@ -403,44 +826,101 @@ var BotObserver = class {
403
826
  ...note !== void 0 ? { note } : {}
404
827
  };
405
828
  this.ring.push(entry);
406
- if (dir === "in" && type === FrameType3.CORRECT) this.lastCorrectEntry = entry;
829
+ if (dir === "in" && type === FrameType4.CORRECT) this.lastCorrectEntry = entry;
407
830
  }
408
831
  /** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
409
832
  inspect(dir, type, bytes, now) {
410
833
  const payload = decodeFrame(bytes).payload;
411
834
  if (dir === "out") {
412
- if (type === FrameType3.CALL) this.calls++;
413
- if (type === FrameType3.WRITE) this.recordWrites(decodeDelta2(this.options.ext, payload), now);
835
+ if (type === FrameType4.CALL) this.calls++;
836
+ if (type === FrameType4.WRITE) this.recordWrites(decodeDelta2(this.ext, payload), now);
837
+ if (type === FrameType4.MSG) return this.onMsg("out", payload);
414
838
  return void 0;
415
839
  }
416
840
  switch (type) {
417
- case FrameType3.WELCOME:
841
+ case FrameType4.WELCOME:
418
842
  return this.onWelcome(decodeWelcome(payload));
419
- case FrameType3.DELTA:
420
- return this.onDelta(decodeDelta2(this.options.ext, payload), now);
421
- case FrameType3.CORRECT: {
843
+ case FrameType4.DELTA:
844
+ return this.onDelta(decodeDelta2(this.ext, payload), now);
845
+ case FrameType4.CORRECT: {
422
846
  const r = new ByteReader(payload);
423
- const delta = decodeDeltaFrom(this.options.ext, r);
847
+ const delta = decodeDeltaFrom(this.ext, r);
424
848
  return this.onCorrect(delta, readCorrectClientTick(r), now);
425
849
  }
426
- case FrameType3.ERROR:
850
+ case FrameType4.ERROR:
427
851
  return this.onError(payload);
428
- case FrameType3.REPLY:
852
+ case FrameType4.REPLY:
429
853
  return this.onReply(payload);
854
+ case FrameType4.SCHEMA:
855
+ return this.onSchema(payload);
856
+ case FrameType4.MSG:
857
+ return this.onMsg("in", payload);
430
858
  default:
431
859
  return void 0;
432
860
  }
433
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
+ }
434
914
  onWelcome(welcome) {
435
915
  this.id = welcome.clientId;
436
916
  this.role = welcome.role;
437
917
  this.roomId = welcome.roomId;
438
- const snapshot = decodeSnapshot(this.options.ext, welcome.snapshot);
918
+ const snapshot = decodeSnapshot(this.ext, welcome.snapshot);
439
919
  this.view = snapshot.state;
440
920
  this.rememberSnapshot(snapshot.state);
441
921
  this.countSpatialSnapshot(snapshot.state);
442
922
  for (const leak of snapshotVisibilityLeaks(
443
- this.options.ext,
923
+ this.ext,
444
924
  this.role,
445
925
  snapshot.state,
446
926
  this.spatialContext()
@@ -449,6 +929,22 @@ var BotObserver = class {
449
929
  }
450
930
  return `joined ${welcome.roomId} as ${welcome.clientId}/${welcome.role || "default"}`;
451
931
  }
932
+ /**
933
+ * Bug #28: a `SCHEMA` frame (D50 additive migrate). Rebuild the decode extension from the
934
+ * descriptor the frame carries, exactly as the real client's `swapSchema` does, so the resync
935
+ * WELCOME about to arrive decodes under the schema it was encoded with. The old view and
936
+ * seen-ids are dropped — they were laid out by descriptors that no longer exist, and the resync
937
+ * WELCOME re-seeds both (`onWelcome`).
938
+ */
939
+ onSchema(payload) {
940
+ const canonical = readSchemaPayload(payload);
941
+ const next = schemaFromCanonical(canonical);
942
+ this.ext = withBuiltins(next);
943
+ this.schemaSwaps++;
944
+ this.view = void 0;
945
+ this.seen.clear();
946
+ return `schema swapped (hash ${next.hash}) \u2014 decode extension rebuilt`;
947
+ }
452
948
  onDelta(delta, now) {
453
949
  this.checkVisibility(delta);
454
950
  this.matchWrites(delta, now);
@@ -458,14 +954,18 @@ var BotObserver = class {
458
954
  this.checkVisibility(delta);
459
955
  const names = delta.collections.map((c) => c.name).join(",");
460
956
  const kind = simulationOnly(
461
- this.options.ext,
957
+ this.ext,
462
958
  delta,
463
- (c, id) => this.predictsBody ? this.predictsBody(c, id) : false
959
+ (c, id) => this.bodyKind ? this.bodyKind(c, id) : "absent"
464
960
  );
465
961
  if (kind === "sync") {
466
962
  this.syncCorrections++;
467
963
  return `synced ${names}`;
468
964
  }
965
+ if (kind === "proxied") {
966
+ this.proxiedCorrections++;
967
+ return `proxied ${names}`;
968
+ }
469
969
  if (kind === "predicted") {
470
970
  return `judged ${names}${clientTick !== void 0 ? ` (clientTick ${clientTick})` : ""}`;
471
971
  }
@@ -479,7 +979,20 @@ var BotObserver = class {
479
979
  true
480
980
  );
481
981
  }
482
- return `corrected ${names}${clientTick !== void 0 ? ` (clientTick ${clientTick})` : ""}`;
982
+ const fields = /* @__PURE__ */ new Set();
983
+ for (const dc of delta.collections) {
984
+ const desc = this.ext.collections.find(
985
+ (c) => c.name === dc.name
986
+ );
987
+ for (const op of dc.ops) {
988
+ if (op.op !== "update") {
989
+ fields.add(`${op.op} ${op.id}`);
990
+ continue;
991
+ }
992
+ for (const index of op.mask.fields) fields.add(desc?.fields[index]?.name ?? `#${index}`);
993
+ }
994
+ }
995
+ return `corrected ${names} [${[...fields].join(" ")}]${clientTick !== void 0 ? ` (clientTick ${clientTick})` : ""}`;
483
996
  }
484
997
  /**
485
998
  * The room's `correct` event, one per correction op — the only source of `previous` (the local
@@ -492,7 +1005,7 @@ var BotObserver = class {
492
1005
  this.suppressedCorrections++;
493
1006
  return;
494
1007
  }
495
- const desc = this.options.ext.collections.find(
1008
+ const desc = this.ext.collections.find(
496
1009
  (c) => c.name === correction.collection
497
1010
  );
498
1011
  const predictedBody = desc?.physics !== void 0 && correction.fields.length > 0 && correction.fields.every((f) => desc.physics?.bodyFields.has(f));
@@ -560,14 +1073,9 @@ var BotObserver = class {
560
1073
  * frame — a departing neighbour, not a leak.
561
1074
  */
562
1075
  checkVisibility(delta) {
563
- if (this.view) applyDelta(this.options.ext, this.view, delta);
1076
+ if (this.view) applyDelta(this.ext, this.view, delta);
564
1077
  this.countSpatialDelta(delta);
565
- for (const leak of deltaVisibilityLeaks(
566
- this.options.ext,
567
- this.role,
568
- delta,
569
- this.spatialContext()
570
- )) {
1078
+ for (const leak of deltaVisibilityLeaks(this.ext, this.role, delta, this.spatialContext())) {
571
1079
  this.violate("visibility-leak", leak);
572
1080
  }
573
1081
  this.remember(delta);
@@ -575,12 +1083,12 @@ var BotObserver = class {
575
1083
  countSpatialDelta(delta) {
576
1084
  if (!this.view || this.id === "") return;
577
1085
  for (const collection of delta.collections) {
578
- const desc = this.options.ext.collection(collection.name);
1086
+ const desc = this.ext.collection(collection.name);
579
1087
  if (desc.visibility === "spatial-grid") this.spatialOpsJudged += collection.ops.length;
580
1088
  }
581
1089
  }
582
1090
  countSpatialSnapshot(state) {
583
- for (const desc of this.options.ext.collections) {
1091
+ for (const desc of this.ext.collections) {
584
1092
  if (desc.visibility !== "spatial-grid") continue;
585
1093
  this.spatialOpsJudged += state[desc.name]?.size ?? 0;
586
1094
  }
@@ -594,7 +1102,7 @@ var BotObserver = class {
594
1102
  return ids;
595
1103
  }
596
1104
  rememberSnapshot(state) {
597
- for (const desc of this.options.ext.collections) {
1105
+ for (const desc of this.ext.collections) {
598
1106
  if (desc.kind !== "entity") continue;
599
1107
  const collection = state[desc.name];
600
1108
  if (!collection) continue;
@@ -619,6 +1127,7 @@ var BotObserver = class {
619
1127
  } catch {
620
1128
  name = `code ${error.code}`;
621
1129
  }
1130
+ if (name === "E_STARTING") return `${name} (room starting \u2014 not counted)`;
622
1131
  if (!error.fatal) {
623
1132
  this.errors++;
624
1133
  this.violate("handler-error", `${name}: ${error.message}`);
@@ -747,6 +1256,59 @@ function freshValue(rng, desc, ctx) {
747
1256
  }
748
1257
 
749
1258
  // src/report.ts
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
+ }
750
1312
  function percentile(sorted, p) {
751
1313
  if (sorted.length === 0) return 0;
752
1314
  const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1));
@@ -796,9 +1358,12 @@ function detailFor(name, observers, violations, thresholds) {
796
1358
  case "correction-storm": {
797
1359
  const corrections = observers.reduce((sum, o) => sum + o.corrections, 0);
798
1360
  const synced = observers.reduce((sum, o) => sum + o.syncCorrections, 0);
1361
+ const proxied = observers.reduce((sum, o) => sum + o.proxiedCorrections, 0);
799
1362
  const suppressed = observers.reduce((sum, o) => sum + o.suppressedCorrections, 0);
800
1363
  const worst = peak(observers, (o) => o.peakCorrectionsPerSec);
801
- 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)}`;
802
1367
  }
803
1368
  case "misprediction": {
804
1369
  const count = observers.reduce((sum, o) => sum + o.mispredictions, 0);
@@ -818,7 +1383,14 @@ function detailFor(name, observers, violations, thresholds) {
818
1383
  const count = observers.reduce((sum, o) => sum + o.disconnects, 0);
819
1384
  return count === 0 ? "every bot stayed connected" : `${count} disconnect(s)${examples(observers, name)}`;
820
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
+ }
821
1392
  case "tick-health":
1393
+ case "party-integrity":
822
1394
  return "";
823
1395
  }
824
1396
  }
@@ -857,10 +1429,19 @@ function buildReport(options) {
857
1429
  const thresholds = options.thresholds ?? DEFAULT_THRESHOLDS;
858
1430
  const durationMs = Math.max(1, options.durationMs);
859
1431
  const seconds = durationMs / 1e3;
860
- 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) => {
861
1437
  if (name === "tick-health") return tickHealthResult(options.tickHealth, thresholds);
1438
+ if (name === "party-integrity") return partyIntegrityResult(matchmaking);
862
1439
  const violations = total(observers, name);
863
- 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
+ );
864
1445
  const state = ok ? "ok" : "violation";
865
1446
  return {
866
1447
  name,
@@ -878,16 +1459,26 @@ function buildReport(options) {
878
1459
  bytesOut: perBot.reduce((sum, b) => sum + b.bytesOut, 0),
879
1460
  corrections: perBot.reduce((sum, b) => sum + b.corrections, 0),
880
1461
  syncCorrections: perBot.reduce((sum, b) => sum + b.syncCorrections, 0),
1462
+ proxiedCorrections: perBot.reduce((sum, b) => sum + b.proxiedCorrections, 0),
881
1463
  suppressedCorrections: perBot.reduce((sum, b) => sum + b.suppressedCorrections, 0),
882
1464
  mispredictions: perBot.reduce((sum, b) => sum + b.mispredictions, 0),
883
1465
  mispredictionMagnitude: perBot.reduce((sum, b) => sum + b.mispredictionMagnitude, 0),
884
1466
  mispredictionMax: perBot.reduce((max, b) => Math.max(max, b.mispredictionMax), 0),
885
1467
  snaps: perBot.reduce((sum, b) => sum + b.snaps, 0),
886
1468
  calls: perBot.reduce((sum, b) => sum + b.calls, 0),
887
- 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)
888
1473
  };
889
1474
  const bots = Math.max(1, perBot.length);
890
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
+ };
891
1482
  return {
892
1483
  ok: invariants.every((i) => i.ok),
893
1484
  bots: perBot.length,
@@ -901,10 +1492,125 @@ function buildReport(options) {
901
1492
  bytesOutPerSecondPerBot: round(totals.bytesOut / seconds / bots),
902
1493
  convergence,
903
1494
  convergenceLagMs: convergence?.p50Ms,
904
- tracePath: options.tracePath
1495
+ tracePath: options.tracePath,
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) } : {}
905
1499
  };
906
1500
  }
907
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
+
1605
+ // src/scenario.ts
1606
+ function defineScenario(scenario) {
1607
+ return scenario;
1608
+ }
1609
+ function looksLikeScenario(value) {
1610
+ const s = value;
1611
+ return typeof s === "object" && s !== null && Number.isInteger(s.bots) && s.bots > 0 && typeof s.script === "function" && typeof s.assert === "function";
1612
+ }
1613
+
908
1614
  // src/script.ts
909
1615
  import { rpcTable } from "@irtio/protocol";
910
1616
  function ownableCollections(schema) {
@@ -920,11 +1626,16 @@ function writableFields(desc, cheat) {
920
1626
  function voidServerRpcs(schema) {
921
1627
  return rpcTable(schema).filter((r) => r.direction === "server" && r.returns === void 0);
922
1628
  }
923
- function asCollection(value) {
1629
+ function asCollection2(value) {
924
1630
  return value && typeof value.ownerOf === "function" ? value : void 0;
925
1631
  }
1632
+ function cheatPredicate(cheat) {
1633
+ if (cheat === void 0) return () => false;
1634
+ if (typeof cheat === "function") return (index) => cheat(index) === true;
1635
+ return () => cheat;
1636
+ }
926
1637
  function randomScript(schema, options = {}) {
927
- const cheat = options.cheat ?? false;
1638
+ const cheats = cheatPredicate(options.cheat);
928
1639
  const stepMs = options.stepMs ?? 50;
929
1640
  const step = options.step ?? 8;
930
1641
  const rpcChance = options.rpcChance ?? 0.05;
@@ -932,24 +1643,25 @@ function randomScript(schema, options = {}) {
932
1643
  const collections = ownableCollections(schema);
933
1644
  const rpcs = voidServerRpcs(schema);
934
1645
  return async (bot) => {
1646
+ const cheat = cheats(bot.index);
935
1647
  const room = bot.room;
936
1648
  const origins = /* @__PURE__ */ new Map();
937
1649
  while (!bot.stopped) {
938
1650
  for (const desc of collections) {
939
- const collection = asCollection(room.state[desc.name]);
1651
+ const collection = asCollection2(room.state[desc.name]);
940
1652
  if (!collection) continue;
941
1653
  for (const id of [...collection.ids()]) {
942
1654
  if (collection.ownerOf(id) !== room.me) continue;
943
1655
  const instance = collection.get(id);
944
1656
  if (!instance) continue;
945
- writeFields(bot, desc, id, instance, origins);
1657
+ writeFields(bot, desc, id, instance, origins, cheat);
946
1658
  }
947
1659
  }
948
1660
  if (rpcs.length > 0 && bot.rng.chance(rpcChance)) await callRandomRpc(bot, room.call, rpcs);
949
1661
  await bot.wait(stepMs);
950
1662
  }
951
1663
  };
952
- function writeFields(bot, desc, id, instance, origins) {
1664
+ function writeFields(bot, desc, id, instance, origins, cheat) {
953
1665
  const fields = writableFields(desc, cheat);
954
1666
  if (fields.length === 0) return;
955
1667
  const count = Math.min(fieldsPerStep, fields.length);
@@ -999,7 +1711,7 @@ import {
999
1711
  joinRoom,
1000
1712
  webSocketTransport
1001
1713
  } from "@irtio/client";
1002
- import { relaySchema, withBuiltins } from "@irtio/protocol";
1714
+ import { relaySchema, withBuiltins as withBuiltins2 } from "@irtio/protocol";
1003
1715
  var DEFAULT_SEED = 96016;
1004
1716
  var DEFAULT_TRACE_LIMIT = 4096;
1005
1717
  var DEFAULT_JOIN_TIMEOUT_MS = 2e4;
@@ -1010,20 +1722,34 @@ function per(value, index) {
1010
1722
  return typeof value === "function" ? value(index) : value;
1011
1723
  }
1012
1724
  var BotImpl = class {
1013
- constructor(index, room, observer, seed, startedAt) {
1725
+ constructor(index, room, observer, seed, startedAt, conditions, shots) {
1014
1726
  this.index = index;
1015
1727
  this.room = room;
1016
1728
  this.observer = observer;
1017
1729
  this.startedAt = startedAt;
1730
+ this.conditions = conditions;
1731
+ this.shots = shots;
1018
1732
  this.rng = makeRng(seed);
1019
1733
  }
1020
1734
  index;
1021
1735
  room;
1022
1736
  observer;
1023
1737
  startedAt;
1738
+ conditions;
1739
+ shots;
1024
1740
  stopped = false;
1025
1741
  rng;
1026
1742
  stopListeners = /* @__PURE__ */ new Set();
1743
+ shot(request) {
1744
+ const tick = this.room.tick;
1745
+ this.shots.push({
1746
+ ...request,
1747
+ bot: this.index,
1748
+ sentAt: Date.now(),
1749
+ clientTick: typeof tick === "number" ? tick : void 0,
1750
+ uplinkMs: (this.conditions?.rttMs ?? 0) / 2
1751
+ });
1752
+ }
1027
1753
  get id() {
1028
1754
  return this.observer.id;
1029
1755
  }
@@ -1114,10 +1840,16 @@ async function withJoinTimeout(joining, index, timeoutMs, observer, sockets) {
1114
1840
  if (timer !== void 0) clearTimeout(timer);
1115
1841
  }
1116
1842
  }
1843
+ var CONDITIONS_SEED_OFFSET = 377022;
1117
1844
  async function spawnBots(n, options = {}) {
1118
1845
  if (!Number.isInteger(n) || n < 1) {
1119
1846
  throw new Error(`irtio bots: spawnBots needs at least one bot, got ${String(n)}`);
1120
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
+ }
1121
1853
  const startedAt = Date.now();
1122
1854
  const thresholds = {
1123
1855
  budgetBytesPerSec: options.budgetBytesPerSec ?? DEFAULT_THRESHOLDS.budgetBytesPerSec,
@@ -1125,15 +1857,18 @@ async function spawnBots(n, options = {}) {
1125
1857
  handlerErrorsMax: options.handlerErrorsMax ?? DEFAULT_THRESHOLDS.handlerErrorsMax,
1126
1858
  mispredictionMagnitudeMax: options.mispredictionMagnitudeMax ?? DEFAULT_THRESHOLDS.mispredictionMagnitudeMax,
1127
1859
  snapsMax: options.snapsMax ?? DEFAULT_THRESHOLDS.snapsMax,
1128
- overrunsMax: options.overrunsMax ?? DEFAULT_THRESHOLDS.overrunsMax
1860
+ overrunsMax: options.overrunsMax ?? DEFAULT_THRESHOLDS.overrunsMax,
1861
+ typedMessageDropsMax: options.typedMessageDropsMax ?? DEFAULT_THRESHOLDS.typedMessageDropsMax
1129
1862
  };
1130
- const ext = options.schema ? withBuiltins(options.schema) : relaySchema;
1863
+ const ext = options.schema ? withBuiltins2(options.schema) : relaySchema;
1131
1864
  const seed = options.seed ?? DEFAULT_SEED;
1132
1865
  const traceLimit = options.traceLimit ?? DEFAULT_TRACE_LIMIT;
1133
1866
  const writeLog = new WriteLog();
1134
1867
  const lags = [];
1135
1868
  const observers = [];
1136
1869
  const bots = [];
1870
+ const shots = [];
1871
+ const conditions = [];
1137
1872
  const joinTimeoutMs = options.joinTimeoutMs ?? DEFAULT_JOIN_TIMEOUT_MS;
1138
1873
  const roomGoneGraceMs = options.roomGoneGraceMs ?? DEFAULT_ROOM_GONE_GRACE_MS;
1139
1874
  const baseTransport = options.transport ?? webSocketTransport;
@@ -1150,13 +1885,25 @@ async function spawnBots(n, options = {}) {
1150
1885
  const role = per(options.role, index);
1151
1886
  const name = per(options.name, index);
1152
1887
  const sockets = [];
1153
- const transport = {
1888
+ const asked = per(options.conditions, index);
1889
+ const injected = hasConditions(asked) ? asked : void 0;
1890
+ const counters = newConditionCounters();
1891
+ conditions[index] = { bot: index, conditions: injected, counters };
1892
+ const recording = {
1154
1893
  connect(url) {
1155
1894
  const socket = baseTransport.connect(url);
1156
1895
  sockets.push(socket);
1157
1896
  return socket;
1158
1897
  }
1159
1898
  };
1899
+ const transport = injected === void 0 ? recording : conditionedTransport(
1900
+ recording,
1901
+ injected,
1902
+ makeRng(seed + index + CONDITIONS_SEED_OFFSET),
1903
+ {
1904
+ counters
1905
+ }
1906
+ );
1160
1907
  const common = {
1161
1908
  room: roomId2,
1162
1909
  ...options.url !== void 0 ? { url: options.url } : {},
@@ -1171,14 +1918,67 @@ async function spawnBots(n, options = {}) {
1171
1918
  ...common,
1172
1919
  ...options.flushMs !== void 0 ? { writeIntervalMs: options.flushMs } : {},
1173
1920
  ...options.rpc !== void 0 ? { rpc: options.rpc } : {},
1174
- ...options.physics !== void 0 ? { physics: options.physics } : {}
1921
+ ...options.physics !== void 0 ? { physics: options.physics } : {},
1922
+ ...options.physics2d !== void 0 ? { physics2d: options.physics2d } : {},
1923
+ ...options.profile === true ? { profile: true } : {}
1175
1924
  }) : joinRelay(common);
1176
1925
  const room = await withJoinTimeout(joining, index, joinTimeoutMs, observer, sockets);
1177
1926
  room.on("correct", (correction) => observer.onCorrection(correction));
1178
1927
  const prediction = room.prediction;
1179
- 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
+ }
1180
1932
  observers[index] = observer;
1181
- return new BotImpl(index, room, observer, seed + index, startedAt);
1933
+ return new BotImpl(
1934
+ index,
1935
+ room,
1936
+ observer,
1937
+ seed + index,
1938
+ startedAt,
1939
+ injected,
1940
+ shots
1941
+ );
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
+ });
1182
1982
  }
1183
1983
  const first = await joinOne(0, options.room ?? "");
1184
1984
  bots.push(first);
@@ -1199,117 +1999,287 @@ async function spawnBots(n, options = {}) {
1199
1999
  if (settled.status === "fulfilled") bots.push(settled.value);
1200
2000
  }
1201
2001
  }
1202
- const scriptErrors = [];
1203
- let firstError;
1204
- const script = options.script;
1205
- const scripts = bots.map(async (bot) => {
1206
- if (!script) {
1207
- await bot.until(() => bot.stopped, { label: "the run to end", timeoutMs: 24 * 36e5 });
1208
- return;
1209
- }
1210
- try {
1211
- await script(bot);
1212
- } catch (error) {
1213
- scriptErrors.push({ bot: bot.index, error });
1214
- firstError ??= error;
1215
- }
2002
+ return finishRun(bots, {
2003
+ roomId,
2004
+ matches: [],
2005
+ rooms: /* @__PURE__ */ new Map([[roomId, bots.map((b) => b.index)]]),
2006
+ matchFailures: []
1216
2007
  });
1217
- const finished = Promise.all(scripts).then(() => void 0);
1218
- let endedBy;
1219
- let deadline;
1220
- if (options.durationMs !== void 0) {
1221
- deadline = setTimeout(() => {
1222
- endedBy ??= "duration";
1223
- for (const bot of bots) bot.stop();
1224
- }, options.durationMs);
1225
- }
1226
- let goneSince = 0;
1227
- let goneTimer;
1228
- if (roomGoneGraceMs > 0) {
1229
- goneTimer = setInterval(() => {
1230
- const down = observers.every(
1231
- (o) => o.lastStatus === "reconnecting" || o.lastStatus === "closed"
1232
- );
1233
- if (!down) {
1234
- 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 });
1235
2016
  return;
1236
2017
  }
1237
- goneSince ||= Date.now();
1238
- if (Date.now() - goneSince < roomGoneGraceMs) return;
1239
- endedBy ??= "room-gone";
1240
- for (const bot of bots) bot.stop();
1241
- }, ROOM_GONE_POLL_MS);
1242
- goneTimer.unref?.();
1243
- }
1244
- void finished.then(() => {
1245
- endedBy ??= "scripts";
1246
- if (deadline) clearTimeout(deadline);
1247
- if (goneTimer) clearInterval(goneTimer);
1248
- });
1249
- let stoppedAt;
1250
- let tickHealth;
1251
- const rings = () => observers.map((o) => o.ring);
1252
- const runner = {
1253
- bots,
1254
- roomId,
1255
- trace: makeTrace(startedAt, rings),
1256
- scriptErrors,
1257
- get endedBy() {
1258
- return endedBy;
1259
- },
1260
- [Symbol.iterator]: () => bots[Symbol.iterator](),
1261
- async done() {
1262
- await finished;
1263
- if (firstError !== void 0) throw firstError;
2018
+ try {
2019
+ await script(bot);
2020
+ } catch (error) {
2021
+ scriptErrors.push({ bot: bot.index, error });
2022
+ firstError ??= error;
2023
+ }
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";
2054
+ if (deadline) clearTimeout(deadline);
2055
+ if (goneTimer) clearInterval(goneTimer);
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
+ }
2111
+ }
2112
+
2113
+ // src/timeline.ts
2114
+ var Collection = class {
2115
+ constructor(raw, entity) {
2116
+ this.raw = raw;
2117
+ this.entity = entity;
2118
+ }
2119
+ raw;
2120
+ entity;
2121
+ entry(id) {
2122
+ if (!this.entity) return void 0;
2123
+ const row = this.raw[id];
2124
+ return typeof row === "object" && row !== null ? row : void 0;
2125
+ }
2126
+ get(id) {
2127
+ const value = this.entry(id)?.value;
2128
+ return typeof value === "object" && value !== null ? value : void 0;
2129
+ }
2130
+ has(id) {
2131
+ return this.entity && Object.hasOwn(this.raw, id);
2132
+ }
2133
+ ids() {
2134
+ return this.entity ? Object.keys(this.raw) : [];
2135
+ }
2136
+ get size() {
2137
+ return this.entity ? Object.keys(this.raw).length : 0;
2138
+ }
2139
+ owner(id) {
2140
+ const owner = this.entry(id)?.owner;
2141
+ return typeof owner === "string" && owner !== "" ? owner : void 0;
2142
+ }
2143
+ get value() {
2144
+ return this.entity ? void 0 : this.raw;
2145
+ }
2146
+ };
2147
+ var UnknownCollectionError = class extends Error {
2148
+ name = "UnknownCollectionError";
2149
+ constructor(collection, known) {
2150
+ super(
2151
+ `irtio scenario: this room has no collection named "${collection}". It has: ${known.length > 0 ? known.join(", ") : "none"}.`
2152
+ );
2153
+ }
2154
+ };
2155
+ var TickNotRecordedError = class extends Error {
2156
+ constructor(tick, first, last, dropped) {
2157
+ super(
2158
+ first === void 0 ? `irtio scenario: nothing was recorded, so tick ${tick} cannot be read.` : `irtio scenario: tick ${tick} is not in the recording, which holds ticks ${first} to ${last}` + (dropped > 0 ? `. ${dropped} earlier tick(s) were dropped by the recorder's cap, so raise it or shorten the run.` : ".")
2159
+ );
2160
+ this.tick = tick;
2161
+ }
2162
+ tick;
2163
+ name = "TickNotRecordedError";
2164
+ };
2165
+ function momentOf(dump, index, read) {
2166
+ const frame = dump.frames[index];
2167
+ read(frame.tick);
2168
+ const known = Object.keys(dump.collections);
2169
+ const cache = /* @__PURE__ */ new Map();
2170
+ return new Proxy({}, {
2171
+ get(_target, property) {
2172
+ if (typeof property !== "string") return void 0;
2173
+ const cached = cache.get(property);
2174
+ if (cached) return cached;
2175
+ const kind = dump.collections[property];
2176
+ if (kind === void 0) throw new UnknownCollectionError(property, known);
2177
+ const raw = frame.state[property];
2178
+ const view = new Collection(
2179
+ typeof raw === "object" && raw !== null ? raw : {},
2180
+ kind === "entity"
2181
+ );
2182
+ cache.set(property, view);
2183
+ return view;
1264
2184
  },
1265
- recordTickHealth(reading) {
1266
- tickHealth = reading;
2185
+ has: (_target, property) => typeof property === "string" && property in dump.collections,
2186
+ ownKeys: () => [...known],
2187
+ getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true })
2188
+ });
2189
+ }
2190
+ function makeTimeline(dump) {
2191
+ const index = /* @__PURE__ */ new Map();
2192
+ dump.frames.forEach((f, i) => index.set(f.tick, i));
2193
+ const ticks = dump.frames.map((f) => f.tick);
2194
+ const results = [];
2195
+ let readTicks = [];
2196
+ const read = (tick) => {
2197
+ readTicks.push(tick);
2198
+ };
2199
+ const at = (tick) => {
2200
+ const i = index.get(tick);
2201
+ if (i === void 0) {
2202
+ throw new TickNotRecordedError(tick, ticks[0], ticks[ticks.length - 1], dump.dropped);
2203
+ }
2204
+ return momentOf(dump, i, read);
2205
+ };
2206
+ return {
2207
+ roomId: dump.roomId,
2208
+ ticks,
2209
+ dropped: dump.dropped,
2210
+ at,
2211
+ find(match) {
2212
+ for (let i = 0; i < dump.frames.length; i++) {
2213
+ const tick = dump.frames[i].tick;
2214
+ const state = momentOf(dump, i, read);
2215
+ if (match(state, tick)) return { tick, state };
2216
+ }
2217
+ return void 0;
1267
2218
  },
1268
- report() {
1269
- return buildReport({
1270
- observers,
1271
- roomId,
1272
- durationMs: (stoppedAt ?? Date.now()) - startedAt,
1273
- lags,
1274
- thresholds,
1275
- tickHealth
1276
- });
2219
+ check(name, assertion) {
2220
+ readTicks = [];
2221
+ try {
2222
+ assertion();
2223
+ results.push({
2224
+ name,
2225
+ ok: true,
2226
+ ...readTicks.length > 0 ? { tick: readTicks[readTicks.length - 1] } : {},
2227
+ detail: readTicks.length === 0 ? "held (read no tick)" : `held, reading tick ${readTicks[readTicks.length - 1]}` + (readTicks.length > 1 ? ` (${readTicks.length} ticks read)` : "")
2228
+ });
2229
+ } catch (err) {
2230
+ results.push({
2231
+ name,
2232
+ ok: false,
2233
+ ...readTicks.length > 0 ? { tick: readTicks[readTicks.length - 1] } : {},
2234
+ detail: err instanceof Error ? err.message : String(err)
2235
+ });
2236
+ }
1277
2237
  },
1278
- async stop() {
1279
- if (deadline) clearTimeout(deadline);
1280
- if (goneTimer) clearInterval(goneTimer);
1281
- for (const bot of bots) bot.stop();
1282
- await finished;
1283
- stoppedAt ??= Date.now();
1284
- for (const observer of observers) observer.stopping = true;
1285
- for (const bot of bots) bot.room.leave();
1286
- return this.report();
2238
+ get results() {
2239
+ return results;
1287
2240
  }
1288
2241
  };
1289
- return runner;
1290
2242
  }
1291
2243
  export {
1292
2244
  BotObserver,
1293
2245
  DEFAULT_JOIN_TIMEOUT_MS,
2246
+ DEFAULT_MATCH_TIMEOUT_MS,
2247
+ DEFAULT_REORDER_MS,
1294
2248
  DEFAULT_ROOM_GONE_GRACE_MS,
1295
2249
  DEFAULT_SEED,
1296
2250
  DEFAULT_THRESHOLDS,
1297
2251
  DEFAULT_TRACE_LIMIT,
1298
2252
  INVARIANT_NAMES,
1299
2253
  JoinTimeoutError,
2254
+ TickNotRecordedError,
1300
2255
  TraceRing,
2256
+ UnknownCollectionError,
1301
2257
  WriteLog,
1302
2258
  buildReport,
2259
+ captureClientState,
2260
+ cheatPredicate,
2261
+ conditionedTransport,
1303
2262
  convergenceStats,
2263
+ correlateShots,
2264
+ defineScenario,
1304
2265
  deltaVisibilityLeaks,
2266
+ describeConditions,
2267
+ diffAgainstSave,
1305
2268
  frameName,
1306
2269
  frameVisibilityLeaks,
1307
2270
  freshValue,
2271
+ hasConditions,
2272
+ looksLikeScenario,
1308
2273
  makeRng,
2274
+ makeTimeline,
1309
2275
  makeTrace,
2276
+ matchBots,
2277
+ matchmakingSummary,
2278
+ newConditionCounters,
1310
2279
  nextValue,
1311
2280
  randomScript,
1312
2281
  relayEchoScript,
2282
+ roomsOf,
1313
2283
  snapshotVisibilityLeaks,
1314
2284
  spawnBots
1315
2285
  };