@irtio/bots 0.6.0 → 0.8.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 +370 -22
  2. package/dist/index.js +483 -118
  3. package/package.json +5 -5
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { PlainState, AnySchema, Delta, TypeDesc } from '@irtio/schema';
2
2
  import { TimelineDump } from '@irtio/runtime';
3
3
  export { TimelineDump, TimelineFrame } from '@irtio/runtime';
4
- import { Correction, Room, RelayRoom, Transport, JoinOptions } from '@irtio/client';
4
+ import { PredictionStatus, Correction, MatchTicket, Room, RelayRoom, Transport, JoinOptions } from '@irtio/client';
5
5
  import { ProfileSnapshot } from '@irtio/protocol';
6
6
 
7
7
  /**
@@ -17,7 +17,7 @@ import { ProfileSnapshot } from '@irtio/protocol';
17
17
  */
18
18
 
19
19
  /** The invariants every simulation checks, in report order. */
20
- 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"];
21
21
  type InvariantName = (typeof INVARIANT_NAMES)[number];
22
22
  /**
23
23
  * D36: three states, not two. `unavailable` exists because the server's own tick counters cannot
@@ -48,6 +48,27 @@ interface TickHealthReading {
48
48
  /** Where the numbers came from, so a reader can go and check. */
49
49
  readonly source?: string;
50
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
+ }
51
72
  interface SpatialVisibilityContext {
52
73
  /**
53
74
  * The state the anchor and the judged positions are read from. Server truth when a test has it
@@ -91,6 +112,16 @@ interface InvariantThresholds {
91
112
  * paid for — and one `--overruns-max` away from being usable on a deliberately busy room.
92
113
  */
93
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;
94
125
  }
95
126
  declare const DEFAULT_THRESHOLDS: InvariantThresholds;
96
127
  /**
@@ -287,21 +318,27 @@ declare function conditionedTransport(base: ConditionedTransport, conditions: Ne
287
318
  *
288
319
  * ## How a shot is correlated
289
320
  *
290
- * Three numbers, and the honesty of the third is the whole feature:
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.
291
324
  *
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.
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.
302
337
  *
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.
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.
305
342
  */
306
343
 
307
344
  /** One aim point: field name to value, over the target collection's numeric fields. */
@@ -315,6 +352,20 @@ interface ShotRequest {
315
352
  readonly aim: AimPoint;
316
353
  /** Free-form label, carried through to the row. */
317
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;
318
369
  }
319
370
  /** A shot as recorded, before any correlation. */
