@irtio/bots 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +652 -58
  2. package/dist/index.js +811 -56
  3. package/package.json +5 -5
package/dist/index.js CHANGED
@@ -13,14 +13,16 @@ var INVARIANT_NAMES = [
13
13
  "correction-storm",
14
14
  "misprediction",
15
15
  "snaps",
16
- "disconnects"
16
+ "disconnects",
17
+ "tick-health"
17
18
  ];
18
19
  var DEFAULT_THRESHOLDS = {
19
20
  budgetBytesPerSec: 128e3,
20
21
  correctionsPerSecMax: 5,
21
22
  handlerErrorsMax: 0,
22
23
  mispredictionMagnitudeMax: Number.POSITIVE_INFINITY,
23
- snapsMax: Number.POSITIVE_INFINITY
24
+ snapsMax: Number.POSITIVE_INFINITY,
25
+ overrunsMax: 0
24
26
  };
25
27
  var widenedSchemas = /* @__PURE__ */ new WeakMap();
26
28
  function widenGrids(ext, slack) {
@@ -108,29 +110,382 @@ function frameVisibilityLeaks(ext, role, type, payload, spatial) {
108
110
  return deltaVisibilityLeaks(ext, role, decodeDelta(ext, payload), spatial);
109
111
  }
110
112
 
113
+ // src/conditions.ts
114
+ import { FrameType as FrameType2 } from "@irtio/protocol";
115
+ var STATE_FRAMES = /* @__PURE__ */ new Set([
116
+ FrameType2.WRITE,
117
+ FrameType2.DELTA,
118
+ FrameType2.CORRECT,
119
+ FrameType2.MSG
120
+ ]);
121
+ var DUPLICATE_GAP_MS = 1;
122
+ var DEFAULT_REORDER_MS = 50;
123
+ function newConditionCounters() {
124
+ return {
125
+ droppedOut: 0,
126
+ droppedIn: 0,
127
+ duplicatedOut: 0,
128
+ duplicatedIn: 0,
129
+ reorderedOut: 0,
130
+ reorderedIn: 0,
131
+ delayed: 0
132
+ };
133
+ }
134
+ function hasConditions(c) {
135
+ if (!c) return false;
136
+ return (c.rttMs ?? 0) > 0 || (c.jitterMs ?? 0) > 0 || (c.loss ?? 0) > 0 || (c.duplicate ?? 0) > 0 || (c.reorder ?? 0) > 0;
137
+ }
138
+ function describeConditions(c) {
139
+ const parts = [];
140
+ if ((c.rttMs ?? 0) > 0) parts.push(`rtt ${c.rttMs} ms`);
141
+ if ((c.jitterMs ?? 0) > 0) parts.push(`jitter ${c.jitterMs} ms`);
142
+ if ((c.loss ?? 0) > 0) parts.push(`loss ${pct(c.loss ?? 0)}`);
143
+ if ((c.duplicate ?? 0) > 0) parts.push(`duplicate ${pct(c.duplicate ?? 0)}`);
144
+ if ((c.reorder ?? 0) > 0) {
145
+ parts.push(`reorder ${pct(c.reorder ?? 0)} within ${c.reorderMs ?? DEFAULT_REORDER_MS} ms`);
146
+ }
147
+ return parts.length === 0 ? "none" : parts.join(", ");
148
+ }
149
+ function pct(v) {
150
+ return `${Math.round(v * 1e3) / 10}%`;
151
+ }
152
+ var realTimers = {
153
+ now: () => Date.now(),
154
+ setTimeout(fn, ms) {
155
+ const timer = setTimeout(fn, ms);
156
+ timer.unref?.();
157
+ return () => clearTimeout(timer);
158
+ }
159
+ };
160
+ function conditionedTransport(base, conditions, rng, options = {}) {
161
+ const counters = options.counters ?? newConditionCounters();
162
+ const timers = options.timers ?? realTimers;
163
+ const rttMs = Math.max(0, conditions.rttMs ?? 0);
164
+ const jitterMs = Math.max(0, conditions.jitterMs ?? 0);
165
+ const loss = clamp01(conditions.loss ?? 0);
166
+ const duplicate = clamp01(conditions.duplicate ?? 0);
167
+ const reorder = clamp01(conditions.reorder ?? 0);
168
+ const reorderMs = Math.max(0, conditions.reorderMs ?? DEFAULT_REORDER_MS);
169
+ return {
170
+ connect(url) {
171
+ const socket = base.connect(url);
172
+ const cancels = /* @__PURE__ */ new Set();
173
+ let closed = false;
174
+ let nextIn = 0;
175
+ let nextOut = 0;
176
+ function schedule(delayMs, run) {
177
+ if (closed) return;
178
+ counters.delayed++;
179
+ let cancel = () => void 0;
180
+ const fire = () => {
181
+ cancels.delete(cancel);
182
+ if (!closed) run();
183
+ };
184
+ if (delayMs <= 0) {
185
+ cancel = timers.setTimeout(fire, 0);
186
+ } else {
187
+ cancel = timers.setTimeout(fire, delayMs);
188
+ }
189
+ cancels.add(cancel);
190
+ }
191
+ function deliver(dir, bytes, run) {
192
+ const type = bytes.length > 0 ? bytes[0] ?? -1 : -1;
193
+ const stateful = STATE_FRAMES.has(type);
194
+ if (stateful && loss > 0 && rng.next() < loss) {
195
+ if (dir === "in") counters.droppedIn++;
196
+ else counters.droppedOut++;
197
+ return;
198
+ }
199
+ const now = timers.now();
200
+ let at = now + rttMs / 2 + (jitterMs > 0 ? rng.next() * jitterMs : 0);
201
+ const reordered = stateful && reorder > 0 && rng.next() < reorder;
202
+ if (reordered) {
203
+ at += reorderMs > 0 ? rng.next() * reorderMs : 0;
204
+ if (dir === "in") counters.reorderedIn++;
205
+ else counters.reorderedOut++;
206
+ } else {
207
+ const floor = dir === "in" ? nextIn : nextOut;
208
+ at = Math.max(at, floor);
209
+ if (dir === "in") nextIn = at;
210
+ else nextOut = at;
211
+ }
212
+ schedule(at - now, run);
213
+ if (stateful && duplicate > 0 && rng.next() < duplicate) {
214
+ if (dir === "in") counters.duplicatedIn++;
215
+ else counters.duplicatedOut++;
216
+ schedule(at - now + DUPLICATE_GAP_MS, run);
217
+ }
218
+ }
219
+ const wrapper = {
220
+ onopen: null,
221
+ onmessage: null,
222
+ onclose: null,
223
+ onerror: null,
224
+ send(bytes) {
225
+ const copy = bytes.slice();
226
+ deliver("out", copy, () => socket.send(copy));
227
+ },
228
+ close() {
229
+ closed = true;
230
+ for (const cancel of cancels) cancel();
231
+ cancels.clear();
232
+ socket.close();
233
+ }
234
+ };
235
+ socket.onopen = () => wrapper.onopen?.();
236
+ socket.onmessage = (bytes) => {
237
+ const copy = bytes.slice();
238
+ deliver("in", copy, () => wrapper.onmessage?.(copy));
239
+ };
240
+ socket.onclose = (info) => {
241
+ closed = true;
242
+ for (const cancel of cancels) cancel();
243
+ cancels.clear();
244
+ wrapper.onclose?.(info);
245
+ };
246
+ socket.onerror = (error) => wrapper.onerror?.(error);
247
+ return wrapper;
248
+ }
249
+ };
250
+ }
251
+ function clamp01(v) {
252
+ return Math.min(1, Math.max(0, v));
253
+ }
254
+
255
+ // src/hits.ts
256
+ function entityValue(frameState, collection, id) {
257
+ const table = frameState[collection];
258
+ if (typeof table !== "object" || table === null) return void 0;
259
+ const record = table[id];
260
+ if (typeof record !== "object" || record === null) return void 0;
261
+ const value = record.value;
262
+ return typeof value === "object" && value !== null ? value : void 0;
263
+ }
264
+ function correlateShots(shots, dump) {
265
+ const frames = dump.frames;
266
+ return shots.map((shot) => {
267
+ const arrivedAt = shot.sentAt + shot.uplinkMs;
268
+ const frame = frames.find((f) => f.at >= arrivedAt);
269
+ if (frame === void 0) {
270
+ return {
271
+ ...shot,
272
+ serverTick: void 0,
273
+ estimated: shot.uplinkMs > 0,
274
+ authoritative: void 0,
275
+ 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})`
277
+ };
278
+ }
279
+ const value = entityValue(frame.state, shot.collection, shot.target);
280
+ if (value === void 0) {
281
+ return {
282
+ ...shot,
283
+ serverTick: frame.tick,
284
+ estimated: shot.uplinkMs > 0,
285
+ authoritative: void 0,
286
+ missDistance: void 0,
287
+ unresolved: `${shot.collection}.${shot.target} is not in the recording at tick ${frame.tick}`
288
+ };
289
+ }
290
+ const authoritative = {};
291
+ let sum = 0;
292
+ let missing;
293
+ for (const [field, aimed] of Object.entries(shot.aim)) {
294
+ const actual = value[field];
295
+ if (typeof actual !== "number") {
296
+ missing ??= `${shot.collection}.${shot.target}.${field} is not a number on the timeline`;
297
+ continue;
298
+ }
299
+ authoritative[field] = actual;
300
+ sum += (actual - aimed) ** 2;
301
+ }
302
+ if (missing !== void 0) {
303
+ return {
304
+ ...shot,
305
+ serverTick: frame.tick,
306
+ estimated: shot.uplinkMs > 0,
307
+ authoritative,
308
+ missDistance: void 0,
309
+ unresolved: missing
310
+ };
311
+ }
312
+ return {
313
+ ...shot,
314
+ serverTick: frame.tick,
315
+ estimated: shot.uplinkMs > 0,
316
+ authoritative,
317
+ missDistance: Math.sqrt(sum)
318
+ };
319
+ });
320
+ }
321
+
322
+ // src/truth.ts
323
+ function captureClientState(schema, state) {
324
+ const view = state ?? {};
325
+ const out = {};
326
+ for (const desc of schema.collections) {
327
+ const raw = view[desc.name];
328
+ if (raw === void 0) continue;
329
+ const collection = asCollection(raw);
330
+ if (collection) {
331
+ const records = {};
332
+ for (const id of collection.ids()) {
333
+ const value = collection.get(id);
334
+ if (value === void 0) continue;
335
+ records[id] = { owner: collection.ownerOf(id), value: plain(value) };
336
+ }
337
+ out[desc.name] = records;
338
+ } else {
339
+ out[desc.name] = plain(raw);
340
+ }
341
+ }
342
+ return out;
343
+ }
344
+ function asCollection(value) {
345
+ return value !== null && typeof value === "object" && typeof value.ownerOf === "function" ? value : void 0;
346
+ }
347
+ function plain(value) {
348
+ if (value === null || typeof value !== "object") return value;
349
+ const out = {};
350
+ for (const [k, v] of Object.entries(value)) out[k] = plain(v);
351
+ return out;
352
+ }
353
+ function diffAgainstSave(clients, saveState, meta) {
354
+ const bots = [];
355
+ const all = [];
356
+ let compared = 0;
357
+ for (const client of clients) {
358
+ const differences = [];
359
+ let seen = 0;
360
+ for (const [collection, held] of Object.entries(client.state)) {
361
+ const authoritative = saveState[collection];
362
+ if (isTable(held)) {
363
+ const table = isTable(authoritative) ? authoritative : {};
364
+ for (const [id, record] of Object.entries(held)) {
365
+ seen++;
366
+ const mine = record;
367
+ const theirs = table[id];
368
+ if (theirs === void 0) {
369
+ differences.push({
370
+ bot: client.bot,
371
+ collection,
372
+ id,
373
+ client: mine.value,
374
+ save: void 0,
375
+ 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.`
376
+ });
377
+ continue;
378
+ }
379
+ if (mine.owner !== theirs.owner) {
380
+ differences.push({
381
+ bot: client.bot,
382
+ collection,
383
+ id,
384
+ field: "owner",
385
+ client: mine.owner,
386
+ save: theirs.owner,
387
+ detail: `bot ${client.bot} has ${collection}.${id} owned by ${String(mine.owner)}, the save says ${String(theirs.owner)}`
388
+ });
389
+ }
390
+ compareValues(client.bot, collection, id, mine.value, theirs.value, differences);
391
+ }
392
+ } else {
393
+ seen++;
394
+ compareValues(client.bot, collection, void 0, held, authoritative, differences);
395
+ }
396
+ }
397
+ compared += seen;
398
+ bots.push({ bot: client.bot, compared: seen, differences, ok: differences.length === 0 });
399
+ all.push(...differences);
400
+ }
401
+ return {
402
+ ok: all.length === 0,
403
+ bots,
404
+ compared,
405
+ differences: all,
406
+ saveTick: meta.saveTick,
407
+ saveVersion: meta.saveVersion
408
+ };
409
+ }
410
+ function isTable(value) {
411
+ return typeof value === "object" && value !== null && !Array.isArray(value);
412
+ }
413
+ function compareValues(bot, collection, id, client, save, out) {
414
+ const where = id === void 0 ? collection : `${collection}.${id}`;
415
+ if (!isTable(client)) {
416
+ if (!same(client, save)) {
417
+ out.push({
418
+ bot,
419
+ collection,
420
+ ...id !== void 0 ? { id } : {},
421
+ client,
422
+ save,
423
+ detail: `bot ${bot} has ${where} = ${show(client)}, the save has ${show(save)}`
424
+ });
425
+ }
426
+ return;
427
+ }
428
+ const theirs = isTable(save) ? save : void 0;
429
+ for (const [field, value] of Object.entries(client)) {
430
+ const actual = theirs?.[field];
431
+ if (same(value, actual)) continue;
432
+ out.push({
433
+ bot,
434
+ collection,
435
+ ...id !== void 0 ? { id } : {},
436
+ field,
437
+ client: value,
438
+ save: actual,
439
+ detail: `bot ${bot} has ${where}.${field} = ${show(value)}, the save has ${show(actual)}`
440
+ });
441
+ }
442
+ }
443
+ function same(a, b) {
444
+ if (a === b) return true;
445
+ if (typeof a === "number" && typeof b === "number") {
446
+ return Number.isNaN(a) && Number.isNaN(b);
447
+ }
448
+ if (isTable(a) && isTable(b)) {
449
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
450
+ for (const k of keys) if (!same(a[k], b[k])) return false;
451
+ return true;
452
+ }
453
+ return false;
454
+ }
455
+ function show(value) {
456
+ if (value === void 0) return "nothing";
457
+ if (typeof value === "number") return String(Math.round(value * 1e3) / 1e3);
458
+ if (typeof value === "string") return JSON.stringify(value);
459
+ if (isTable(value)) return JSON.stringify(value);
460
+ return String(value);
461
+ }
462
+
111
463
  // src/observer.ts
