@irtio/client 0.7.0 → 0.9.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 CHANGED
@@ -1,6 +1,7 @@
1
- import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, PhysicsBodyChannel, ReadonlyCollection, RoleOf, VisibleKeys, SchemaDefs, EntityDef, DeepReadonly, InferFields, Owned, SingletonDef, ClientImplementations, ClientRpcs, SchemaRpc, MessageChannels, ClientCallProxy } from '@irtio/schema';
1
+ import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, PhysicsBodyChannel, ClientImplementations, ClientRpcs, SchemaRpc, RoleOf, VisibleKeys, SchemaDefs, EntityDef, ReadonlyCollection, DeepReadonly, InferFields, Owned, SingletonDef, ClientCallProxy, MessageChannels } from '@irtio/schema';
2
2
  import * as _irtio_protocol from '@irtio/protocol';
3
3
  import { PresenceRecord, ProfileLedger, ProfileSnapshot } from '@irtio/protocol';
4
+ import RAPIER2D from '@dimforge/rapier2d-compat';
4
5
  import * as MATTER from 'matter-js';
5
6
  import RAPIER from '@dimforge/rapier3d-compat';
6
7
 
@@ -51,6 +52,8 @@ interface CorrectionOp {
51
52
  /** `true` when the correction outran the resim window and everything it named snapped. */
52
53
  readonly snapped: boolean;
53
54
  }
