@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.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { PlainState, AnySchema, Delta, TypeDesc } from '@irtio/schema';
2
+ import { TimelineDump } from '@irtio/runtime';
3
+ export { TimelineDump, TimelineFrame } from '@irtio/runtime';
2
4
  import { Correction, Room, RelayRoom, Transport, JoinOptions } from '@irtio/client';
5
+ import { ProfileSnapshot } from '@irtio/protocol';
3
6
 
4
7
  /**
5
8
  * The built-in invariants, and the pure checkers behind the two that need real protocol knowledge.
@@ -14,15 +17,37 @@ import { Correction, Room, RelayRoom, Transport, JoinOptions } from '@irtio/clie
14
17
  */
15
18
 
16
19
  /** The invariants every simulation checks, in report order. */
17
- declare const INVARIANT_NAMES: readonly ["schema-validity", "visibility-leak", "bandwidth", "handler-error", "correction-storm", "misprediction", "snaps", "disconnects"];
20
+ declare const INVARIANT_NAMES: readonly ["schema-validity", "visibility-leak", "bandwidth", "handler-error", "correction-storm", "misprediction", "snaps", "disconnects", "tick-health"];
18
21
  type InvariantName = (typeof INVARIANT_NAMES)[number];
22
+ /**
23
+ * D36: three states, not two. `unavailable` exists because the server's own tick counters cannot
24
+ * always be fetched, and reporting `ok` for a number nobody read is exactly the class of lie the
25
+ * M2 exit tests were built to catch. An `unavailable` invariant does not fail a run.
26
+ */
27
+ type InvariantState = 'ok' | 'violation' | 'unavailable';
19
28
  interface InvariantResult {
20
29
  readonly name: InvariantName;
30
+ /** False only for `violation`. An `unavailable` invariant is not a failure. */
21
31
  readonly ok: boolean;
32
+ readonly state: InvariantState;
22
33
  readonly violations: number;
23
34
  /** One line a human can act on: the threshold, the peak, the first offenders. */
24
35
  readonly detail: string;
25
36
  }
37
+ /**
38
+ * D36: what the server said about its own tick loop over the run window, as the caller managed to
39
+ * read it. Every field is optional because every field can be missing, and the report says which.
40
+ */
41
+ interface TickHealthReading {
42
+ /** Tick overruns inside the run window (a delta, not a lifetime count). */
43
+ readonly overruns?: number;
44
+ /** The worst single tick in ms the server saw. Lifetime, not windowed. */
45
+ readonly maxTickMs?: number;
46
+ /** Why the numbers could not be read. Present ⇒ the invariant reports `unavailable`. */
47
+ readonly unavailable?: string;
48
+ /** Where the numbers came from, so a reader can go and check. */
49
+ readonly source?: string;
50
+ }
26
51
  interface SpatialVisibilityContext {
27
52
  /**
28
53
  * The state the anchor and the judged positions are read from. Server truth when a test has it
@@ -60,6 +85,12 @@ interface InvariantThresholds {
60
85
  readonly mispredictionMagnitudeMax: number;
61
86
  /** `snaps`: tolerated cap-exceeded reconciliations (corrections past the resim window), per run. */
62
87
  readonly snapsMax: number;
88
+ /**
89
+ * `tick-health`: server tick overruns tolerated inside the run window. Zero by default, which
90
+ * is strict on purpose — a room dropping even one tick's backlog is shedding work a player
91
+ * paid for — and one `--overruns-max` away from being usable on a deliberately busy room.
92
+ */
93
+ readonly overrunsMax: number;
63
94
  }
64
95
  declare const DEFAULT_THRESHOLDS: InvariantThresholds;
65
96
  /**
@@ -82,6 +113,342 @@ declare function snapshotVisibilityLeaks(ext: AnySchema, role: string, state: Pl
82
113
  */
83
114
  declare function frameVisibilityLeaks(ext: AnySchema, role: string, type: number, payload: Uint8Array, spatial?: SpatialVisibilityContext): string[];
84
115
 