112
464
  import {
113
- FrameType as FrameType3,
465
+ FrameType as FrameType4,
114
466
  decodeErrorPayload,
115
467
  decodeFrame,
116
468
  decodeReply,
117
469
  decodeWelcome,
118
470
  errorByCode,
119
- readCorrectClientTick
471
+ readCorrectClientTick,
472
+ readSchemaPayload,
473
+ withBuiltins
120
474
  } from "@irtio/protocol";
121
475
  import {
122
476
  ByteReader,
123
477
  applyDelta,
124
478
  decodeDelta as decodeDelta2,
125
479
  decodeDeltaFrom,
126
- decodeSnapshot
480
+ decodeSnapshot,
481
+ schemaFromCanonical
127
482
  } from "@irtio/schema";
128
483
 
129
484
  // src/trace.ts
130
485
  import { writeFile } from "fs/promises";
131
- import { FrameType as FrameType2 } from "@irtio/protocol";
486
+ import { FrameType as FrameType3 } from "@irtio/protocol";
132
487
  var FRAME_NAMES = new Map(
133
- Object.entries(FrameType2).map(([name, value]) => [value, name])
488
+ Object.entries(FrameType3).map(([name, value]) => [value, name])
134
489
  );
135
490
  function frameName(type) {
136
491
  return FRAME_NAMES.get(type) ?? `UNKNOWN(${type})`;
@@ -202,9 +557,15 @@ function simulationOnly(ext, delta, predicts) {
202
557
  const predicted = predicts(dc.name, op.id);
203
558
  for (const index of op.mask.fields) {
204
559
  const field = desc.fields[index];
205
- if (!field || !physics.bodyFields.has(field.name)) return "mixed";
206
- if (predicted) sawPredicted = true;
207
- else sawSync = true;
560
+ if (!field) return "mixed";
561
+ if (physics.bodyFields.has(field.name)) {
562
+ if (predicted) sawPredicted = true;
563
+ else sawSync = true;
564
+ } else if (!predicted && !physics.intents.includes(field.name)) {
565
+ sawSync = true;
566
+ } else {
567
+ return "mixed";
568
+ }
208
569
  }
209
570
  }
210
571
  }