55
+ /** A row appeared (`onAdd`) or disappeared (`onRemove`) in one entity collection. */
56
+ type EntityCallback = (id: string, row: unknown) => void;
54
57
  declare class ClientStore {
55
58
  /** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
56
59
  ext: AnySchema;
@@ -60,6 +63,13 @@ declare class ClientStore {
60
63
  private tracked;
61
64
  private readonly descs;
62
65
  private readonly frozen;
66
+ /**
67
+ * Per-collection add/remove subscribers (`room.onAdd` / `room.onRemove`). Kept here rather than
68
+ * in the session because this is the only place that sees a frame's ops against the state the
69
+ * frame is about to change: a removed row's last values exist for exactly as long as it takes
70
+ * `applyDelta` to run.
71
+ */
72
+ private readonly entityListeners;
63
73
  /** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
64
74
  private readonly facades;
65
75
  /** The object handed out as `room.state`; identity survives a resync. */
@@ -123,6 +133,28 @@ declare class ClientStore {
123
133
  private freeze;
124
134
  private entityHint;
125
135
  private singletonHint;
136
+ private listenersFor;
137
+ /**
138
+ * Subscribes to rows appearing in one entity collection. Rows that are **already present** are
139
+ * announced synchronously as the subscription is made — the join snapshot has usually landed
140
+ * before user code runs, and a listener that had to reconcile the initial set by hand would
141
+ * make the event useless for exactly the case (a spawn effect) it exists for.
142
+ */
143
+ onEntityAdd(name: string, cb: EntityCallback): () => void;
144
+ /** Subscribes to rows disappearing from one entity collection (never fires for past removals). */
145
+ onEntityRemove(name: string, cb: EntityCallback): () => void;
146
+ private emitAdd;
147
+ private emitRemove;
148
+ /**
149
+ * The adds and removes a delta is about to make, captured against the state as it stands now:
150
+ * a remove's last values are only readable before `applyDelta` drops the row.
151
+ */
152
+ private captureOps;
153
+ private flushOps;
154
+ /** Fires adds and removes for the difference a wholesale snapshot load made. */
155
+ private emitSnapshotDiff;
156
+ /** The rows every subscribed collection holds right now, for the snapshot diff. */
157
+ private captureRows;
126
158
  /**
127
159
  * Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
128
160
  * (server wins only through `CORRECT`); adds, removes and owner changes always apply.
@@ -345,8 +377,21 @@ interface PredictionStats {
345
377
  resimSteps: number;
346
378
  /** Rebase passes (one per authoritative arrival batch). */
347
379
  rebases: number;
348
- /** Rebases whose lead outran the resim depth: the body snapped to authority. */
380
+ /**
381
+ * Rebases that rendered every body at authority. Since bugs.md #71 an outrun lead clamps
382
+ * instead of snapping (`leadClamped`), so nothing increments this today; the field stays
383
+ * because a zero here is a fence several suites hold, and a future hard-snap path would count
384
+ * here again.
385
+ */
349
386
  snaps: number;
387
+ /**
388
+ * Rebases whose lead outran `MAX_LEAD` and were re-stepped that many ticks instead (bugs.md
389
+ * #71). The local character still answers input; the anchor sits closer to authority than the
390
+ * round trip wants, so releases overshoot by the difference. A value that climbs and keeps
391
+ * climbing means the measured rtt is past the cap's horizon (667 ms at 60 Hz) — degraded
392
+ * prediction, where a snap would have been none.
393
+ */
394
+ leadClamped: number;
350
395
  /** Corrections whose values matched the local prediction within epsilon. */
351
396
  suppressed: number;
352
397
  /**
@@ -788,8 +833,8 @@ declare class Predictor {
788
833
  * written, so plain state holds exactly what the server said), then re-step the world by the
789
834
  * client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
790
835
  * the newest buffered unjudged write stamped at or before it, or the baseline (the newest
791
- * judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead snaps to
792
- * authority and counts (`stats.snaps`).
836
+ * judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead re-steps that
837
+ * many and counts (`stats.leadClamped`, bugs.md #71).
793
838
  */
794
839
  private rebase;
795
840
  /**
@@ -826,6 +871,126 @@ declare class Predictor {
826
871
  private isF32;
827
872
  }
828
873
 
874
+ /**
875
+ * The rapier2d half of client-side physics prediction: `joinRoom({ physics2d: { engine:
876
+ * 'rapier2d', … } })`'s option type, the lazy `@dimforge/rapier2d-compat` load, and the adapter
877
+ * that is the only place in the client where a rapier2d type is touched.
878
+ *
879
+ * The loop is `predictor.ts`, shared byte for byte with the other two engines. This file is the
880
+ * seam list, and it is transcribed from `physics.ts` — the Rapier one — rather than from
881
+ * `physics2d.ts`, for the same reason `core/rapier2d.ts` is transcribed from `core/physics.ts`:
882
+ *
883
+ * - **The world applies gravity**, so there is no `settle` hook here and there must not be one.
884
+ * `EngineAdapter.settle` exists for matter2d, whose engine gravity is usually zero and whose
885
+ * rooms apply it per body; a rapier2d world that gained one would fall twice as fast on the
886
+ * client and be corrected every tick.
887
+ * - **Velocities are per second**, so `velocityTolerance` is `epsilon / dt`, the 3D answer, not
888
+ * matter's per-step one.
889
+ * - **The step is `world.step()`** against a `world.timestep` fixed once at start.
890
+ *
891
+ * What it takes from the 2D side is only the plane: `{ x, y }` gravity, a scalar rotation and a
892
+ * scalar spin, mapped onto the thirteen 3D pose numbers through `@irtio/schema`'s `channelOf2d` /
893
+ * `applyChannel2d` / `angleFrom2d`. That mapping is written once, in the schema package, and this
894
+ * file reads it rather than restating it — a second copy is a way for the two sides to disagree
895
+ * by a sign.
896
+ *
897
+ * There is no planar-lock warning here (bug 6's friction trap needs a third axis to lock, and this
898
+ * engine has none), and the engine is loaded lazily, so a game predicting with matter2d or rapier3d
899
+ * never pulls the 2D WASM in.
900
+ */
901
+
902
+ type ClientRapier2dModule = typeof RAPIER2D;
903
+ type ClientRapier2dWorld = RAPIER2D.World;
904
+ type ClientRapier2dBody = RAPIER2D.RigidBody;
905
+ interface ClientVector2d {
906
+ readonly x: number;
907
+ readonly y: number;
908
+ }
909
+ /**
910
+ * What a client-side rapier2d body factory returns — the same shape the room config's factories
911
+ * use (`Rapier2dBodySpec` in `@irtio/server`). Rapier bodies *have* colliders rather than being
912
+ * their geometry, which is the one structural difference from the matter2d spec.
913
+ */
914
+ interface ClientRapier2dBodySpec {
915
+ readonly body: RAPIER2D.RigidBodyDesc;
916
+ readonly colliders?: readonly RAPIER2D.ColliderDesc[];
917
+ }
918
+ /**
919
+ * Method-syntax members check bivariantly, so a builder's factory or intent hook written against
920
+ * its own instance type (`(body, ball: Ball) => …`) is accepted — the values really passed are
921
+ * the schema's records for that collection.
922
+ */
923
+ type ClientRapier2dBodyFactory = {
924
+ factory(rapier: ClientRapier2dModule, instance: AnyRecord$2, id: string): ClientRapier2dBodySpec;
925
+ }['factory'];
926
+ /**
927
+ * The client's half of `Rapier2dIntentHook`. Same five parameters in the same order as the server
928
+ * type, so one hook exported from a shared world module typechecks against both sides without a
929
+ * cast.
930
+ */
931
+ type ClientRapier2dIntentHook = {
932
+ hook(body: ClientRapier2dBody, instance: AnyRecord$2, rapier: ClientRapier2dModule, world: ClientRapier2dWorld, timestep: number): void;
933
+ }['hook'];
934
+ /**
935
+ * `joinRoom({ physics2d: { engine: 'rapier2d', … } })` — the client half of the shared
936
+ * world-builder contract for a rapier2d room.
937
+ *
938
+ * It rides inside the `physics2d` option rather than beside it because it is the same *option*:
939
+ * a planar world, a planar gravity, one 2D engine. `engine` is the discriminant, and it is
940
+ * required here and optional (defaulting to `'matter2d'`) on the matter2d member, so every
941
+ * matter2d room written before this engine existed still compiles unchanged.
942
+ *
943
+ * There is no `settle`. See the module docblock.
944
+ */
945
+ interface ClientRapier2dOptions {
946
+ /** The discriminant. Required: `physics2d` without one is a matter2d world. */
947
+ readonly engine: 'rapier2d';
948
+ /** Must equal the room config's gravity. Rapier's convention is y-up; nothing flips it. */
949
+ readonly gravity: ClientVector2d;
950
+ /** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
951
+ readonly timestep?: number;
952
+ /** The shared static-geometry builder (the room's `physics.setup`). */
953
+ readonly setup?: (world: ClientRapier2dWorld, rapier: ClientRapier2dModule) => void;
954
+ /** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
955
+ readonly bodies?: Readonly<Record<string, ClientRapier2dBodyFactory>>;
956
+ /**
957
+ * Intent → force, applied before every predicted step for bodies this client owns — the same
958
+ * function the room's `tick()` calls per instance, shared so both simulations agree.
959
+ */
960
+ readonly intents?: Readonly<Record<string, ClientRapier2dIntentHook>>;
961
+ /**
962
+ * D21 cap: how many **non-owned** predicted bodies this client simulates ahead. Default 64.
963
+ * Over-cap instances get a kinematic proxy instead (see `maxProxyBodies`), so a predicted body
964
+ * still stands on them. Counted as `stats.overCap`.
965
+ */
966
+ readonly maxPredictedBodies?: number;
967
+ /**
968
+ * D71 cap: how many **kinematic proxies** this client keeps. Default `MAX_PROXY_BODIES`. Past
969
+ * it, instances are absent from the local world and predicted bodies pass through them (warned,
970
+ * counted as `stats.absent`). See `ClientPhysicsOptions.maxProxyBodies` for why it is its own
971
+ * number.
972
+ */
973
+ readonly maxProxyBodies?: number;
974
+ /**
975
+ * A body-field correction whose every value is within this tolerance of the local prediction is
976
+ * *suppressed*: authority still applies, but it is not a misprediction. Positions compare
977
+ * against `epsilon` world units; velocity channels against `epsilon / timestep`, because a
978
+ * rapier2d velocity is per second. Default 0.05, which is sized for a metre-scale world.
979
+ */
980
+ readonly epsilon?: number;
981
+ /**
982
+ * How fast the drawn position eases back onto the simulation after a re-simulation moved it, as
983
+ * a half-life in milliseconds. `0` turns the smoothing off. Default 70. See
984
+ * `ClientPhysicsOptions.smoothingHalfLifeMs` for why this smooths the error and not the motion.
985
+ */
986
+ readonly smoothingHalfLifeMs?: number;
987
+ /**
988
+ * How far the drawn position may be held from the simulation while an offset eases away, in
989
+ * world units. Past it the offset is dropped and the body appears where it is. Default 4.
990
+ */
991
+ readonly smoothingSnapUnits?: number;
992
+ }
993
+
829
994
  /**
830
995
  * The matter2d half of client-side physics prediction (D45, D57): `joinRoom({ physics2d })`'s
831
996
  * option type, the lazy `matter-js` load, and the adapter that is the only place in the client
@@ -900,6 +1065,11 @@ type ClientIntent2dHook = {
900
1065
  * Every function here should be the very export the room config imports.
901
1066
  */
902
1067
  interface ClientPhysics2dOptions {
1068
+ /**
1069
+ * The `physics2d` discriminant. Optional and defaulting to `'matter2d'`, so every matter2d
1070
+ * client written before rapier2d existed keeps compiling and keeps predicting with matter.
1071
+ */
1072
+ readonly engine?: 'matter2d';
903
1073
  /** Must equal the room config's gravity, in matter's own convention (y is down). */
904
1074
  readonly gravity: ClientVector2;
905
1075
  /** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
@@ -1314,6 +1484,12 @@ type ClientState<S, Role extends string = RoleOf<S> & string> = {
1314
1484
  serverOwned: true;
1315
1485
  } ? ClientCollection<DeepReadonly<InferFields<F>>> : ClientCollection<Owned<InferFields<F>>> : SchemaDefs<S>[K] extends SingletonDef<infer F, any> ? DeepReadonly<InferFields<F>> : never;
1316
1486
  };
1487
+ /** The entity-collection names visible to `Role` — the keys `room.onAdd`/`room.onRemove` take. */
1488
+ type EntityKeys<S, Role extends string = RoleOf<S> & string> = {
1489
+ [K in keyof ClientState<S, Role>]: ClientState<S, Role>[K] extends ReadonlyCollection<any> ? K : never;
1490
+ }[keyof ClientState<S, Role>];
1491
+ /** The row type of one entity collection, as `room.state` hands it out. */
1492
+ type RowOf<S, Role extends string, K extends keyof ClientState<S, Role>> = ClientState<S, Role>[K] extends ReadonlyCollection<infer T> ? T : never;
1317
1493
  /** `connecting` → `starting`? → `connected` ⇄ `reconnecting` → `closed`. */
1318
1494
  type Status = 'connecting' | 'starting' | 'connected' | 'reconnecting' | 'closed';
1319
1495
  /**
@@ -1505,7 +1681,7 @@ interface JoinOptions<S, Role extends string = string> {
1505
1681
  * take different functions; passing both is an error at join. The wire carries no engine
1506
1682
  * name, so passing the one that does not match the room is a game bug the client cannot see.
1507
1683
  */
1508
- readonly physics2d?: ClientPhysics2dOptions;
1684
+ readonly physics2d?: ClientPhysics2dOptions | ClientRapier2dOptions;
1509
1685
  /** @internal */
1510
1686
  readonly transport?: Transport;
1511
1687
  /** @internal */
@@ -1734,9 +1910,29 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
1734
1910
  /** D70: this socket's message counters, including typed frames it could not read. */
1735
1911
  readonly stats: RoomStats;
1736
1912
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
1913
+ /**
1914
+ * Fires when a row appears in one entity collection — a server-owned spawn, another client's
1915
+ * entity, a resync that brought a row this client had not seen.
1916
+ *
1917
+ * Rows that are **already present** when you subscribe are announced synchronously, inside the
1918
+ * `onAdd` call itself: the join snapshot has landed by the time `joinRoom` resolves, so a
1919
+ * listener registered right after the join sees the whole initial set and then every later
1920
+ * arrival, with no separate pass over `room.state`.
1921
+ */
1922
+ onAdd<K extends EntityKeys<S, Role>>(collection: K, cb: (id: string, row: RowOf<S, Role, K>) => void): Unsubscribe;
1923
+ /**
1924
+ * Fires when a row disappears from one entity collection, carrying its **last values** — the
1925
+ * row itself is the death event, so a corpse stain or a score tick needs no parallel RPC. The
1926
+ * row handed to the callback is read-only and is not in `room.state` any more.
1927
+ */
1928
+ onRemove<K extends EntityKeys<S, Role>>(collection: K, cb: (id: string, lastRow: RowOf<S, Role, K>) => void): Unsubscribe;
1737
1929
  /** Sends any pending owned writes immediately instead of at the next flush window. */
1738
1930
  flush(): void;
1739
- leave(): void;
1931
+ /**
1932
+ * Leaves for good. The returned promise resolves once the socket has closed (immediately if it
1933
+ * was already closed), so a script can `await room.leave()` before exiting.
1934
+ */
1935
+ leave(): Promise<void>;
1740
1936
  }
1741
1937
  /**
1742
1938
  * What `joinRelay` returns: presence and the message channel, nothing else.
@@ -1766,7 +1962,8 @@ interface RelayRoom<S = never> {
1766
1962
  /** D70: this socket's message counters, including typed frames it could not read. */
1767
1963
  readonly stats: RoomStats;
1768
1964
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
1769
- leave(): void;
1965
+ /** Leaves for good; resolves once the socket has closed (immediately if it already had). */
1966
+ leave(): Promise<void>;
1770
1967
  }
1771
1968
 
1772
1969
  /**
@@ -1934,6 +2131,230 @@ declare function matchRoom<S extends AnySchema, Role extends string = RoleOf<S>
1934
2131
  } & {
1935
2132
  readonly identity?: boolean;
1936
2133
  }): Promise<Room<S, Role>>;
2134
+ /** What a `mode: "public"` answer carries. */
2135
+ interface PublicTicket {
2136
+ readonly room: string;
2137
+ readonly queue: string;
2138
+ /** How many players the queue this lobby belongs to seats. */
2139
+ readonly size: number;
2140
+ /** True when this caller minted the room rather than joining one somebody else was waiting in. */
2141
+ readonly created: boolean;
2142
+ }
2143
+ /**
2144
+ * Asks the control plane for a public room of `queue`: the oldest open one, or a fresh code.
2145
+ *
2146
+ * The lower half of {@link joinPublic}, exported because the element's chooser is driven by events
2147
+ * rather than by a call, and an app that wires its own **Join a game** button may want the code
2148
+ * without the join.
2149
+ *
2150
+ * `exclude` names a room this caller already failed to join, so the second answer is a different
2151
+ * one. See `joinPublic` for when that happens.
2152
+ */
2153
+ declare function findPublic(project: string, options?: Pick<MatchOptions, 'queue' | 'controlUrl' | 'fetch'> & {
2154
+ readonly identity?: string;
2155
+ readonly exclude?: string;
2156
+ }): Promise<PublicTicket>;
2157
+ /**
2158
+ * Join a game: land in the next open public lobby, or open one and wait in it.
2159
+ *
2160
+ * ```ts
2161
+ * const room = await joinPublic(schema); // the default queue
2162
+ * const room = await joinPublic(schema, { queue: '4p', identity: true });
2163
+ * ```
2164
+ *
2165
+ * This is the other shape of matchmaking and it is deliberately not `matchRoom`. `matchRoom`
2166
+ * holds a long poll until a whole party is there and answers `E_NO_MATCH` if one never is;
2167
+ * `joinPublic` resolves on the first round trip and puts the player in a room that says "1/4
2168
+ * players". Waiting in a lobby you can see is a different experience from waiting on a spinner,
2169
+ * and it is the one a **Join a game** button should give.
2170
+ *
2171
+ * Nothing about the join differs from a friend sharing a link — the room neither knows nor cares
2172
+ * that the registry sent you — which is the property every part of this feature is built on.
2173
+ *
2174
+ * **The one retry.** The registry answers from an occupancy reading a few seconds old, so the room
2175
+ * may have filled on the way there. That is refused with `E_ROOM_FULL`, and the honest recovery is
2176
+ * to ask once more, naming the room that did not work so the second answer cannot be the same one.
2177
+ * Once, and never a loop: a retry that kept going would turn a busy game into a client that
2178
+ * hammers control, and a second failure means something a third attempt will not fix.
2179
+ */
2180
+ declare function joinPublic<S extends AnySchema, Role extends string = RoleOf<S> & string>(schema: S, options?: Pick<MatchOptions, 'queue' | 'controlUrl' | 'fetch'> & JoinOptions<S, Role> & {
2181
+ role?: RoleOf<S> & string;
2182
+ } & {
2183
+ readonly identity?: boolean;
2184
+ }): Promise<Room<S, Role>>;
2185
+
2186
+ /**
2187
+ * D75 (M6 lane H): the client's half of the lobby, and the glue that wires `<irt-lobby>` up.
2188
+ *
2189
+ * Two exports and a clean division between them.
2190
+ *
2191
+ * `lobbyOf(room)` is the **view**: it reads the lobby fragment out of ordinary room state and
2192
+ * answers the structural shape `@irtio/lobby`'s `AttachableRoom.lobby` asks for. It is a reader
2193
+ * plus one owner write (the ready flag), and it holds nothing the room does not already say.
2194
+ *
2195
+ * `attachLobby(element, schema, options)` is the **glue**: it listens for the panel's two chooser
2196
+ * events, makes the calls the panel deliberately cannot make itself, and attaches the room it gets
2197
+ * back. Sugar, not capability — the same rule `matchRoom` is held to. An app that wants to answer
2198
+ * the events itself keeps every part of this and needs none of it.
2199
+ *
2200
+ * ## Why the view polls
2201
+ *
2202
+ * `room.on(...)` has no "state changed" event: the client SDK publishes `status`, `clients`, `rtt`,
2203
+ * `error` and `correct`, and lobby state is ordinary entity state that arrives in a `DELTA` like
2204
+ * everything else. So `on('change')` samples — on every `clients` event, and on a cheap timer in
2205
+ * between — and fires only when a small signature actually differs.
2206
+ *
2207
+ * That is a real cost and it is written here rather than hidden: one shallow string build per
2208
+ * sample, over at most `maxClients` records, for as long as somebody is watching. It is the honest
2209
+ * price of the element importing nothing from this package. A `state` event on `Room` would replace
2210
+ * the timer with a subscription and is recorded as a debt in the lane report rather than smuggled
2211
+ * into this lane's protocol-free budget.
2212
+ */
2213
+
2214
+ /**
2215
+ * The two collection names the lobby fragment declares.
2216
+ *
2217
+ * Literals here rather than an import from `@irtio/server`, which is where they are defined. The
2218
+ * client SDK ships to browsers and depends on `@irtio/schema` and `@irtio/protocol` alone; taking
2219
+ * a dependency on the room-file API to read two strings would put the whole of it in every game's
2220
+ * bundle. This is the same fence `@irtio/server`'s own `MAX_AWAKE_MAX` sits behind, and it is
2221
+ * pinned the same way: `test/lobby-view.test.ts` imports both and asserts the pairs equal, from a
2222
+ * package that legitimately depends on both, so a rename is a failing test rather than a lobby
2223
+ * that silently never appears.
2224
+ */
2225
+ declare const LOBBY_STATE = "irtLobby";
2226
+ declare const LOBBY_MEMBERS = "irtLobbyMembers";
2227
+ /** How often the view samples for a change between `clients` events. */
2228
+ declare const LOBBY_POLL_MS = 120;
2229
+ /** One row of the lobby roster, in join order. */
2230
+ interface LobbyPlayer {
2231
+ readonly clientId: string;
2232
+ readonly ready: boolean;
2233
+ readonly connected: boolean;
2234
+ /** True for the row belonging to the client holding this view. */
2235
+ readonly me: boolean;
2236
+ }
2237
+ /** What `lobbyOf` answers: `@irtio/lobby`'s structural `lobby` member, with names. */
2238
+ interface LobbyView {
2239
+ readonly phase: 'lobby' | 'started';
2240
+ readonly players: readonly LobbyPlayer[];
2241
+ readonly public: boolean;
2242
+ readonly readyUi: boolean;
2243
+ /** How many the room starts at under `'when-full'`, or 0 when the room has not said. */
2244
+ readonly capacity: number;
2245
+ ready(value: boolean): void;
2246
+ setPublic?(value: boolean): void;
2247
+ on(event: 'change', cb: () => void): () => void;
2248
+ }
2249
+ interface StateLike {
2250
+ [collection: string]: unknown;
2251
+ }
2252
+ interface RoomLike {
2253
+ readonly me: string;
2254
+ readonly state: StateLike;
2255
+ readonly clients: readonly {
2256
+ clientId: string;
2257
+ connected: boolean;
2258
+ }[];
2259
+ on(event: 'clients', cb: (clients: readonly {
2260
+ clientId: string;
2261
+ connected: boolean;
2262
+ }[]) => void): () => void;
2263
+ flush(): void;
2264
+ }
2265
+ /**
2266
+ * Does this room carry a lobby?
2267
+ *
2268
+ * A schema that did not spread `lobbyCollections` has neither collection, and the honest answer for
2269
+ * such a room is that it has no lobby rather than an empty one — `<irt-lobby>` renders no lobby UI
2270
+ * at all in that case, which is what keeps every existing consumer unchanged.
2271
+ */
2272
+ declare function hasLobby(room: unknown): boolean;
2273
+ /**
2274
+ * Reads a joined room's lobby.
2275
+ *
2276
+ * ```ts
2277
+ * const room = await joinPublic(schema);
2278
+ * lobby.attach(room, { lobby: lobbyOf(room) });
2279
+ * ```
2280
+ *
2281
+ * `setPublic` is present only when the caller supplies one, and that is a consequence of there
2282
+ * being no built-in RPC for it. The platform never takes a public toggle from a client — that
2283
+ * would be a protocol change — so the room's own game code owns the door. A game that wants the
2284
+ * panel's toggle to work declares its own RPC and passes it here:
2285
+ *
2286
+ * ```ts
2287
+ * lobbyOf(room, { setPublic: (value) => void room.call.setPublic({ value }) })
2288
+ * ```
2289
+ *
2290
+ * Without one, the panel renders the room's public state and no toggle, which is the truthful
2291
+ * rendering of a room whose code has not offered the control.
2292
+ */
2293
+ declare function lobbyOf(room: unknown, options?: {
2294
+ readonly setPublic?: (value: boolean) => void;
2295
+ }): LobbyView | undefined;
2296
+ /** What the panel's `quickmatch` event carries. */
2297
+ interface QuickMatchDetail {
2298
+ readonly queue?: string;
2299
+ }
2300
+ /** The element surface `attachLobby` needs. Structural, so a test can pass a stand-in. */
2301
+ interface LobbyElementLike {
2302
+ addEventListener(type: string, cb: (event: Event) => void): void;
2303
+ removeEventListener(type: string, cb: (event: Event) => void): void;
2304
+ setAttribute(name: string, value: string): void;
2305
+ removeAttribute(name: string): void;
2306
+ attach(room: unknown): () => void;
2307
+ }
2308
+ /**
2309
+ * The room, as `<irt-lobby>`'s structural `AttachableRoom` wants it: everything the panel already
2310
+ * read, plus the optional `lobby` member the design's §6 adds.
2311
+ *
2312
+ * A wrapper of getters rather than a spread, because a `Room`'s fields are accessors on a class:
2313
+ * `{ ...room }` would freeze `status`, `id` and `clients` at the moment of the copy, and the panel
2314
+ * re-reads all three on every render precisely so a reconnect that changes the code is picked up.
2315
+ * Mutating the room to hang a `lobby` on it was the other option and is worse — the SDK's own
2316
+ * object would then carry a field its type does not declare.
2317
+ */
2318
+ declare function attachable(room: RoomLike & {
2319
+ id: string;
2320
+ link: string;
2321
+ status: string;
2322
+ maxClients?: number;
2323
+ rtt?: number;
2324
+ }, view: LobbyView | undefined): unknown;
2325
+ interface AttachLobbyOptions<S extends AnySchema, Role extends string> {
2326
+ /** Passed through to `joinRoom` / `joinPublic`. */
2327
+ readonly join?: JoinOptions<S, Role> & {
2328
+ role?: RoleOf<S> & string;
2329
+ };
2330
+ /** Passed through to the control-plane call. */
2331
+ readonly match?: Pick<MatchOptions, 'controlUrl' | 'fetch'> & {
2332
+ readonly identity?: boolean;
2333
+ };
2334
+ /** The room's own public-toggle RPC, if it has one. See `lobbyOf`. */
2335
+ readonly setPublic?: (room: Room<S, Role>, value: boolean) => void;
2336
+ /** Called with the room once it is joined, so game code can start drawing. */
2337
+ readonly onRoom?: (room: Room<S, Role>) => void;
2338
+ /** Called when a join fails. Default: the panel's status goes back to idle and the error is
2339
+ * rethrown on the microtask queue so it reaches `window.onerror` rather than vanishing. */
2340
+ readonly onError?: (err: unknown) => void;
2341
+ }
2342
+ /**
2343
+ * Wires `<irt-lobby>`'s chooser to the two calls it deliberately cannot make itself.
2344
+ *
2345
+ * ```ts
2346
+ * attachLobby(document.querySelector('irt-lobby')!, schema, { onRoom: (room) => start(room) });
2347
+ * ```
2348
+ *
2349
+ * The panel emits and this answers: `private` becomes an ordinary `joinRoom` with no code (which
2350
+ * creates one), `quickmatch` becomes `joinPublic`. Both then `attach` the room, exactly as an app
2351
+ * doing it by hand would. Returns an unsubscribe.
2352
+ *
2353
+ * `@irtio/lobby` imports nothing from this package and never will — that is what lets a React or
2354
+ * Svelte app drive the panel with attributes and no SDK. This function is the other side of that
2355
+ * rule rather than an exception to it: the dependency points this way.
2356
+ */
2357
+ declare function attachLobby<S extends AnySchema, Role extends string = RoleOf<S> & string>(element: LobbyElementLike, schema: S, options?: AttachLobbyOptions<S, Role>): () => void;
1937
2358
 
1938
2359
  /**
1939
2360
  * The default `Scheduler`. In a browser the write batcher aligns to `requestAnimationFrame` (one
@@ -2135,7 +2556,7 @@ interface SessionOptions {
2135
2556
  /** The shared world-builder half the client predicts with (D22 part 2). */
2136
2557
  readonly physics?: ClientPhysicsOptions | undefined;
2137
2558
  /** The same, for a matter2d room (D57). Never both; `joinRoom` refuses that. */
2138
- readonly physics2d?: ClientPhysics2dOptions | undefined;
2559
+ readonly physics2d?: ClientPhysics2dOptions | ClientRapier2dOptions | undefined;
2139
2560
  readonly transport?: Transport | undefined;
2140
2561
  readonly scheduler?: Scheduler | undefined;
2141
2562
  readonly onFrame?: FrameHook | undefined;
@@ -2272,8 +2693,15 @@ declare class Session {
2272
2693
  private connect;
2273
2694
  private transportFailed;
2274
2695
  private sendHello;
2275
- /** Leaves for good: no reconnect, pending calls reject, the socket closes. */
2276
- leave(): void;
2696
+ /**
2697
+ * Leaves for good: no reconnect, pending calls reject, the socket closes.
2698
+ *
2699
+ * The promise resolves when the socket reports its close (or immediately when there was
2700
+ * nothing open), so `await room.leave()` is a real wait rather than a guessed timeout. A
2701
+ * transport that never reports one is bounded by `LEAVE_CLOSE_TIMEOUT_MS` so the caller cannot
2702
+ * hang on it.
2703
+ */
2704
+ leave(): Promise<void>;
2277
2705
  private fatal;
2278
2706
  private onSocketClosed;
2279
2707
  private stopTimers;
@@ -2586,4 +3014,4 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
2586
3014
  */
2587
3015
  declare function joinRelay<S extends AnySchema = never>(options?: JoinRelayOptions<S extends AnySchema ? S : AnySchema>): Promise<RelayRoom<S>>;
2588
3016
 
2589
- export { ACCOUNT_STORAGE_KEY, CALL_TIMEOUT_MS, type ClientBody2dFactory, type ClientBody2dSpec, type ClientBodySpec, type ClientCollection, type ClientIntent2dHook, type ClientMatterBody, type ClientMatterConstraint, type ClientMatterEngine, type ClientMatterModule, type ClientPhysics2dOptions, type ClientPhysicsOptions, type ClientRapierBody, type ClientRapierModule, type ClientRapierWorld, type ClientState, ClientStore, type ClientVector2, type ClientVector3, type Correction, DEFAULT_CONTROL_URL, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, E_IDENTITY_RATE_LIMITED, type FrameHook, INTERNAL_VOICE_TRACKS, Identity, IdentityError, type IdentityOptions, type IdentityStorage, type JoinOptions, type JoinRelayOptions, MAX_IDENTITY_RETRY_WAIT_MS, MAX_PREDICTED_BODIES, MAX_PROXY_BODIES, MatchError, type MatchOptions, type MatchTicket, type MessageTarget, PING_INTERVAL_MS, PREDICTION_EPSILON, type PartyTicket, type PredictionStats, type PredictionStatus, REGION_RE, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, type RoomMessageStats, type RoomMessages, type RoomProfile, type RoomStats, SMOOTHING_HALF_LIFE_MS, SMOOTHING_SNAP_UNITS, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, type VoiceHandle, type VoiceOptions, type VoicePeerState, type VoiceTrackAccess, createParty, defaultScheduler, findMatch, identityStorageKey, joinRelay, joinRoom, joinVoice, linkForUrl, matchRoom, resolveUrl, roomIdFrom, webSocketTransport };
3017
+ export { ACCOUNT_STORAGE_KEY, type AttachLobbyOptions, CALL_TIMEOUT_MS, type ClientBody2dFactory, type ClientBody2dSpec, type ClientBodySpec, type ClientCollection, type ClientIntent2dHook, type ClientMatterBody, type ClientMatterConstraint, type ClientMatterEngine, type ClientMatterModule, type ClientPhysics2dOptions, type ClientPhysicsOptions, type ClientRapier2dBody, type ClientRapier2dBodyFactory, type ClientRapier2dBodySpec, type ClientRapier2dIntentHook, type ClientRapier2dModule, type ClientRapier2dOptions, type ClientRapier2dWorld, type ClientRapierBody, type ClientRapierModule, type ClientRapierWorld, type ClientState, ClientStore, type ClientVector2, type ClientVector2d, type ClientVector3, type Correction, DEFAULT_CONTROL_URL, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, E_IDENTITY_RATE_LIMITED, type EntityKeys, type FrameHook, INTERNAL_VOICE_TRACKS, Identity, IdentityError, type IdentityOptions, type IdentityStorage, type JoinOptions, type JoinRelayOptions, LOBBY_MEMBERS, LOBBY_POLL_MS, LOBBY_STATE, type LobbyPlayer, type LobbyView, MAX_IDENTITY_RETRY_WAIT_MS, MAX_PREDICTED_BODIES, MAX_PROXY_BODIES, MatchError, type MatchOptions, type MatchTicket, type MessageTarget, PING_INTERVAL_MS, PREDICTION_EPSILON, type PartyTicket, type PredictionStats, type PredictionStatus, type PublicTicket, type QuickMatchDetail, REGION_RE, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, type RoomMessageStats, type RoomMessages, type RoomProfile, type RoomStats, type RowOf, SMOOTHING_HALF_LIFE_MS, SMOOTHING_SNAP_UNITS, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, type VoiceHandle, type VoiceOptions, type VoicePeerState, type VoiceTrackAccess, attachLobby, attachable, createParty, defaultScheduler, findMatch, findPublic, hasLobby, identityStorageKey, joinPublic, joinRelay, joinRoom, joinVoice, linkForUrl, lobbyOf, matchRoom, resolveUrl, roomIdFrom, webSocketTransport };