116
+ /**
117
+ * Seeded randomness and schema-driven value generation.
118
+ *
119
+ * Every bot gets its own generator seeded from `seed + index`, so a whole run replays exactly:
120
+ * a red simulation is worth nothing if the next run takes a different path.
121
+ *
122
+ * Value generation reads `@irtio/schema`'s `TypeDesc` directly rather than duplicating the type
123
+ * menu, so a new type kind is a compile error here instead of a silent gap.
124
+ */
125
+
126
+ /** A seeded pseudo-random source behind `bot.random()`. */
127
+ interface Rng {
128
+ /** Uniform in [0, 1). */
129
+ next(): number;
130
+ /** Uniform integer in [lo, hi] (inclusive). */
131
+ int(lo: number, hi: number): number;
132
+ /** Uniform in [lo, hi). */
133
+ float(lo: number, hi: number): number;
134
+ /** `true` with probability `p`. */
135
+ chance(p: number): boolean;
136
+ /** One element, or `undefined` for an empty array. */
137
+ pick<T>(items: readonly T[]): T | undefined;
138
+ }
139
+ /**
140
+ * mulberry32: 32 bits of state, good enough for behaviour scripts and — unlike `Math.random` —
141
+ * reproducible. Not cryptographic, and never used for anything that needs to be.
142
+ */
143
+ declare function makeRng(seed: number): Rng;
144
+ /** How a value is generated: a small walk (normal play) or a teleport (`--cheat`). */
145
+ interface ValueContext {
146
+ /** Magnitude of one step for unbounded numeric fields. */
147
+ readonly step: number;
148
+ /** Produce values a server validator should reject. */
149
+ readonly cheat: boolean;
150
+ /**
151
+ * The value this field started at. Numeric walks are mean-reverting toward it, which keeps a
152
+ * long run inside whatever world bounds the room's validator enforces without the bot having to
153
+ * know them — the type system only declares a *representable* range, never a legal one.
154
+ */
155
+ readonly origin?: number | undefined;
156
+ /** Ids that a `ref` field may legally point at. */
157
+ readonly refIds?: readonly string[] | undefined;
158
+ }
159
+ /**
160
+ * The next value for a field that currently holds `current`. Always inside the type's declared
161
+ * range, so `track()` accepts it and the *server* — not the client — is the one that gets to say
162
+ * a value is illegal. In cheat mode the value is still type-valid and wildly out of play.
163
+ */
164
+ declare function nextValue(rng: Rng, desc: TypeDesc, current: unknown, ctx: ValueContext): unknown;
165
+ /** A fresh valid value for a field with no current value — RPC params, mostly. */
166
+ declare function freshValue(rng: Rng, desc: TypeDesc, ctx: ValueContext): unknown;
167
+
168
+ /**
169
+ * D42: injected network conditions, as a wrapper around the transport seam.
170
+ *
171
+ * `SpawnOptions.transport` exists so a test can get at the socket, and that is exactly the shape
172
+ * an adversarial run needs: a bot under conditions is still a real `@irtio/client` session
173
+ * speaking the real wire, it just gets its frames late, twice, out of order, or not at all. No
174
+ * protocol change, no frame change, and nothing below this file knows the difference.
175
+ *
176
+ * Three rules decide the design, and all three are load-bearing.
177
+ *
178
+ * 1. **Loss, duplication and reorder touch state frames only.** `@irtio/testing`'s in-process
179
+ * harness restricts its loss die the same way, for the same reason: dropping a HELLO wedges
180
+ * the join and dropping a REPLY wedges an RPC that has no retry, and a run that hangs on a
181
+ * wedged join is a D36 regression, not an adversarial test. The peek is a read of the frame's
182
+ * own type byte against `@irtio/protocol`'s `FrameType`, which is the envelope the protocol
183
+ * already guarantees; nothing here decodes a payload.
184
+ * 2. **Delay is symmetric unless it is split.** `rttMs` is the round trip, so each direction
185
+ * carries `rttMs / 2` plus a jitter draw. A stream is kept in order by a per-direction floor,
186
+ * the way a real WebSocket is, and `reorder` is the only thing that lifts it.
187
+ * 3. **Reorder is bounded.** A reordered frame is held back by at most `reorderMs`, so frames
188
+ * swap places with their neighbours rather than arriving at the end of the run. An unbounded
189
+ * shuffle is indistinguishable from loss plus a very long tail, and it would make a run's
190
+ * end time a function of the die.
191
+ *
192
+ * Determinism: every die is drawn from the rng handed in, which `spawnBots` seeds per bot index.
193
+ * The same seed injects the same delays, drops the same frames and duplicates the same ones. It
194
+ * does not make the *run* deterministic (real sockets and a real clock are still underneath), it
195
+ * makes the injection deterministic, which is the half this file owns.
196
+ */
197
+
198
+ /** Default width of the reorder window when `reorder` is set and `reorderMs` is not. */
199
+ declare const DEFAULT_REORDER_MS = 50;
200
+ interface NetworkConditions {
201
+ /** Round-trip time in ms. Each direction carries half of it. */
202
+ readonly rttMs?: number;
203
+ /** Extra delay per frame, drawn uniformly from `[0, jitterMs)`. */
204
+ readonly jitterMs?: number;
205
+ /** Chance in `[0, 1]` that a state frame is dropped. Session and RPC frames are never dropped. */
206
+ readonly loss?: number;
207
+ /** Chance in `[0, 1]` that a state frame is delivered twice. */
208
+ readonly duplicate?: number;
209
+ /** Chance in `[0, 1]` that a state frame is held back past its neighbours. */
210
+ readonly reorder?: number;
211
+ /** How far a reordered frame may be held back, in ms. Default {@link DEFAULT_REORDER_MS}. */
212
+ readonly reorderMs?: number;
213
+ }
214
+ /** What the wrapper did, per bot. Reported so a run says what was injected, not what was asked for. */
215
+ interface ConditionCounters {
216
+ /** Frames the loss die dropped, client to server. */
217
+ droppedOut: number;
218
+ /** Frames the loss die dropped, server to client. */
219
+ droppedIn: number;
220
+ duplicatedOut: number;
221
+ duplicatedIn: number;
222
+ reorderedOut: number;
223
+ reorderedIn: number;
224
+ /** Frames that were delayed at all (that is, every frame the wrapper saw and did not drop). */
225
+ delayed: number;
226
+ }
227
+ declare function newConditionCounters(): ConditionCounters;
228
+ /**
229
+ * True when these conditions would change anything. `spawnBots` uses it to leave an unconditioned
230
+ * bot on the bare transport, so a plain run keeps exactly the timing it had before D42.
231
+ */
232
+ declare function hasConditions(c: NetworkConditions | undefined): c is NetworkConditions;
233
+ /** One line describing what was injected, for the report and for `bot.conditions`. */
234
+ declare function describeConditions(c: NetworkConditions): string;
235
+ /**
236
+ * The transport-seam shapes this file needs. Declared structurally rather than imported from
237
+ * `@irtio/client` so that `conditionedTransport` can be unit-tested against a fake transport with
238
+ * no socket anywhere near it.
239
+ */
240
+ interface ConditionedSocket {
241
+ send(bytes: Uint8Array): void;
242
+ close(): void;
243
+ onopen: (() => void) | null;
244
+ onmessage: ((bytes: Uint8Array) => void) | null;
245
+ onclose: ((info?: {
246
+ code?: number;
247
+ reason?: string;
248
+ }) => void) | null;
249
+ onerror: ((error: unknown) => void) | null;
250
+ }
251
+ interface ConditionedTransport {
252
+ connect(url: string): ConditionedSocket;
253
+ }
254
+ /** Injectable timers, so a unit test can drive delivery without waiting on the real clock. */
255
+ interface ConditionTimers {
256
+ now(): number;
257
+ setTimeout(fn: () => void, ms: number): () => void;
258
+ }
259
+ interface ConditionedTransportOptions {
260
+ readonly counters?: ConditionCounters;
261
+ readonly timers?: ConditionTimers;
262
+ }
263
+ /**
264
+ * Wraps `base` so every frame crossing this socket is subject to `conditions`.
265
+ *
266
+ * Outbound frames are delayed on their way into `base.send`; inbound frames are delayed on their
267
+ * way out of `base`'s `onmessage`. `onopen`, `onclose` and `onerror` pass through untouched: they
268
+ * are socket lifecycle, not frames, and delaying a close would only make a teardown slower to
269
+ * notice.
270
+ */
271
+ declare function conditionedTransport(base: ConditionedTransport, conditions: NetworkConditions, rng: Rng, options?: ConditionedTransportOptions): ConditionedTransport;
272
+
273
+ /**
274
+ * D42: hit registration.
275
+ *
276
+ * The sniper case from the v1 plan. A bot fires at a moving target; by the time the shot reaches
277
+ * the server the target has moved, and the report says by how much. Nothing here compensates for
278
+ * it, and that is the point: this is how a builder sees why lag compensation would matter, on
279
+ * their own room, before anyone ships one.
280
+ *
281
+ * ## What a shot is
282
+ *
283
+ * `bot.shot({ collection, target, aim })`. It is **bookkeeping, not a wire event**: the game's own
284
+ * RPC still does the shooting, and this call records that it happened, at whom, and where the
285
+ * shooter was aiming, in the shooter's own client-side view. No frame changes and no protocol
286
+ * changes, which is what keeps a hit-registration run a normal run of the same room.
287
+ *
288
+ * ## How a shot is correlated
289
+ *
290
+ * Three numbers, and the honesty of the third is the whole feature:
291
+ *
292
+ * - **`sentAt`** is the shooter's wall clock at the moment it fired.
293
+ * - **The server's receive tick** is estimated: the first recorded tick whose state settled at or
294
+ * after `sentAt` plus the shot's uplink delay (`rttMs / 2` of whatever was injected into that
295
+ * bot). It is an estimate, it is labelled one whenever conditions were injected, and it rests
296
+ * on the recorder and the shooter sharing a clock — true under `irtio dev`, where the room runs
297
+ * in a worker thread of the same process, and the reason this is a local-dev capability exactly
298
+ * as the recorded timeline is.
299
+ * - **The miss distance** is the Euclidean distance between the aim and the target's
300
+ * *authoritative* value at that tick, over the fields the aim names. The authoritative value is
301
+ * read straight out of the recording, so a row can be checked against the timeline file by hand.
302
+ *
303
+ * A row whose target was not in the recording at that tick, or whose shot landed past the end of
304
+ * the recording, says so rather than reporting a distance of zero.
305
+ */
306
+
307
+ /** One aim point: field name to value, over the target collection's numeric fields. */
308
+ type AimPoint = Readonly<Record<string, number>>;
309
+ interface ShotRequest {
310
+ /** The collection the target lives in. */
311
+ readonly collection: string;
312
+ /** The target entity's id. */
313
+ readonly target: string;
314
+ /** Where the shooter aimed, in its own client-side view. */
315
+ readonly aim: AimPoint;
316
+ /** Free-form label, carried through to the row. */
317
+ readonly label?: string;
318
+ }
319
+ /** A shot as recorded, before any correlation. */
320
+ interface ShotRecord extends ShotRequest {
321
+ /** Which bot fired. */
322
+ readonly bot: number;
323
+ /** The shooter's wall clock when it fired. */
324
+ readonly sentAt: number;
325
+ /** The last server tick the shooter had seen when it fired, when the client exposes one. */
326
+ readonly clientTick: number | undefined;
327
+ /** The uplink delay injected into this bot, in ms: `rttMs / 2`. `0` with no conditions. */
328
+ readonly uplinkMs: number;
329
+ }
330
+ /** One correlated shot: what the shooter aimed at, and what the server actually held. */
331
+ interface HitRow extends ShotRecord {
332
+ /** The server tick the shot is judged against, or `undefined` when none could be found. */
333
+ readonly serverTick: number | undefined;
334
+ /**
335
+ * `true` when `serverTick` was derived through an injected uplink delay rather than read off a
336
+ * frame the shot could only have landed in. The report prints the word.
337
+ */
338
+ readonly estimated: boolean;
339
+ /** The target's authoritative value at `serverTick`, over the fields the aim named. */
340
+ readonly authoritative: AimPoint | undefined;
341
+ /** Euclidean distance between `aim` and `authoritative`, or `undefined` when unknown. */
342
+ readonly missDistance: number | undefined;
343
+ /** Why a row has no distance, in one clause. Absent when it has one. */
344
+ readonly unresolved?: string;
345
+ }
346
+ /**
347
+ * Correlates every shot against the recording. Pure: same shots and same dump, same rows, which
348
+ * is what lets a scenario assert on them.
349
+ */
350
+ declare function correlateShots(shots: readonly ShotRecord[], dump: TimelineDump): HitRow[];
351
+
352
+ /**
353
+ * D43: the truth seam.
354
+ *
355
+ * The claim the scenario API's credibility rests on is "the wire matched authoritative state",
356
+ * and until this file existed nobody could check it: a room save was bytes only `RoomCore.restore`
357
+ * could read, and it read them by hydrating a live room. `decodeSave` in `@irtio/runtime` made the
358
+ * bytes readable; this file is what compares them against what the clients were told.
359
+ *
360
+ * ## What is compared
361
+ *
362
+ * Per bot, not globally. A client legitimately never received entities outside its area of
363
+ * interest or outside its role's visibility, so "the client holds the whole save" is not the
364
+ * question and answering it would fail every room with an AOI. The question is the other
365
+ * direction, and it has two halves:
366
+ *
367
+ * 1. **Every entity the client held is in the save**, with the same owner and the same field
368
+ * values. A client holding something authority does not is the class of desync worth catching.
369
+ * 2. **Every value it held matches.** Field by field, on the fields the client actually has.
370
+ *
371
+ * Nothing is asserted about entities in the save that the client never received: that is
372
+ * visibility working, not a difference.
373
+ *
374
+ * ## What "clean" means, precisely
375
+ *
376
+ * Clean is: nothing the client held is absent from the save, and nothing it held disagrees with
377
+ * the save. It is not "the client saw everything". The report prints the sentence that way, and
378
+ * the docs do too, because the shorter version is the overclaim D43 exists to retire.
379
+ *
380
+ * ## The ordering that makes it meaningful
381
+ *
382
+ * The save is taken when the room is quiescent and the clients have had a settle window to
383
+ * receive the last flush. A room still changing after its save will differ from every client, and
384
+ * that difference is real: it is the run reporting that it compared two different instants. The
385
+ * negative fixture is built out of exactly that, which is the honest mechanism the plan asked for
386
+ * rather than a mutation invented for the test.
387
+ */
388
+
389
+ /** One client's view, in the same shape `inspectState` and `decodeSave` produce. */
390
+ type ClientStateView = Record<string, unknown>;
391
+ /** One disagreement between a client's view and the decoded save. */
392
+ interface TruthDifference {
393
+ readonly bot: number;
394
+ readonly collection: string;
395
+ /** The entity id, or `undefined` for a singleton collection. */
396
+ readonly id?: string;
397
+ /** The field, or `undefined` when the whole record is missing from the save. */
398
+ readonly field?: string;
399
+ /** What the client held. */
400
+ readonly client: unknown;
401
+ /** What the save held, or `undefined` when the save has nothing there. */
402
+ readonly save: unknown;
403
+ /** One sentence naming what went wrong, ready to print. */
404
+ readonly detail: string;
405
+ }
406
+ /** One bot's verdict. */
407
+ interface TruthBotResult {
408
+ readonly bot: number;
409
+ /** Records the client held and the save agreed with. */
410
+ readonly compared: number;
411
+ readonly differences: readonly TruthDifference[];
412
+ readonly ok: boolean;
413
+ }
414
+ /** The whole diff. */
415
+ interface TruthDiff {
416
+ readonly ok: boolean;
417
+ readonly bots: readonly TruthBotResult[];
418
+ /** Records compared across every bot. */
419
+ readonly compared: number;
420
+ readonly differences: readonly TruthDifference[];
421
+ /** The save's own tick, carried so the report can say what instant was compared. */
422
+ readonly saveTick: number;
423
+ /** The save blob's format version byte. */
424
+ readonly saveVersion: number;
425
+ }
426
+ /**
427
+ * Captures one bot's client-visible state, in `inspectState`'s shape.
428
+ *
429
+ * It reads the live `room.state` the client built from every frame it received, which is the only
430
+ * place "what this client was told" exists: the frame trace records headers, not payloads. No
431
+ * trace change and no payload retention are needed because of that.
432
+ *
433
+ * `schema` is the project schema as authored, which is what says which collections are entity
434
+ * tables and which are singletons.
435
+ */
436
+ declare function captureClientState(schema: AnySchema, state: unknown): ClientStateView;
437
+ /**
438
+ * Diffs each client's captured view against the decoded save, within that client's visibility.
439
+ *
440
+ * `saveState` is `decodeSave(bytes, schema).state`. Both sides are therefore `inspectState` shape
441
+ * and the comparison is like against like, which is the reason the decoder returns that shape
442
+ * rather than a `PlainState`.
443
+ */
444
+ declare function diffAgainstSave(clients: readonly {
445
+ bot: number;
446
+ state: ClientStateView;
447
+ }[], saveState: Record<string, unknown>, meta: {
448
+ saveTick: number;
449
+ saveVersion: number;
450
+ }): TruthDiff;
451
+
85
452
  /**
86
453
  * The trace recorder: every frame in and out, per bot, with a timestamp and the decoded frame
87
454
  * type — the thing you actually read when a simulation goes red.
@@ -213,13 +580,29 @@ interface ObserverOptions {
213
580
  declare class BotObserver {
214
581
  private readonly options;
215
582
  readonly ring: TraceRing;
216
- readonly violations: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects", string[]>;
217
- readonly counts: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects", number>;
583
+ readonly violations: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects" | "tick-health", string[]>;
584
+ readonly counts: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects" | "tick-health", number>;
585
+ /**
586
+ * Bug #28: the decode extension, *mutable*. It starts as `options.ext` (the schema the run was
587
+ * spawned with) and is rebuilt in place when an inbound `SCHEMA` frame (13) lands — a D50
588
+ * additive `migrate` swaps the wire mid-session, the real client rebuilds its own decoders
589
+ * (`session.swapSchema`), and an observer still holding the v1 descriptor underruns on the v2
590
+ * resync WELCOME and fails `schema-validity` on a swap that was perfect.
591
+ */
592
+ private ext;
593
+ /** How many `SCHEMA` frames rebuilt {@link ext}. For tests and the trace, like the client's. */
594
+ schemaSwaps: number;
218
595
  id: string;
