@irtio/bots 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +917 -63
- package/dist/index.js +1114 -144
- 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 {
|
|
2
|
+
import { TimelineDump } from '@irtio/runtime';
|
|
3
|
+
export { TimelineDump, TimelineFrame } from '@irtio/runtime';
|
|
4
|
+
import { PredictionStatus, Correction, MatchTicket, 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,7 +17,7 @@ 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", "tick-health"];
|
|
20
|
+
declare const INVARIANT_NAMES: readonly ["schema-validity", "visibility-leak", "bandwidth", "handler-error", "correction-storm", "misprediction", "snaps", "disconnects", "tick-health", "typed-message-drops", "party-integrity"];
|
|
18
21
|
type InvariantName = (typeof INVARIANT_NAMES)[number];
|
|
19
22
|
/**
|
|
20
23
|
* D36: three states, not two. `unavailable` exists because the server's own tick counters cannot
|
|
@@ -45,6 +48,27 @@ interface TickHealthReading {
|
|
|
45
48
|
/** Where the numbers came from, so a reader can go and check. */
|
|
46
49
|
readonly source?: string;
|
|
47
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* M6 lane E (D73-c): what the queue answered, per bot, for the `party-integrity` invariant and the
|
|
53
|
+
* matchmaking report section. Absent ⇒ the run did not queue and the invariant is `unavailable`,
|
|
54
|
+
* which is D36's rule applied here: "nobody asked" and "nothing was wrong" stay different answers.
|
|
55
|
+
*/
|
|
56
|
+
interface MatchmakingReading {
|
|
57
|
+
readonly tickets: readonly {
|
|
58
|
+
readonly bot: number;
|
|
59
|
+
readonly room: string;
|
|
60
|
+
readonly queue: string;
|
|
61
|
+
/** Seats the queue says the room holds. `-1` seat means a backfill answer, not a position. */
|
|
62
|
+
readonly size: number;
|
|
63
|
+
readonly backfill: boolean;
|
|
64
|
+
readonly waitedMs: number;
|
|
65
|
+
readonly party?: number;
|
|
66
|
+
}[];
|
|
67
|
+
readonly failures: readonly {
|
|
68
|
+
readonly requested: number;
|
|
69
|
+
readonly code: string;
|
|
70
|
+
}[];
|
|
71
|
+
}
|
|
48
72
|
interface SpatialVisibilityContext {
|
|
49
73
|
/**
|
|
50
74
|
* The state the anchor and the judged positions are read from. Server truth when a test has it
|
|
@@ -88,6 +112,16 @@ interface InvariantThresholds {
|
|
|
88
112
|
* paid for — and one `--overruns-max` away from being usable on a deliberately busy room.
|
|
89
113
|
*/
|
|
90
114
|
readonly overrunsMax: number;
|
|
115
|
+
/**
|
|
116
|
+
* D70 `typed-message-drops`: typed peer messages a bot dropped across the run — an index its
|
|
117
|
+
* schema does not have, or a payload it could not decode.
|
|
118
|
+
*
|
|
119
|
+
* Zero by default, and strict on purpose. Every drop is a peer sending a shape this bot's
|
|
120
|
+
* schema does not describe, which in a simulation means the scenario and the room disagree.
|
|
121
|
+
* Before this the disagreement was invisible: the message simply never arrived, and the run
|
|
122
|
+
* went green.
|
|
123
|
+
*/
|
|
124
|
+
readonly typedMessageDropsMax: number;
|
|
91
125
|
}
|
|
92
126
|
declare const DEFAULT_THRESHOLDS: InvariantThresholds;
|
|
93
127
|
/**
|
|
@@ -110,6 +144,371 @@ declare function snapshotVisibilityLeaks(ext: AnySchema, role: string, state: Pl
|
|
|
110
144
|
*/
|
|
111
145
|
declare function frameVisibilityLeaks(ext: AnySchema, role: string, type: number, payload: Uint8Array, spatial?: SpatialVisibilityContext): string[];
|
|
112
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Seeded randomness and schema-driven value generation.
|
|
149
|
+
*
|
|
150
|
+
* Every bot gets its own generator seeded from `seed + index`, so a whole run replays exactly:
|
|
151
|
+
* a red simulation is worth nothing if the next run takes a different path.
|
|
152
|
+
*
|
|
153
|
+
* Value generation reads `@irtio/schema`'s `TypeDesc` directly rather than duplicating the type
|
|
154
|
+
* menu, so a new type kind is a compile error here instead of a silent gap.
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
/** A seeded pseudo-random source behind `bot.random()`. */
|
|
158
|
+
interface Rng {
|
|
159
|
+
/** Uniform in [0, 1). */
|
|
160
|
+
next(): number;
|
|
161
|
+
/** Uniform integer in [lo, hi] (inclusive). */
|
|
162
|
+
int(lo: number, hi: number): number;
|
|
163
|
+
/** Uniform in [lo, hi). */
|
|
164
|
+
float(lo: number, hi: number): number;
|
|
165
|
+
/** `true` with probability `p`. */
|
|
166
|
+
chance(p: number): boolean;
|
|
167
|
+
/** One element, or `undefined` for an empty array. */
|
|
168
|
+
pick<T>(items: readonly T[]): T | undefined;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* mulberry32: 32 bits of state, good enough for behaviour scripts and — unlike `Math.random` —
|
|
172
|
+
* reproducible. Not cryptographic, and never used for anything that needs to be.
|
|
173
|
+
*/
|
|
174
|
+
declare function makeRng(seed: number): Rng;
|
|
175
|
+
/** How a value is generated: a small walk (normal play) or a teleport (`--cheat`). */
|
|
176
|
+
interface ValueContext {
|
|
177
|
+
/** Magnitude of one step for unbounded numeric fields. */
|
|
178
|
+
readonly step: number;
|
|
179
|
+
/** Produce values a server validator should reject. */
|
|
180
|
+
readonly cheat: boolean;
|
|
181
|
+
/**
|
|
182
|
+
* The value this field started at. Numeric walks are mean-reverting toward it, which keeps a
|
|
183
|
+
* long run inside whatever world bounds the room's validator enforces without the bot having to
|
|
184
|
+
* know them — the type system only declares a *representable* range, never a legal one.
|
|
185
|
+
*/
|
|
186
|
+
readonly origin?: number | undefined;
|
|
187
|
+
/** Ids that a `ref` field may legally point at. */
|
|
188
|
+
readonly refIds?: readonly string[] | undefined;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The next value for a field that currently holds `current`. Always inside the type's declared
|
|
192
|
+
* range, so `track()` accepts it and the *server* — not the client — is the one that gets to say
|
|
193
|
+
* a value is illegal. In cheat mode the value is still type-valid and wildly out of play.
|
|
194
|
+
*/
|
|
195
|
+
declare function nextValue(rng: Rng, desc: TypeDesc, current: unknown, ctx: ValueContext): unknown;
|
|
196
|
+
/** A fresh valid value for a field with no current value — RPC params, mostly. */
|
|
197
|
+
declare function freshValue(rng: Rng, desc: TypeDesc, ctx: ValueContext): unknown;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* D42: injected network conditions, as a wrapper around the transport seam.
|
|
201
|
+
*
|
|
202
|
+
* `SpawnOptions.transport` exists so a test can get at the socket, and that is exactly the shape
|
|
203
|
+
* an adversarial run needs: a bot under conditions is still a real `@irtio/client` session
|
|
204
|
+
* speaking the real wire, it just gets its frames late, twice, out of order, or not at all. No
|
|
205
|
+
* protocol change, no frame change, and nothing below this file knows the difference.
|
|
206
|
+
*
|
|
207
|
+
* Three rules decide the design, and all three are load-bearing.
|
|
208
|
+
*
|
|
209
|
+
* 1. **Loss, duplication and reorder touch state frames only.** `@irtio/testing`'s in-process
|
|
210
|
+
* harness restricts its loss die the same way, for the same reason: dropping a HELLO wedges
|
|
211
|
+
* the join and dropping a REPLY wedges an RPC that has no retry, and a run that hangs on a
|
|
212
|
+
* wedged join is a D36 regression, not an adversarial test. The peek is a read of the frame's
|
|
213
|
+
* own type byte against `@irtio/protocol`'s `FrameType`, which is the envelope the protocol
|
|
214
|
+
* already guarantees; nothing here decodes a payload.
|
|
215
|
+
* 2. **Delay is symmetric unless it is split.** `rttMs` is the round trip, so each direction
|
|
216
|
+
* carries `rttMs / 2` plus a jitter draw. A stream is kept in order by a per-direction floor,
|
|
217
|
+
* the way a real WebSocket is, and `reorder` is the only thing that lifts it.
|
|
218
|
+
* 3. **Reorder is bounded.** A reordered frame is held back by at most `reorderMs`, so frames
|
|
219
|
+
* swap places with their neighbours rather than arriving at the end of the run. An unbounded
|
|
220
|
+
* shuffle is indistinguishable from loss plus a very long tail, and it would make a run's
|
|
221
|
+
* end time a function of the die.
|
|
222
|
+
*
|
|
223
|
+
* Determinism: every die is drawn from the rng handed in, which `spawnBots` seeds per bot index.
|
|
224
|
+
* The same seed injects the same delays, drops the same frames and duplicates the same ones. It
|
|
225
|
+
* does not make the *run* deterministic (real sockets and a real clock are still underneath), it
|
|
226
|
+
* makes the injection deterministic, which is the half this file owns.
|
|
227
|
+
*/
|
|
228
|
+
|
|
229
|
+
/** Default width of the reorder window when `reorder` is set and `reorderMs` is not. */
|
|
230
|
+
declare const DEFAULT_REORDER_MS = 50;
|
|
231
|
+
interface NetworkConditions {
|
|
232
|
+
/** Round-trip time in ms. Each direction carries half of it. */
|
|
233
|
+
readonly rttMs?: number;
|
|
234
|
+
/** Extra delay per frame, drawn uniformly from `[0, jitterMs)`. */
|
|
235
|
+
readonly jitterMs?: number;
|
|
236
|
+
/** Chance in `[0, 1]` that a state frame is dropped. Session and RPC frames are never dropped. */
|
|
237
|
+
readonly loss?: number;
|
|
238
|
+
/** Chance in `[0, 1]` that a state frame is delivered twice. */
|
|
239
|
+
readonly duplicate?: number;
|
|
240
|
+
/** Chance in `[0, 1]` that a state frame is held back past its neighbours. */
|
|
241
|
+
readonly reorder?: number;
|
|
242
|
+
/** How far a reordered frame may be held back, in ms. Default {@link DEFAULT_REORDER_MS}. */
|
|
243
|
+
readonly reorderMs?: number;
|
|
244
|
+
}
|
|
245
|
+
/** What the wrapper did, per bot. Reported so a run says what was injected, not what was asked for. */
|
|
246
|
+
interface ConditionCounters {
|
|
247
|
+
/** Frames the loss die dropped, client to server. */
|
|
248
|
+
droppedOut: number;
|
|
249
|
+
/** Frames the loss die dropped, server to client. */
|
|
250
|
+
droppedIn: number;
|
|
251
|
+
duplicatedOut: number;
|
|
252
|
+
duplicatedIn: number;
|
|
253
|
+
reorderedOut: number;
|
|
254
|
+
reorderedIn: number;
|
|
255
|
+
/** Frames that were delayed at all (that is, every frame the wrapper saw and did not drop). */
|
|
256
|
+
delayed: number;
|
|
257
|
+
}
|
|
258
|
+
declare function newConditionCounters(): ConditionCounters;
|
|
259
|
+
/**
|
|
260
|
+
* True when these conditions would change anything. `spawnBots` uses it to leave an unconditioned
|
|
261
|
+
* bot on the bare transport, so a plain run keeps exactly the timing it had before D42.
|
|
262
|
+
*/
|
|
263
|
+
declare function hasConditions(c: NetworkConditions | undefined): c is NetworkConditions;
|
|
264
|
+
/** One line describing what was injected, for the report and for `bot.conditions`. */
|
|
265
|
+
declare function describeConditions(c: NetworkConditions): string;
|
|
266
|
+
/**
|
|
267
|
+
* The transport-seam shapes this file needs. Declared structurally rather than imported from
|
|
268
|
+
* `@irtio/client` so that `conditionedTransport` can be unit-tested against a fake transport with
|
|
269
|
+
* no socket anywhere near it.
|
|
270
|
+
*/
|
|
271
|
+
interface ConditionedSocket {
|
|
272
|
+
send(bytes: Uint8Array): void;
|
|
273
|
+
close(): void;
|
|
274
|
+
onopen: (() => void) | null;
|
|
275
|
+
onmessage: ((bytes: Uint8Array) => void) | null;
|
|
276
|
+
onclose: ((info?: {
|
|
277
|
+
code?: number;
|
|
278
|
+
reason?: string;
|
|
279
|
+
}) => void) | null;
|
|
280
|
+
onerror: ((error: unknown) => void) | null;
|
|
281
|
+
}
|
|
282
|
+
interface ConditionedTransport {
|
|
283
|
+
connect(url: string): ConditionedSocket;
|
|
284
|
+
}
|
|
285
|
+
/** Injectable timers, so a unit test can drive delivery without waiting on the real clock. */
|
|
286
|
+
interface ConditionTimers {
|
|
287
|
+
now(): number;
|
|
288
|
+
setTimeout(fn: () => void, ms: number): () => void;
|
|
289
|
+
}
|
|
290
|
+
interface ConditionedTransportOptions {
|
|
291
|
+
readonly counters?: ConditionCounters;
|
|
292
|
+
readonly timers?: ConditionTimers;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Wraps `base` so every frame crossing this socket is subject to `conditions`.
|
|
296
|
+
*
|
|
297
|
+
* Outbound frames are delayed on their way into `base.send`; inbound frames are delayed on their
|
|
298
|
+
* way out of `base`'s `onmessage`. `onopen`, `onclose` and `onerror` pass through untouched: they
|
|
299
|
+
* are socket lifecycle, not frames, and delaying a close would only make a teardown slower to
|
|
300
|
+
* notice.
|
|
301
|
+
*/
|
|
302
|
+
declare function conditionedTransport(base: ConditionedTransport, conditions: NetworkConditions, rng: Rng, options?: ConditionedTransportOptions): ConditionedTransport;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* D42: hit registration.
|
|
306
|
+
*
|
|
307
|
+
* The sniper case from the v1 plan. A bot fires at a moving target; by the time the shot reaches
|
|
308
|
+
* the server the target has moved, and the report says by how much. Nothing here compensates for
|
|
309
|
+
* it, and that is the point: this is how a builder sees why lag compensation would matter, on
|
|
310
|
+
* their own room, before anyone ships one.
|
|
311
|
+
*
|
|
312
|
+
* ## What a shot is
|
|
313
|
+
*
|
|
314
|
+
* `bot.shot({ collection, target, aim })`. It is **bookkeeping, not a wire event**: the game's own
|
|
315
|
+
* RPC still does the shooting, and this call records that it happened, at whom, and where the
|
|
316
|
+
* shooter was aiming, in the shooter's own client-side view. No frame changes and no protocol
|
|
317
|
+
* changes, which is what keeps a hit-registration run a normal run of the same room.
|
|
318
|
+
*
|
|
319
|
+
* ## How a shot is correlated
|
|
320
|
+
*
|
|
321
|
+
* Every row names one server tick — **the tick the shot was judged at** — and the miss distance is
|
|
322
|
+
* how far the aim was from where authority had the target at that tick. There are two ways to
|
|
323
|
+
* learn that tick, and the difference between them is why every row says which one it used.
|
|
324
|
+
*
|
|
325
|
+
* - **Exact (D72).** The game's own RPC tells the bot which tick it judged the shot at, and the
|
|
326
|
+
* bot passes it to `bot.shot({ ..., serverTick })`. Nothing is inferred and no clock is compared
|
|
327
|
+
* with any other clock. A room with no lag compensation reports the tick the `CALL` landed on, a
|
|
328
|
+
* round trip after the shooter fired; a room that rewinds reports the tick its rewind answered
|
|
329
|
+
* from, which is the tick the shooter was looking at. Moving that tick **is** lag compensation,
|
|
330
|
+
* and the miss distance follows it.
|
|
331
|
+
* - **Estimated (D42, the fallback).** With no reported tick, the receive tick is the first
|
|
332
|
+
* recorded tick whose state settled at or after `sentAt` plus the shot's uplink delay
|
|
333
|
+
* (`rttMs / 2` of whatever was injected into that bot). It is an estimate, it is labelled one on
|
|
334
|
+
* every row, and it rests on the recorder and the shooter sharing a clock — true under
|
|
335
|
+
* `irtio dev`, where the room runs in a worker thread of the same process, and the reason this
|
|
336
|
+
* is a local-dev capability exactly as the recorded timeline is.
|
|
337
|
+
*
|
|
338
|
+
* The authoritative value is read straight out of the recording either way, so a row can be
|
|
339
|
+
* checked against the timeline file by hand. A row whose target was not in the recording at that
|
|
340
|
+
* tick, or whose shot landed past the end of the recording, says so rather than reporting a
|
|
341
|
+
* distance of zero.
|
|
342
|
+
*/
|
|
343
|
+
|
|
344
|
+
/** One aim point: field name to value, over the target collection's numeric fields. */
|
|
345
|
+
type AimPoint = Readonly<Record<string, number>>;
|
|
346
|
+
interface ShotRequest {
|
|
347
|
+
/** The collection the target lives in. */
|
|
348
|
+
readonly collection: string;
|
|
349
|
+
/** The target entity's id. */
|
|
350
|
+
readonly target: string;
|
|
351
|
+
/** Where the shooter aimed, in its own client-side view. */
|
|
352
|
+
readonly aim: AimPoint;
|
|
353
|
+
/** Free-form label, carried through to the row. */
|
|
354
|
+
readonly label?: string;
|
|
355
|
+
/**
|
|
356
|
+
* D72: the server tick the room says it **judged this shot at**, when the game's own RPC
|
|
357
|
+
* reports one. Supplying it makes the row exact — no clock is compared with any other clock —
|
|
358
|
+
* and the report drops the word `(estimated)`.
|
|
359
|
+
*
|
|
360
|
+
* For a room with no lag compensation that is the tick the `CALL` was applied at, a round trip
|
|
361
|
+
* after the shooter fired. For a room that rewinds it is the tick the rewind answered from,
|
|
362
|
+
* which is the tick the shooter was looking at. That difference is the whole measurement: the
|
|
363
|
+
* miss distance is always "how far the aim was from where authority had the target **at the
|
|
364
|
+
* tick the shot was judged**", and lag compensation is what moves that tick.
|
|
365
|
+
*/
|
|
366
|
+
readonly serverTick?: number | undefined;
|
|
367
|
+
/** D72: whether the room resolved this shot through `room.rewind`, when it says. */
|
|
368
|
+
readonly rewound?: boolean | undefined;
|
|
369
|
+
}
|
|
370
|
+
/** A shot as recorded, before any correlation. */
|
|
371
|
+
interface ShotRecord extends ShotRequest {
|
|
372
|
+
/** Which bot fired. */
|
|
373
|
+
readonly bot: number;
|
|
374
|
+
/** The shooter's wall clock when it fired. */
|
|
375
|
+
readonly sentAt: number;
|
|
376
|
+
/** The last server tick the shooter had seen when it fired, when the client exposes one. */
|
|
377
|
+
readonly clientTick: number | undefined;
|
|
378
|
+
/** The uplink delay injected into this bot, in ms: `rttMs / 2`. `0` with no conditions. */
|
|
379
|
+
readonly uplinkMs: number;
|
|
380
|
+
}
|
|
381
|
+
/** One correlated shot: what the shooter aimed at, and what the server actually held. */
|
|
382
|
+
interface HitRow extends ShotRecord {
|
|
383
|
+
/** The server tick the shot is judged against, or `undefined` when none could be found. */
|
|
384
|
+
readonly serverTick: number | undefined;
|
|
385
|
+
/**
|
|
386
|
+
* `true` when `serverTick` was derived through an injected uplink delay rather than read off a
|
|
387
|
+
* frame the shot could only have landed in. The report prints the word.
|
|
388
|
+
*
|
|
389
|
+
* D72: a shot whose room reported its own judging tick is never estimated, whatever the
|
|
390
|
+
* latency, because nothing was inferred from two clocks.
|
|
391
|
+
*/
|
|
392
|
+
readonly estimated: boolean;
|
|
393
|
+
/** The target's authoritative value at `serverTick`, over the fields the aim named. */
|
|
394
|
+
readonly authoritative: AimPoint | undefined;
|
|
395
|
+
/** Euclidean distance between `aim` and `authoritative`, or `undefined` when unknown. */
|
|
396
|
+
readonly missDistance: number | undefined;
|
|
397
|
+
/** D72: whether the room resolved this shot through `room.rewind`. `undefined` when it did not say. */
|
|
398
|
+
readonly rewound: boolean | undefined;
|
|
399
|
+
/** Why a row has no distance, in one clause. Absent when it has one. */
|
|
400
|
+
readonly unresolved?: string;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Correlates every shot against the recording. Pure: same shots and same dump, same rows, which
|
|
404
|
+
* is what lets a scenario assert on them.
|
|
405
|
+
*
|
|
406
|
+
* D72: a shot that carries `serverTick` is correlated to exactly that frame and is not estimated.
|
|
407
|
+
* A shot that does not falls back to the wall-clock path this started as, which is an estimate
|
|
408
|
+
* and says so on every row.
|
|
409
|
+
*/
|
|
410
|
+
declare function correlateShots(shots: readonly ShotRecord[], dump: TimelineDump): HitRow[];
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* D43: the truth seam.
|
|
414
|
+
*
|
|
415
|
+
* The claim the scenario API's credibility rests on is "the wire matched authoritative state",
|
|
416
|
+
* and until this file existed nobody could check it: a room save was bytes only `RoomCore.restore`
|
|
417
|
+
* could read, and it read them by hydrating a live room. `decodeSave` in `@irtio/runtime` made the
|
|
418
|
+
* bytes readable; this file is what compares them against what the clients were told.
|
|
419
|
+
*
|
|
420
|
+
* ## What is compared
|
|
421
|
+
*
|
|
422
|
+
* Per bot, not globally. A client legitimately never received entities outside its area of
|
|
423
|
+
* interest or outside its role's visibility, so "the client holds the whole save" is not the
|
|
424
|
+
* question and answering it would fail every room with an AOI. The question is the other
|
|
425
|
+
* direction, and it has two halves:
|
|
426
|
+
*
|
|
427
|
+
* 1. **Every entity the client held is in the save**, with the same owner and the same field
|
|
428
|
+
* values. A client holding something authority does not is the class of desync worth catching.
|
|
429
|
+
* 2. **Every value it held matches.** Field by field, on the fields the client actually has.
|
|
430
|
+
*
|
|
431
|
+
* Nothing is asserted about entities in the save that the client never received: that is
|
|
432
|
+
* visibility working, not a difference.
|
|
433
|
+
*
|
|
434
|
+
* ## What "clean" means, precisely
|
|
435
|
+
*
|
|
436
|
+
* Clean is: nothing the client held is absent from the save, and nothing it held disagrees with
|
|
437
|
+
* the save. It is not "the client saw everything". The report prints the sentence that way, and
|
|
438
|
+
* the docs do too, because the shorter version is the overclaim D43 exists to retire.
|
|
439
|
+
*
|
|
440
|
+
* ## The ordering that makes it meaningful
|
|
441
|
+
*
|
|
442
|
+
* The save is taken when the room is quiescent and the clients have had a settle window to
|
|
443
|
+
* receive the last flush. A room still changing after its save will differ from every client, and
|
|
444
|
+
* that difference is real: it is the run reporting that it compared two different instants. The
|
|
445
|
+
* negative fixture is built out of exactly that, which is the honest mechanism the plan asked for
|
|
446
|
+
* rather than a mutation invented for the test.
|
|
447
|
+
*/
|
|
448
|
+
|
|
449
|
+
/** One client's view, in the same shape `inspectState` and `decodeSave` produce. */
|
|
450
|
+
type ClientStateView = Record<string, unknown>;
|
|
451
|
+
/** One disagreement between a client's view and the decoded save. */
|
|
452
|
+
interface TruthDifference {
|
|
453
|
+
readonly bot: number;
|
|
454
|
+
readonly collection: string;
|
|
455
|
+
/** The entity id, or `undefined` for a singleton collection. */
|
|
456
|
+
readonly id?: string;
|
|
457
|
+
/** The field, or `undefined` when the whole record is missing from the save. */
|
|
458
|
+
readonly field?: string;
|
|
459
|
+
/** What the client held. */
|
|
460
|
+
readonly client: unknown;
|
|
461
|
+
/** What the save held, or `undefined` when the save has nothing there. */
|
|
462
|
+
readonly save: unknown;
|
|
463
|
+
/** One sentence naming what went wrong, ready to print. */
|
|
464
|
+
readonly detail: string;
|
|
465
|
+
}
|
|
466
|
+
/** One bot's verdict. */
|
|
467
|
+
interface TruthBotResult {
|
|
468
|
+
readonly bot: number;
|
|
469
|
+
/** Records the client held and the save agreed with. */
|
|
470
|
+
readonly compared: number;
|
|
471
|
+
readonly differences: readonly TruthDifference[];
|
|
472
|
+
readonly ok: boolean;
|
|
473
|
+
}
|
|
474
|
+
/** The whole diff. */
|
|
475
|
+
interface TruthDiff {
|
|
476
|
+
readonly ok: boolean;
|
|
477
|
+
readonly bots: readonly TruthBotResult[];
|
|
478
|
+
/** Records compared across every bot. */
|
|
479
|
+
readonly compared: number;
|
|
480
|
+
readonly differences: readonly TruthDifference[];
|
|
481
|
+
/** The save's own tick, carried so the report can say what instant was compared. */
|
|
482
|
+
readonly saveTick: number;
|
|
483
|
+
/** The save blob's format version byte. */
|
|
484
|
+
readonly saveVersion: number;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Captures one bot's client-visible state, in `inspectState`'s shape.
|
|
488
|
+
*
|
|
489
|
+
* It reads the live `room.state` the client built from every frame it received, which is the only
|
|
490
|
+
* place "what this client was told" exists: the frame trace records headers, not payloads. No
|
|
491
|
+
* trace change and no payload retention are needed because of that.
|
|
492
|
+
*
|
|
493
|
+
* `schema` is the project schema as authored, which is what says which collections are entity
|
|
494
|
+
* tables and which are singletons.
|
|
495
|
+
*/
|
|
496
|
+
declare function captureClientState(schema: AnySchema, state: unknown): ClientStateView;
|
|
497
|
+
/**
|
|
498
|
+
* Diffs each client's captured view against the decoded save, within that client's visibility.
|
|
499
|
+
*
|
|
500
|
+
* `saveState` is `decodeSave(bytes, schema).state`. Both sides are therefore `inspectState` shape
|
|
501
|
+
* and the comparison is like against like, which is the reason the decoder returns that shape
|
|
502
|
+
* rather than a `PlainState`.
|
|
503
|
+
*/
|
|
504
|
+
declare function diffAgainstSave(clients: readonly {
|
|
505
|
+
bot: number;
|
|
506
|
+
state: ClientStateView;
|
|
507
|
+
}[], saveState: Record<string, unknown>, meta: {
|
|
508
|
+
saveTick: number;
|
|
509
|
+
saveVersion: number;
|
|
510
|
+
}): TruthDiff;
|
|
511
|
+
|
|
113
512
|
/**
|
|
114
513
|
* The trace recorder: every frame in and out, per bot, with a timestamp and the decoded frame
|
|
115
514
|
* type — the thing you actually read when a simulation goes red.
|
|
@@ -172,6 +571,16 @@ declare function makeTrace(startedAt: number, rings: () => readonly TraceRing[])
|
|
|
172
571
|
* bytes does not.
|
|
173
572
|
*/
|
|
174
573
|
|
|
574
|
+
/**
|
|
575
|
+
* M6 lane E (D73-b): what this client's local world holds for one instance right now.
|
|
576
|
+
*
|
|
577
|
+
* The three states `room.prediction` distinguishes since D71, in the one word each the observer
|
|
578
|
+
* needs. `predicted` is simulated locally; `proxied` is a kinematic collider at the drawn pose,
|
|
579
|
+
* which predicted bodies stand on and never moves on its own; `absent` is no body at all, which a
|
|
580
|
+
* predicted body falls straight through. A bot that joined without physics sees everything as
|
|
581
|
+
* `absent`, which is exactly what it had before this existed.
|
|
582
|
+
*/
|
|
583
|
+
type BodyKind = 'predicted' | 'proxied' | 'absent';
|
|
175
584
|
/** Per-bot counters the report prints verbatim. */
|
|
176
585
|
interface BotStats {
|
|
177
586
|
readonly index: number;
|
|
@@ -191,6 +600,16 @@ interface BotStats {
|
|
|
191
600
|
* the meaning: once the client re-steps predicted bodies, these become real mispredictions.
|
|
192
601
|
*/
|
|
193
602
|
readonly syncCorrections: number;
|
|
603
|
+
/**
|
|
604
|
+
* M6 lane E (D73-b): corrections that touched only body fields of **proxied** instances.
|
|
605
|
+
*
|
|
606
|
+
* Reported, never thresholded. A proxy is moved to the pose the renderer draws, so a correction
|
|
607
|
+
* on one is the server disagreeing with what this client drew — the interpolation residual —
|
|
608
|
+
* rather than a prediction being wrong or a body arriving for the first time. Split out of
|
|
609
|
+
* `syncCorrections`, where these used to land: a run with no proxies counts exactly what it
|
|
610
|
+
* counted before.
|
|
611
|
+
*/
|
|
612
|
+
readonly proxiedCorrections: number;
|
|
194
613
|
/**
|
|
195
614
|
* D22 part 2: predicted-body corrections whose values matched the local prediction within the
|
|
196
615
|
* epsilon — authority confirming the prediction, not disagreeing with it. Counted apart so a
|
|
@@ -212,6 +631,37 @@ interface BotStats {
|
|
|
212
631
|
readonly disconnects: number;
|
|
213
632
|
readonly peakBytesInPerSec: number;
|
|
214
633
|
readonly peakCorrectionsPerSec: number;
|
|
634
|
+
/** D70: peer messages this bot sent, raw and typed together. */
|
|
635
|
+
readonly messagesSent: number;
|
|
636
|
+
/** D70: peer messages this bot received and could read. */
|
|
637
|
+
readonly messagesReceived: number;
|
|
638
|
+
/** D70: typed peer messages this bot could not read — an unknown index, or a bad payload. */
|
|
639
|
+
readonly messagesDropped: number;
|
|
640
|
+
/**
|
|
641
|
+
* M6 lane E (D73-a): did this bot's client ever simulate a local world during the run?
|
|
642
|
+
*
|
|
643
|
+
* Latched from `room.prediction.active` as frames arrive, rather than read when the report is
|
|
644
|
+
* built, and both halves of that are load-bearing. The engine loads *off* the join path, so a
|
|
645
|
+
* read at join time says `false` on every run; and `stop()` leaves the room before it takes the
|
|
646
|
+
* report, and a room that has been left has freed its world, so a read at the end says `false`
|
|
647
|
+
* too. What a caller wants to know is whether prediction was live while the run happened.
|
|
648
|
+
*
|
|
649
|
+
* False on a bot that joined without `physics`/`physics2d`. The report prints it rather than
|
|
650
|
+
* assuming that a run which asked for prediction got it.
|
|
651
|
+
*/
|
|
652
|
+
readonly predicting: boolean;
|
|
653
|
+
/**
|
|
654
|
+
* D73-b: the most kinematic proxies this bot's local world held at once
|
|
655
|
+
* (`room.prediction.stats.proxies`), sampled as frames arrived. Zero without prediction.
|
|
656
|
+
*/
|
|
657
|
+
readonly proxies: number;
|
|
658
|
+
/**
|
|
659
|
+
* D73-b: the most instances this bot's local world had **no body at all** for at once
|
|
660
|
+
* (`room.prediction.stats.absent`). A predicted body passes straight through one of these, so a
|
|
661
|
+
* non-zero value on a collection anything stands on is a gameplay bug rather than a tradeoff —
|
|
662
|
+
* which is why it is reported beside the proxies rather than folded into them.
|
|
663
|
+
*/
|
|
664
|
+
readonly absent: number;
|
|
215
665
|
}
|
|
216
666
|
/**
|
|
217
667
|
* Shared across bots: the bridge that turns "bot A wrote x=7" plus "bot B was told x=7" into a
|
|
@@ -241,8 +691,18 @@ interface ObserverOptions {
|
|
|
241
691
|
declare class BotObserver {
|
|
242
692
|
private readonly options;
|
|
243
693
|
readonly ring: TraceRing;
|
|
244
|
-
readonly violations: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects" | "tick-health", string[]>;
|
|
245
|
-
readonly counts: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects" | "tick-health", number>;
|
|
694
|
+
readonly violations: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects" | "tick-health" | "typed-message-drops" | "party-integrity", string[]>;
|
|
695
|
+
readonly counts: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects" | "tick-health" | "typed-message-drops" | "party-integrity", number>;
|
|
696
|
+
/**
|
|
697
|
+
* Bug #28: the decode extension, *mutable*. It starts as `options.ext` (the schema the run was
|
|
698
|
+
* spawned with) and is rebuilt in place when an inbound `SCHEMA` frame (13) lands — a D50
|
|
699
|
+
* additive `migrate` swaps the wire mid-session, the real client rebuilds its own decoders
|
|
700
|
+
* (`session.swapSchema`), and an observer still holding the v1 descriptor underruns on the v2
|
|
701
|
+
* resync WELCOME and fails `schema-validity` on a swap that was perfect.
|
|
702
|
+
*/
|
|
703
|
+
private ext;
|
|
704
|
+
/** How many `SCHEMA` frames rebuilt {@link ext}. For tests and the trace, like the client's. */
|
|
705
|
+
schemaSwaps: number;
|
|
246
706
|
id: string;
|
|
247
707
|
role: string;
|
|
248
708
|
roomId: string;
|
|
@@ -260,14 +720,29 @@ declare class BotObserver {
|
|
|
260
720
|
bytesOut: number;
|
|
261
721
|
corrections: number;
|
|
262
722
|
syncCorrections: number;
|
|
723
|
+
proxiedCorrections: number;
|
|
263
724
|
suppressedCorrections: number;
|
|
264
725
|
mispredictions: number;
|
|
265
726
|
/**
|
|
266
|
-
*
|
|
267
|
-
* `spawnBots` once the room exists (`room.prediction`);
|
|
268
|
-
* it cannot be derived from the bytes the observer otherwise sticks to.
|
|
727
|
+
* M6 lane E (D73-b): what this bot's client's local world holds for `collection[id]` right now.
|
|
728
|
+
* Assigned by `spawnBots` once the room exists (`room.prediction`); the local world's shape is
|
|
729
|
+
* client-local, so it cannot be derived from the bytes the observer otherwise sticks to.
|
|
730
|
+
*
|
|
731
|
+
* Unset means "no local world at all", which answers `absent` for everything — the behaviour a
|
|
732
|
+
* bot without physics has always had.
|
|
269
733
|
*/
|
|
270
|
-
|
|
734
|
+
bodyKind: ((collection: string, id: string) => BodyKind) | undefined;
|
|
735
|
+
/**
|
|
736
|
+
* M6 lane E (D73-a): the client's own `room.prediction`, when this bot joined with `physics` or
|
|
737
|
+
* `physics2d`. Held rather than copied because `active` flips once the engine has loaded, which
|
|
738
|
+
* happens off the join path — a boolean read at join time would say `false` on every run.
|
|
739
|
+
*/
|
|
740
|
+
prediction: PredictionStatus | undefined;
|
|
741
|
+
/** D73-a: latched true the first time {@link prediction} reported an active local world. */
|
|
742
|
+
predicted: boolean;
|
|
743
|
+
/** D73-b: the high-water marks of `prediction.stats.proxies` / `.absent` across the run. */
|
|
744
|
+
proxies: number;
|
|
745
|
+
absent: number;
|
|
271
746
|
mispredictionMagnitude: number;
|
|
272
747
|
mispredictionMax: number;
|
|
273
748
|
snaps: number;
|
|
@@ -276,6 +751,10 @@ declare class BotObserver {
|
|
|
276
751
|
disconnects: number;
|
|
277
752
|
peakBytesInPerSec: number;
|
|
278
753
|
peakCorrectionsPerSec: number;
|
|
754
|
+
/** D70: peer-message counters, filled by `inspect`'s MSG case in both directions. */
|
|
755
|
+
messagesSent: number;
|
|
756
|
+
messagesReceived: number;
|
|
757
|
+
messagesDropped: number;
|
|
279
758
|
/**
|
|
280
759
|
* Spatial-grid ops actually put to the AOI policy. Zero on a run whose room has no spatial
|
|
281
760
|
* collection; zero on a run that *does* and would mean the invariant passed vacuously, which is
|
|
@@ -310,7 +789,31 @@ declare class BotObserver {
|
|
|
310
789
|
onFrame(dir: 'in' | 'out', type: number, bytes: Uint8Array): void;
|
|
311
790
|
/** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
|
|
312
791
|
private inspect;
|
|
792
|
+
/**
|
|
793
|
+
* D70: one peer message, in either direction, as a trace note.
|
|
794
|
+
*
|
|
795
|
+
* Before this, `MSG` fell through to `undefined` and a trace showed a frame with a byte count
|
|
796
|
+
* and nothing else — which was tolerable while every message was opaque bytes and is not now
|
|
797
|
+
* that some of them have declared shapes. A raw message notes its target; a typed one notes its
|
|
798
|
+
* name and its value when this bot holds the schema, and its index when it does not.
|
|
799
|
+
*
|
|
800
|
+
* A typed message this bot cannot read is counted as a **drop**, which is what the
|
|
801
|
+
* `typed-message-drops` invariant fails a run on. That is the whole point of the counter: a
|
|
802
|
+
* scenario sending a shape the schema does not describe used to be a message that silently
|
|
803
|
+
* never arrived.
|
|
804
|
+
*/
|
|
805
|
+
private onMsg;
|
|
806
|
+
/** D70: one typed message this bot could not read — a counter and an invariant violation. */
|
|
807
|
+
private dropTyped;
|
|
313
808
|
private onWelcome;
|
|
809
|
+
/**
|
|
810
|
+
* Bug #28: a `SCHEMA` frame (D50 additive migrate). Rebuild the decode extension from the
|
|
811
|
+
* descriptor the frame carries, exactly as the real client's `swapSchema` does, so the resync
|
|
812
|
+
* WELCOME about to arrive decodes under the schema it was encoded with. The old view and
|
|
813
|
+
* seen-ids are dropped — they were laid out by descriptors that no longer exist, and the resync
|
|
814
|
+
* WELCOME re-seeds both (`onWelcome`).
|
|
815
|
+
*/
|
|
816
|
+
private onSchema;
|
|
314
817
|
private onDelta;
|
|
315
818
|
private onCorrect;
|
|
316
819
|
/**
|
|
@@ -341,58 +844,6 @@ declare class BotObserver {
|
|
|
341
844
|
private matchWrites;
|
|
342
845
|
}
|
|
343
846
|
|
|
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
847
|
/**
|
|
397
848
|
* The end-of-run summary: one line per invariant, one row per bot, and the handful of aggregate
|
|
398
849
|
* numbers a load run cares about (frames/s, per-bot bandwidth, corrections, convergence lag).
|
|
@@ -416,6 +867,9 @@ interface SimulationTotals {
|
|
|
416
867
|
readonly corrections: number;
|
|
417
868
|
/** D22: corrections carrying only simulated body state — the sync path, not a disagreement. */
|
|
418
869
|
readonly syncCorrections: number;
|
|
870
|
+
/** D73-b: corrections carrying only body state of **proxied** instances — the interpolation
|
|
871
|
+
* residual, not a misprediction. Reported, never thresholded. */
|
|
872
|
+
readonly proxiedCorrections: number;
|
|
419
873
|
/** D22 part 2: predicted-body corrections matching the prediction within epsilon (quiet). */
|
|
420
874
|
readonly suppressedCorrections: number;
|
|
421
875
|
/** Correction ops seen via the rooms' `correct` events, across every bot. */
|
|
@@ -428,6 +882,70 @@ interface SimulationTotals {
|
|
|
428
882
|
readonly snaps: number;
|
|
429
883
|
readonly calls: number;
|
|
430
884
|
readonly errors: number;
|
|
885
|
+
/** D70: peer messages sent across every bot, raw and typed together. */
|
|
886
|
+
readonly messagesSent: number;
|
|
887
|
+
/** D70: peer messages received and read across every bot. */
|
|
888
|
+
readonly messagesReceived: number;
|
|
889
|
+
/** D70: typed peer messages no bot could read. Any of these fails `typed-message-drops`. */
|
|
890
|
+
readonly messagesDropped: number;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* M6 lane E (D73-b): what the bots' local worlds held, across the run. Absent when no bot
|
|
894
|
+
* predicted, so a run without physics prints exactly what it printed before.
|
|
895
|
+
*
|
|
896
|
+
* `proxies` and `absent` are the **peaks**, summed over the bots that predicted — the worst
|
|
897
|
+
* moment, not an average, because the number that matters is how much of the world a client was
|
|
898
|
+
* ever unable to simulate. There is deliberately no "predicted bodies" count: `PredictionStats`
|
|
899
|
+
* exposes none, and inventing one here would mean the observer enumerating the local world every
|
|
900
|
+
* frame to answer a question the client can answer itself.
|
|
901
|
+
*/
|
|
902
|
+
interface PredictionSummary {
|
|
903
|
+
/** Bots whose client held a live local world at some point in the run. */
|
|
904
|
+
readonly bots: number;
|
|
905
|
+
/** Peak kinematic proxies (D71), summed across those bots. */
|
|
906
|
+
readonly proxies: number;
|
|
907
|
+
/** Peak instances with no local body at all, summed across those bots. */
|
|
908
|
+
readonly absent: number;
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* M6 lane E (D73-c): what the queue did, for the report's matchmaking section.
|
|
912
|
+
*
|
|
913
|
+
* `waitP50Ms`/`waitP95Ms` are over the tickets that were answered; a timeout contributes to
|
|
914
|
+
* `timeouts` and not to the percentiles, because folding a deadline into a wait distribution turns
|
|
915
|
+
* "nobody came" into "everybody waited exactly the timeout", which reads as a slow queue rather
|
|
916
|
+
* than an empty one.
|
|
917
|
+
*/
|
|
918
|
+
interface MatchmakingSummary {
|
|
919
|
+
/** Tickets answered. */
|
|
920
|
+
readonly tickets: number;
|
|
921
|
+
/** Distinct rooms those tickets named. */
|
|
922
|
+
readonly rooms: number;
|
|
923
|
+
readonly waitP50Ms: number;
|
|
924
|
+
readonly waitP95Ms: number;
|
|
925
|
+
readonly waitMaxMs: number;
|
|
926
|
+
/** Tickets that came back `E_NO_MATCH` — the queue's own deadline. */
|
|
927
|
+
readonly timeouts: number;
|
|
928
|
+
/** Every other way a ticket failed, by code. */
|
|
929
|
+
readonly failures: readonly {
|
|
930
|
+
readonly requested: number;
|
|
931
|
+
readonly code: string;
|
|
932
|
+
}[];
|
|
933
|
+
/** Tickets answered with a seat in a room that was already running. */
|
|
934
|
+
readonly backfills: number;
|
|
935
|
+
/** Parties that landed whole, out of the parties that queued. */
|
|
936
|
+
readonly partiesIntact: number;
|
|
937
|
+
readonly parties: number;
|
|
938
|
+
/** Rooms holding more bots than their ticket said they seat. Any is a `party-integrity` fail. */
|
|
939
|
+
readonly overfullRooms: readonly {
|
|
940
|
+
readonly room: string;
|
|
941
|
+
readonly bots: number;
|
|
942
|
+
readonly size: number;
|
|
943
|
+
}[];
|
|
944
|
+
/** Parties whose members did not all land in one room. */
|
|
945
|
+
readonly splitParties: readonly {
|
|
946
|
+
readonly party: number;
|
|
947
|
+
readonly rooms: readonly string[];
|
|
948
|
+
}[];
|
|
431
949
|
}
|
|
432
950
|
interface SimulationReport {
|
|
433
951
|
/** True when every invariant passed. `irtio simulate` exits 1 when it is not. */
|
|
@@ -446,7 +964,19 @@ interface SimulationReport {
|
|
|
446
964
|
/** The headline number: median ms from one bot's write to another bot seeing it. */
|
|
447
965
|
readonly convergenceLagMs: number | undefined;
|
|
448
966
|
readonly tracePath: string | undefined;
|
|
967
|
+
/**
|
|
968
|
+
* D65: every bot's bandwidth ledger, merged, when the run asked for one. Cumulative for the
|
|
969
|
+
* whole run and for every bot together — a reader divides by `bots` and `durationMs` to get the
|
|
970
|
+
* per-bot rate the rest of this report prints. Absent when nobody profiled.
|
|
971
|
+
*/
|
|
972
|
+
readonly profile?: ProfileSnapshot;
|
|
973
|
+
/** D73-b: the local worlds the bots held. Absent when no bot predicted. */
|
|
974
|
+
readonly prediction?: PredictionSummary;
|
|
975
|
+
/** D73-c: what the queue did. Absent when the run did not queue. */
|
|
976
|
+
readonly matchmaking?: MatchmakingSummary;
|
|
449
977
|
}
|
|
978
|
+
/** D73-c: folds the queue's answers into the summary and the `party-integrity` verdict. */
|
|
979
|
+
declare function matchmakingSummary(reading: MatchmakingReading): MatchmakingSummary;
|
|
450
980
|
/** `undefined` when nothing converged during the run — an honest gap beats a fabricated zero. */
|
|
451
981
|
declare function convergenceStats(lags: readonly number[]): ConvergenceStats | undefined;
|
|
452
982
|
interface BuildReportOptions {
|
|
@@ -461,10 +991,103 @@ interface BuildReportOptions {
|
|
|
461
991
|
* reports `unavailable`, which is the honest answer for a caller that could not read it.
|
|
462
992
|
*/
|
|
463
993
|
readonly tickHealth?: TickHealthReading | undefined;
|
|
994
|
+
/** D73-c: what the queue answered per bot. Absent ⇒ `party-integrity` reports `unavailable`. */
|
|
995
|
+
readonly matchmaking?: MatchmakingReading | undefined;
|
|
996
|
+
/** D65: one ledger per bot, merged into `SimulationReport.profile`. */
|
|
997
|
+
readonly profiles?: readonly ProfileSnapshot[] | undefined;
|
|
464
998
|
}
|
|
465
999
|
/** Folds the observers into the report `irtio simulate` prints and tests assert on. */
|
|
466
1000
|
declare function buildReport(options: BuildReportOptions): SimulationReport;
|
|
467
1001
|
|
|
1002
|
+
/**
|
|
1003
|
+
* M6 lane E (D73-c): bots that queue.
|
|
1004
|
+
*
|
|
1005
|
+
* `spawnBots` has always put every bot in one room by construction — bot 0 creates it and the rest
|
|
1006
|
+
* join the code it came back with — which is right for a load run and means matchmaking, the front
|
|
1007
|
+
* door every game will use, had never been driven by the harness that exists to drive things. This
|
|
1008
|
+
* module is the other way in: each bot calls the real `findMatch` against a real control plane and
|
|
1009
|
+
* joins whatever room its ticket names.
|
|
1010
|
+
*
|
|
1011
|
+
* Real, in the same sense the rest of `@irtio/bots` is real. `findMatch` is `@irtio/client`'s own
|
|
1012
|
+
* function over its own HTTP, `createParty` mints a real code, and the room the ticket names is
|
|
1013
|
+
* joined through the ordinary `joinRoom` — nothing here knows it was a matchmaker that filled the
|
|
1014
|
+
* room, which is the property D52's whole design rests on. The one seam is `fetch`, injected so a
|
|
1015
|
+
* unit test can answer tickets without a plane.
|
|
1016
|
+
*
|
|
1017
|
+
* Parties are a group of bot indices sharing a `party(index)` value. One member mints the code and
|
|
1018
|
+
* every member sends it with its own call, exactly as two friends would: nobody sends anybody
|
|
1019
|
+
* else's identity, because a device credential is a secret that never leaves the browser that
|
|
1020
|
+
* minted it. A group of one queues alone rather than minting a party of one, which the queue would
|
|
1021
|
+
* accept and which would prove nothing.
|
|
1022
|
+
*/
|
|
1023
|
+
|
|
1024
|
+
/** How long a bot waits for a ticket when nothing says otherwise. Control caps this at two
|
|
1025
|
+
* minutes and applies its own default (30 s) when it is omitted; a run wants a bound it can
|
|
1026
|
+
* outlive rather than one it inherits. */
|
|
1027
|
+
declare const DEFAULT_MATCH_TIMEOUT_MS = 20000;
|
|
1028
|
+
interface BotMatchOptions {
|
|
1029
|
+
/** The control plane origin. `irtio dev` runs none, so a queued run needs a real one. */
|
|
1030
|
+
readonly controlUrl: string;
|
|
1031
|
+
/** The project the queue belongs to — a project key, as `schema.project` carries it. */
|
|
1032
|
+
readonly project: string;
|
|
1033
|
+
/** Queue name. Omitted uses the project's `default` queue (a party of two). */
|
|
1034
|
+
readonly queue?: string;
|
|
1035
|
+
/**
|
|
1036
|
+
* A platform identity assertion per bot, when the run has them.
|
|
1037
|
+
*
|
|
1038
|
+
* The queue's one-ticket-per-player rule keys on the account an identity belongs to; without
|
|
1039
|
+
* one it keys on a fresh anonymous id per request, which is why twenty anonymous bots can queue
|
|
1040
|
+
* at once and why a run that wants to prove the dedupe rule has to mint identities first.
|
|
1041
|
+
*/
|
|
1042
|
+
readonly identity?: (index: number) => string | undefined;
|
|
1043
|
+
/**
|
|
1044
|
+
* Which party group this bot queues in, or `undefined` to queue alone. Bots answering the same
|
|
1045
|
+
* value are one party and land in one room.
|
|
1046
|
+
*/
|
|
1047
|
+
readonly party?: (index: number) => number | undefined;
|
|
1048
|
+
readonly timeoutMs?: number;
|
|
1049
|
+
/** @internal Test seam: the unit tests answer tickets without a control plane. */
|
|
1050
|
+
readonly fetch?: typeof fetch;
|
|
1051
|
+
}
|
|
1052
|
+
/** One bot's answer from the queue. */
|
|
1053
|
+
interface BotTicket {
|
|
1054
|
+
/** The bot this became, once the failures were dropped — see {@link BotMatchResult}. */
|
|
1055
|
+
readonly bot: number;
|
|
1056
|
+
/** Which of the `n` queue attempts this was. Equal to `bot` when nothing failed. */
|
|
1057
|
+
readonly requested: number;
|
|
1058
|
+
readonly ticket: MatchTicket;
|
|
1059
|
+
/** Wall clock from the call to the answer. The number a "still looking…" screen would show. */
|
|
1060
|
+
readonly waitedMs: number;
|
|
1061
|
+
/** The party group this bot queued in, when it queued in one. */
|
|
1062
|
+
readonly party?: number;
|
|
1063
|
+
}
|
|
1064
|
+
/** One bot that never got a ticket. A timeout is one of these, not a hang. */
|
|
1065
|
+
interface BotMatchFailure {
|
|
1066
|
+
readonly requested: number;
|
|
1067
|
+
readonly code: string;
|
|
1068
|
+
readonly message: string;
|
|
1069
|
+
readonly waitedMs: number;
|
|
1070
|
+
readonly party?: number;
|
|
1071
|
+
}
|
|
1072
|
+
interface BotMatchResult {
|
|
1073
|
+
/** Tickets in request order, renumbered densely so `bot` indexes the run that follows. */
|
|
1074
|
+
readonly tickets: readonly BotTicket[];
|
|
1075
|
+
readonly failures: readonly BotMatchFailure[];
|
|
1076
|
+
/** The party code minted per group, for the groups that queued as parties. */
|
|
1077
|
+
readonly parties: ReadonlyMap<number, string>;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Queues `n` bots and answers what the queue said, per bot.
|
|
1081
|
+
*
|
|
1082
|
+
* Nothing throws for one bot's failure: a queue that refused three of twenty is a measurement, and
|
|
1083
|
+
* the caller decides what to do with it (`spawnBots` runs the ones that got in and fails the run).
|
|
1084
|
+
* A party whose code could not be minted fails as a whole, because a party that has lost its code
|
|
1085
|
+
* is a group of strangers and queueing them anyway would quietly test something else.
|
|
1086
|
+
*/
|
|
1087
|
+
declare function matchBots(n: number, options: BotMatchOptions): Promise<BotMatchResult>;
|
|
1088
|
+
/** Bot indices per room, in the order the tickets came back. */
|
|
1089
|
+
declare function roomsOf(tickets: readonly BotTicket[]): Map<string, number[]>;
|
|
1090
|
+
|
|
468
1091
|
/**
|
|
469
1092
|
* `spawnBots` — N real `@irtio/client` sessions in one Node process, each running a script, each
|
|
470
1093
|
* watched by a `BotObserver`.
|
|
@@ -526,6 +1149,16 @@ interface Bot<S = undefined> {
|
|
|
526
1149
|
/** This bot's slice of the trace. */
|
|
527
1150
|
readonly trace: Trace;
|
|
528
1151
|
readonly stats: BotStats;
|
|
1152
|
+
/** D42: the network conditions injected into this bot's socket, or `undefined` for a clean one. */
|
|
1153
|
+
readonly conditions: NetworkConditions | undefined;
|
|
1154
|
+
/**
|
|
1155
|
+
* D42: records that this bot fired at something, for the hit-registration report.
|
|
1156
|
+
*
|
|
1157
|
+
* Bookkeeping only. It sends nothing and changes no frame: the game's own RPC still does the
|
|
1158
|
+
* shooting, and this call sits beside it saying what was aimed at and when. The runner
|
|
1159
|
+
* correlates it with the recorded authoritative timeline afterwards.
|
|
1160
|
+
*/
|
|
1161
|
+
shot(request: ShotRequest): void;
|
|
529
1162
|
}
|
|
530
1163
|
type BotScript<S = undefined> = (bot: Bot<S>) => void | Promise<void>;
|
|
531
1164
|
interface SpawnOptionsBase {
|
|
@@ -553,10 +1186,22 @@ interface SpawnOptionsBase {
|
|
|
553
1186
|
readonly handlerErrorsMax?: number;
|
|
554
1187
|
/** `misprediction` threshold: numeric units one correction may snap. Default: infinite. */
|
|
555
1188
|
readonly mispredictionMagnitudeMax?: number;
|
|
1189
|
+
/**
|
|
1190
|
+
* D65: give every bot a bandwidth ledger, merged into `SimulationReport.profile`. This is the
|
|
1191
|
+
* view from the outside — what a real client's connection carries — as opposed to what the room
|
|
1192
|
+
* believes it sent. Off by default.
|
|
1193
|
+
*/
|
|
1194
|
+
readonly profile?: boolean;
|
|
556
1195
|
/** `snaps` tolerance: cap-exceeded reconciliations per bot. Default: infinite. */
|
|
557
1196
|
readonly snapsMax?: number;
|
|
558
1197
|
/** `tick-health` threshold: server tick overruns tolerated in the run window. Default 0. */
|
|
559
1198
|
readonly overrunsMax?: number;
|
|
1199
|
+
/**
|
|
1200
|
+
* D70 `typed-message-drops` threshold: typed peer messages a bot may fail to read across the
|
|
1201
|
+
* run. Default 0 — a drop means a peer sent a shape this schema does not declare, which in a
|
|
1202
|
+
* simulation is a scenario and a room disagreeing.
|
|
1203
|
+
*/
|
|
1204
|
+
readonly typedMessageDropsMax?: number;
|
|
560
1205
|
/**
|
|
561
1206
|
* D36: how long one bot's initial join may take before `spawnBots` gives up on the whole run.
|
|
562
1207
|
* Default {@link DEFAULT_JOIN_TIMEOUT_MS}; `0` restores the old unbounded wait.
|
|
@@ -569,6 +1214,25 @@ interface SpawnOptionsBase {
|
|
|
569
1214
|
readonly roomGoneGraceMs?: number;
|
|
570
1215
|
/** @internal Wrap `webSocketTransport` to get at the socket (the reconnection scenarios). */
|
|
571
1216
|
readonly transport?: Transport;
|
|
1217
|
+
/**
|
|
1218
|
+
* D42: network conditions injected into every bot's socket, or a function of the bot index for
|
|
1219
|
+
* a split run (one lagged shooter against one clean target). A bot whose conditions are absent,
|
|
1220
|
+
* or would change nothing, keeps the bare transport and the timing a plain run has.
|
|
1221
|
+
*
|
|
1222
|
+
* This is a *socket-level* model on a real wire, which is a different instrument from
|
|
1223
|
+
* `@irtio/testing`'s `LatencySpec`: that one is an in-process harness on a fake clock, with no
|
|
1224
|
+
* sockets at all. The vocabulary is deliberately the same and the two are not interchangeable.
|
|
1225
|
+
*/
|
|
1226
|
+
readonly conditions?: NetworkConditions | ((index: number) => NetworkConditions | undefined);
|
|
1227
|
+
/**
|
|
1228
|
+
* M6 lane E (D73-c): queue for rooms instead of building one.
|
|
1229
|
+
*
|
|
1230
|
+
* With this set the one-room contract below does not apply: every bot calls the real
|
|
1231
|
+
* `findMatch` against the control plane named here and joins whatever room its ticket names, so
|
|
1232
|
+
* a run drives the front door a game actually has. Bots sharing a `party(index)` value queue as
|
|
1233
|
+
* one party and land together. `room` is ignored — the queue decides.
|
|
1234
|
+
*/
|
|
1235
|
+
readonly match?: BotMatchOptions;
|
|
572
1236
|
}
|
|
573
1237
|
interface SpawnOptions<S extends AnySchema> extends SpawnOptionsBase {
|
|
574
1238
|
readonly schema: S;
|
|
@@ -581,15 +1245,46 @@ interface SpawnOptions<S extends AnySchema> extends SpawnOptionsBase {
|
|
|
581
1245
|
* corrections classify as real mispredictions (in world units) instead of `syncCorrections`.
|
|
582
1246
|
*/
|
|
583
1247
|
readonly physics?: JoinOptions<S>['physics'];
|
|
1248
|
+
/**
|
|
1249
|
+
* M6 lane E (D73-a): the matter2d twin of `physics`, passed to every bot's
|
|
1250
|
+
* `joinRoom({ physics2d })`.
|
|
1251
|
+
*
|
|
1252
|
+
* The two are mutually exclusive for the same reason `joinRoom` makes them so — a room runs one
|
|
1253
|
+
* engine and the client predicts with that one — and passing both throws with the sentence
|
|
1254
|
+
* `joinRoom` uses, before any socket opens.
|
|
1255
|
+
*/
|
|
1256
|
+
readonly physics2d?: JoinOptions<S>['physics2d'];
|
|
584
1257
|
}
|
|
585
1258
|
/** Schema-less relay: no schema, so `joinRelay` and a `RelayRoom`. */
|
|
586
1259
|
interface RelaySpawnOptions extends SpawnOptionsBase {
|
|
587
1260
|
readonly schema?: undefined;
|
|
588
1261
|
readonly script?: BotScript<undefined>;
|
|
589
1262
|
}
|
|
1263
|
+
/** D42: one bot's injected conditions, beside what the wrapper counted while injecting them. */
|
|
1264
|
+
interface BotConditions {
|
|
1265
|
+
readonly bot: number;
|
|
1266
|
+
/** What was asked for, or `undefined` when this bot ran on the bare transport. */
|
|
1267
|
+
readonly conditions: NetworkConditions | undefined;
|
|
1268
|
+
/** What the wrapper did. All zero when nothing was injected. */
|
|
1269
|
+
readonly counters: ConditionCounters;
|
|
1270
|
+
}
|
|
590
1271
|
interface BotRunner<S = undefined> extends Iterable<Bot<S>> {
|
|
591
1272
|
readonly bots: readonly Bot<S>[];
|
|
1273
|
+
/**
|
|
1274
|
+
* The room this run is about. With `match`, several rooms exist and this is the first ticket's
|
|
1275
|
+
* — the one a caller reading the server's own counters has to pick one of. `rooms` has them all.
|
|
1276
|
+
*/
|
|
592
1277
|
readonly roomId: string;
|
|
1278
|
+
/** D73-c: the ticket each bot queued for, in bot order. Empty on a run that did not queue. */
|
|
1279
|
+
readonly matches: readonly BotTicket[];
|
|
1280
|
+
/** D73-c: bot indices by room. One entry on a run that did not queue. */
|
|
1281
|
+
readonly rooms: ReadonlyMap<string, readonly number[]>;
|
|
1282
|
+
/** D73-c: bots that never got a ticket, and what the queue said. Empty when nothing failed. */
|
|
1283
|
+
readonly matchFailures: readonly {
|
|
1284
|
+
requested: number;
|
|
1285
|
+
code: string;
|
|
1286
|
+
message: string;
|
|
1287
|
+
}[];
|
|
593
1288
|
/** Every bot's frames, merged and time-ordered. `trace.save(path)` writes JSON. */
|
|
594
1289
|
readonly trace: Trace;
|
|
595
1290
|
/** Anything a script threw, in the order it happened. */
|
|
@@ -597,6 +1292,10 @@ interface BotRunner<S = undefined> extends Iterable<Bot<S>> {
|
|
|
597
1292
|
bot: number;
|
|
598
1293
|
error: unknown;
|
|
599
1294
|
}[];
|
|
1295
|
+
/** D42: every `bot.shot(...)`, in the order the bots recorded them. */
|
|
1296
|
+
readonly shots: readonly ShotRecord[];
|
|
1297
|
+
/** D42: what was injected per bot index, and what the wrapper actually did with it. */
|
|
1298
|
+
readonly conditions: readonly BotConditions[];
|
|
600
1299
|
/**
|
|
601
1300
|
* D36: what ended the run, once something has. `scripts` (every script returned), `duration`
|
|
602
1301
|
* (the deadline fired) or `room-gone` (every bot was disconnected for longer than the grace).
|
|
@@ -630,6 +1329,149 @@ declare class JoinTimeoutError extends Error {
|
|
|
630
1329
|
declare function spawnBots<S extends AnySchema>(n: number, options: SpawnOptions<S>): Promise<BotRunner<S>>;
|
|
631
1330
|
declare function spawnBots(n: number, options?: RelaySpawnOptions): Promise<BotRunner<undefined>>;
|
|
632
1331
|
|
|
1332
|
+
/**
|
|
1333
|
+
* D41: the read-only view a scenario's `assert` gets over the recorded authoritative timeline.
|
|
1334
|
+
*
|
|
1335
|
+
* The whole API is on this page on purpose. D41's constraint is that an agent can write a correct
|
|
1336
|
+
* scenario from one docs page, and the fastest way to lose that is to grow matchers, query
|
|
1337
|
+
* helpers and a second vocabulary for state a room author already has names for. So a moment is
|
|
1338
|
+
* the room's own state, keyed by the collection names from the schema, and the only verbs are
|
|
1339
|
+
* "read this tick", "find the first tick where", and "label this assertion".
|
|
1340
|
+
*
|
|
1341
|
+
* Reading is exact and never quietly empty. `at()` on a tick the recorder never held, or dropped,
|
|
1342
|
+
* throws with the range that survived. A collection name the schema does not have throws with the
|
|
1343
|
+
* names it does. Both of those would otherwise become an assertion that passes because it read
|
|
1344
|
+
* nothing, which is worse than an assertion that fails.
|
|
1345
|
+
*/
|
|
1346
|
+
|
|
1347
|
+
/** One entity's fields, or one singleton's fields, as recorded. */
|
|
1348
|
+
type TimelineRecord = Readonly<Record<string, unknown>>;
|
|
1349
|
+
/** One collection at one tick. Entity collections have entries; a singleton has `value`. */
|
|
1350
|
+
interface TimelineCollection {
|
|
1351
|
+
/** The entity's fields, or `undefined` when no entity had that id at this tick. */
|
|
1352
|
+
get(id: string): TimelineRecord | undefined;
|
|
1353
|
+
has(id: string): boolean;
|
|
1354
|
+
/** The ids present at this tick. */
|
|
1355
|
+
ids(): readonly string[];
|
|
1356
|
+
readonly size: number;
|
|
1357
|
+
/** The client that owned this entity at this tick, if any. */
|
|
1358
|
+
owner(id: string): string | undefined;
|
|
1359
|
+
/** A singleton collection's fields. `undefined` for an entity collection. */
|
|
1360
|
+
readonly value: TimelineRecord | undefined;
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* The room's authoritative state at one tick, keyed by the schema's own collection names.
|
|
1364
|
+
*
|
|
1365
|
+
* The schema is not known at compile time, so this is an index signature and a project with
|
|
1366
|
+
* `noUncheckedIndexedAccess` types each collection as possibly undefined. At runtime it never is:
|
|
1367
|
+
* a name your schema does have always resolves, and a name it does not have throws
|
|
1368
|
+
* `UnknownCollectionError` rather than answering with a blank.
|
|
1369
|
+
*/
|
|
1370
|
+
interface TimelineState {
|
|
1371
|
+
readonly [collection: string]: TimelineCollection;
|
|
1372
|
+
}
|
|
1373
|
+
interface TimelineMoment {
|
|
1374
|
+
readonly tick: number;
|
|
1375
|
+
readonly state: TimelineState;
|
|
1376
|
+
}
|
|
1377
|
+
/** One labelled assertion and how it went. This is what the report's scenario section lists. */
|
|
1378
|
+
interface AssertionResult {
|
|
1379
|
+
readonly name: string;
|
|
1380
|
+
readonly ok: boolean;
|
|
1381
|
+
/** The last tick the assertion read, when it read one. */
|
|
1382
|
+
readonly tick?: number;
|
|
1383
|
+
/** On a pass, the ticks it read. On a failure, the thrown message. */
|
|
1384
|
+
readonly detail: string;
|
|
1385
|
+
}
|
|
1386
|
+
interface Timeline {
|
|
1387
|
+
readonly roomId: string;
|
|
1388
|
+
/** Every recorded tick, oldest first. */
|
|
1389
|
+
readonly ticks: readonly number[];
|
|
1390
|
+
/** Ticks the recorder's caps evicted. Non-zero means this is a tail, not the whole run. */
|
|
1391
|
+
readonly dropped: number;
|
|
1392
|
+
/** The room's state at that tick. Throws when the tick was never recorded or was dropped. */
|
|
1393
|
+
at(tick: number): TimelineState;
|
|
1394
|
+
/** The first recorded tick where `match` holds, oldest first. */
|
|
1395
|
+
find(match: (state: TimelineState, tick: number) => boolean): TimelineMoment | undefined;
|
|
1396
|
+
/**
|
|
1397
|
+
* Runs one labelled assertion. A throw inside it fails the scenario, and the report names the
|
|
1398
|
+
* label, the tick the assertion last read, and the message you threw. The throw does not
|
|
1399
|
+
* escape, so later checks still run and the report lists every verdict. A throw outside a
|
|
1400
|
+
* `check` also fails the scenario; it just has no label to print.
|
|
1401
|
+
*/
|
|
1402
|
+
check(name: string, assertion: () => void): void;
|
|
1403
|
+
/** Every `check` that ran, in order. The runner reads this to build the report. */
|
|
1404
|
+
readonly results: readonly AssertionResult[];
|
|
1405
|
+
}
|
|
1406
|
+
/** The scenario read a collection the schema does not have. Almost always a typo, never empty. */
|
|
1407
|
+
declare class UnknownCollectionError extends Error {
|
|
1408
|
+
readonly name = "UnknownCollectionError";
|
|
1409
|
+
constructor(collection: string, known: readonly string[]);
|
|
1410
|
+
}
|
|
1411
|
+
/** The scenario read a tick the recording does not hold. See `Timeline.dropped`. */
|
|
1412
|
+
declare class TickNotRecordedError extends Error {
|
|
1413
|
+
readonly tick: number;
|
|
1414
|
+
readonly name = "TickNotRecordedError";
|
|
1415
|
+
constructor(tick: number, first: number | undefined, last: number | undefined, dropped: number);
|
|
1416
|
+
}
|
|
1417
|
+
/** Builds the read-only `Timeline` a scenario's `assert` is handed, over a recorder's dump. */
|
|
1418
|
+
declare function makeTimeline(dump: TimelineDump): Timeline;
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* D41: `defineScenario`. A scenario is a TypeScript module next to the room file: how many bots,
|
|
1422
|
+
* what they do, and what must be true of the recorded authoritative timeline afterwards.
|
|
1423
|
+
*
|
|
1424
|
+
* The type is small because the docs page has to be. `script` is the `BotScript` `@irtio/bots`
|
|
1425
|
+
* already has, so there is no second bot vocabulary to learn, and `assert` is an ordinary
|
|
1426
|
+
* function over the timeline, so there is no matcher library either. `defineScenario` itself only
|
|
1427
|
+
* exists for the types: it returns its argument, and the CLI's loader shape-checks the export so a
|
|
1428
|
+
* wrong one produces a sentence instead of a stack trace.
|
|
1429
|
+
*/
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* D42/D43: what an adversarial scenario can read beyond the timeline. Both fields are empty or
|
|
1433
|
+
* `undefined` unless the scenario asked for them, so a scenario that fires no shots and never
|
|
1434
|
+
* sets `truth` reads and behaves exactly as it did in part 3.
|
|
1435
|
+
*/
|
|
1436
|
+
interface ScenarioEvidence {
|
|
1437
|
+
/** One row per `bot.shot(...)`, correlated against the recording. Empty when nothing fired. */
|
|
1438
|
+
readonly hits: readonly HitRow[];
|
|
1439
|
+
/** The save-versus-clients diff, when `truth` asked for one and one could be taken. */
|
|
1440
|
+
readonly truth: TruthDiff | undefined;
|
|
1441
|
+
}
|
|
1442
|
+
interface ScenarioDefinition<S extends AnySchema = AnySchema> {
|
|
1443
|
+
/** How many bots to spawn. Bot 0 creates the room; the rest join it. */
|
|
1444
|
+
readonly bots: number;
|
|
1445
|
+
/** What each bot does. The same `bot` object `spawnBots` scripts get. */
|
|
1446
|
+
readonly script: BotScript<S>;
|
|
1447
|
+
/**
|
|
1448
|
+
* What must be true afterwards. Assertions run against the recorded timeline, not a live room,
|
|
1449
|
+
* so a scenario replays: same seed, same recording, same verdict. A throw fails the scenario.
|
|
1450
|
+
*/
|
|
1451
|
+
readonly assert: (timeline: Timeline, evidence: ScenarioEvidence) => void | Promise<void>;
|
|
1452
|
+
/** How long to run before the scripts are asked to stop. Default 10. */
|
|
1453
|
+
readonly seconds?: number;
|
|
1454
|
+
/** Base seed; bot `i` uses `seed + i`. Default 0x17710, the same as `irtio simulate`. */
|
|
1455
|
+
readonly seed?: number;
|
|
1456
|
+
/**
|
|
1457
|
+
* D42: network conditions injected into every bot, or a function of the bot index. A scenario is
|
|
1458
|
+
* where a per-bot split belongs, because a scenario is code already: one lagged shooter against
|
|
1459
|
+
* one clean target is two lines here and a flag language on the CLI.
|
|
1460
|
+
*/
|
|
1461
|
+
readonly conditions?: NetworkConditions | ((index: number) => NetworkConditions | undefined);
|
|
1462
|
+
/** D42: which bots write values a validator should reject. `true` means all of them. */
|
|
1463
|
+
readonly cheat?: boolean | ((index: number) => boolean);
|
|
1464
|
+
/**
|
|
1465
|
+
* D43: take a room save at the end of the run and diff it against what each client received,
|
|
1466
|
+
* within that client's visibility. The verdict lands in `evidence.truth` and in the report.
|
|
1467
|
+
*/
|
|
1468
|
+
readonly truth?: boolean;
|
|
1469
|
+
}
|
|
1470
|
+
/** Identity, typed. The export `irtio simulate --scenario` looks for. */
|
|
1471
|
+
declare function defineScenario<S extends AnySchema = AnySchema>(scenario: ScenarioDefinition<S>): ScenarioDefinition<S>;
|
|
1472
|
+
/** True when a loaded module's default export is shaped like a scenario. */
|
|
1473
|
+
declare function looksLikeScenario(value: unknown): value is ScenarioDefinition;
|
|
1474
|
+
|
|
633
1475
|
/**
|
|
634
1476
|
* The generic behaviour script: play the room without knowing anything about it.
|
|
635
1477
|
*
|
|
@@ -650,8 +1492,14 @@ declare function spawnBots(n: number, options?: RelaySpawnOptions): Promise<BotR
|
|
|
650
1492
|
*/
|
|
651
1493
|
|
|
652
1494
|
interface RandomScriptOptions {
|
|
653
|
-
/**
|
|
654
|
-
|
|
1495
|
+
/**
|
|
1496
|
+
* Write values a validator should reject, and expect corrections back.
|
|
1497
|
+
*
|
|
1498
|
+
* D42: per bot as well as global. A function of the bot index makes one bot hostile while the
|
|
1499
|
+
* rest play honestly, which is what an adversarial run against a real room usually looks like.
|
|
1500
|
+
* `--cheat` on the CLI still means every bot cheats.
|
|
1501
|
+
*/
|
|
1502
|
+
readonly cheat?: boolean | ((index: number) => boolean);
|
|
655
1503
|
/** Milliseconds between behaviour steps. Default 50 — one per client flush window. */
|
|
656
1504
|
readonly stepMs?: number;
|
|
657
1505
|
/** Magnitude of one numeric step for a well-behaved bot. Default 8. */
|
|
@@ -661,6 +1509,12 @@ interface RandomScriptOptions {
|
|
|
661
1509
|
/** Fields touched per owned instance per step. Default 2. */
|
|
662
1510
|
readonly fieldsPerStep?: number;
|
|
663
1511
|
}
|
|
1512
|
+
/**
|
|
1513
|
+
* D42: `cheat` as a predicate over the bot index, whatever shape it was given in. Exported so the
|
|
1514
|
+
* CLI and the report can say *which* bots cheated rather than only that some did, which is the
|
|
1515
|
+
* difference between "the room accepted an illegal write" and a line an agent can act on.
|
|
1516
|
+
*/
|
|
1517
|
+
declare function cheatPredicate(cheat: boolean | ((index: number) => boolean) | undefined): (index: number) => boolean;
|
|
664
1518
|
/**
|
|
665
1519
|
* A behaviour script for any schema: random writes to the instances this bot owns, and the odd
|
|
666
1520
|
* void RPC with valid params. Runs until the bot is stopped.
|
|
@@ -679,4 +1533,4 @@ interface RelayEchoScriptOptions {
|
|
|
679
1533
|
*/
|
|
680
1534
|
declare function relayEchoScript(options?: RelayEchoScriptOptions): BotScript<undefined>;
|
|
681
1535
|
|
|
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 };
|
|
1536
|
+
export { type AimPoint, type AssertionResult, type BodyKind, type Bot, type BotConditions, type BotMatchFailure, type BotMatchOptions, type BotMatchResult, BotObserver, type BotRoom, type BotRunner, type BotScript, type BotStats, type BotTicket, type BuildReportOptions, type ClientStateView, type ConditionCounters, type ConditionTimers, type ConditionedSocket, type ConditionedTransport, type ConditionedTransportOptions, type ConvergenceStats, DEFAULT_JOIN_TIMEOUT_MS, DEFAULT_MATCH_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 MatchmakingReading, type MatchmakingSummary, type NetworkConditions, type ObserverOptions, type PredictionSummary, 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, matchBots, matchmakingSummary, newConditionCounters, nextValue, randomScript, relayEchoScript, roomsOf, snapshotVisibilityLeaks, spawnBots };
|