@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.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.
@@ -110,6 +113,342 @@ declare function snapshotVisibilityLeaks(ext: AnySchema, role: string, state: Pl
110
113
  */
111
114
  declare function frameVisibilityLeaks(ext: AnySchema, role: string, type: number, payload: Uint8Array, spatial?: SpatialVisibilityContext): string[];
112
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
+
113
452
  /**
114
453
  * The trace recorder: every frame in and out, per bot, with a timestamp and the decoded frame
115
454
  * type — the thing you actually read when a simulation goes red.
@@ -243,6 +582,16 @@ declare class BotObserver {
243
582
  readonly ring: TraceRing;
244
583
  readonly violations: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects" | "tick-health", string[]>;
245
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;
246
595
  id: string;
247
596
  role: string;
248
597
  roomId: string;
@@ -311,6 +660,14 @@ declare class BotObserver {
311
660
  /** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
312
661
  private inspect;
313
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;
314
671
  private onDelta;
315
672
  private onCorrect;
316
673
  /**
@@ -341,58 +698,6 @@ declare class BotObserver {
341
698
  private matchWrites;
342
699
  }
343
700
 
344
- /**
345
- * Seeded randomness and schema-driven value generation.
346
- *
347
- * Every bot gets its own generator seeded from `seed + index`, so a whole run replays exactly:
348
- * a red simulation is worth nothing if the next run takes a different path.
349
- *
350
- * Value generation reads `@irtio/schema`'s `TypeDesc` directly rather than duplicating the type
351
- * menu, so a new type kind is a compile error here instead of a silent gap.
352
- */
353
-
354
- /** A seeded pseudo-random source behind `bot.random()`. */
355
- interface Rng {
356
- /** Uniform in [0, 1). */
357
- next(): number;
358
- /** Uniform integer in [lo, hi] (inclusive). */
359
- int(lo: number, hi: number): number;
360
- /** Uniform in [lo, hi). */
361
- float(lo: number, hi: number): number;
362
- /** `true` with probability `p`. */
363
- chance(p: number): boolean;
364
- /** One element, or `undefined` for an empty array. */
365
- pick<T>(items: readonly T[]): T | undefined;
366
- }
367
- /**
368
- * mulberry32: 32 bits of state, good enough for behaviour scripts and — unlike `Math.random` —
369
- * reproducible. Not cryptographic, and never used for anything that needs to be.
370
- */
371
- declare function makeRng(seed: number): Rng;
372
- /** How a value is generated: a small walk (normal play) or a teleport (`--cheat`). */
373
- interface ValueContext {
374
- /** Magnitude of one step for unbounded numeric fields. */
375
- readonly step: number;
376
- /** Produce values a server validator should reject. */
377
- readonly cheat: boolean;
378
- /**
379
- * The value this field started at. Numeric walks are mean-reverting toward it, which keeps a
380
- * long run inside whatever world bounds the room's validator enforces without the bot having to
381
- * know them — the type system only declares a *representable* range, never a legal one.
382
- */
383
- readonly origin?: number | undefined;
384
- /** Ids that a `ref` field may legally point at. */
385
- readonly refIds?: readonly string[] | undefined;
386
- }
387
- /**
388
- * The next value for a field that currently holds `current`. Always inside the type's declared
389
- * range, so `track()` accepts it and the *server* — not the client — is the one that gets to say
390
- * a value is illegal. In cheat mode the value is still type-valid and wildly out of play.
391
- */
392
- declare function nextValue(rng: Rng, desc: TypeDesc, current: unknown, ctx: ValueContext): unknown;
393
- /** A fresh valid value for a field with no current value — RPC params, mostly. */
394
- declare function freshValue(rng: Rng, desc: TypeDesc, ctx: ValueContext): unknown;
395
-
396
701
  /**
397
702
  * The end-of-run summary: one line per invariant, one row per bot, and the handful of aggregate
398
703
  * numbers a load run cares about (frames/s, per-bot bandwidth, corrections, convergence lag).
@@ -446,6 +751,12 @@ interface SimulationReport {
446
751
  /** The headline number: median ms from one bot's write to another bot seeing it. */
447
752
  readonly convergenceLagMs: number | undefined;
448
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;
449
760
  }
450
761
  /** `undefined` when nothing converged during the run — an honest gap beats a fabricated zero. */
451
762
  declare function convergenceStats(lags: readonly number[]): ConvergenceStats | undefined;
@@ -461,6 +772,8 @@ interface BuildReportOptions {
461
772
  * reports `unavailable`, which is the honest answer for a caller that could not read it.
462
773
  */
463
774
  readonly tickHealth?: TickHealthReading | undefined;
775
+ /** D65: one ledger per bot, merged into `SimulationReport.profile`. */
776
+ readonly profiles?: readonly ProfileSnapshot[] | undefined;
464
777
  }
