@irtio/bots 0.5.2 → 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 +561 -55
  2. package/dist/index.js +651 -46
  3. package/package.json +5 -5
package/dist/index.js CHANGED
@@ -110,29 +110,382 @@ function frameVisibilityLeaks(ext, role, type, payload, spatial) {
110
110
  return deltaVisibilityLeaks(ext, role, decodeDelta(ext, payload), spatial);
111
111
  }
112
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
+
113
463
  // src/observer.ts
114
464
  import {
115
- FrameType as FrameType3,
465
+ FrameType as FrameType4,
116
466
  decodeErrorPayload,
117
467
  decodeFrame,
118
468
  decodeReply,
119
469
  decodeWelcome,
120
470
  errorByCode,
121
- readCorrectClientTick
471
+ readCorrectClientTick,
472
+ readSchemaPayload,
473
+ withBuiltins
122
474
  } from "@irtio/protocol";
123
475
  import {
124
476
  ByteReader,
125
477
  applyDelta,
126
478
  decodeDelta as decodeDelta2,
127
479
  decodeDeltaFrom,
128
- decodeSnapshot
480
+ decodeSnapshot,
481
+ schemaFromCanonical
129
482
  } from "@irtio/schema";
130
483
 
131
484
  // src/trace.ts
132
485
  import { writeFile } from "fs/promises";
133
- import { FrameType as FrameType2 } from "@irtio/protocol";
486
+ import { FrameType as FrameType3 } from "@irtio/protocol";
134
487
  var FRAME_NAMES = new Map(
135
- Object.entries(FrameType2).map(([name, value]) => [value, name])
488
+ Object.entries(FrameType3).map(([name, value]) => [value, name])
136
489
  );
137
490
  function frameName(type) {
138
491
  return FRAME_NAMES.get(type) ?? `UNKNOWN(${type})`;
@@ -204,9 +557,15 @@ function simulationOnly(ext, delta, predicts) {
204
557
  const predicted = predicts(dc.name, op.id);
205
558
  for (const index of op.mask.fields) {
206
559
  const field = desc.fields[index];
207
- if (!field || !physics.bodyFields.has(field.name)) return "mixed";
208
- if (predicted) sawPredicted = true;
209
- 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
+ }
210
569
  }
211
570
  }
212
571
  }
@@ -262,11 +621,22 @@ var BotObserver = class {
262
621
  constructor(options) {
263
622
  this.options = options;
264
623
  this.ring = new TraceRing(options.traceLimit);
624
+ this.ext = options.ext;
265
625
  }
266
626
  options;
267
627
  ring;
268
628
  violations = /* @__PURE__ */ new Map();
269
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;
270
640
  id = "";
271
641
  role = "";
272
642
  roomId = "";
@@ -403,30 +773,32 @@ var BotObserver = class {
403
773
  ...note !== void 0 ? { note } : {}
404
774
  };
405
775
  this.ring.push(entry);
406
- if (dir === "in" && type === FrameType3.CORRECT) this.lastCorrectEntry = entry;
776
+ if (dir === "in" && type === FrameType4.CORRECT) this.lastCorrectEntry = entry;
407
777
  }