219
596
  role: string;
220
597
  roomId: string;
221
598
  /** Set before `leave()`, so a deliberate teardown is not reported as a disconnect. */
222
599
  stopping: boolean;
600
+ /**
601
+ * D36: the last connection status this bot's client reported, recorded whatever it was. This
602
+ * is the *fact* of where the connection is — the room-gone watcher and the join-timeout
603
+ * message read it — while `disconnects` below stays the judgement, with its old rules.
604
+ */
605
+ lastStatus: string;
223
606
  framesIn: number;
224
607
  framesOut: number;
225
608
  bytesIn: number;
@@ -277,6 +660,14 @@ declare class BotObserver {
277
660
  /** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
278
661
  private inspect;
279
662
  private onWelcome;
663
+ /**
664
+ * Bug #28: a `SCHEMA` frame (D50 additive migrate). Rebuild the decode extension from the
665
+ * descriptor the frame carries, exactly as the real client's `swapSchema` does, so the resync
666
+ * WELCOME about to arrive decodes under the schema it was encoded with. The old view and
667
+ * seen-ids are dropped — they were laid out by descriptors that no longer exist, and the resync
668
+ * WELCOME re-seeds both (`onWelcome`).
669
+ */
670
+ private onSchema;
280
671
  private onDelta;
281
672
  private onCorrect;
282
673
  /**
@@ -307,58 +698,6 @@ declare class BotObserver {
307
698
  private matchWrites;
308
699
  }
309
700
 
310
- /**
311
- * Seeded randomness and schema-driven value generation.
312
- *
313
- * Every bot gets its own generator seeded from `seed + index`, so a whole run replays exactly:
314
- * a red simulation is worth nothing if the next run takes a different path.
315
- *
316
- * Value generation reads `@irtio/schema`'s `TypeDesc` directly rather than duplicating the type
317
- * menu, so a new type kind is a compile error here instead of a silent gap.
318
- */
319
-
320
- /** A seeded pseudo-random source behind `bot.random()`. */
321
- interface Rng {
322
- /** Uniform in [0, 1). */
323
- next(): number;
324
- /** Uniform integer in [lo, hi] (inclusive). */
325
- int(lo: number, hi: number): number;
326
- /** Uniform in [lo, hi). */
327
- float(lo: number, hi: number): number;
328
- /** `true` with probability `p`. */
329
- chance(p: number): boolean;
330
- /** One element, or `undefined` for an empty array. */
331
- pick<T>(items: readonly T[]): T | undefined;
332
- }
333
- /**
334
- * mulberry32: 32 bits of state, good enough for behaviour scripts and — unlike `Math.random` —
335
- * reproducible. Not cryptographic, and never used for anything that needs to be.
336
- */
337
- declare function makeRng(seed: number): Rng;
338
- /** How a value is generated: a small walk (normal play) or a teleport (`--cheat`). */
339
- interface ValueContext {
340
- /** Magnitude of one step for unbounded numeric fields. */
341
- readonly step: number;
342
- /** Produce values a server validator should reject. */
343
- readonly cheat: boolean;
344
- /**
345
- * The value this field started at. Numeric walks are mean-reverting toward it, which keeps a
346
- * long run inside whatever world bounds the room's validator enforces without the bot having to
347
- * know them — the type system only declares a *representable* range, never a legal one.
348
- */
349
- readonly origin?: number | undefined;
350
- /** Ids that a `ref` field may legally point at. */
351
- readonly refIds?: readonly string[] | undefined;
352
- }
353
- /**
354
- * The next value for a field that currently holds `current`. Always inside the type's declared
355
- * range, so `track()` accepts it and the *server* — not the client — is the one that gets to say
356
- * a value is illegal. In cheat mode the value is still type-valid and wildly out of play.
357
- */
358
- declare function nextValue(rng: Rng, desc: TypeDesc, current: unknown, ctx: ValueContext): unknown;
359
- /** A fresh valid value for a field with no current value — RPC params, mostly. */
360
- declare function freshValue(rng: Rng, desc: TypeDesc, ctx: ValueContext): unknown;
361
-
362
701
  /**
363
702
  * The end-of-run summary: one line per invariant, one row per bot, and the handful of aggregate
364
703
  * numbers a load run cares about (frames/s, per-bot bandwidth, corrections, convergence lag).
@@ -412,6 +751,12 @@ interface SimulationReport {
412
751
  /** The headline number: median ms from one bot's write to another bot seeing it. */
413
752
  readonly convergenceLagMs: number | undefined;
414
753
  readonly tracePath: string | undefined;
754
+ /**
755
+ * D65: every bot's bandwidth ledger, merged, when the run asked for one. Cumulative for the
756
+ * whole run and for every bot together — a reader divides by `bots` and `durationMs` to get the
757
+ * per-bot rate the rest of this report prints. Absent when nobody profiled.
758
+ */
759
+ readonly profile?: ProfileSnapshot;
415
760
  }