@@ -260,16 +621,33 @@ var BotObserver = class {
260
621
  constructor(options) {
261
622
  this.options = options;
262
623
  this.ring = new TraceRing(options.traceLimit);
624
+ this.ext = options.ext;
263
625
  }
264
626
  options;
265
627
  ring;
266
628
  violations = /* @__PURE__ */ new Map();
267
629
  counts = /* @__PURE__ */ new Map();
630
+ /**
631
+ * Bug #28: the decode extension, *mutable*. It starts as `options.ext` (the schema the run was
632
+ * spawned with) and is rebuilt in place when an inbound `SCHEMA` frame (13) lands — a D50
633
+ * additive `migrate` swaps the wire mid-session, the real client rebuilds its own decoders
634
+ * (`session.swapSchema`), and an observer still holding the v1 descriptor underruns on the v2
635
+ * resync WELCOME and fails `schema-validity` on a swap that was perfect.
636
+ */
637
+ ext;
638
+ /** How many `SCHEMA` frames rebuilt {@link ext}. For tests and the trace, like the client's. */
639
+ schemaSwaps = 0;
268
640
  id = "";
269
641
  role = "";
270
642
  roomId = "";
271
643
  /** Set before `leave()`, so a deliberate teardown is not reported as a disconnect. */
272
644
  stopping = false;
645
+ /**
646
+ * D36: the last connection status this bot's client reported, recorded whatever it was. This
647
+ * is the *fact* of where the connection is — the room-gone watcher and the join-timeout
648
+ * message read it — while `disconnects` below stays the judgement, with its old rules.
649
+ */
650
+ lastStatus = "connecting";
273
651
  framesIn = 0;
274
652
  framesOut = 0;
275
653
  bytesIn = 0;
@@ -353,6 +731,7 @@ var BotObserver = class {
353
731
  }
354
732
  /** `room.on('status')`: transitions away from a healthy connection, ignored during teardown. */
355
733
  onStatus(status) {
734
+ this.lastStatus = status;
356
735
  if (this.stopping) return;
357
736
  if (status !== "reconnecting" && status !== "closed") return;
358
737
  this.disconnects++;
@@ -394,30 +773,32 @@ var BotObserver = class {
394
773
  ...note !== void 0 ? { note } : {}
395
774
  };
396
775
  this.ring.push(entry);
397
- if (dir === "in" && type === FrameType3.CORRECT) this.lastCorrectEntry = entry;
776
+ if (dir === "in" && type === FrameType4.CORRECT) this.lastCorrectEntry = entry;
398
777
  }
399
778
  /** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
400
779
  inspect(dir, type, bytes, now) {
401
780
  const payload = decodeFrame(bytes).payload;
402
781
  if (dir === "out") {
403
- if (type === FrameType3.CALL) this.calls++;
404
- if (type === FrameType3.WRITE) this.recordWrites(decodeDelta2(this.options.ext, payload), now);
782
+ if (type === FrameType4.CALL) this.calls++;
783
+ if (type === FrameType4.WRITE) this.recordWrites(decodeDelta2(this.ext, payload), now);
405
784
  return void 0;
406
785
  }
407
786
  switch (type) {
408
- case FrameType3.WELCOME:
787
+ case FrameType4.WELCOME:
409
788
  return this.onWelcome(decodeWelcome(payload));
410
- case FrameType3.DELTA:
411
- return this.onDelta(decodeDelta2(this.options.ext, payload), now);
412
- case FrameType3.CORRECT: {
789
+ case FrameType4.DELTA:
790
+ return this.onDelta(decodeDelta2(this.ext, payload), now);
791
+ case FrameType4.CORRECT: {
413
792
  const r = new ByteReader(payload);
414
- const delta = decodeDeltaFrom(this.options.ext, r);
793
+ const delta = decodeDeltaFrom(this.ext, r);
415
794
  return this.onCorrect(delta, readCorrectClientTick(r), now);
416
795
  }
417
- case FrameType3.ERROR:
796
+ case FrameType4.ERROR:
418
797
  return this.onError(payload);
419
- case FrameType3.REPLY:
798
+ case FrameType4.REPLY:
420
799
  return this.onReply(payload);
800
+ case FrameType4.SCHEMA:
801
+ return this.onSchema(payload);
421
802
  default:
422
803
  return void 0;
423
804
  }
@@ -426,12 +807,12 @@ var BotObserver = class {
426
807
  this.id = welcome.clientId;
427
808
  this.role = welcome.role;
428
809
  this.roomId = welcome.roomId;
429
- const snapshot = decodeSnapshot(this.options.ext, welcome.snapshot);
810
+ const snapshot = decodeSnapshot(this.ext, welcome.snapshot);
430
811
  this.view = snapshot.state;
431
812
  this.rememberSnapshot(snapshot.state);
432
813
  this.countSpatialSnapshot(snapshot.state);
433
814
  for (const leak of snapshotVisibilityLeaks(
434
- this.options.ext,
815
+ this.ext,
435
816
  this.role,
436
817
  snapshot.state,
437
818
  this.spatialContext()
@@ -440,6 +821,22 @@ var BotObserver = class {
440
821
  }
441
822
  return `joined ${welcome.roomId} as ${welcome.clientId}/${welcome.role || "default"}`;
442
823
  }
824
+ /**
825
+ * Bug #28: a `SCHEMA` frame (D50 additive migrate). Rebuild the decode extension from the
826
+ * descriptor the frame carries, exactly as the real client's `swapSchema` does, so the resync
827
+ * WELCOME about to arrive decodes under the schema it was encoded with. The old view and
828
+ * seen-ids are dropped — they were laid out by descriptors that no longer exist, and the resync
829
+ * WELCOME re-seeds both (`onWelcome`).
830
+ */
831
+ onSchema(payload) {
832
+ const canonical = readSchemaPayload(payload);
833
+ const next = schemaFromCanonical(canonical);
834
+ this.ext = withBuiltins(next);
835
+ this.schemaSwaps++;
836
+ this.view = void 0;
837
+ this.seen.clear();
838
+ return `schema swapped (hash ${next.hash}) \u2014 decode extension rebuilt`;
839
+ }
443
840
  onDelta(delta, now) {
444
841
  this.checkVisibility(delta);
445
842
  this.matchWrites(delta, now);
@@ -449,7 +846,7 @@ var BotObserver = class {
449
846
  this.checkVisibility(delta);
450
847
  const names = delta.collections.map((c) => c.name).join(",");
451
848
  const kind = simulationOnly(
452
- this.options.ext,
849
+ this.ext,
453
850
  delta,
454
851
  (c, id) => this.predictsBody ? this.predictsBody(c, id) : false
455
852
  );
@@ -470,7 +867,20 @@ var BotObserver = class {
470
867
  true
471
868
  );
472
869
  }
473
- return `corrected ${names}${clientTick !== void 0 ? ` (clientTick ${clientTick})` : ""}`;
870
+ const fields = /* @__PURE__ */ new Set();
871
+ for (const dc of delta.collections) {
872
+ const desc = this.ext.collections.find(
873
+ (c) => c.name === dc.name
874
+ );
875
+ for (const op of dc.ops) {
876
+ if (op.op !== "update") {
877
+ fields.add(`${op.op} ${op.id}`);
878
+ continue;
879
+ }
880
+ for (const index of op.mask.fields) fields.add(desc?.fields[index]?.name ?? `#${index}`);
881
+ }
882
+ }
883
+ return `corrected ${names} [${[...fields].join(" ")}]${clientTick !== void 0 ? ` (clientTick ${clientTick})` : ""}`;
474
884
  }
475
885
  /**
476
886
  * The room's `correct` event, one per correction op — the only source of `previous` (the local
@@ -483,7 +893,7 @@ var BotObserver = class {
483
893
  this.suppressedCorrections++;
484
894
  return;
485
895
  }
486
- const desc = this.options.ext.collections.find(
896
+ const desc = this.ext.collections.find(
487
897
  (c) => c.name === correction.collection
488
898
  );
489
899
  const predictedBody = desc?.physics !== void 0 && correction.fields.length > 0 && correction.fields.every((f) => desc.physics?.bodyFields.has(f));
@@ -551,14 +961,9 @@ var BotObserver = class {
551
961
  * frame — a departing neighbour, not a leak.
552
962
  */
553
963
  checkVisibility(delta) {
554
- if (this.view) applyDelta(this.options.ext, this.view, delta);
964
+ if (this.view) applyDelta(this.ext, this.view, delta);
555
965
  this.countSpatialDelta(delta);
556
- for (const leak of deltaVisibilityLeaks(
557
- this.options.ext,
558
- this.role,
559
- delta,
560
- this.spatialContext()
561
- )) {
966
+ for (const leak of deltaVisibilityLeaks(this.ext, this.role, delta, this.spatialContext())) {
562
967
  this.violate("visibility-leak", leak);
563
968
  }
564
969
  this.remember(delta);
@@ -566,12 +971,12 @@ var BotObserver = class {
566
971
  countSpatialDelta(delta) {
567
972
  if (!this.view || this.id === "") return;
568
973
  for (const collection of delta.collections) {
569
- const desc = this.options.ext.collection(collection.name);
974
+ const desc = this.ext.collection(collection.name);
570
975
  if (desc.visibility === "spatial-grid") this.spatialOpsJudged += collection.ops.length;
571
976
  }
572
977
  }
573
978
  countSpatialSnapshot(state) {
574
- for (const desc of this.options.ext.collections) {
979
+ for (const desc of this.ext.collections) {
575
980
  if (desc.visibility !== "spatial-grid") continue;
576
981
  this.spatialOpsJudged += state[desc.name]?.size ?? 0;
577
982
  }
@@ -585,7 +990,7 @@ var BotObserver = class {
585
990
  return ids;
586
991
  }
587
992
  rememberSnapshot(state) {
588
- for (const desc of this.options.ext.collections) {
993
+ for (const desc of this.ext.collections) {
589
994
  if (desc.kind !== "entity") continue;
590
995
  const collection = state[desc.name];
591
996
  if (!collection) continue;
@@ -610,6 +1015,7 @@ var BotObserver = class {
610
1015
  } catch {
611
1016
  name = `code ${error.code}`;
612
1017
  }
1018
+ if (name === "E_STARTING") return `${name} (room starting \u2014 not counted)`;
613
1019
  if (!error.fatal) {
614
1020
  this.errors++;
615
1021
  this.violate("handler-error", `${name}: ${error.message}`);
@@ -738,6 +1144,7 @@ function freshValue(rng, desc, ctx) {
738
1144
  }
739
1145
 
740
1146
  // src/report.ts
1147
+ import { mergeProfiles } from "@irtio/protocol";
741
1148
  function percentile(sorted, p) {
742
1149
  if (sorted.length === 0) return 0;
743
1150
  const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1));
@@ -809,7 +1216,39 @@ function detailFor(name, observers, violations, thresholds) {
809
1216
  const count = observers.reduce((sum, o) => sum + o.disconnects, 0);
810
1217
  return count === 0 ? "every bot stayed connected" : `${count} disconnect(s)${examples(observers, name)}`;
811
1218
  }
1219
+ case "tick-health":
1220
+ return "";
1221
+ }
1222
+ }
1223
+ function tickHealthResult(reading, thresholds) {
1224
+ const where = reading?.source !== void 0 ? ` (from ${reading.source})` : "";
1225
+ if (reading === void 0 || reading.unavailable !== void 0) {
1226
+ return {
1227
+ name: "tick-health",
1228
+ ok: true,
1229
+ state: "unavailable",
1230
+ violations: 0,
1231
+ detail: reading?.unavailable ?? "the server was not asked for its tick counters, so this run says nothing about them"
1232
+ };
1233
+ }
1234
+ if (reading.overruns === void 0) {
1235
+ return {
1236
+ name: "tick-health",
1237
+ ok: true,
1238
+ state: "unavailable",
1239
+ violations: 0,
1240
+ detail: `no overrun count came back${where}`
1241
+ };
812
1242
  }
1243
+ const worst = reading.maxTickMs !== void 0 ? `, worst tick ${round(reading.maxTickMs)} ms` : "";
1244
+ const violated = reading.overruns > thresholds.overrunsMax;
1245
+ return {
1246
+ name: "tick-health",
1247
+ ok: !violated,
1248
+ state: violated ? "violation" : "ok",
1249
+ violations: violated ? reading.overruns : 0,
1250
+ detail: `${reading.overruns} tick overrun(s) in the run window, tolerated ${thresholds.overrunsMax}${worst}${where}` + (violated ? " \u2014 the room fell behind and dropped the backlog" : "")
1251
+ };
813
1252
  }
814
1253
  function buildReport(options) {
815
1254
  const { observers, roomId, lags } = options;
@@ -817,9 +1256,17 @@ function buildReport(options) {
817
1256
  const durationMs = Math.max(1, options.durationMs);
818
1257
  const seconds = durationMs / 1e3;
819
1258
  const invariants = INVARIANT_NAMES.map((name) => {
1259
+ if (name === "tick-health") return tickHealthResult(options.tickHealth, thresholds);
820
1260
  const violations = total(observers, name);
821
1261
  const ok = name === "handler-error" ? violations <= thresholds.handlerErrorsMax : violations === 0;
822
- return { name, ok, violations, detail: detailFor(name, observers, violations, thresholds) };
1262
+ const state = ok ? "ok" : "violation";
1263
+ return {
1264
+ name,
1265
+ ok,
1266
+ state,
1267
+ violations,
1268
+ detail: detailFor(name, observers, violations, thresholds)
1269
+ };
823
1270
  });
824
1271
  const perBot = observers.map((o) => o.stats);
825
1272
  const totals = {
@@ -852,10 +1299,20 @@ function buildReport(options) {
852
1299
  bytesOutPerSecondPerBot: round(totals.bytesOut / seconds / bots),
853
1300
  convergence,
854
1301
  convergenceLagMs: convergence?.p50Ms,
855
- tracePath: options.tracePath
1302
+ tracePath: options.tracePath,
1303
+ ...options.profiles !== void 0 && options.profiles.length > 0 ? { profile: options.profiles.reduce(mergeProfiles) } : {}
856
1304
  };
857
1305
  }
858
1306
 
1307
+ // src/scenario.ts
1308
+ function defineScenario(scenario) {
1309
+ return scenario;
1310
+ }
1311
+ function looksLikeScenario(value) {
1312
+ const s = value;
1313
+ return typeof s === "object" && s !== null && Number.isInteger(s.bots) && s.bots > 0 && typeof s.script === "function" && typeof s.assert === "function";
1314
+ }
1315
+
859
1316
  // src/script.ts
860
1317
  import { rpcTable } from "@irtio/protocol";
861
1318
  function ownableCollections(schema) {
@@ -871,11 +1328,16 @@ function writableFields(desc, cheat) {
871
1328
  function voidServerRpcs(schema) {
872
1329
  return rpcTable(schema).filter((r) => r.direction === "server" && r.returns === void 0);
873
1330
  }
874
- function asCollection(value) {
1331
+ function asCollection2(value) {
875
1332
  return value && typeof value.ownerOf === "function" ? value : void 0;
876
1333
  }
1334
+ function cheatPredicate(cheat) {
1335
+ if (cheat === void 0) return () => false;
1336
+ if (typeof cheat === "function") return (index) => cheat(index) === true;
1337
+ return () => cheat;
1338
+ }
877
1339
  function randomScript(schema, options = {}) {
878
- const cheat = options.cheat ?? false;
1340
+ const cheats = cheatPredicate(options.cheat);
879
1341
  const stepMs = options.stepMs ?? 50;
880
1342
  const step = options.step ?? 8;
881
1343
  const rpcChance = options.rpcChance ?? 0.05;
@@ -883,24 +1345,25 @@ function randomScript(schema, options = {}) {
883
1345
  const collections = ownableCollections(schema);
884
1346
  const rpcs = voidServerRpcs(schema);
885
1347
  return async (bot) => {
1348
+ const cheat = cheats(bot.index);
886
1349
  const room = bot.room;
887
1350
  const origins = /* @__PURE__ */ new Map();
888
1351
  while (!bot.stopped) {
889
1352
  for (const desc of collections) {
890
- const collection = asCollection(room.state[desc.name]);
1353
+ const collection = asCollection2(room.state[desc.name]);
891
1354
  if (!collection) continue;
892
1355
  for (const id of [...collection.ids()]) {
893
1356
  if (collection.ownerOf(id) !== room.me) continue;
894
1357
  const instance = collection.get(id);
895
1358
  if (!instance) continue;
896
- writeFields(bot, desc, id, instance, origins);
1359
+ writeFields(bot, desc, id, instance, origins, cheat);
897
1360
  }
898
1361
  }
899
1362
  if (rpcs.length > 0 && bot.rng.chance(rpcChance)) await callRandomRpc(bot, room.call, rpcs);
900
1363
  await bot.wait(stepMs);
901
1364
  }
902
1365
  };
903
- function writeFields(bot, desc, id, instance, origins) {
1366
+ function writeFields(bot, desc, id, instance, origins, cheat) {
904
1367
  const fields = writableFields(desc, cheat);
905
1368
  if (fields.length === 0) return;
906
1369
  const count = Math.min(fieldsPerStep, fields.length);
@@ -947,30 +1410,48 @@ function relayEchoScript(options = {}) {
947
1410
  // src/spawn.ts
948
1411
  import {
949
1412
  joinRelay,
950
- joinRoom
1413
+ joinRoom,
1414
+ webSocketTransport
951
1415
  } from "@irtio/client";
952
- import { relaySchema, withBuiltins } from "@irtio/protocol";
1416
+ import { relaySchema, withBuiltins as withBuiltins2 } from "@irtio/protocol";
953
1417
  var DEFAULT_SEED = 96016;
954
1418
  var DEFAULT_TRACE_LIMIT = 4096;
1419
+ var DEFAULT_JOIN_TIMEOUT_MS = 2e4;
1420
+ var DEFAULT_ROOM_GONE_GRACE_MS = 5e3;
1421
+ var ROOM_GONE_POLL_MS = 250;
955
1422
  function per(value, index) {
956
1423
  if (value === void 0) return void 0;
957
1424
  return typeof value === "function" ? value(index) : value;
958
1425
  }
959
1426
  var BotImpl = class {
960
- constructor(index, room, observer, seed, startedAt) {
1427
+ constructor(index, room, observer, seed, startedAt, conditions, shots) {
961
1428
  this.index = index;
962
1429
  this.room = room;
963
1430
  this.observer = observer;
964
1431
  this.startedAt = startedAt;
1432
+ this.conditions = conditions;
1433
+ this.shots = shots;
965
1434
  this.rng = makeRng(seed);
966
1435
  }
967
1436
  index;
968
1437
  room;
969
1438
  observer;
970
1439
  startedAt;
1440
+ conditions;
1441
+ shots;
971
1442
  stopped = false;
972
1443
  rng;
973
1444
  stopListeners = /* @__PURE__ */ new Set();
1445
+ shot(request) {
1446
+ const tick = this.room.tick;
1447
+ this.shots.push({
1448
+ ...request,
1449
+ bot: this.index,
1450
+ sentAt: Date.now(),
1451
+ clientTick: typeof tick === "number" ? tick : void 0,
1452
+ uplinkMs: (this.conditions?.rttMs ?? 0) / 2
1453
+ });
1454
+ }
974
1455
  get id() {
975
1456
  return this.observer.id;
976
1457
  }
@@ -1025,6 +1506,43 @@ var BotImpl = class {
1025
1506
  }
1026
1507
  }
1027
1508
  };
1509
+ var JoinTimeoutError = class extends Error {
1510
+ constructor(bot, timeoutMs, lastStatus) {
1511
+ super(
1512
+ `irtio bots: bot ${bot} did not finish joining within ${timeoutMs} ms (last status: ${lastStatus})` + (lastStatus === "starting" ? " \u2014 the room kept answering E_STARTING, so it never came up" : "")
1513
+ );
1514
+ this.bot = bot;
1515
+ this.timeoutMs = timeoutMs;
1516
+ this.lastStatus = lastStatus;
1517
+ }
1518
+ bot;
1519
+ timeoutMs;
1520
+ lastStatus;
1521
+ name = "JoinTimeoutError";
1522
+ };
1523
+ async function withJoinTimeout(joining, index, timeoutMs, observer, sockets) {
1524
+ if (timeoutMs <= 0) return joining;
1525
+ let timer;
1526
+ try {
1527
+ return await Promise.race([
1528
+ joining,
1529
+ new Promise((_resolve, reject) => {
1530
+ timer = setTimeout(() => {
1531
+ for (const socket of sockets) {
1532
+ try {
1533
+ socket.close();
1534
+ } catch {
1535
+ }
1536
+ }
1537
+ reject(new JoinTimeoutError(index, timeoutMs, observer.lastStatus));
1538
+ }, timeoutMs);
1539
+ })
1540
+ ]);
1541
+ } finally {
1542
+ if (timer !== void 0) clearTimeout(timer);
1543
+ }
1544
+ }
1545
+ var CONDITIONS_SEED_OFFSET = 377022;
1028
1546
  async function spawnBots(n, options = {}) {
1029
1547
  if (!Number.isInteger(n) || n < 1) {
1030
1548
  throw new Error(`irtio bots: spawnBots needs at least one bot, got ${String(n)}`);
@@ -1035,15 +1553,21 @@ async function spawnBots(n, options = {}) {
1035
1553
  correctionsPerSecMax: options.correctionsPerSecMax ?? DEFAULT_THRESHOLDS.correctionsPerSecMax,
1036
1554
  handlerErrorsMax: options.handlerErrorsMax ?? DEFAULT_THRESHOLDS.handlerErrorsMax,
1037
1555
  mispredictionMagnitudeMax: options.mispredictionMagnitudeMax ?? DEFAULT_THRESHOLDS.mispredictionMagnitudeMax,
1038
- snapsMax: options.snapsMax ?? DEFAULT_THRESHOLDS.snapsMax
1556
+ snapsMax: options.snapsMax ?? DEFAULT_THRESHOLDS.snapsMax,
1557
+ overrunsMax: options.overrunsMax ?? DEFAULT_THRESHOLDS.overrunsMax
1039
1558
  };
1040
- const ext = options.schema ? withBuiltins(options.schema) : relaySchema;
1559
+ const ext = options.schema ? withBuiltins2(options.schema) : relaySchema;
1041
1560
  const seed = options.seed ?? DEFAULT_SEED;
1042
1561
  const traceLimit = options.traceLimit ?? DEFAULT_TRACE_LIMIT;
1043
1562
  const writeLog = new WriteLog();
1044
1563
  const lags = [];
1045
1564
  const observers = [];
1046
1565
  const bots = [];
1566
+ const shots = [];
1567
+ const conditions = [];
1568
+ const joinTimeoutMs = options.joinTimeoutMs ?? DEFAULT_JOIN_TIMEOUT_MS;
1569
+ const roomGoneGraceMs = options.roomGoneGraceMs ?? DEFAULT_ROOM_GONE_GRACE_MS;
1570
+ const baseTransport = options.transport ?? webSocketTransport;
1047
1571
  async function joinOne(index, roomId2) {
1048
1572
  const observer = new BotObserver({
1049
1573
  index,
@@ -1056,34 +1580,76 @@ async function spawnBots(n, options = {}) {
1056
1580
  });
1057
1581
  const role = per(options.role, index);
1058
1582
  const name = per(options.name, index);
1583
+ const sockets = [];
1584
+ const asked = per(options.conditions, index);
1585
+ const injected = hasConditions(asked) ? asked : void 0;
1586
+ const counters = newConditionCounters();
1587
+ conditions[index] = { bot: index, conditions: injected, counters };
1588
+ const recording = {
1589
+ connect(url) {
1590
+ const socket = baseTransport.connect(url);
1591
+ sockets.push(socket);
1592
+ return socket;
1593
+ }
1594
+ };
1595
+ const transport = injected === void 0 ? recording : conditionedTransport(
1596
+ recording,
1597
+ injected,
1598
+ makeRng(seed + index + CONDITIONS_SEED_OFFSET),
1599
+ {
1600
+ counters
1601
+ }
1602
+ );
1059
1603
  const common = {
1060
1604
  room: roomId2,
1061
1605
  ...options.url !== void 0 ? { url: options.url } : {},
1062
1606
  ...options.key !== void 0 ? { key: options.key } : {},
1063
1607
  ...role !== void 0 ? { role } : {},
1064
1608
  ...name !== void 0 ? { name } : {},
1065
- ...options.transport !== void 0 ? { transport: options.transport } : {},
1609
+ transport,
1066
1610
  onFrame: (dir, type, bytes) => observer.onFrame(dir, type, bytes),
1067
1611
  onStatus: (status) => observer.onStatus(status)
1068
1612
  };
1069
- const room = options.schema ? await joinRoom(options.schema, {
1613
+ const joining = options.schema ? joinRoom(options.schema, {
1070
1614
  ...common,
1071
1615
  ...options.flushMs !== void 0 ? { writeIntervalMs: options.flushMs } : {},
1072
1616
  ...options.rpc !== void 0 ? { rpc: options.rpc } : {},
1073
- ...options.physics !== void 0 ? { physics: options.physics } : {}
1074
- }) : await joinRelay(common);
1617
+ ...options.physics !== void 0 ? { physics: options.physics } : {},
1618
+ ...options.profile === true ? { profile: true } : {}
1619
+ }) : joinRelay(common);
1620
+ const room = await withJoinTimeout(joining, index, joinTimeoutMs, observer, sockets);
1075
1621
  room.on("correct", (correction) => observer.onCorrection(correction));
1076
1622
  const prediction = room.prediction;
1077
1623
  if (prediction) observer.predictsBody = (c, id) => prediction.predicts(c, id);
1078
1624
  observers[index] = observer;
1079
- return new BotImpl(index, room, observer, seed + index, startedAt);
1625
+ return new BotImpl(
1626
+ index,
1627
+ room,
1628
+ observer,
1629
+ seed + index,
1630
+ startedAt,
1631
+ injected,
1632
+ shots
1633
+ );
1080
1634
  }
1081
1635
  const first = await joinOne(0, options.room ?? "");
1082
1636
  bots.push(first);
1083
1637
  const roomId = first.room.id;
1084
1638
  if (n > 1) {
1085
- const rest = await Promise.all(Array.from({ length: n - 1 }, (_, i) => joinOne(i + 1, roomId)));
1086
- bots.push(...rest);
1639
+ const rest = await Promise.allSettled(
1640
+ Array.from({ length: n - 1 }, (_, i) => joinOne(i + 1, roomId))
1641
+ );
1642
+ const failure = rest.find((r) => r.status === "rejected");
1643
+ if (failure !== void 0) {
1644
+ for (const settled of rest) {
1645
+ if (settled.status === "fulfilled") settled.value.room.leave();
1646
+ }
1647
+ first.room.leave();
1648
+ throw failure.reason;
1649
+ }
1650
+ for (const settled of rest) {
1651
+ if (settled.status === "fulfilled") bots.push(settled.value);
1652
+ }
1087
1653
  }
1088
1654
  const scriptErrors = [];
1089
1655
  let firstError;
@@ -1101,36 +1667,77 @@ async function spawnBots(n, options = {}) {
1101
1667
  }
1102
1668
  });
1103
1669
  const finished = Promise.all(scripts).then(() => void 0);
1670
+ let endedBy;
1104
1671
  let deadline;
1105
1672
  if (options.durationMs !== void 0) {
1106
1673
  deadline = setTimeout(() => {
1674
+ endedBy ??= "duration";
1107
1675
  for (const bot of bots) bot.stop();
1108
1676
  }, options.durationMs);
1109
- deadline.unref?.();
1110
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;
1687
+ return;
1688
+ }
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
+ });
1111
1701
  let stoppedAt;
1702
+ let tickHealth;
1112
1703
  const rings = () => observers.map((o) => o.ring);
1704
+ let profiles;
1113
1705
  const runner = {
1114
1706
  bots,
1115
1707
  roomId,
1116
1708
  trace: makeTrace(startedAt, rings),
1117
1709
  scriptErrors,
1710
+ shots,
1711
+ conditions,
1712
+ get endedBy() {
1713
+ return endedBy;
1714
+ },
1118
1715
  [Symbol.iterator]: () => bots[Symbol.iterator](),
1119
1716
  async done() {
1120
1717
  await finished;
1121
1718
  if (firstError !== void 0) throw firstError;
1122
1719
  },
1720
+ recordTickHealth(reading) {
1721
+ tickHealth = reading;
1722
+ },
1123
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;
1727
+ }
1124
1728
  return buildReport({
1125
1729
  observers,
1126
1730
  roomId,
1127
1731
  durationMs: (stoppedAt ?? Date.now()) - startedAt,
1128
1732
  lags,
1129
- thresholds
1733
+ thresholds,
1734
+ tickHealth,
1735
+ ...profiles !== void 0 ? { profiles } : {}
1130
1736
  });
1131
1737
  },
1132
1738
  async stop() {
1133
1739
  if (deadline) clearTimeout(deadline);
1740
+ if (goneTimer) clearInterval(goneTimer);
1134
1741
  for (const bot of bots) bot.stop();
1135
1742
  await finished;
1136
1743
  stoppedAt ??= Date.now();
@@ -1141,22 +1748,170 @@ async function spawnBots(n, options = {}) {
1141
1748
  };
1142
1749
  return runner;
1143
1750
  }
1751
+
1752
+ // src/timeline.ts
1753
+ var Collection = class {
1754
+ constructor(raw, entity) {
1755
+ this.raw = raw;
1756
+ this.entity = entity;
1757
+ }
1758
+ raw;
1759
+ entity;
1760
+ entry(id) {
1761
+ if (!this.entity) return void 0;
1762
+ const row = this.raw[id];
1763
+ return typeof row === "object" && row !== null ? row : void 0;
1764
+ }
1765
+ get(id) {
1766
+ const value = this.entry(id)?.value;
1767
+ return typeof value === "object" && value !== null ? value : void 0;
1768
+ }
1769
+ has(id) {
1770
+ return this.entity && Object.hasOwn(this.raw, id);
1771
+ }
1772
+ ids() {
1773
+ return this.entity ? Object.keys(this.raw) : [];
1774
+ }
1775
+ get size() {
1776
+ return this.entity ? Object.keys(this.raw).length : 0;
1777
+ }
1778
+ owner(id) {
1779
+ const owner = this.entry(id)?.owner;
1780
+ return typeof owner === "string" && owner !== "" ? owner : void 0;
1781
+ }
1782
+ get value() {
1783
+ return this.entity ? void 0 : this.raw;
1784
+ }
1785
+ };
1786
+ var UnknownCollectionError = class extends Error {
1787
+ name = "UnknownCollectionError";
1788
+ constructor(collection, known) {
1789
+ super(
1790
+ `irtio scenario: this room has no collection named "${collection}". It has: ${known.length > 0 ? known.join(", ") : "none"}.`
1791
+ );
1792
+ }
1793
+ };
1794
+ var TickNotRecordedError = class extends Error {
1795
+ constructor(tick, first, last, dropped) {
1796
+ super(
1797
+ 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.` : ".")
1798
+ );
1799
+ this.tick = tick;
1800
+ }
1801
+ tick;
1802
+ name = "TickNotRecordedError";
1803
+ };
1804
+ function momentOf(dump, index, read) {
1805
+ const frame = dump.frames[index];
1806
+ read(frame.tick);
1807
+ const known = Object.keys(dump.collections);
1808
+ const cache = /* @__PURE__ */ new Map();
1809
+ return new Proxy({}, {
1810
+ get(_target, property) {
1811
+ if (typeof property !== "string") return void 0;
1812
+ const cached = cache.get(property);
1813
+ if (cached) return cached;
1814
+ const kind = dump.collections[property];
1815
+ if (kind === void 0) throw new UnknownCollectionError(property, known);
1816
+ const raw = frame.state[property];
1817
+ const view = new Collection(
1818
+ typeof raw === "object" && raw !== null ? raw : {},
1819
+ kind === "entity"
1820
+ );
1821
+ cache.set(property, view);
1822
+ return view;
1823
+ },
1824
+ has: (_target, property) => typeof property === "string" && property in dump.collections,
1825
+ ownKeys: () => [...known],
1826
+ getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true })
1827
+ });
1828
+ }
1829
+ function makeTimeline(dump) {
1830
+ const index = /* @__PURE__ */ new Map();
1831
+ dump.frames.forEach((f, i) => index.set(f.tick, i));
1832
+ const ticks = dump.frames.map((f) => f.tick);
1833
+ const results = [];
1834
+ let readTicks = [];
1835
+ const read = (tick) => {
1836
+ readTicks.push(tick);
1837
+ };
1838
+ const at = (tick) => {
1839
+ const i = index.get(tick);
1840
+ if (i === void 0) {
1841
+ throw new TickNotRecordedError(tick, ticks[0], ticks[ticks.length - 1], dump.dropped);
1842
+ }
1843
+ return momentOf(dump, i, read);
1844
+ };
1845
+ return {
1846
+ roomId: dump.roomId,
1847
+ ticks,
1848
+ dropped: dump.dropped,
1849
+ at,
1850
+ find(match) {
1851
+ for (let i = 0; i < dump.frames.length; i++) {
1852
+ const tick = dump.frames[i].tick;
1853
+ const state = momentOf(dump, i, read);
1854
+ if (match(state, tick)) return { tick, state };
1855
+ }
1856
+ return void 0;
1857
+ },
1858
+ check(name, assertion) {
1859
+ readTicks = [];
1860
+ try {
1861
+ assertion();
1862
+ results.push({
1863
+ name,
1864
+ ok: true,
1865
+ ...readTicks.length > 0 ? { tick: readTicks[readTicks.length - 1] } : {},
1866
+ detail: readTicks.length === 0 ? "held (read no tick)" : `held, reading tick ${readTicks[readTicks.length - 1]}` + (readTicks.length > 1 ? ` (${readTicks.length} ticks read)` : "")
1867
+ });
1868
+ } catch (err) {
1869
+ results.push({
1870
+ name,
1871
+ ok: false,
1872
+ ...readTicks.length > 0 ? { tick: readTicks[readTicks.length - 1] } : {},
1873
+ detail: err instanceof Error ? err.message : String(err)
1874
+ });
1875
+ }
1876
+ },
1877
+ get results() {
1878
+ return results;
1879
+ }
1880
+ };
1881
+ }
1144
1882
  export {
1145
1883
  BotObserver,
1884
+ DEFAULT_JOIN_TIMEOUT_MS,
1885
+ DEFAULT_REORDER_MS,
1886
+ DEFAULT_ROOM_GONE_GRACE_MS,
1146
1887
  DEFAULT_SEED,
1147
1888
  DEFAULT_THRESHOLDS,
1148
1889
  DEFAULT_TRACE_LIMIT,
1149
1890
  INVARIANT_NAMES,
1891
+ JoinTimeoutError,
1892
+ TickNotRecordedError,
1150
1893
  TraceRing,
1894
+ UnknownCollectionError,
1151
1895
  WriteLog,
1152
1896
  buildReport,
1897
+ captureClientState,
1898
+ cheatPredicate,
1899
+ conditionedTransport,
1153
1900
  convergenceStats,
1901
+ correlateShots,
1902
+ defineScenario,
1154
1903
  deltaVisibilityLeaks,
1904
+ describeConditions,
1905
+ diffAgainstSave,
1155
1906
  frameName,
1156
1907
  frameVisibilityLeaks,
1157
1908
  freshValue,
1909
+ hasConditions,
1910
+ looksLikeScenario,
1158
1911
  makeRng,
1912
+ makeTimeline,
1159
1913
  makeTrace,
1914
+ newConditionCounters,
1160
1915
  nextValue,
1161
1916
  randomScript,
1162
1917
  relayEchoScript,