408
778
  /** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
409
779
  inspect(dir, type, bytes, now) {
410
780
  const payload = decodeFrame(bytes).payload;
411
781
  if (dir === "out") {
412
- if (type === FrameType3.CALL) this.calls++;
413
- 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);
414
784
  return void 0;
415
785
  }
416
786
  switch (type) {
417
- case FrameType3.WELCOME:
787
+ case FrameType4.WELCOME:
418
788
  return this.onWelcome(decodeWelcome(payload));
419
- case FrameType3.DELTA:
420
- return this.onDelta(decodeDelta2(this.options.ext, payload), now);
421
- case FrameType3.CORRECT: {
789
+ case FrameType4.DELTA:
790
+ return this.onDelta(decodeDelta2(this.ext, payload), now);
791
+ case FrameType4.CORRECT: {
422
792
  const r = new ByteReader(payload);
423
- const delta = decodeDeltaFrom(this.options.ext, r);
793
+ const delta = decodeDeltaFrom(this.ext, r);
424
794
  return this.onCorrect(delta, readCorrectClientTick(r), now);
425
795
  }
426
- case FrameType3.ERROR:
796
+ case FrameType4.ERROR:
427
797
  return this.onError(payload);
428
- case FrameType3.REPLY:
798
+ case FrameType4.REPLY:
429
799
  return this.onReply(payload);
800
+ case FrameType4.SCHEMA:
801
+ return this.onSchema(payload);
430
802
  default:
431
803
  return void 0;
432
804
  }
@@ -435,12 +807,12 @@ var BotObserver = class {
435
807
  this.id = welcome.clientId;
436
808
  this.role = welcome.role;
437
809
  this.roomId = welcome.roomId;
438
- const snapshot = decodeSnapshot(this.options.ext, welcome.snapshot);
810
+ const snapshot = decodeSnapshot(this.ext, welcome.snapshot);
439
811
  this.view = snapshot.state;
440
812
  this.rememberSnapshot(snapshot.state);
441
813
  this.countSpatialSnapshot(snapshot.state);
442
814
  for (const leak of snapshotVisibilityLeaks(
443
- this.options.ext,
815
+ this.ext,
444
816
  this.role,
445
817
  snapshot.state,
446
818
  this.spatialContext()
@@ -449,6 +821,22 @@ var BotObserver = class {
449
821
  }
450
822
  return `joined ${welcome.roomId} as ${welcome.clientId}/${welcome.role || "default"}`;
451
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
+ }
452
840
  onDelta(delta, now) {
453
841
  this.checkVisibility(delta);
454
842
  this.matchWrites(delta, now);
@@ -458,7 +846,7 @@ var BotObserver = class {
458
846
  this.checkVisibility(delta);
459
847
  const names = delta.collections.map((c) => c.name).join(",");
460
848
  const kind = simulationOnly(
461
- this.options.ext,
849
+ this.ext,
462
850
  delta,
463
851
  (c, id) => this.predictsBody ? this.predictsBody(c, id) : false
464
852
  );
@@ -479,7 +867,20 @@ var BotObserver = class {
479
867
  true
480
868
  );
481
869
  }
482
- 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})` : ""}`;
483
884
  }
484
885
  /**
485
886
  * The room's `correct` event, one per correction op — the only source of `previous` (the local
@@ -492,7 +893,7 @@ var BotObserver = class {
492
893
  this.suppressedCorrections++;
493
894
  return;
494
895
  }
495
- const desc = this.options.ext.collections.find(
896
+ const desc = this.ext.collections.find(
496
897
  (c) => c.name === correction.collection
497
898
  );
498
899
  const predictedBody = desc?.physics !== void 0 && correction.fields.length > 0 && correction.fields.every((f) => desc.physics?.bodyFields.has(f));
@@ -560,14 +961,9 @@ var BotObserver = class {
560
961
  * frame — a departing neighbour, not a leak.
561
962
  */