465
778
  /** Folds the observers into the report `irtio simulate` prints and tests assert on. */
466
779
  declare function buildReport(options: BuildReportOptions): SimulationReport;
@@ -526,6 +839,16 @@ interface Bot<S = undefined> {
526
839
  /** This bot's slice of the trace. */
527
840
  readonly trace: Trace;
528
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;
529
852
  }
530
853
  type BotScript<S = undefined> = (bot: Bot<S>) => void | Promise<void>;
531
854
  interface SpawnOptionsBase {
@@ -553,6 +876,12 @@ interface SpawnOptionsBase {
553
876
  readonly handlerErrorsMax?: number;
554
877
  /** `misprediction` threshold: numeric units one correction may snap. Default: infinite. */
555
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;
556
885
  /** `snaps` tolerance: cap-exceeded reconciliations per bot. Default: infinite. */
557
886
  readonly snapsMax?: number;
558
887
  /** `tick-health` threshold: server tick overruns tolerated in the run window. Default 0. */
@@ -569,6 +898,16 @@ interface SpawnOptionsBase {
569
898
  readonly roomGoneGraceMs?: number;
570
899
  /** @internal Wrap `webSocketTransport` to get at the socket (the reconnection scenarios). */
571
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);
572
911
  }
573
912
  interface SpawnOptions<S extends AnySchema> extends SpawnOptionsBase {
574
913
  readonly schema: S;
@@ -587,6 +926,14 @@ interface RelaySpawnOptions extends SpawnOptionsBase {
587
926
  readonly schema?: undefined;
588
927
  readonly script?: BotScript<undefined>;
589
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
+ }
590
937
  interface BotRunner<S = undefined> extends Iterable<Bot<S>> {
591
938
  readonly bots: readonly Bot<S>[];
592
939
  readonly roomId: string;
@@ -597,6 +944,10 @@ interface BotRunner<S = undefined> extends Iterable<Bot<S>> {
597
944
  bot: number;
598
945
  error: unknown;
599
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[];
600
951
  /**
601
952
  * D36: what ended the run, once something has. `scripts` (every script returned), `duration`
602
953
  * (the deadline fired) or `room-gone` (every bot was disconnected for longer than the grace).
@@ -630,6 +981,149 @@ declare class JoinTimeoutError extends Error {
630
981
  declare function spawnBots<S extends AnySchema>(n: number, options: SpawnOptions<S>): Promise<BotRunner<S>>;
631
982
  declare function spawnBots(n: number, options?: RelaySpawnOptions): Promise<BotRunner<undefined>>;
632
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
+
633
1127
  /**
634
1128
  * The generic behaviour script: play the room without knowing anything about it.
635
1129
  *
@@ -650,8 +1144,14 @@ declare function spawnBots(n: number, options?: RelaySpawnOptions): Promise<BotR
650
1144
  */
651
1145
 
652
1146
  interface RandomScriptOptions {
653
- /** Write values a validator should reject, and expect corrections back. */
654
- 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);
655
1155
  /** Milliseconds between behaviour steps. Default 50 — one per client flush window. */
656
1156
  readonly stepMs?: number;
657
1157
  /** Magnitude of one numeric step for a well-behaved bot. Default 8. */
@@ -661,6 +1161,12 @@ interface RandomScriptOptions {
661
1161
  /** Fields touched per owned instance per step. Default 2. */
662
1162
  readonly fieldsPerStep?: number;
663
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;
664
1170
  /**
665
1171
  * A behaviour script for any schema: random writes to the instances this bot owns, and the odd
666
1172
  * void RPC with valid params. Runs until the bot is stopped.
@@ -679,4 +1185,4 @@ interface RelayEchoScriptOptions {
679
1185
  */
680
1186
  declare function relayEchoScript(options?: RelayEchoScriptOptions): BotScript<undefined>;
681
1187
 
682
- export { type Bot, BotObserver, type BotRoom, type BotRunner, type BotScript, type BotStats, type BuildReportOptions, type ConvergenceStats, DEFAULT_JOIN_TIMEOUT_MS, DEFAULT_ROOM_GONE_GRACE_MS, DEFAULT_SEED, DEFAULT_THRESHOLDS, DEFAULT_TRACE_LIMIT, INVARIANT_NAMES, type InvariantName, type InvariantResult, type InvariantState, type InvariantThresholds, JoinTimeoutError, type ObserverOptions, type RandomScriptOptions, type RelayEchoScriptOptions, type RelaySpawnOptions, type Rng, type RunEnd, type SimulationReport, type SimulationTotals, type SpatialVisibilityContext, type SpawnOptions, type TickHealthReading, 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 };