416
761
  /** `undefined` when nothing converged during the run — an honest gap beats a fabricated zero. */
417
762
  declare function convergenceStats(lags: readonly number[]): ConvergenceStats | undefined;
@@ -422,6 +767,13 @@ interface BuildReportOptions {
422
767
  readonly lags: readonly number[];
423
768
  readonly thresholds?: InvariantThresholds;
424
769
  readonly tracePath?: string | undefined;
770
+ /**
771
+ * D36: the server's own view of its tick loop over the run window. Absent ⇒ `tick-health`
772
+ * reports `unavailable`, which is the honest answer for a caller that could not read it.
773
+ */
774
+ readonly tickHealth?: TickHealthReading | undefined;
775
+ /** D65: one ledger per bot, merged into `SimulationReport.profile`. */
776
+ readonly profiles?: readonly ProfileSnapshot[] | undefined;
425
777
  }
426
778
  /** Folds the observers into the report `irtio simulate` prints and tests assert on. */
427
779
  declare function buildReport(options: BuildReportOptions): SimulationReport;
@@ -444,6 +796,21 @@ declare function buildReport(options: BuildReportOptions): SimulationReport;
444
796
  declare const DEFAULT_SEED = 96016;
445
797
  /** Trace ring size per bot. 20 bots × 4096 ≈ 80k headers, a few megabytes at worst. */