320
371
  interface ShotRecord extends ShotRequest {
@@ -334,18 +385,27 @@ interface HitRow extends ShotRecord {
334
385
  /**
335
386
  * `true` when `serverTick` was derived through an injected uplink delay rather than read off a
336
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.
337
391
  */
338
392
  readonly estimated: boolean;
339
393
  /** The target's authoritative value at `serverTick`, over the fields the aim named. */
340
394
  readonly authoritative: AimPoint | undefined;
341
395
  /** Euclidean distance between `aim` and `authoritative`, or `undefined` when unknown. */
342
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;
343
399
  /** Why a row has no distance, in one clause. Absent when it has one. */
344
400
  readonly unresolved?: string;
345
401
  }
346
402
  /**
347
403
  * Correlates every shot against the recording. Pure: same shots and same dump, same rows, which
348
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.
349
409
  */
350
410
  declare function correlateShots(shots: readonly ShotRecord[], dump: TimelineDump): HitRow[];
351
411
 
@@ -511,6 +571,16 @@ declare function makeTrace(startedAt: number, rings: () => readonly TraceRing[])
511
571
  * bytes does not.
512
572
  */
513
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';
514
584
  /** Per-bot counters the report prints verbatim. */
515
585
  interface BotStats {
516
586
  readonly index: number;
@@ -530,6 +600,16 @@ interface BotStats {
530
600
  * the meaning: once the client re-steps predicted bodies, these become real mispredictions.
531
601
  */
532
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;
533
613
  /**
534
614
  * D22 part 2: predicted-body corrections whose values matched the local prediction within the
535
615
  * epsilon — authority confirming the prediction, not disagreeing with it. Counted apart so a
@@ -551,6 +631,37 @@ interface BotStats {
551
631
  readonly disconnects: number;
552
632
  readonly peakBytesInPerSec: number;
553
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;
554
665
  }
555
666
  /**
556
667
  * Shared across bots: the bridge that turns "bot A wrote x=7" plus "bot B was told x=7" into a
@@ -580,8 +691,8 @@ interface ObserverOptions {
580
691
  declare class BotObserver {
581
692
  private readonly options;
582
693
  readonly ring: TraceRing;
583
- readonly violations: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects" | "tick-health", string[]>;
584
- readonly counts: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects" | "tick-health", number>;
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>;
585
696
  /**
586
697
  * Bug #28: the decode extension, *mutable*. It starts as `options.ext` (the schema the run was
587
698
  * spawned with) and is rebuilt in place when an inbound `SCHEMA` frame (13) lands — a D50
@@ -609,14 +720,29 @@ declare class BotObserver {
609
720
  bytesOut: number;
610
721
  corrections: number;
611
722
  syncCorrections: number;
723
+ proxiedCorrections: number;
612
724
  suppressedCorrections: number;
613
725
  mispredictions: number;
614
726
  /**
615
- * Does this bot's client currently predict `collection[id]` in a local world? Assigned by
616
- * `spawnBots` once the room exists (`room.prediction`); prediction status is client-local, so
617
- * 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.
733
+ */
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.
618
739
  */
619
- predictsBody: ((collection: string, id: string) => boolean) | undefined;
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;
620
746
  mispredictionMagnitude: number;
621
747
  mispredictionMax: number;
622
748
  snaps: number;
@@ -625,6 +751,10 @@ declare class BotObserver {
625
751
  disconnects: number;
626
752
  peakBytesInPerSec: number;
627
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;
628
758
  /**
629
759
  * Spatial-grid ops actually put to the AOI policy. Zero on a run whose room has no spatial
630
760
  * collection; zero on a run that *does* and would mean the invariant passed vacuously, which is
@@ -659,6 +789,22 @@ declare class BotObserver {
659
789
  onFrame(dir: 'in' | 'out', type: number, bytes: Uint8Array): void;
660
790
  /** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
661
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;
662
808
  private onWelcome;
663
809
  /**
664
810
  * Bug #28: a `SCHEMA` frame (D50 additive migrate). Rebuild the decode extension from the
@@ -721,6 +867,9 @@ interface SimulationTotals {
721
867
  readonly corrections: number;
722
868
  /** D22: corrections carrying only simulated body state — the sync path, not a disagreement. */
723
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;
724
873
  /** D22 part 2: predicted-body corrections matching the prediction within epsilon (quiet). */
725
874
  readonly suppressedCorrections: number;
726
875
  /** Correction ops seen via the rooms' `correct` events, across every bot. */
@@ -733,6 +882,70 @@ interface SimulationTotals {
733
882
  readonly snaps: number;
734
883
  readonly calls: number;
735
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
+ }[];
736
949
  }
737
950
  interface SimulationReport {
738
951
  /** True when every invariant passed. `irtio simulate` exits 1 when it is not. */
@@ -757,7 +970,13 @@ interface SimulationReport {
757
970
  * per-bot rate the rest of this report prints. Absent when nobody profiled.
758
971
  */
759
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;
760
977
  }
978
+ /** D73-c: folds the queue's answers into the summary and the `party-integrity` verdict. */
979
+ declare function matchmakingSummary(reading: MatchmakingReading): MatchmakingSummary;
761
980
  /** `undefined` when nothing converged during the run — an honest gap beats a fabricated zero. */
762
981
  declare function convergenceStats(lags: readonly number[]): ConvergenceStats | undefined;
763
982
  interface BuildReportOptions {
@@ -772,12 +991,103 @@ interface BuildReportOptions {
772
991
  * reports `unavailable`, which is the honest answer for a caller that could not read it.
773
992
  */
774
993
  readonly tickHealth?: TickHealthReading | undefined;
994
+ /** D73-c: what the queue answered per bot. Absent ⇒ `party-integrity` reports `unavailable`. */
995
+ readonly matchmaking?: MatchmakingReading | undefined;
775
996
  /** D65: one ledger per bot, merged into `SimulationReport.profile`. */
776
997
  readonly profiles?: readonly ProfileSnapshot[] | undefined;
777
998
  }
778
999
  /** Folds the observers into the report `irtio simulate` prints and tests assert on. */
779
1000
  declare function buildReport(options: BuildReportOptions): SimulationReport;
780
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
+
781
1091
  /**
782
1092
  * `spawnBots` — N real `@irtio/client` sessions in one Node process, each running a script, each
783
1093
  * watched by a `BotObserver`.
@@ -886,6 +1196,12 @@ interface SpawnOptionsBase {
886
1196
  readonly snapsMax?: number;
887
1197
  /** `tick-health` threshold: server tick overruns tolerated in the run window. Default 0. */
888
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;
889
1205
  /**
890
1206
  * D36: how long one bot's initial join may take before `spawnBots` gives up on the whole run.
891
1207
  * Default {@link DEFAULT_JOIN_TIMEOUT_MS}; `0` restores the old unbounded wait.
@@ -908,6 +1224,15 @@ interface SpawnOptionsBase {
908
1224
  * sockets at all. The vocabulary is deliberately the same and the two are not interchangeable.
909
1225
  */
910
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;
911
1236
  }
912
1237
  interface SpawnOptions<S extends AnySchema> extends SpawnOptionsBase {
913
1238
  readonly schema: S;
@@ -920,6 +1245,15 @@ interface SpawnOptions<S extends AnySchema> extends SpawnOptionsBase {
920
1245
  * corrections classify as real mispredictions (in world units) instead of `syncCorrections`.
921
1246
  */
922
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'];
923
1257
  }
924
1258
  /** Schema-less relay: no schema, so `joinRelay` and a `RelayRoom`. */
925
1259
  interface RelaySpawnOptions extends SpawnOptionsBase {
@@ -936,7 +1270,21 @@ interface BotConditions {
936
1270
  }
937
1271
  interface BotRunner<S = undefined> extends Iterable<Bot<S>> {
938
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
+ */
939
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
+ }[];
940
1288
  /** Every bot's frames, merged and time-ordered. `trace.save(path)` writes JSON. */
941
1289
  readonly trace: Trace;
942
1290
  /** Anything a script threw, in the order it happened. */
@@ -1185,4 +1533,4 @@ interface RelayEchoScriptOptions {
1185
1533
  */
1186
1534
  declare function relayEchoScript(options?: RelayEchoScriptOptions): BotScript<undefined>;
1187
1535
 
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 };
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 };