562
963
  checkVisibility(delta) {
563
- if (this.view) applyDelta(this.options.ext, this.view, delta);
964
+ if (this.view) applyDelta(this.ext, this.view, delta);
564
965
  this.countSpatialDelta(delta);
565
- for (const leak of deltaVisibilityLeaks(
566
- this.options.ext,
567
- this.role,
568
- delta,
569
- this.spatialContext()
570
- )) {
966
+ for (const leak of deltaVisibilityLeaks(this.ext, this.role, delta, this.spatialContext())) {
571
967
  this.violate("visibility-leak", leak);
572
968
  }
573
969
  this.remember(delta);
@@ -575,12 +971,12 @@ var BotObserver = class {
575
971
  countSpatialDelta(delta) {
576
972
  if (!this.view || this.id === "") return;
577
973
  for (const collection of delta.collections) {
578
- const desc = this.options.ext.collection(collection.name);
974
+ const desc = this.ext.collection(collection.name);
579
975
  if (desc.visibility === "spatial-grid") this.spatialOpsJudged += collection.ops.length;
580
976
  }
581
977
  }
582
978
  countSpatialSnapshot(state) {
583
- for (const desc of this.options.ext.collections) {
979
+ for (const desc of this.ext.collections) {
584
980
  if (desc.visibility !== "spatial-grid") continue;
585
981
  this.spatialOpsJudged += state[desc.name]?.size ?? 0;
586
982
  }
@@ -594,7 +990,7 @@ var BotObserver = class {
594
990
  return ids;
595
991
  }
596
992
  rememberSnapshot(state) {
597
- for (const desc of this.options.ext.collections) {
993
+ for (const desc of this.ext.collections) {
598
994
  if (desc.kind !== "entity") continue;
599
995
  const collection = state[desc.name];
600
996
  if (!collection) continue;
@@ -619,6 +1015,7 @@ var BotObserver = class {
619
1015
  } catch {
620
1016
  name = `code ${error.code}`;
621
1017
  }
1018
+ if (name === "E_STARTING") return `${name} (room starting \u2014 not counted)`;
622
1019
  if (!error.fatal) {
623
1020
  this.errors++;
624
1021
  this.violate("handler-error", `${name}: ${error.message}`);
@@ -747,6 +1144,7 @@ function freshValue(rng, desc, ctx) {
747
1144
  }
748
1145
 
749
1146
  // src/report.ts
1147
+ import { mergeProfiles } from "@irtio/protocol";
750
1148
  function percentile(sorted, p) {
751
1149
  if (sorted.length === 0) return 0;
752
1150
  const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1));
@@ -901,10 +1299,20 @@ function buildReport(options) {
901
1299
  bytesOutPerSecondPerBot: round(totals.bytesOut / seconds / bots),
902
1300
  convergence,
903
1301
  convergenceLagMs: convergence?.p50Ms,
904
- tracePath: options.tracePath
1302
+ tracePath: options.tracePath,
1303
+ ...options.profiles !== void 0 && options.profiles.length > 0 ? { profile: options.profiles.reduce(mergeProfiles) } : {}
905
1304
  };
906
1305
  }
907
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
+
908
1316
  // src/script.ts
909
1317
  import { rpcTable } from "@irtio/protocol";
910
1318
  function ownableCollections(schema) {
@@ -920,11 +1328,16 @@ function writableFields(desc, cheat) {
920
1328
  function voidServerRpcs(schema) {
921
1329
  return rpcTable(schema).filter((r) => r.direction === "server" && r.returns === void 0);
922
1330
  }
923
- function asCollection(value) {
1331
+ function asCollection2(value) {
924
1332
  return value && typeof value.ownerOf === "function" ? value : void 0;
925
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
+ }
926
1339
  function randomScript(schema, options = {}) {
927
- const cheat = options.cheat ?? false;
1340
+ const cheats = cheatPredicate(options.cheat);
928
1341
  const stepMs = options.stepMs ?? 50;
929
1342
  const step = options.step ?? 8;
930
1343
  const rpcChance = options.rpcChance ?? 0.05;
@@ -932,24 +1345,25 @@ function randomScript(schema, options = {}) {
932
1345
  const collections = ownableCollections(schema);
933
1346
  const rpcs = voidServerRpcs(schema);
934
1347
  return async (bot) => {
1348
+ const cheat = cheats(bot.index);
935
1349
  const room = bot.room;
936
1350
  const origins = /* @__PURE__ */ new Map();
937
1351
  while (!bot.stopped) {
938
1352
  for (const desc of collections) {
939
- const collection = asCollection(room.state[desc.name]);
1353
+ const collection = asCollection2(room.state[desc.name]);
940
1354
  if (!collection) continue;
941
1355
  for (const id of [...collection.ids()]) {
942
1356
  if (collection.ownerOf(id) !== room.me) continue;
943
1357
  const instance = collection.get(id);
944
1358
  if (!instance) continue;
945
- writeFields(bot, desc, id, instance, origins);
1359
+ writeFields(bot, desc, id, instance, origins, cheat);
946
1360
  }
947
1361
  }
948
1362
  if (rpcs.length > 0 && bot.rng.chance(rpcChance)) await callRandomRpc(bot, room.call, rpcs);
949
1363
  await bot.wait(stepMs);
950
1364
  }
951
1365
  };
952
- function writeFields(bot, desc, id, instance, origins) {
1366
+ function writeFields(bot, desc, id, instance, origins, cheat) {
953
1367
  const fields = writableFields(desc, cheat);
954
1368
  if (fields.length === 0) return;
955
1369
  const count = Math.min(fieldsPerStep, fields.length);
@@ -999,7 +1413,7 @@ import {
999
1413
  joinRoom,
1000
1414
  webSocketTransport
1001
1415
  } from "@irtio/client";
1002
- import { relaySchema, withBuiltins } from "@irtio/protocol";
1416
+ import { relaySchema, withBuiltins as withBuiltins2 } from "@irtio/protocol";
1003
1417
  var DEFAULT_SEED = 96016;
1004
1418
  var DEFAULT_TRACE_LIMIT = 4096;
1005
1419
  var DEFAULT_JOIN_TIMEOUT_MS = 2e4;
@@ -1010,20 +1424,34 @@ function per(value, index) {
1010
1424
  return typeof value === "function" ? value(index) : value;
1011
1425
  }
1012
1426
  var BotImpl = class {
1013
- constructor(index, room, observer, seed, startedAt) {
1427
+ constructor(index, room, observer, seed, startedAt, conditions, shots) {
1014
1428
  this.index = index;
1015
1429
  this.room = room;
1016
1430
  this.observer = observer;
1017
1431
  this.startedAt = startedAt;
1432
+ this.conditions = conditions;
1433
+ this.shots = shots;
1018
1434
  this.rng = makeRng(seed);
1019
1435
  }
1020
1436
  index;
1021
1437
  room;
1022
1438
  observer;
1023
1439
  startedAt;
1440
+ conditions;
1441
+ shots;
1024
1442
  stopped = false;
1025
1443
  rng;
1026
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
+ }
1027
1455
  get id() {
1028
1456
  return this.observer.id;
1029
1457
  }
@@ -1114,6 +1542,7 @@ async function withJoinTimeout(joining, index, timeoutMs, observer, sockets) {
1114
1542
  if (timer !== void 0) clearTimeout(timer);
1115
1543
  }
1116
1544
  }
1545
+ var CONDITIONS_SEED_OFFSET = 377022;
1117
1546
  async function spawnBots(n, options = {}) {
1118
1547
  if (!Number.isInteger(n) || n < 1) {
1119
1548
  throw new Error(`irtio bots: spawnBots needs at least one bot, got ${String(n)}`);
@@ -1127,13 +1556,15 @@ async function spawnBots(n, options = {}) {
1127
1556
  snapsMax: options.snapsMax ?? DEFAULT_THRESHOLDS.snapsMax,
1128
1557
  overrunsMax: options.overrunsMax ?? DEFAULT_THRESHOLDS.overrunsMax
1129
1558
  };
1130
- const ext = options.schema ? withBuiltins(options.schema) : relaySchema;
1559
+ const ext = options.schema ? withBuiltins2(options.schema) : relaySchema;
1131
1560
  const seed = options.seed ?? DEFAULT_SEED;
1132
1561
  const traceLimit = options.traceLimit ?? DEFAULT_TRACE_LIMIT;
1133
1562
  const writeLog = new WriteLog();
1134
1563
  const lags = [];
1135
1564
  const observers = [];
1136
1565
  const bots = [];
1566
+ const shots = [];
1567
+ const conditions = [];
1137
1568
  const joinTimeoutMs = options.joinTimeoutMs ?? DEFAULT_JOIN_TIMEOUT_MS;
1138
1569
  const roomGoneGraceMs = options.roomGoneGraceMs ?? DEFAULT_ROOM_GONE_GRACE_MS;
1139
1570
  const baseTransport = options.transport ?? webSocketTransport;
@@ -1150,13 +1581,25 @@ async function spawnBots(n, options = {}) {
1150
1581
  const role = per(options.role, index);
1151
1582
  const name = per(options.name, index);
1152
1583
  const sockets = [];
1153
- const transport = {
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 = {
1154
1589
  connect(url) {
1155
1590
  const socket = baseTransport.connect(url);
1156
1591
  sockets.push(socket);
1157
1592
  return socket;
1158
1593
  }
1159
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
+ );
1160
1603
  const common = {
1161
1604
  room: roomId2,
1162
1605
  ...options.url !== void 0 ? { url: options.url } : {},
@@ -1171,14 +1614,23 @@ async function spawnBots(n, options = {}) {
1171
1614
  ...common,
1172
1615
  ...options.flushMs !== void 0 ? { writeIntervalMs: options.flushMs } : {},
1173
1616
  ...options.rpc !== void 0 ? { rpc: options.rpc } : {},
1174
- ...options.physics !== void 0 ? { physics: options.physics } : {}
1617
+ ...options.physics !== void 0 ? { physics: options.physics } : {},
1618
+ ...options.profile === true ? { profile: true } : {}
1175
1619
  }) : joinRelay(common);
1176
1620
  const room = await withJoinTimeout(joining, index, joinTimeoutMs, observer, sockets);
1177
1621
  room.on("correct", (correction) => observer.onCorrection(correction));
1178
1622
  const prediction = room.prediction;
1179
1623
  if (prediction) observer.predictsBody = (c, id) => prediction.predicts(c, id);
1180
1624
  observers[index] = observer;
1181
- 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
+ );
1182
1634
  }
1183
1635
  const first = await joinOne(0, options.room ?? "");
1184
1636
  bots.push(first);
@@ -1249,11 +1701,14 @@ async function spawnBots(n, options = {}) {
1249
1701
  let stoppedAt;
1250
1702
  let tickHealth;
1251
1703
  const rings = () => observers.map((o) => o.ring);
1704
+ let profiles;
1252
1705
  const runner = {
1253
1706
  bots,
1254
1707
  roomId,
1255
1708
  trace: makeTrace(startedAt, rings),
1256
1709
  scriptErrors,
1710
+ shots,
1711
+ conditions,
1257
1712
  get endedBy() {
1258
1713
  return endedBy;
1259
1714
  },
@@ -1266,13 +1721,18 @@ async function spawnBots(n, options = {}) {
1266
1721
  tickHealth = reading;
1267
1722
  },
1268
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
+ }
1269
1728
  return buildReport({
1270
1729
  observers,
1271
1730
  roomId,
1272
1731
  durationMs: (stoppedAt ?? Date.now()) - startedAt,
1273
1732
  lags,
1274
1733
  thresholds,
1275
- tickHealth
1734
+ tickHealth,
1735
+ ...profiles !== void 0 ? { profiles } : {}
1276
1736
  });
1277
1737
  },
1278
1738
  async stop() {
@@ -1288,25 +1748,170 @@ async function spawnBots(n, options = {}) {
1288
1748
  };
1289
1749
  return runner;
1290
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
+ }
1291
1882
  export {
1292
1883
  BotObserver,
1293
1884
  DEFAULT_JOIN_TIMEOUT_MS,
1885
+ DEFAULT_REORDER_MS,
1294
1886
  DEFAULT_ROOM_GONE_GRACE_MS,
1295
1887
  DEFAULT_SEED,
1296
1888
  DEFAULT_THRESHOLDS,
1297
1889
  DEFAULT_TRACE_LIMIT,
1298
1890
  INVARIANT_NAMES,
1299
1891
  JoinTimeoutError,
1892
+ TickNotRecordedError,
1300
1893
  TraceRing,
1894
+ UnknownCollectionError,
1301
1895
  WriteLog,
1302
1896
  buildReport,
1897
+ captureClientState,
1898
+ cheatPredicate,
1899
+ conditionedTransport,
1303
1900
  convergenceStats,
1901
+ correlateShots,
1902
+ defineScenario,
1304
1903
  deltaVisibilityLeaks,
1904
+ describeConditions,
1905
+ diffAgainstSave,
1305
1906
  frameName,
1306
1907
  frameVisibilityLeaks,
1307
1908
  freshValue,
1909
+ hasConditions,
1910
+ looksLikeScenario,
1308
1911
  makeRng,
1912
+ makeTimeline,
1309
1913
  makeTrace,
1914
+ newConditionCounters,
1310
1915
  nextValue,
1311
1916
  randomScript,
1312
1917
  relayEchoScript,