446
798
  declare const DEFAULT_TRACE_LIMIT = 4096;
799
+ /**
800
+ * D36 defect A: the client's patience with `E_STARTING` is documented, correct, and unbounded —
801
+ * `session.start()` has no deadline, so a room that answers `starting` forever never settles the
802
+ * join and the run's duration timer (armed *after* the join) never even gets set. The bound
803
+ * belongs here rather than in the client, because other callers rely on that patience.
804
+ */
805
+ declare const DEFAULT_JOIN_TIMEOUT_MS = 20000;
806
+ /**
807
+ * D36: how long every bot may be disconnected at once before the run is called dead. A room that
808
+ * went away takes every socket with it; a room that restarted takes them for a moment and hands
809
+ * them back. This is the width of "for a moment".
810
+ */
811
+ declare const DEFAULT_ROOM_GONE_GRACE_MS = 5000;
812
+ /** Why a run stopped. `undefined` until it has. */
813
+ type RunEnd = 'scripts' | 'duration' | 'room-gone';
447
814
  /** The room a bot holds: a full `Room` with a schema, or a schemaless `RelayRoom`. */
448
815
  type BotRoom<S> = S extends AnySchema ? Room<S> : RelayRoom;
449
816
  interface UntilOptions {
@@ -472,6 +839,16 @@ interface Bot<S = undefined> {
472
839
  /** This bot's slice of the trace. */
473
840
  readonly trace: Trace;
474
841
  readonly stats: BotStats;
842
+ /** D42: the network conditions injected into this bot's socket, or `undefined` for a clean one. */
843
+ readonly conditions: NetworkConditions | undefined;
844
+ /**
845
+ * D42: records that this bot fired at something, for the hit-registration report.
846
+ *
847
+ * Bookkeeping only. It sends nothing and changes no frame: the game's own RPC still does the
848
+ * shooting, and this call sits beside it saying what was aimed at and when. The runner
849
+ * correlates it with the recorded authoritative timeline afterwards.
850
+ */
851
+ shot(request: ShotRequest): void;
475
852
  }
476
853
  type BotScript<S = undefined> = (bot: Bot<S>) => void | Promise<void>;
477
854
  interface SpawnOptionsBase {
@@ -499,10 +876,38 @@ interface SpawnOptionsBase {
499
876
  readonly handlerErrorsMax?: number;
500
877
  /** `misprediction` threshold: numeric units one correction may snap. Default: infinite. */
501
878
  readonly mispredictionMagnitudeMax?: number;
879
+ /**
880
+ * D65: give every bot a bandwidth ledger, merged into `SimulationReport.profile`. This is the
881
+ * view from the outside — what a real client's connection carries — as opposed to what the room
882
+ * believes it sent. Off by default.
883
+ */
884
+ readonly profile?: boolean;
502
885
  /** `snaps` tolerance: cap-exceeded reconciliations per bot. Default: infinite. */
503
886
  readonly snapsMax?: number;
887
+ /** `tick-health` threshold: server tick overruns tolerated in the run window. Default 0. */
888
+ readonly overrunsMax?: number;
889
+ /**
890
+ * D36: how long one bot's initial join may take before `spawnBots` gives up on the whole run.
891
+ * Default {@link DEFAULT_JOIN_TIMEOUT_MS}; `0` restores the old unbounded wait.
892
+ */
893
+ readonly joinTimeoutMs?: number;
894
+ /**
895
+ * D36: how long every bot may be disconnected at once before the run ends as `room-gone`.
896
+ * Default {@link DEFAULT_ROOM_GONE_GRACE_MS}; `0` disables the watcher.
897
+ */
898
+ readonly roomGoneGraceMs?: number;
504
899
  /** @internal Wrap `webSocketTransport` to get at the socket (the reconnection scenarios). */
505
900
  readonly transport?: Transport;
901
+ /**
902
+ * D42: network conditions injected into every bot's socket, or a function of the bot index for
903
+ * a split run (one lagged shooter against one clean target). A bot whose conditions are absent,
904
+ * or would change nothing, keeps the bare transport and the timing a plain run has.
905
+ *
906
+ * This is a *socket-level* model on a real wire, which is a different instrument from
907
+ * `@irtio/testing`'s `LatencySpec`: that one is an in-process harness on a fake clock, with no
908
+ * sockets at all. The vocabulary is deliberately the same and the two are not interchangeable.
909
+ */
910
+ readonly conditions?: NetworkConditions | ((index: number) => NetworkConditions | undefined);
506
911
  }
507
912
  interface SpawnOptions<S extends AnySchema> extends SpawnOptionsBase {
508
913
  readonly schema: S;
@@ -521,6 +926,14 @@ interface RelaySpawnOptions extends SpawnOptionsBase {
521
926
  readonly schema?: undefined;
522
927
  readonly script?: BotScript<undefined>;
523
928
  }
929
+ /** D42: one bot's injected conditions, beside what the wrapper counted while injecting them. */
930
+ interface BotConditions {
931
+ readonly bot: number;
932
+ /** What was asked for, or `undefined` when this bot ran on the bare transport. */
933
+ readonly conditions: NetworkConditions | undefined;
934
+ /** What the wrapper did. All zero when nothing was injected. */
935
+ readonly counters: ConditionCounters;
936
+ }
524
937
  interface BotRunner<S = undefined> extends Iterable<Bot<S>> {
525
938
  readonly bots: readonly Bot<S>[];
526
939
  readonly roomId: string;
@@ -531,6 +944,21 @@ interface BotRunner<S = undefined> extends Iterable<Bot<S>> {
531
944
  bot: number;
532
945
  error: unknown;
533
946
  }[];
947
+ /** D42: every `bot.shot(...)`, in the order the bots recorded them. */
948
+ readonly shots: readonly ShotRecord[];
949
+ /** D42: what was injected per bot index, and what the wrapper actually did with it. */
950
+ readonly conditions: readonly BotConditions[];
951
+ /**
952
+ * D36: what ended the run, once something has. `scripts` (every script returned), `duration`
953
+ * (the deadline fired) or `room-gone` (every bot was disconnected for longer than the grace).
954
+ */
955
+ readonly endedBy: RunEnd | undefined;
956
+ /**
957
+ * D36: hand the runner the server's own tick counters for the run window, taken however the
958
+ * caller can take them. Every report built afterwards carries the `tick-health` invariant;
959
+ * without this call it reports `unavailable`.
960
+ */
961
+ recordTickHealth(reading: TickHealthReading): void;
534
962
  /** Resolves when every script has finished; rejects with the first one that threw. */
535
963
  done(): Promise<void>;
536
964
  /** Stops the scripts, leaves every room, and returns the final report. */
@@ -538,10 +966,164 @@ interface BotRunner<S = undefined> extends Iterable<Bot<S>> {
538
966
  /** The report as of now — safe to call mid-run. */
539
967
  report(): SimulationReport;
540
968
  }
969
+ /**
970
+ * D36: a join that never settled. Thrown by `spawnBots`, and the reason `irtio simulate` can say
971
+ * "the run was not performed" instead of exiting 13 with nothing printed.
972
+ */
973
+ declare class JoinTimeoutError extends Error {
974
+ readonly bot: number;
975
+ readonly timeoutMs: number;
976
+ readonly lastStatus: string;
977
+ readonly name = "JoinTimeoutError";
978
+ constructor(bot: number, timeoutMs: number, lastStatus: string);
979
+ }
541
980
  /** Spawns `n` real clients on one room, runs `script` on each, and watches every invariant. */
542
981
  declare function spawnBots<S extends AnySchema>(n: number, options: SpawnOptions<S>): Promise<BotRunner<S>>;
543
982
  declare function spawnBots(n: number, options?: RelaySpawnOptions): Promise<BotRunner<undefined>>;
544
983
 
984
+ /**
985
+ * D41: the read-only view a scenario's `assert` gets over the recorded authoritative timeline.
986
+ *
987
+ * The whole API is on this page on purpose. D41's constraint is that an agent can write a correct
988
+ * scenario from one docs page, and the fastest way to lose that is to grow matchers, query
989
+ * helpers and a second vocabulary for state a room author already has names for. So a moment is
990
+ * the room's own state, keyed by the collection names from the schema, and the only verbs are
991
+ * "read this tick", "find the first tick where", and "label this assertion".
992
+ *
993
+ * Reading is exact and never quietly empty. `at()` on a tick the recorder never held, or dropped,
994
+ * throws with the range that survived. A collection name the schema does not have throws with the
995
+ * names it does. Both of those would otherwise become an assertion that passes because it read
996
+ * nothing, which is worse than an assertion that fails.
997
+ */
998
+
999
+ /** One entity's fields, or one singleton's fields, as recorded. */
1000
+ type TimelineRecord = Readonly<Record<string, unknown>>;
1001
+ /** One collection at one tick. Entity collections have entries; a singleton has `value`. */
1002
+ interface TimelineCollection {
1003
+ /** The entity's fields, or `undefined` when no entity had that id at this tick. */
1004
+ get(id: string): TimelineRecord | undefined;
1005
+ has(id: string): boolean;
1006
+ /** The ids present at this tick. */
1007
+ ids(): readonly string[];
1008
+ readonly size: number;
1009
+ /** The client that owned this entity at this tick, if any. */
1010
+ owner(id: string): string | undefined;
1011
+ /** A singleton collection's fields. `undefined` for an entity collection. */
1012
+ readonly value: TimelineRecord | undefined;
1013
+ }
1014
+ /**
1015
+ * The room's authoritative state at one tick, keyed by the schema's own collection names.
1016
+ *
1017
+ * The schema is not known at compile time, so this is an index signature and a project with
1018
+ * `noUncheckedIndexedAccess` types each collection as possibly undefined. At runtime it never is:
1019
+ * a name your schema does have always resolves, and a name it does not have throws
1020
+ * `UnknownCollectionError` rather than answering with a blank.
1021
+ */
1022
+ interface TimelineState {
1023
+ readonly [collection: string]: TimelineCollection;
1024
+ }
1025
+ interface TimelineMoment {
1026
+ readonly tick: number;
1027
+ readonly state: TimelineState;
1028
+ }
1029
+ /** One labelled assertion and how it went. This is what the report's scenario section lists. */
1030
+ interface AssertionResult {
1031
+ readonly name: string;
1032
+ readonly ok: boolean;
1033
+ /** The last tick the assertion read, when it read one. */
1034
+ readonly tick?: number;
1035
+ /** On a pass, the ticks it read. On a failure, the thrown message. */
1036
+ readonly detail: string;
1037
+ }
1038
+ interface Timeline {
1039
+ readonly roomId: string;
1040
+ /** Every recorded tick, oldest first. */
1041
+ readonly ticks: readonly number[];
1042
+ /** Ticks the recorder's caps evicted. Non-zero means this is a tail, not the whole run. */
1043
+ readonly dropped: number;
1044
+ /** The room's state at that tick. Throws when the tick was never recorded or was dropped. */
1045
+ at(tick: number): TimelineState;
1046
+ /** The first recorded tick where `match` holds, oldest first. */
1047
+ find(match: (state: TimelineState, tick: number) => boolean): TimelineMoment | undefined;
1048
+ /**
1049
+ * Runs one labelled assertion. A throw inside it fails the scenario, and the report names the
1050
+ * label, the tick the assertion last read, and the message you threw. The throw does not
1051
+ * escape, so later checks still run and the report lists every verdict. A throw outside a
1052
+ * `check` also fails the scenario; it just has no label to print.
1053
+ */
1054
+ check(name: string, assertion: () => void): void;
1055
+ /** Every `check` that ran, in order. The runner reads this to build the report. */
1056
+ readonly results: readonly AssertionResult[];
1057
+ }
1058
+ /** The scenario read a collection the schema does not have. Almost always a typo, never empty. */
1059
+ declare class UnknownCollectionError extends Error {
1060
+ readonly name = "UnknownCollectionError";
1061
+ constructor(collection: string, known: readonly string[]);
1062
+ }
1063
+ /** The scenario read a tick the recording does not hold. See `Timeline.dropped`. */
1064
+ declare class TickNotRecordedError extends Error {
1065
+ readonly tick: number;
1066
+ readonly name = "TickNotRecordedError";
1067
+ constructor(tick: number, first: number | undefined, last: number | undefined, dropped: number);
1068
+ }
1069
+ /** Builds the read-only `Timeline` a scenario's `assert` is handed, over a recorder's dump. */
1070
+ declare function makeTimeline(dump: TimelineDump): Timeline;
1071
+
1072
+ /**
1073
+ * D41: `defineScenario`. A scenario is a TypeScript module next to the room file: how many bots,
1074
+ * what they do, and what must be true of the recorded authoritative timeline afterwards.
1075
+ *
1076
+ * The type is small because the docs page has to be. `script` is the `BotScript` `@irtio/bots`
1077
+ * already has, so there is no second bot vocabulary to learn, and `assert` is an ordinary
1078
+ * function over the timeline, so there is no matcher library either. `defineScenario` itself only
1079
+ * exists for the types: it returns its argument, and the CLI's loader shape-checks the export so a
1080
+ * wrong one produces a sentence instead of a stack trace.
1081
+ */
1082
+
1083
+ /**
1084
+ * D42/D43: what an adversarial scenario can read beyond the timeline. Both fields are empty or
1085
+ * `undefined` unless the scenario asked for them, so a scenario that fires no shots and never
1086
+ * sets `truth` reads and behaves exactly as it did in part 3.
1087
+ */
1088
+ interface ScenarioEvidence {
1089
+ /** One row per `bot.shot(...)`, correlated against the recording. Empty when nothing fired. */
1090
+ readonly hits: readonly HitRow[];
1091
+ /** The save-versus-clients diff, when `truth` asked for one and one could be taken. */
1092
+ readonly truth: TruthDiff | undefined;
1093
+ }
1094
+ interface ScenarioDefinition<S extends AnySchema = AnySchema> {
1095
+ /** How many bots to spawn. Bot 0 creates the room; the rest join it. */
1096
+ readonly bots: number;
1097
+ /** What each bot does. The same `bot` object `spawnBots` scripts get. */
1098
+ readonly script: BotScript<S>;
1099
+ /**
1100
+ * What must be true afterwards. Assertions run against the recorded timeline, not a live room,
1101
+ * so a scenario replays: same seed, same recording, same verdict. A throw fails the scenario.
1102
+ */
1103
+ readonly assert: (timeline: Timeline, evidence: ScenarioEvidence) => void | Promise<void>;
1104
+ /** How long to run before the scripts are asked to stop. Default 10. */
1105
+ readonly seconds?: number;
1106
+ /** Base seed; bot `i` uses `seed + i`. Default 0x17710, the same as `irtio simulate`. */
1107
+ readonly seed?: number;
1108
+ /**
1109
+ * D42: network conditions injected into every bot, or a function of the bot index. A scenario is
1110
+ * where a per-bot split belongs, because a scenario is code already: one lagged shooter against
1111
+ * one clean target is two lines here and a flag language on the CLI.
1112
+ */
1113
+ readonly conditions?: NetworkConditions | ((index: number) => NetworkConditions | undefined);
1114
+ /** D42: which bots write values a validator should reject. `true` means all of them. */
1115
+ readonly cheat?: boolean | ((index: number) => boolean);
1116
+ /**
1117
+ * D43: take a room save at the end of the run and diff it against what each client received,
1118
+ * within that client's visibility. The verdict lands in `evidence.truth` and in the report.
1119
+ */
1120
+ readonly truth?: boolean;
1121
+ }
1122
+ /** Identity, typed. The export `irtio simulate --scenario` looks for. */
1123
+ declare function defineScenario<S extends AnySchema = AnySchema>(scenario: ScenarioDefinition<S>): ScenarioDefinition<S>;
1124
+ /** True when a loaded module's default export is shaped like a scenario. */
1125
+ declare function looksLikeScenario(value: unknown): value is ScenarioDefinition;
1126
+
545
1127
  /**
546
1128
  * The generic behaviour script: play the room without knowing anything about it.
547
1129
  *
@@ -562,8 +1144,14 @@ declare function spawnBots(n: number, options?: RelaySpawnOptions): Promise<BotR
562
1144
  */
563
1145
 
564
1146
  interface RandomScriptOptions {
565
- /** Write values a validator should reject, and expect corrections back. */
566
- readonly cheat?: boolean;
1147
+ /**
1148
+ * Write values a validator should reject, and expect corrections back.
1149
+ *
1150
+ * D42: per bot as well as global. A function of the bot index makes one bot hostile while the
1151
+ * rest play honestly, which is what an adversarial run against a real room usually looks like.
1152
+ * `--cheat` on the CLI still means every bot cheats.
1153
+ */
1154
+ readonly cheat?: boolean | ((index: number) => boolean);
567
1155
  /** Milliseconds between behaviour steps. Default 50 — one per client flush window. */
568
1156
  readonly stepMs?: number;
569
1157
  /** Magnitude of one numeric step for a well-behaved bot. Default 8. */
@@ -573,6 +1161,12 @@ interface RandomScriptOptions {
573
1161
  /** Fields touched per owned instance per step. Default 2. */
574
1162
  readonly fieldsPerStep?: number;
575
1163
  }
1164
+ /**
1165
+ * D42: `cheat` as a predicate over the bot index, whatever shape it was given in. Exported so the
1166
+ * CLI and the report can say *which* bots cheated rather than only that some did, which is the
1167
+ * difference between "the room accepted an illegal write" and a line an agent can act on.
1168
+ */
1169
+ declare function cheatPredicate(cheat: boolean | ((index: number) => boolean) | undefined): (index: number) => boolean;
576
1170
  /**
577
1171
  * A behaviour script for any schema: random writes to the instances this bot owns, and the odd
578
1172
  * void RPC with valid params. Runs until the bot is stopped.
@@ -591,4 +1185,4 @@ interface RelayEchoScriptOptions {
591
1185
  */
592
1186
  declare function relayEchoScript(options?: RelayEchoScriptOptions): BotScript<undefined>;
593
1187
 
594
- export { type Bot, BotObserver, type BotRoom, type BotRunner, type BotScript, type BotStats, type BuildReportOptions, type ConvergenceStats, DEFAULT_SEED, DEFAULT_THRESHOLDS, DEFAULT_TRACE_LIMIT, INVARIANT_NAMES, type InvariantName, type InvariantResult, type InvariantThresholds, type ObserverOptions, type RandomScriptOptions, type RelayEchoScriptOptions, type RelaySpawnOptions, type Rng, type SimulationReport, type SimulationTotals, type SpatialVisibilityContext, type SpawnOptions, type Trace, type TraceDump, type TraceEntry, TraceRing, type UntilOptions, type ValueContext, WriteLog, buildReport, convergenceStats, deltaVisibilityLeaks, frameName, frameVisibilityLeaks, freshValue, makeRng, makeTrace, nextValue, randomScript, relayEchoScript, snapshotVisibilityLeaks, spawnBots };
1188
+ export { type AimPoint, type AssertionResult, type Bot, type BotConditions, BotObserver, type BotRoom, type BotRunner, type BotScript, type BotStats, type BuildReportOptions, type ClientStateView, type ConditionCounters, type ConditionTimers, type ConditionedSocket, type ConditionedTransport, type ConditionedTransportOptions, type ConvergenceStats, DEFAULT_JOIN_TIMEOUT_MS, DEFAULT_REORDER_MS, DEFAULT_ROOM_GONE_GRACE_MS, DEFAULT_SEED, DEFAULT_THRESHOLDS, DEFAULT_TRACE_LIMIT, type HitRow, INVARIANT_NAMES, type InvariantName, type InvariantResult, type InvariantState, type InvariantThresholds, JoinTimeoutError, type NetworkConditions, type ObserverOptions, type RandomScriptOptions, type RelayEchoScriptOptions, type RelaySpawnOptions, type Rng, type RunEnd, type ScenarioDefinition, type ScenarioEvidence, type ShotRecord, type ShotRequest, type SimulationReport, type SimulationTotals, type SpatialVisibilityContext, type SpawnOptions, type TickHealthReading, TickNotRecordedError, type Timeline, type TimelineCollection, type TimelineMoment, type TimelineRecord, type TimelineState, type Trace, type TraceDump, type TraceEntry, TraceRing, type TruthBotResult, type TruthDiff, type TruthDifference, UnknownCollectionError, type UntilOptions, type ValueContext, WriteLog, buildReport, captureClientState, cheatPredicate, conditionedTransport, convergenceStats, correlateShots, defineScenario, deltaVisibilityLeaks, describeConditions, diffAgainstSave, frameName, frameVisibilityLeaks, freshValue, hasConditions, looksLikeScenario, makeRng, makeTimeline, makeTrace, newConditionCounters, nextValue, randomScript, relayEchoScript, snapshotVisibilityLeaks, spawnBots };