@irtio/client 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-7UTJ7RSF.js → chunk-6J6PKFUS.js} +69 -8
- package/dist/{physics-H2VDQLAU.js → chunk-TE66EDBB.js} +174 -239
- package/dist/index.d.ts +1170 -158
- package/dist/index.js +1240 -42
- package/dist/physics-S5LHL3HK.js +196 -0
- package/dist/physics2d-ZV2IRRRZ.js +206 -0
- package/package.json +15 -6
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, ReadonlyCollection, RoleOf, VisibleKeys, SchemaDefs, EntityDef, DeepReadonly, InferFields, Owned, SingletonDef, ClientImplementations, ClientRpcs, SchemaRpc, ClientCallProxy } from '@irtio/schema';
|
|
1
|
+
import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, PhysicsBodyChannel, ReadonlyCollection, RoleOf, VisibleKeys, SchemaDefs, EntityDef, DeepReadonly, InferFields, Owned, SingletonDef, ClientImplementations, ClientRpcs, SchemaRpc, ClientCallProxy } from '@irtio/schema';
|
|
2
|
+
import * as _irtio_protocol from '@irtio/protocol';
|
|
3
|
+
import { PresenceRecord, ProfileLedger, ProfileSnapshot } from '@irtio/protocol';
|
|
4
|
+
import * as MATTER from 'matter-js';
|
|
2
5
|
import RAPIER from '@dimforge/rapier3d-compat';
|
|
3
|
-
import { PresenceRecord } from '@irtio/protocol';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* The client's state layer.
|
|
@@ -50,13 +52,16 @@ interface CorrectionOp {
|
|
|
50
52
|
readonly snapped: boolean;
|
|
51
53
|
}
|
|
52
54
|
declare class ClientStore {
|
|
53
|
-
|
|
55
|
+
/** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
|
|
56
|
+
ext: AnySchema;
|
|
54
57
|
/** Reads the current client id — it is not known until the first `WELCOME`. */
|
|
55
58
|
private readonly meOf;
|
|
56
59
|
plain: PlainState;
|
|
57
60
|
private tracked;
|
|
58
61
|
private readonly descs;
|
|
59
62
|
private readonly frozen;
|
|
63
|
+
/** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
|
|
64
|
+
private readonly facades;
|
|
60
65
|
/** The object handed out as `room.state`; identity survives a resync. */
|
|
61
66
|
readonly view: AnyRecord$3;
|
|
62
67
|
/** Flushed-but-unjudged writes, oldest first, at most `RESIM_DEPTH` entries (D19). */
|
|
@@ -70,9 +75,24 @@ declare class ClientStore {
|
|
|
70
75
|
* walks the window tick by tick starts from here, not from the newest local value.
|
|
71
76
|
*/
|
|
72
77
|
private readonly baselineIntents;
|
|
73
|
-
constructor(
|
|
78
|
+
constructor(
|
|
79
|
+
/** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
|
|
80
|
+
ext: AnySchema,
|
|
74
81
|
/** Reads the current client id — it is not known until the first `WELCOME`. */
|
|
75
82
|
meOf: () => string);
|
|
83
|
+
/**
|
|
84
|
+
* D50: rebuild every schema-derived handle in this store against `newExt`, keeping the objects
|
|
85
|
+
* user code holds. `view` keeps its identity (contractual, and pinned by test), and so does each
|
|
86
|
+
* collection facade behind it, because a game may well have hoisted `room.state.players` into a
|
|
87
|
+
* local.
|
|
88
|
+
*
|
|
89
|
+
* State itself is *not* carried across: `plain` is re-created empty under the new schema. That
|
|
90
|
+
* is not a loss, because a swap only ever happens immediately before the resync WELCOME that
|
|
91
|
+
* `rejoinAll` sends, and `loadSnapshot` re-seeds everything from it. Carrying the old records
|
|
92
|
+
* forward would be actively wrong — `normalizeRecord` rejects field names the old schema did
|
|
93
|
+
* not have, and a record built under the old descriptor is missing the new fields entirely.
|
|
94
|
+
*/
|
|
95
|
+
swapSchema(newExt: AnySchema): void;
|
|
76
96
|
loadSnapshot(bytes: Uint8Array): void;
|
|
77
97
|
/**
|
|
78
98
|
* Captures the field values of every pending owned write, keyed by collection then id then
|
|
@@ -87,7 +107,17 @@ declare class ClientStore {
|
|
|
87
107
|
* whatever the resync snapshot happened to carry for that field.
|
|
88
108
|
*/
|
|
89
109
|
applyPendingWrites(pending: Map<string, Map<string, AnyRecord$3>>): void;
|
|
90
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Defines one accessor per collection on `view`. Called once at construction and again on every
|
|
112
|
+
* schema swap, always against the SAME object, so `room.state`'s identity never changes.
|
|
113
|
+
*
|
|
114
|
+
* A collection that survives the swap keeps its facade and is retargeted at the new descriptor;
|
|
115
|
+
* a collection the new schema adds gets a fresh one; a collection that disappears has its
|
|
116
|
+
* accessor deleted. Only the first two can happen on an additive swap, but a `delete` is one
|
|
117
|
+
* line and a stale accessor reading a collection that no longer exists would throw from inside
|
|
118
|
+
* a getter, which is a miserable thing to debug.
|
|
119
|
+
*/
|
|
120
|
+
private buildViewInto;
|
|
91
121
|
/** The writable tracked proxy when this client owns `id`, otherwise a frozen one. */
|
|
92
122
|
instance(desc: CollectionDesc, id: string): unknown;
|
|
93
123
|
private freeze;
|
|
@@ -171,29 +201,30 @@ declare class ClientStore {
|
|
|
171
201
|
}
|
|
172
202
|
|
|
173
203
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
204
|
+
* The engine-neutral half of client-side physics prediction (D22 part 2, D21, D57): the loop, the
|
|
205
|
+
* render clock, the pose ring, the error smoothing, the cap, and the rebase replay. Everything an
|
|
206
|
+
* engine actually does sits behind {@link EngineAdapter}, and there is exactly one implementation
|
|
207
|
+
* of the loop for every engine irtio blesses.
|
|
176
208
|
*
|
|
177
209
|
* ## The contract
|
|
178
210
|
*
|
|
179
|
-
* The world is built from the **shared world-builder** the game passes to
|
|
180
|
-
* `
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
211
|
+
* The world is built from the **shared world-builder** the game passes to `joinRoom`: the same
|
|
212
|
+
* `setup` (static geometry), `bodies` (shape factories) and `intents` (intent → force, per step)
|
|
213
|
+
* functions the room's config uses, imported by both sides from one module (`irtio/world.ts` by
|
|
214
|
+
* convention — a convention, not a mechanism). The builder must be pure over synced inputs: no
|
|
215
|
+
* `Math.random()`, no clock, seed/level params read from synced state — or the two worlds are not
|
|
216
|
+
* the same world.
|
|
185
217
|
*
|
|
186
218
|
* ## The loop
|
|
187
219
|
*
|
|
188
|
-
* The local world **free-runs** on a fixed-timestep accumulator driven by render reads: each
|
|
189
|
-
*
|
|
220
|
+
* The local world **free-runs** on a fixed-timestep accumulator driven by render reads: each step
|
|
221
|
+
* applies the owner's current intent values through the `intents` hook, then steps. When
|
|
190
222
|
* authoritative body state arrives (a `CORRECT` for an owned body, a `DELTA` for a non-owned
|
|
191
223
|
* predicted one), every predicted body is **rebased** to the server's values and the world
|
|
192
224
|
* re-steps the client's lead — one step per tick of estimated one-way latency, replaying the
|
|
193
|
-
* buffered unjudged intent frames oldest-first (the write buffer holds exactly the server's
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* counted.
|
|
225
|
+
* buffered unjudged intent frames oldest-first (the write buffer holds exactly the server's input
|
|
226
|
+
* frames — only intents are writable on a physics entity). The lead is bounded by the shared
|
|
227
|
+
* resimulation depth (20, D19): beyond it the body snaps to authority and the snap is counted.
|
|
197
228
|
*
|
|
198
229
|
* ## What the renderer sees
|
|
199
230
|
*
|
|
@@ -214,94 +245,68 @@ declare class ClientStore {
|
|
|
214
245
|
* snapped back by the next correction. That is the documented constraint: predict both
|
|
215
246
|
* (`predicted: true`, within the cap) or interpolate both.
|
|
216
247
|
*
|
|
217
|
-
* The
|
|
218
|
-
*
|
|
248
|
+
* The engines themselves are loaded lazily, by an adapter module behind a dynamic `import()` from
|
|
249
|
+
* `session.ts`, so a game with no physics option — or one that predicts with the other engine —
|
|
250
|
+
* pays nothing for the one it does not use.
|
|
219
251
|
*/
|
|
220
252
|
|
|
221
253
|
type AnyRecord$2 = Record<string, unknown>;
|
|
222
|
-
type ClientRapierModule = typeof RAPIER;
|
|
223
|
-
type ClientRapierWorld = RAPIER.World;
|
|
224
|
-
type ClientRapierBody = RAPIER.RigidBody;
|
|
225
|
-
interface ClientVector3 {
|
|
226
|
-
readonly x: number;
|
|
227
|
-
readonly y: number;
|
|
228
|
-
readonly z: number;
|
|
229
|
-
}
|
|
230
|
-
/** What a client-side body factory returns — the same shape the room config's factories use. */
|
|
231
|
-
interface ClientBodySpec {
|
|
232
|
-
readonly body: RAPIER.RigidBodyDesc;
|
|
233
|
-
readonly colliders?: readonly RAPIER.ColliderDesc[];
|
|
234
|
-
}
|
|
235
254
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* the schema's records for that collection.
|
|
255
|
+
* One engine's body, opaque to this file. Created, read, stepped and removed only through the
|
|
256
|
+
* adapter — the core stores the handle and hands it straight back.
|
|
239
257
|
*/
|
|
240
|
-
type
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
type ClientIntentHook = {
|
|
244
|
-
hook(body: ClientRapierBody, instance: AnyRecord$2, rapier: ClientRapierModule, world: ClientRapierWorld): void;
|
|
245
|
-
}['hook'];
|
|
258
|
+
type EngineBody = object;
|
|
259
|
+
/** The schema's channel mapping for one collection, as `applyRecord` wants it. */
|
|
260
|
+
type BodyChannels = readonly (readonly [PhysicsBodyChannel, string])[];
|
|
246
261
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
262
|
+
* Everything an engine does, and nothing else. One adapter per blessed engine
|
|
263
|
+
* (`physics.ts` for Rapier, `physics2d.ts` for matter-js); the adapter owns its module handle,
|
|
264
|
+
* its world, and the game's engine-shaped options (`gravity`, `setup`, `bodies`, `intents`), so
|
|
265
|
+
* no engine type reaches this file.
|
|
250
266
|
*/
|
|
251
|
-
interface
|
|
252
|
-
/**
|
|
253
|
-
readonly
|
|
254
|
-
/**
|
|
255
|
-
readonly
|
|
256
|
-
/** The
|
|
257
|
-
readonly
|
|
258
|
-
/**
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
interface EngineAdapter {
|
|
268
|
+
/** The module specifier, for the "failed to load" line. */
|
|
269
|
+
readonly engineName: string;
|
|
270
|
+
/** The `joinRoom` option these hooks came from (`physics`, `physics2d`), for warnings. */
|
|
271
|
+
readonly optionName: string;
|
|
272
|
+
/** The game's explicit timestep in seconds, when it set one. */
|
|
273
|
+
readonly timestep: number | undefined;
|
|
274
|
+
/** Loads the engine, builds the local world and runs `setup`. Rejects if the load fails. */
|
|
275
|
+
start(timestepSeconds: number): Promise<void>;
|
|
276
|
+
/** Drops the world. The core clears its own bookkeeping. */
|
|
277
|
+
free(): void;
|
|
278
|
+
/** Did the game pass a body factory for this collection? */
|
|
279
|
+
hasFactory(collection: string): boolean;
|
|
280
|
+
/** Factory call plus every engine-specific warning about what it returned. */
|
|
281
|
+
createBody(desc: CollectionDesc, id: string, record: AnyRecord$2, warn: (key: string, message: string) => void): EngineBody | undefined;
|
|
282
|
+
removeBody(body: EngineBody): void;
|
|
283
|
+
/** One fixed-timestep step of the local world. */
|
|
284
|
+
step(): void;
|
|
285
|
+
/** Server record → body state, in whatever order this engine's setters require. */
|
|
286
|
+
applyRecord(body: EngineBody, channels: BodyChannels, record: AnyRecord$2): void;
|
|
287
|
+
/** Body state → a 3D pose. A planar engine fills `z = 0` and a quaternion about Z. */
|
|
288
|
+
readPose(body: EngineBody, into: Pose): void;
|
|
289
|
+
/** The game's intent hook for an owned body, before one step. A no-op when it declared none. */
|
|
290
|
+
applyIntent(collection: string, body: EngineBody, instance: AnyRecord$2): void;
|
|
291
|
+
/**
|
|
292
|
+
* A per-step force applied to **every** local body of a collection, owned or not, after the
|
|
293
|
+
* intent pass. Present only on engines whose worlds do not apply gravity themselves: a matter2d
|
|
294
|
+
* room applies gravity per body from its own hooks, so a non-owned predicted crate would hang
|
|
295
|
+
* in the air through the whole lead without it. Rapier has no equivalent, and must not gain one.
|
|
296
|
+
*/
|
|
297
|
+
settle?(collection: string, body: EngineBody, instance: AnyRecord$2): void;
|
|
298
|
+
/**
|
|
299
|
+
* The tolerance a velocity channel is compared against, given the position tolerance and the
|
|
300
|
+
* timestep. Rapier's velocities are per second (`epsilon / dt`); matter's are per step, and one
|
|
301
|
+
* step of a velocity error of `epsilon` is `epsilon` of position, so its answer is `epsilon`.
|
|
302
|
+
*/
|
|
303
|
+
velocityTolerance(epsilon: number, timestepSeconds: number): number;
|
|
304
|
+
}
|
|
305
|
+
/** The engine-neutral tuning knobs; both engines' option objects carry these names. */
|
|
306
|
+
interface PredictorTuning {
|
|
271
307
|
readonly maxPredictedBodies?: number;
|
|
272
|
-
/**
|
|
273
|
-
* A body-field correction whose every value is within this tolerance of the local prediction
|
|
274
|
-
* is *suppressed*: authority still applies, but it is not a misprediction — steady state stays
|
|
275
|
-
* quiet. Positions compare against `epsilon` world units directly; velocity channels compare
|
|
276
|
-
* against `epsilon / timestep` (a velocity disagreement matters by what it moves in one tick —
|
|
277
|
-
* an input frame landing one tick late on the server is invisible, not a storm). Default 0.05.
|
|
278
|
-
*/
|
|
279
308
|
readonly epsilon?: number;
|
|
280
|
-
/**
|
|
281
|
-
* How fast the drawn position eases back onto the simulation after a re-simulation moved it,
|
|
282
|
-
* as a half-life in milliseconds. `0` turns the smoothing off. Default 70.
|
|
283
|
-
*
|
|
284
|
-
* A rebase can move a body that has already been drawn — because the server disagreed, or
|
|
285
|
-
* because a newly-flushed intent changed what the last few ticks should have been. Handing
|
|
286
|
-
* that straight to the renderer is a step, and a step in the middle of steady motion is what
|
|
287
|
-
* a player calls a yank. Instead the jump is taken out of the drawn pose and put into a
|
|
288
|
-
* per-body offset that decays: the character keeps moving smoothly and arrives at the truth a
|
|
289
|
-
* moment later.
|
|
290
|
-
*
|
|
291
|
-
* This smooths the **error**, not the motion. A rate limiter on the drawn position — the
|
|
292
|
-
* obvious version, and the one a game writes for itself — cannot tell a correction from the
|
|
293
|
-
* character running, so it lags real movement too. Nothing here touches motion the simulation
|
|
294
|
-
* actually produced.
|
|
295
|
-
*/
|
|
296
309
|
readonly smoothingHalfLifeMs?: number;
|
|
297
|
-
/**
|
|
298
|
-
* How far the drawn position may be held from the simulation while an offset eases away, in
|
|
299
|
-
* world units. Past it the offset is dropped and the body appears where it is. Default 4.
|
|
300
|
-
*
|
|
301
|
-
* This is the teleport case: a respawn, an area change, a resync. Easing across one of those
|
|
302
|
-
* draws the body sliding through the level, which is worse than the step it avoids. It is the
|
|
303
|
-
* one number here that depends on how big a world unit is in your game.
|
|
304
|
-
*/
|
|
305
310
|
readonly smoothingSnapUnits?: number;
|
|
306
311
|
}
|
|
307
312
|
/** Counters for the report and `room.prediction.stats`. */
|
|
@@ -339,20 +344,53 @@ interface PredictionStats {
|
|
|
339
344
|
* applied tick judges a stamped write.
|
|
340
345
|
*/
|
|
341
346
|
stampGap: number;
|
|
347
|
+
/**
|
|
348
|
+
* The same gap before the `[0, RESIM_DEPTH]` clamp, smoothed the same way (bugs.md #47). A
|
|
349
|
+
* negative reading is a stamp that *trails* the server's application: the lead under-estimated
|
|
350
|
+
* the transit and the clamp is hiding it from `stampGap`. Diagnostic; the replay never reads it.
|
|
351
|
+
*/
|
|
352
|
+
stampGapRaw: number;
|
|
353
|
+
/** The stamp the newest judged `WRITE` carried, and the server tick it was applied at. */
|
|
354
|
+
lastStampTick: number;
|
|
355
|
+
lastAppliedTick: number;
|
|
342
356
|
}
|
|
343
|
-
|
|
357
|
+
/**
|
|
358
|
+
* One body's transform, as plain mutable objects: the ring below is overwritten every tick and
|
|
359
|
+
* must not allocate to do it.
|
|
360
|
+
*/
|
|
361
|
+
interface Pose {
|
|
362
|
+
readonly t: Vec;
|
|
363
|
+
readonly r: Quat;
|
|
364
|
+
readonly v: Vec;
|
|
365
|
+
readonly w: Vec;
|
|
366
|
+
}
|
|
367
|
+
type Vec = {
|
|
368
|
+
x: number;
|
|
369
|
+
y: number;
|
|
370
|
+
z: number;
|
|
371
|
+
};
|
|
372
|
+
type Quat = {
|
|
373
|
+
x: number;
|
|
374
|
+
y: number;
|
|
375
|
+
z: number;
|
|
376
|
+
w: number;
|
|
377
|
+
};
|
|
378
|
+
declare class Predictor {
|
|
344
379
|
private readonly store;
|
|
345
|
-
private readonly
|
|
380
|
+
private readonly adapter;
|
|
381
|
+
private readonly tuning;
|
|
346
382
|
private readonly meOf;
|
|
347
383
|
private readonly rttOf;
|
|
348
384
|
private readonly tickIntervalOf;
|
|
349
385
|
private readonly log;
|
|
350
386
|
readonly stats: PredictionStats;
|
|
351
|
-
|
|
352
|
-
private
|
|
387
|
+
/** True once the adapter's world exists. The `world !== undefined` of the one-engine version. */
|
|
388
|
+
private worldReady;
|
|
389
|
+
/** Seconds per step, fixed for the life of the world. */
|
|
390
|
+
private timestepSeconds;
|
|
353
391
|
private readonly bodies;
|
|
354
|
-
/** Physics-backed entity collections, in schema order. */
|
|
355
|
-
private
|
|
392
|
+
/** Physics-backed entity collections, in schema order. D50: not `readonly`, see `swapSchema`. */
|
|
393
|
+
private collections;
|
|
356
394
|
private readonly warned;
|
|
357
395
|
/** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
|
|
358
396
|
private readonly overCapHigh;
|
|
@@ -379,6 +417,8 @@ declare class PhysicsPredictor {
|
|
|
379
417
|
private renderTick;
|
|
380
418
|
/** Scratch for one interpolated pose. `read()` is synchronous, so one is enough. */
|
|
381
419
|
private readonly scratch;
|
|
420
|
+
/** Scratch for a direct body reading, so it never races `scratch`'s interpolated one. */
|
|
421
|
+
private readonly live;
|
|
382
422
|
/** Scratch quaternions, so the per-body per-frame offset maths allocates nothing. */
|
|
383
423
|
private readonly qa;
|
|
384
424
|
private readonly qb;
|
|
@@ -397,11 +437,35 @@ declare class PhysicsPredictor {
|
|
|
397
437
|
private gapMeasured;
|
|
398
438
|
/** The newest stamp already folded into `stampGap`, so a repeated echo is not re-weighted. */
|
|
399
439
|
private gapSampledThrough;
|
|
440
|
+
/** The lead the last rebase re-stepped, so a change in it can be folded into `stampGap`. */
|
|
441
|
+
private lastLead;
|
|
400
442
|
private readonly predictedTicks;
|
|
401
443
|
private readonly history;
|
|
402
|
-
/**
|
|
444
|
+
/**
|
|
445
|
+
* History kept per body: a correction for tick T is judged against the prediction recorded for
|
|
446
|
+
* T, and T is `lead` ticks behind the head when it arrives, so this has to clear `MAX_LEAD`
|
|
447
|
+
* with room for delivery jitter.
|
|
448
|
+
*/
|
|
403
449
|
private static readonly HISTORY_TICKS;
|
|
404
|
-
constructor(ext: AnySchema, store: ClientStore,
|
|
450
|
+
constructor(ext: AnySchema, store: ClientStore, adapter: EngineAdapter, tuning: PredictorTuning, meOf: () => string, rttOf: () => number, tickIntervalOf: () => number, log?: (message: string) => void);
|
|
451
|
+
/**
|
|
452
|
+
* D50: re-derive the predicted-collection list from a rebuilt schema.
|
|
453
|
+
*
|
|
454
|
+
* This is deliberately shallow, and it is safe to be shallow because of the scope rule the
|
|
455
|
+
* supervisor enforces: **any change touching a physics collection classifies breaking**
|
|
456
|
+
* (`packages/supervisor/src/schema-swap.ts`), so a swap that reaches this method is guaranteed
|
|
457
|
+
* to leave every physics collection structurally identical. What changes is descriptor
|
|
458
|
+
* *identity*, not content, and this brings the predictor's references back in line with the
|
|
459
|
+
* store's rather than leaving two equal-but-distinct descriptor trees in play.
|
|
460
|
+
*
|
|
461
|
+
* The live bodies are dropped instead of retargeted, for the same reason the render buffers
|
|
462
|
+
* are: `reset()` follows immediately, the resync WELCOME re-seeds authority, and a body carried
|
|
463
|
+
* across a schema boundary is exactly the kind of thing that would look fine and be wrong.
|
|
464
|
+
*
|
|
465
|
+
* When the physics scope rule is lifted, this is where the real work goes: rebuilding each
|
|
466
|
+
* `PredictedBody.desc` and re-deriving collider shapes from the new descriptors.
|
|
467
|
+
*/
|
|
468
|
+
swapSchema(newExt: AnySchema): void;
|
|
405
469
|
get epsilon(): number;
|
|
406
470
|
/** `true` once the engine is loaded and the local world exists. */
|
|
407
471
|
get ready(): boolean;
|
|
@@ -437,6 +501,12 @@ declare class PhysicsPredictor {
|
|
|
437
501
|
* write. Clamped into `[0, RESIM_DEPTH]`: a stamp taken before this client had any physics
|
|
438
502
|
* authority is on the session's bare write counter rather than the server's tick stream, and
|
|
439
503
|
* differencing the two clocks is meaningless — 0 is the old behaviour and the honest default.
|
|
504
|
+
*
|
|
505
|
+
* The floor is also load-bearing in a way that is not obvious, and `bugs.md` #45 is the
|
|
506
|
+
* measurement: a stamp can *trail* the server's application, when the lead over-estimates the
|
|
507
|
+
* transit, and correcting for that by letting the gap go negative delays every write's replay by
|
|
508
|
+
* the same amount. It buys an accurate stop and pays for it with a late start. The lever for
|
|
509
|
+
* that is the lead, not this.
|
|
440
510
|
*/
|
|
441
511
|
noteWriteApplied(stampTick: number, appliedTick: number): void;
|
|
442
512
|
/** Does the local world currently simulate `collection[id]`? */
|
|
@@ -444,8 +514,8 @@ declare class PhysicsPredictor {
|
|
|
444
514
|
/**
|
|
445
515
|
* Is a correction's every value within the suppression tolerance of the prediction it judges?
|
|
446
516
|
* Position (and rotation) channels compare against `epsilon` directly; velocity and angular
|
|
447
|
-
* channels against
|
|
448
|
-
* moves in one tick.
|
|
517
|
+
* channels against the adapter's per-engine velocity tolerance, because a velocity
|
|
518
|
+
* disagreement matters by what it moves in one tick.
|
|
449
519
|
*/
|
|
450
520
|
withinEpsilon(desc: CollectionDesc, fields: readonly string[], patch: AnyRecord$2, predicted: AnyRecord$2): boolean;
|
|
451
521
|
/** Is `collection` one this client would predict at all (owned always; non-owned per D21)? */
|
|
@@ -565,43 +635,32 @@ declare class PhysicsPredictor {
|
|
|
565
635
|
private warnOverCap;
|
|
566
636
|
private warnOnce;
|
|
567
637
|
/**
|
|
568
|
-
* The client's lead over authority, in ticks:
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
* Every tick of lead beyond that transit is a tick of motion the client draws on the
|
|
572
|
-
* assumption that the input it is holding will still be held when the server gets there. On a
|
|
573
|
-
* release that assumption is wrong by construction, and the invented travel is handed back as
|
|
574
|
-
* a backwards yank — bug 1's "keeps moving after key up and then snaps back". It was
|
|
575
|
-
* `ceil(owd) + 1`, which on a fast link is two whole ticks of margin over a transit of nearly
|
|
576
|
-
* zero. Measured on the arena fixture at no injected latency (release overshoot of a 6 u/s
|
|
577
|
-
* held-input character, `physics-predict.test.ts`):
|
|
578
|
-
*
|
|
579
|
-
* | lead | drawn overshoot past the server's stop | worst backwards step |
|
|
580
|
-
* | --- | --- | --- |
|
|
581
|
-
* | `ceil(owd) + 1` (was) | 0.585 u | -0.585 u |
|
|
582
|
-
* | `max(1, round(owd))` (is) | 0.293 u | -0.292 u |
|
|
583
|
-
* | `round(owd)`, floor 0 | 0.0008 u | -0.0008 u |
|
|
638
|
+
* The client's lead over authority, in ticks: a full round trip, rounded to the nearest tick
|
|
639
|
+
* (bugs.md #47, Candidate A).
|
|
584
640
|
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
641
|
+
* The authority in hand left the server one one-way transit ago, so the server is at
|
|
642
|
+
* `authority + owd` right now, and an input flushed now reaches it another one-way transit
|
|
643
|
+
* later: the first tick it can take effect at is `authority + rtt`. The head has to be there
|
|
644
|
+
* for two reasons. The stamp is `head + 1`, and a stamp that names the tick the server will
|
|
645
|
+
* actually apply the write at is what lets the replay put a release where the server put it;
|
|
646
|
+
* with the head at half a round trip (server-now) the stamp trailed the application by
|
|
647
|
+
* `owd - 1` ticks, `stampGap`'s floor at zero discarded the sign, and every release was
|
|
648
|
+
* replayed a one-way transit late: the local body stopped, then followed authority forward for
|
|
649
|
+
* the rest of the round trip. And the intent the local body is simulating under is the intent
|
|
650
|
+
* the server will be simulating under at the same tick, which is the whole point of predicting.
|
|
588
651
|
*
|
|
589
|
-
* The
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
* bot suites' correction-storm invariant fires immediately (21 corrections/s per bot against a
|
|
594
|
-
* threshold of 5 in `packages/cli/test/simulate-physics.test.ts`). One tick of speculation is
|
|
595
|
-
* the price of predicting at all; two was the bug.
|
|
652
|
+
* The price is the distance between the drawn body and the authority it is anchored to, which
|
|
653
|
+
* is the size of every misprediction the client has not been told about yet: a full round trip
|
|
654
|
+
* of motion instead of half. dive's lag budget states that distance against the round trip the
|
|
655
|
+
* test measures.
|
|
596
656
|
*
|
|
597
|
-
* Erring
|
|
598
|
-
*
|
|
599
|
-
* it is
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
* of the server's application the stamps still land, and it should sit at 0.
|
|
657
|
+
* Erring high is still the expensive direction, and the rounding is to nearest for that reason:
|
|
658
|
+
* a lead longer than the real round trip is a tick of motion the client draws on the assumption
|
|
659
|
+
* that the input it is holding will still be held when the server gets there, and on a release
|
|
660
|
+
* that assumption is wrong by construction (bug 1's "keeps moving after key up and then snaps
|
|
661
|
+
* back"). `stats.stampGap` is the running check: it reports how far ahead of the server's
|
|
662
|
+
* application the stamps land, and `stampGapRaw` the same before the clamp. Both should sit
|
|
663
|
+
* between 0 and 1.
|
|
605
664
|
*/
|
|
606
665
|
private leadTicks;
|
|
607
666
|
private timestepMs;
|
|
@@ -611,8 +670,8 @@ declare class PhysicsPredictor {
|
|
|
611
670
|
* written, so plain state holds exactly what the server said), then re-step the world by the
|
|
612
671
|
* client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
|
|
613
672
|
* the newest buffered unjudged write stamped at or before it, or the baseline (the newest
|
|
614
|
-
* judged write) before the first of them. Bounded by
|
|
615
|
-
*
|
|
673
|
+
* judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead snaps to
|
|
674
|
+
* authority and counts (`stats.snaps`).
|
|
616
675
|
*/
|
|
617
676
|
private rebase;
|
|
618
677
|
/**
|
|
@@ -632,6 +691,11 @@ declare class PhysicsPredictor {
|
|
|
632
691
|
* tick this step predicts (during a rebase — see `rebase`'s replay walk), else the instance's
|
|
633
692
|
* current values (free-run: plain state carries the newest local intent writes, which is
|
|
634
693
|
* correct there because free-run steps are the ticks *after* every buffered write).
|
|
694
|
+
*
|
|
695
|
+
* Then, on an engine that declares one, the `settle` pass: a per-step force over **every**
|
|
696
|
+
* local body of a collection, owned or not, run after the whole intent pass rather than
|
|
697
|
+
* interleaved with it. It is what gives a non-owned predicted body its gravity on an engine
|
|
698
|
+
* whose world has none of its own.
|
|
635
699
|
*/
|
|
636
700
|
private applyIntents;
|
|
637
701
|
/** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
|
|
@@ -639,6 +703,447 @@ declare class PhysicsPredictor {
|
|
|
639
703
|
private isF32;
|
|
640
704
|
}
|
|
641
705
|
|
|
706
|
+
/**
|
|
707
|
+
* The matter2d half of client-side physics prediction (D45, D57): `joinRoom({ physics2d })`'s
|
|
708
|
+
* option type, the lazy `matter-js` load, and the adapter that is the only place in the client
|
|
709
|
+
* where a matter.js type is touched.
|
|
710
|
+
*
|
|
711
|
+
* The loop is `predictor.ts`, shared byte for byte with the Rapier path. This file is the seam
|
|
712
|
+
* list, and it mirrors `packages/runtime/src/core/matter.ts` — the room's own matter world — the
|
|
713
|
+
* way `physics.ts` mirrors the Rapier runtime. Three things in it are load-bearing and none of
|
|
714
|
+
* them are visible from the loop:
|
|
715
|
+
*
|
|
716
|
+
* **The units are matter.js's, not translated.** `gravity` goes into `engine.gravity` verbatim
|
|
717
|
+
* (matter's own y is *down*), and a velocity is neither per second nor, strictly, per step:
|
|
718
|
+
* `Body.updateVelocities` normalises `body.velocity` against `Body._baseDelta`, a sixtieth of a
|
|
719
|
+
* second, whatever the engine's step is, and `Body.setVelocity` reads the same units back. The
|
|
720
|
+
* wire carries what the runtime writes, so the client reads and writes them unchanged, and
|
|
721
|
+
* `velocityTolerance` divides by `dt / baseDelta` rather than by `dt`. In a 60 Hz room those are
|
|
722
|
+
* the same thing and the tolerance is plain `epsilon`.
|
|
723
|
+
*
|
|
724
|
+
* **A rebase has to leave the body integrating from where it was put.** matter is a Verlet
|
|
725
|
+
* integrator: the next step's motion is `position - positionPrev`, so writing a position without
|
|
726
|
+
* moving `positionPrev` with it makes the whole jump this step's velocity. `Body.setPosition`
|
|
727
|
+
* shifts `positionPrev` by the same delta and `Body.setVelocity` re-derives it from the current
|
|
728
|
+
* position, which is why this uses the setters and follows `applyRecordToBody`'s order rather
|
|
729
|
+
* than assigning fields.
|
|
730
|
+
*
|
|
731
|
+
* **The timestep never varies.** `Body.update` scales a body's carried velocity by the ratio of
|
|
732
|
+
* consecutive deltas, so a constant delta makes that ratio 1. The same constant the room uses is
|
|
733
|
+
* the only safe one, which is why the predictor fixes it at start.
|
|
734
|
+
*
|
|
735
|
+
* ## `settle`
|
|
736
|
+
*
|
|
737
|
+
* A matter2d room applies gravity per body, from its own hooks, because `engine.gravity` is often
|
|
738
|
+
* zero (different bodies fall at different rates). An owned body gets that through `intents`. A
|
|
739
|
+
* non-owned predicted one — a crate the player stands on — has no intent hook and would hang in
|
|
740
|
+
* the air for the whole prediction lead, so `settle` runs the same per-step force over every local
|
|
741
|
+
* body of a collection, owned or not, after the intent pass. Rapier has no equivalent because
|
|
742
|
+
* engine gravity does that job there.
|
|
743
|
+
*/
|
|
744
|
+
|
|
745
|
+
type ClientMatterModule = typeof MATTER;
|
|
746
|
+
type ClientMatterEngine = MATTER.Engine;
|
|
747
|
+
type ClientMatterBody = MATTER.Body;
|
|
748
|
+
type ClientMatterConstraint = MATTER.Constraint;
|
|
749
|
+
interface ClientVector2 {
|
|
750
|
+
readonly x: number;
|
|
751
|
+
readonly y: number;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* What a client-side matter2d body factory returns — the same shape the room config's factories
|
|
755
|
+
* use. matter.js has no separate collider concept: a body *is* its geometry.
|
|
756
|
+
*/
|
|
757
|
+
interface ClientBody2dSpec {
|
|
758
|
+
readonly body: ClientMatterBody;
|
|
759
|
+
/** Added to the local world with the body, and removed with it. */
|
|
760
|
+
readonly constraints?: readonly ClientMatterConstraint[];
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Method-syntax members check bivariantly, so a builder's factory or hook written against its own
|
|
764
|
+
* instance type (`(body, player: Player) => …`) is accepted — the values really passed are the
|
|
765
|
+
* schema's records for that collection.
|
|
766
|
+
*/
|
|
767
|
+
type ClientBody2dFactory = {
|
|
768
|
+
factory(matter: ClientMatterModule, instance: AnyRecord$2, id: string): ClientBody2dSpec;
|
|
769
|
+
}['factory'];
|
|
770
|
+
type ClientIntent2dHook = {
|
|
771
|
+
hook(body: ClientMatterBody, instance: AnyRecord$2, matter: ClientMatterModule, engine: ClientMatterEngine, timestep: number): void;
|
|
772
|
+
}['hook'];
|
|
773
|
+
/**
|
|
774
|
+
* `joinRoom({ physics2d })` — the client half of the shared world-builder contract for a matter2d
|
|
775
|
+
* room. A sibling of `physics`, not a variant inside it: passing both is an error at join, and the
|
|
776
|
+
* wire carries no engine name, so passing the *wrong* one is a game bug the client cannot detect.
|
|
777
|
+
* Every function here should be the very export the room config imports.
|
|
778
|
+
*/
|
|
779
|
+
interface ClientPhysics2dOptions {
|
|
780
|
+
/** Must equal the room config's gravity, in matter's own convention (y is down). */
|
|
781
|
+
readonly gravity: ClientVector2;
|
|
782
|
+
/** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
|
|
783
|
+
readonly timestep?: number;
|
|
784
|
+
/** The shared static-geometry builder (the room's `physics.setup`). */
|
|
785
|
+
readonly setup?: (engine: ClientMatterEngine, matter: ClientMatterModule) => void;
|
|
786
|
+
/** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
|
|
787
|
+
readonly bodies?: Readonly<Record<string, ClientBody2dFactory>>;
|
|
788
|
+
/**
|
|
789
|
+
* Intent → force, applied before every predicted step for bodies this client owns — the same
|
|
790
|
+
* function the room's `tick()` calls per instance, shared so both simulations agree.
|
|
791
|
+
*/
|
|
792
|
+
readonly intents?: Readonly<Record<string, ClientIntent2dHook>>;
|
|
793
|
+
/**
|
|
794
|
+
* A per-step force over **every** local body of a collection, owned or not, run after the whole
|
|
795
|
+
* intent pass. This is where a matter2d room's per-body gravity goes for the bodies nobody
|
|
796
|
+
* steers: without it a predicted crate stands still in mid-air for the length of the lead and
|
|
797
|
+
* is snapped back by every correction.
|
|
798
|
+
*/
|
|
799
|
+
readonly settle?: Readonly<Record<string, ClientIntent2dHook>>;
|
|
800
|
+
/**
|
|
801
|
+
* D21 cap: how many **non-owned** predicted bodies this client simulates. Over-cap instances
|
|
802
|
+
* are **absent from the local world** — they render by interpolation, but predicted bodies
|
|
803
|
+
* pass straight through them, so anything a predicted body stands on or is blocked by has to
|
|
804
|
+
* be under the cap (warned, counted as `stats.overCap`). Default 64.
|
|
805
|
+
*/
|
|
806
|
+
readonly maxPredictedBodies?: number;
|
|
807
|
+
/**
|
|
808
|
+
* A body-field correction whose every value is within this tolerance of the local prediction
|
|
809
|
+
* is *suppressed*: authority still applies, but it is not a misprediction. Positions compare
|
|
810
|
+
* against `epsilon` world units; velocity channels compare against what one step of the error
|
|
811
|
+
* would actually move the body by, which on a 60 Hz room is also `epsilon` (matter stores a
|
|
812
|
+
* velocity per sixtieth of a second, not per second). Default 0.05.
|
|
813
|
+
*/
|
|
814
|
+
readonly epsilon?: number;
|
|
815
|
+
/**
|
|
816
|
+
* How fast the drawn position eases back onto the simulation after a re-simulation moved it,
|
|
817
|
+
* as a half-life in milliseconds. `0` turns the smoothing off. Default 70. See
|
|
818
|
+
* `ClientPhysicsOptions.smoothingHalfLifeMs` for why this smooths the error and not the motion.
|
|
819
|
+
*/
|
|
820
|
+
readonly smoothingHalfLifeMs?: number;
|
|
821
|
+
/**
|
|
822
|
+
* How far the drawn position may be held from the simulation while an offset eases away, in
|
|
823
|
+
* world units. Past it the offset is dropped and the body appears where it is. Default 4.
|
|
824
|
+
*/
|
|
825
|
+
readonly smoothingSnapUnits?: number;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* The Rapier half of client-side physics prediction (D22 part 2, D21): `joinRoom({ physics })`'s
|
|
830
|
+
* option type, the lazy `@dimforge/rapier3d-compat` load, and the adapter that is the only place
|
|
831
|
+
* in the client where a Rapier type is touched.
|
|
832
|
+
*
|
|
833
|
+
* The loop itself — the free-run accumulator, the rebase replay, the pose ring, the render clock,
|
|
834
|
+
* the error smoothing and the D21 cap — lives in `predictor.ts` and knows about no engine at all.
|
|
835
|
+
* Read that file's header for what the predictor does and why; this one is the seam list:
|
|
836
|
+
* `loadEngine`, world construction and `setup`, the body factory and its warnings, `world.step()`,
|
|
837
|
+
* the record→body and body→pose mappings, the intent hook call, and the per-second velocity
|
|
838
|
+
* tolerance. `physics2d.ts` is the same list written against matter-js.
|
|
839
|
+
*
|
|
840
|
+
* The engine is loaded lazily via dynamic `import()`, so a game with no physics option — or one
|
|
841
|
+
* that predicts with matter2d — pays nothing for it.
|
|
842
|
+
*/
|
|
843
|
+
|
|
844
|
+
type ClientRapierModule = typeof RAPIER;
|
|
845
|
+
type ClientRapierWorld = RAPIER.World;
|
|
846
|
+
type ClientRapierBody = RAPIER.RigidBody;
|
|
847
|
+
interface ClientVector3 {
|
|
848
|
+
readonly x: number;
|
|
849
|
+
readonly y: number;
|
|
850
|
+
readonly z: number;
|
|
851
|
+
}
|
|
852
|
+
/** What a client-side body factory returns — the same shape the room config's factories use. */
|
|
853
|
+
interface ClientBodySpec {
|
|
854
|
+
readonly body: RAPIER.RigidBodyDesc;
|
|
855
|
+
readonly colliders?: readonly RAPIER.ColliderDesc[];
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Method-syntax members check bivariantly, so a builder's factory or intent hook written against
|
|
859
|
+
* its own instance type (`(body, ball: Ball) => …`) is accepted — the values really passed are
|
|
860
|
+
* the schema's records for that collection.
|
|
861
|
+
*/
|
|
862
|
+
type ClientBodyFactory = {
|
|
863
|
+
factory(rapier: ClientRapierModule, instance: AnyRecord$2, id: string): ClientBodySpec;
|
|
864
|
+
}['factory'];
|
|
865
|
+
type ClientIntentHook = {
|
|
866
|
+
hook(body: ClientRapierBody, instance: AnyRecord$2, rapier: ClientRapierModule, world: ClientRapierWorld): void;
|
|
867
|
+
}['hook'];
|
|
868
|
+
/**
|
|
869
|
+
* `joinRoom({ physics })` — the client half of the shared world-builder contract. Every function
|
|
870
|
+
* here should be the very export the room config imports, so "same code both sides" stays
|
|
871
|
+
* literally true.
|
|
872
|
+
*/
|
|
873
|
+
interface ClientPhysicsOptions {
|
|
874
|
+
/** Must equal the room config's gravity (put it in the shared module). */
|
|
875
|
+
readonly gravity: ClientVector3;
|
|
876
|
+
/** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
|
|
877
|
+
readonly timestep?: number;
|
|
878
|
+
/** The shared static-geometry builder (the room's `physics.setup`). */
|
|
879
|
+
readonly setup?: (world: ClientRapierWorld, rapier: ClientRapierModule) => void;
|
|
880
|
+
/** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
|
|
881
|
+
readonly bodies?: Readonly<Record<string, ClientBodyFactory>>;
|
|
882
|
+
/**
|
|
883
|
+
* Intent → force, applied before every predicted step for bodies this client owns — the same
|
|
884
|
+
* function the room's `tick()` calls per instance, shared so both simulations agree.
|
|
885
|
+
*/
|
|
886
|
+
readonly intents?: Readonly<Record<string, ClientIntentHook>>;
|
|
887
|
+
/**
|
|
888
|
+
* D21 cap: how many **non-owned** predicted bodies this client simulates. Over-cap instances
|
|
889
|
+
* are **absent from the local world** — they render by interpolation, but predicted bodies
|
|
890
|
+
* pass straight through them, so anything a predicted body stands on or is blocked by has to
|
|
891
|
+
* be under the cap (warned, counted as `stats.overCap`). Default 64.
|
|
892
|
+
*/
|
|
893
|
+
readonly maxPredictedBodies?: number;
|
|
894
|
+
/**
|
|
895
|
+
* A body-field correction whose every value is within this tolerance of the local prediction
|
|
896
|
+
* is *suppressed*: authority still applies, but it is not a misprediction — steady state stays
|
|
897
|
+
* quiet. Positions compare against `epsilon` world units directly; velocity channels compare
|
|
898
|
+
* against `epsilon / timestep` (a velocity disagreement matters by what it moves in one tick —
|
|
899
|
+
* an input frame landing one tick late on the server is invisible, not a storm). Default 0.05.
|
|
900
|
+
*/
|
|
901
|
+
readonly epsilon?: number;
|
|
902
|
+
/**
|
|
903
|
+
* How fast the drawn position eases back onto the simulation after a re-simulation moved it,
|
|
904
|
+
* as a half-life in milliseconds. `0` turns the smoothing off. Default 70.
|
|
905
|
+
*
|
|
906
|
+
* A rebase can move a body that has already been drawn — because the server disagreed, or
|
|
907
|
+
* because a newly-flushed intent changed what the last few ticks should have been. Handing
|
|
908
|
+
* that straight to the renderer is a step, and a step in the middle of steady motion is what
|
|
909
|
+
* a player calls a yank. Instead the jump is taken out of the drawn pose and put into a
|
|
910
|
+
* per-body offset that decays: the character keeps moving smoothly and arrives at the truth a
|
|
911
|
+
* moment later.
|
|
912
|
+
*
|
|
913
|
+
* This smooths the **error**, not the motion. A rate limiter on the drawn position — the
|
|
914
|
+
* obvious version, and the one a game writes for itself — cannot tell a correction from the
|
|
915
|
+
* character running, so it lags real movement too. Nothing here touches motion the simulation
|
|
916
|
+
* actually produced.
|
|
917
|
+
*/
|
|
918
|
+
readonly smoothingHalfLifeMs?: number;
|
|
919
|
+
/**
|
|
920
|
+
* How far the drawn position may be held from the simulation while an offset eases away, in
|
|
921
|
+
* world units. Past it the offset is dropped and the body appears where it is. Default 4.
|
|
922
|
+
*
|
|
923
|
+
* This is the teleport case: a respawn, an area change, a resync. Easing across one of those
|
|
924
|
+
* draws the body sliding through the level, which is worse than the step it avoids. It is the
|
|
925
|
+
* one number here that depends on how big a world unit is in your game.
|
|
926
|
+
*/
|
|
927
|
+
readonly smoothingSnapUnits?: number;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* D53 client side: the anonymous persistent identity, and the assertion exchange that keeps it
|
|
932
|
+
* out of the game.
|
|
933
|
+
*
|
|
934
|
+
* ## The one rule this module exists to enforce
|
|
935
|
+
*
|
|
936
|
+
* **The identity token never goes to a room socket.** It is stored in `localStorage`, sent to the
|
|
937
|
+
* control plane over HTTPS, and traded there for a short-lived, project-bound assertion. That
|
|
938
|
+
* assertion is the only thing that reaches the game.
|
|
939
|
+
*
|
|
940
|
+
* The reason matters more than the mechanism. Room code runs inside the game's own tenant, and a
|
|
941
|
+
* game you did not write is code you cannot vouch for — so anything the socket carries has to be
|
|
942
|
+
* worthless to a hostile game. An assertion is: it names one project (so it cannot be replayed
|
|
943
|
+
* against another game), it expires in minutes, and its subject is a per-project pseudonym, so
|
|
944
|
+
* two games holding assertions for the same person cannot tell that they do.
|
|
945
|
+
*
|
|
946
|
+
* ## What a game has to do
|
|
947
|
+
*
|
|
948
|
+
* ```ts
|
|
949
|
+
* const room = await joinRoom(schema, { identity: true });
|
|
950
|
+
* ```
|
|
951
|
+
*
|
|
952
|
+
* That is all. `joinRoom` mints an identity on first run, keeps it, exchanges it for an assertion
|
|
953
|
+
* before every HELLO — every reconnect included, because an assertion that outlives its exchange
|
|
954
|
+
* would be refused with `E_TOKEN_EXPIRED` — and `ctx.playerId` in room code is the stable
|
|
955
|
+
* `irt:<subject>` for that project.
|
|
956
|
+
*
|
|
957
|
+
* ## Storage, and what D61 changed about it
|
|
958
|
+
*
|
|
959
|
+
* `localStorage`, under ONE key per origin: `irtio.account`.
|
|
960
|
+
*
|
|
961
|
+
* It used to be one key per project (`irtio.identity.<project>`), so two games on one origin were
|
|
962
|
+
* two players. That was right while a credential WAS the player, and wrong the moment an account
|
|
963
|
+
* sat above it: the credential identifies a device, the account identifies the person, and what
|
|
964
|
+
* keeps two games from correlating a player is the per-project pseudonymous subject, which is
|
|
965
|
+
* derived at the control plane and owes nothing to how many keys a browser holds. Splitting the
|
|
966
|
+
* credential per project bought no privacy and cost the player an account per game.
|
|
967
|
+
*
|
|
968
|
+
* A value left under the old key is ADOPTED on first run and the old key is removed. A migrated
|
|
969
|
+
* identity is already an account (the migration backfilled one per identity with the identity's
|
|
970
|
+
* own id), so adopting it is a rename of a storage key and the player keeps every board position
|
|
971
|
+
* they had.
|
|
972
|
+
*
|
|
973
|
+
* The honest limits are unchanged and they are why linking exists: a player clearing site data is
|
|
974
|
+
* a new player, a private window is a new player, and a different browser is a different player
|
|
975
|
+
* until they link it. `linkCode()` and `redeemLinkCode()` are how a second device stops being a
|
|
976
|
+
* second player.
|
|
977
|
+
*
|
|
978
|
+
* Every storage access is wrapped: `localStorage` throws outright in some embedded contexts, and
|
|
979
|
+
* an identity that cannot be stored should degrade to an ordinary key join rather than take the
|
|
980
|
+
* game down.
|
|
981
|
+
*/
|
|
982
|
+
/** The `localStorage` key: one credential per origin, whatever the project (D61). */
|
|
983
|
+
declare const ACCOUNT_STORAGE_KEY = "irtio.account";
|
|
984
|
+
/**
|
|
985
|
+
* The pre-D61 per-project key, kept only so a returning player's credential can be adopted once
|
|
986
|
+
* and then removed. Nothing writes this any more.
|
|
987
|
+
*/
|
|
988
|
+
declare function identityStorageKey(project: string): string;
|
|
989
|
+
interface IdentityOptions {
|
|
990
|
+
/** The project key. Defaults to the schema's, exactly as `joinRoom` resolves it. */
|
|
991
|
+
readonly project: string;
|
|
992
|
+
/**
|
|
993
|
+
* The control plane's HTTPS origin. Defaults to `https://irt.io` — the apex where the control
|
|
994
|
+
* API lives (D-single-origin). A local `irtio dev` has no control plane at all, so a game
|
|
995
|
+
* testing identities against one must pass this.
|
|
996
|
+
*/
|
|
997
|
+
readonly controlUrl?: string | undefined;
|
|
998
|
+
/** Injected in tests; `fetch` otherwise. */
|
|
999
|
+
readonly fetch?: typeof fetch | undefined;
|
|
1000
|
+
/**
|
|
1001
|
+
* Where the identity token is kept. Defaults to `localStorage`. A game with its own storage
|
|
1002
|
+
* (a native shell, an extension) can supply one; a game that passes `null` keeps the identity
|
|
1003
|
+
* only for the life of the page.
|
|
1004
|
+
*/
|
|
1005
|
+
readonly storage?: IdentityStorage | null | undefined;
|
|
1006
|
+
}
|
|
1007
|
+
interface IdentityStorage {
|
|
1008
|
+
getItem(key: string): string | null;
|
|
1009
|
+
setItem(key: string, value: string): void;
|
|
1010
|
+
removeItem(key: string): void;
|
|
1011
|
+
}
|
|
1012
|
+
/** The default control origin: the apex that serves both the site and the control API. */
|
|
1013
|
+
declare const DEFAULT_CONTROL_URL = "https://irt.io";
|
|
1014
|
+
declare class IdentityError extends Error {
|
|
1015
|
+
readonly code: string;
|
|
1016
|
+
readonly name = "IdentityError";
|
|
1017
|
+
/**
|
|
1018
|
+
* How long the control plane asked us to wait, in milliseconds, when it said so. Only ever set
|
|
1019
|
+
* on `E_IDENTITY_RATE_LIMITED`: a rate limit is a wait, not an outage, and a game that cannot
|
|
1020
|
+
* tell the two apart drops to its offline path forever over a sixty-second backoff (bug #36).
|
|
1021
|
+
*/
|
|
1022
|
+
readonly retryAfterMs?: number | undefined;
|
|
1023
|
+
constructor(code: string, message: string, retryAfterMs?: number | undefined);
|
|
1024
|
+
}
|
|
1025
|
+
/** The distinct code a 429 from the control plane produces. */
|
|
1026
|
+
declare const E_IDENTITY_RATE_LIMITED = "E_IDENTITY_RATE_LIMITED";
|
|
1027
|
+
/**
|
|
1028
|
+
* The longest `ensure()` will sit and wait before retrying a mint. Past this the wait is longer
|
|
1029
|
+
* than any game's patience, so the caller gets the named error with `retryAfterMs` attached and
|
|
1030
|
+
* decides for itself — sleeping for minutes inside a join is worse than saying why.
|
|
1031
|
+
*/
|
|
1032
|
+
declare const MAX_IDENTITY_RETRY_WAIT_MS = 60000;
|
|
1033
|
+
/**
|
|
1034
|
+
* A player's identity for one project, and the assertion provider `joinRoom` hands the session.
|
|
1035
|
+
*
|
|
1036
|
+
* One instance per project per page. It caches the current assertion and re-exchanges when it is
|
|
1037
|
+
* close to expiring, so a reconnect storm does not become an exchange storm.
|
|
1038
|
+
*/
|
|
1039
|
+
declare class Identity {
|
|
1040
|
+
private readonly project;
|
|
1041
|
+
private readonly controlUrl;
|
|
1042
|
+
private readonly fetchImpl;
|
|
1043
|
+
private readonly storage;
|
|
1044
|
+
private token;
|
|
1045
|
+
private accountId;
|
|
1046
|
+
private cached;
|
|
1047
|
+
private inFlight;
|
|
1048
|
+
constructor(options: IdentityOptions);
|
|
1049
|
+
/**
|
|
1050
|
+
* Reads the stored credential, adopting a pre-D61 per-project one if that is all there is.
|
|
1051
|
+
*
|
|
1052
|
+
* Adoption is a rename and nothing more: migration 020 gave every pre-D61 identity an account
|
|
1053
|
+
* whose id is the identity's own id, so the credential that was this player on this project is
|
|
1054
|
+
* already this player's account credential everywhere. Moving it to the origin-wide key is what
|
|
1055
|
+
* makes the same person on the same browser one player across the games on that origin.
|
|
1056
|
+
*
|
|
1057
|
+
* The old key is removed after a successful adoption. If the write fails (a storage that reads
|
|
1058
|
+
* but will not write), the old value is used anyway and adoption is retried next time, which is
|
|
1059
|
+
* the same degrade-quietly rule the rest of this module follows.
|
|
1060
|
+
*/
|
|
1061
|
+
private load;
|
|
1062
|
+
/** The stored identity token, or `undefined` before the first mint. Never sent to a room. */
|
|
1063
|
+
get stored(): string | undefined;
|
|
1064
|
+
/** The player id the room will see, once an assertion has been fetched. */
|
|
1065
|
+
get playerId(): string | undefined;
|
|
1066
|
+
/**
|
|
1067
|
+
* Mints an identity if this browser has none, and returns the token.
|
|
1068
|
+
*
|
|
1069
|
+
* A mint refused with 429 is retried ONCE, after the window the control plane named, as long as
|
|
1070
|
+
* that window is short enough to wait out (`MAX_IDENTITY_RETRY_WAIT_MS`). Everything else — and
|
|
1071
|
+
* a second refusal — throws, and a rate limit throws `E_IDENTITY_RATE_LIMITED` with
|
|
1072
|
+
* `retryAfterMs` rather than the unreachable-control-plane code, so a game can tell "wait" from
|
|
1073
|
+
* "gone" (bug #36).
|
|
1074
|
+
*/
|
|
1075
|
+
ensure(): Promise<string>;
|
|
1076
|
+
private mint;
|
|
1077
|
+
/**
|
|
1078
|
+
* A currently-valid assertion for this project, exchanging one if the cached one is gone or
|
|
1079
|
+
* within thirty seconds of expiry.
|
|
1080
|
+
*
|
|
1081
|
+
* The margin is what keeps a long reconnect from presenting a token that expires mid-handshake.
|
|
1082
|
+
* Concurrent callers share one exchange, so a burst of reconnects is one HTTP request.
|
|
1083
|
+
*/
|
|
1084
|
+
assertion(now?: number): Promise<string>;
|
|
1085
|
+
private exchange;
|
|
1086
|
+
/** Drops the stored identity. The next `ensure()` mints a new player. */
|
|
1087
|
+
forget(): void;
|
|
1088
|
+
/**
|
|
1089
|
+
* Asks the control plane for a link code to read out on another device.
|
|
1090
|
+
*
|
|
1091
|
+
* The code is short and typable because a person carries it between two screens. Show it, do
|
|
1092
|
+
* not store it, and let it expire: a code left on a screen for the ten minutes it lives is the
|
|
1093
|
+
* one thing about this that a player controls.
|
|
1094
|
+
*/
|
|
1095
|
+
linkCode(): Promise<{
|
|
1096
|
+
code: string;
|
|
1097
|
+
expiresInMs: number;
|
|
1098
|
+
}>;
|
|
1099
|
+
/**
|
|
1100
|
+
* Redeems a code read off another device, and **replaces** this browser's credential with a new
|
|
1101
|
+
* one on that code's account.
|
|
1102
|
+
*
|
|
1103
|
+
* Two things happen in that order and both matter. The new credential is stored first, so a
|
|
1104
|
+
* failure between the two leaves the player linked rather than credential-less. Then whatever
|
|
1105
|
+
* this browser used to be is retired on the account it is leaving.
|
|
1106
|
+
*
|
|
1107
|
+
* **That retirement deletes the old account when this was its only device**, rather than
|
|
1108
|
+
* revoking the credential and walking away. Revoking the last device leaves an account nothing
|
|
1109
|
+
* can ever authenticate as, whose board rows and saved games are then unreachable by the player
|
|
1110
|
+
* and undeletable by anyone. An account with other devices only loses this one.
|
|
1111
|
+
*
|
|
1112
|
+
* So this call can destroy the progress held on THIS browser. Warn the player first; the docs
|
|
1113
|
+
* page has wording for it. The retirement is best-effort: if it fails the link still stands,
|
|
1114
|
+
* because failing a link that already worked is the worse direction.
|
|
1115
|
+
*/
|
|
1116
|
+
redeemLinkCode(code: string): Promise<{
|
|
1117
|
+
account: string;
|
|
1118
|
+
}>;
|
|
1119
|
+
/** The devices on this account: ids and timestamps, plus which one this browser is. */
|
|
1120
|
+
devices(): Promise<{
|
|
1121
|
+
account: string;
|
|
1122
|
+
self: string;
|
|
1123
|
+
devices: {
|
|
1124
|
+
id: string;
|
|
1125
|
+
createdAt: string;
|
|
1126
|
+
lastSeen: string;
|
|
1127
|
+
}[];
|
|
1128
|
+
}>;
|
|
1129
|
+
/**
|
|
1130
|
+
* Revokes one device on this account. Revoking the device this browser IS leaves this instance
|
|
1131
|
+
* holding a credential the control plane no longer knows, so it forgets it: the next `ensure()`
|
|
1132
|
+
* mints a fresh player rather than looping on a refused exchange.
|
|
1133
|
+
*/
|
|
1134
|
+
revokeDevice(deviceId: string): Promise<void>;
|
|
1135
|
+
/**
|
|
1136
|
+
* Deletes this account and everything keyed on it, in every project it played, and forgets the
|
|
1137
|
+
* credential. There is no undo and the control plane does not keep a copy.
|
|
1138
|
+
*/
|
|
1139
|
+
deleteAccount(): Promise<void>;
|
|
1140
|
+
/** The account id, once anything has told us what it is. Opaque; never parse it. */
|
|
1141
|
+
get account(): string | undefined;
|
|
1142
|
+
/** One authenticated account-route call: credential in the header, project in the query. */
|
|
1143
|
+
private accountFetch;
|
|
1144
|
+
private post;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
642
1147
|
/**
|
|
643
1148
|
* A client-side entity collection: the read API of `@irtio/schema`'s `ReadonlyCollection`, plus
|
|
644
1149
|
* index sugar so `state.players[room.me]` reads the same as `state.players.get(room.me)`.
|
|
@@ -722,6 +1227,16 @@ interface RoomEvents {
|
|
|
722
1227
|
status: Status;
|
|
723
1228
|
error: RoomError;
|
|
724
1229
|
correct: Correction;
|
|
1230
|
+
/**
|
|
1231
|
+
* Fires whenever the built-in presence collection changes (someone joins or leaves, or a
|
|
1232
|
+
* presence field updates), carrying the same array `room.clients` returns.
|
|
1233
|
+
*/
|
|
1234
|
+
clients: readonly PresenceRecord[];
|
|
1235
|
+
/**
|
|
1236
|
+
* Fires after each `PONG` updates the smoothed round-trip time, about every `PING_INTERVAL_MS`
|
|
1237
|
+
* (2s), carrying the same value `room.rtt` returns.
|
|
1238
|
+
*/
|
|
1239
|
+
rtt: number;
|
|
725
1240
|
}
|
|
726
1241
|
type Unsubscribe = () => void;
|
|
727
1242
|
/**
|
|
@@ -797,12 +1312,32 @@ interface JoinOptions<S, Role extends string = string> {
|
|
|
797
1312
|
* back a fresh token when the old one nears expiry. Omitted ⇒ the key-only join, unchanged.
|
|
798
1313
|
*/
|
|
799
1314
|
readonly token?: string | (() => string | Promise<string>);
|
|
1315
|
+
/**
|
|
1316
|
+
* D53: join under an irtio anonymous persistent identity. `true` mints one on this browser's
|
|
1317
|
+
* first run, stores it, and exchanges it for a short-lived project-bound assertion before
|
|
1318
|
+
* every HELLO; an `Identity` instance is used as-is, so several joins can share one.
|
|
1319
|
+
*
|
|
1320
|
+
* `ctx.playerId` in room code becomes a stable `irt:<subject>` that survives reconnection,
|
|
1321
|
+
* hibernation and closing the tab — which is what a leaderboard keys on.
|
|
1322
|
+
*
|
|
1323
|
+
* The identity token itself never reaches the room. What rides the socket is the assertion:
|
|
1324
|
+
* bound to this one project, valid for minutes, and carrying a per-project pseudonym rather
|
|
1325
|
+
* than a platform id, so the game learns nothing about the player anywhere else. That is a
|
|
1326
|
+
* deliberate property and not an accident of the implementation — see `identity.ts`.
|
|
1327
|
+
*
|
|
1328
|
+
* Mutually exclusive with `token`: a join asserts one identity, and a client offering two
|
|
1329
|
+
* would be asking the server to pick.
|
|
1330
|
+
*/
|
|
1331
|
+
readonly identity?: boolean | Identity;
|
|
1332
|
+
/** D53: the control plane origin the identity exchange talks to. Defaults to https://irt.io;
|
|
1333
|
+
* a local `irtio dev` has no control plane, so testing identities locally needs this. */
|
|
1334
|
+
readonly controlUrl?: string;
|
|
800
1335
|
readonly onStatus?: (status: Status) => void;
|
|
801
1336
|
/** Hard cap on the owned-write flush window, in ms. Default 50. */
|
|
802
1337
|
readonly writeIntervalMs?: number;
|
|
803
1338
|
/**
|
|
804
1339
|
* How far behind arrival `room.render` draws non-owned entities, in ms (D20). Default:
|
|
805
|
-
* `
|
|
1340
|
+
* `2000 / tickRate` (twice the room's tick interval, learned from `WELCOME`), floored at 50 ms.
|
|
806
1341
|
*/
|
|
807
1342
|
readonly interpDelayMs?: number;
|
|
808
1343
|
/**
|
|
@@ -814,12 +1349,25 @@ interface JoinOptions<S, Role extends string = string> {
|
|
|
814
1349
|
* code-splits keeps Rapier out of the critical path entirely.
|
|
815
1350
|
*/
|
|
816
1351
|
readonly physics?: ClientPhysicsOptions;
|
|
1352
|
+
/**
|
|
1353
|
+
* D57: the same contract for a matter2d room — `setup`, `bodies`, `intents`, plus `settle`,
|
|
1354
|
+
* the per-step pass over every local body of a collection that gives a crate nobody owns its
|
|
1355
|
+
* gravity. A sibling of `physics` rather than a variant inside it, because the two engines
|
|
1356
|
+
* take different functions; passing both is an error at join. The wire carries no engine
|
|
1357
|
+
* name, so passing the one that does not match the room is a game bug the client cannot see.
|
|
1358
|
+
*/
|
|
1359
|
+
readonly physics2d?: ClientPhysics2dOptions;
|
|
817
1360
|
/** @internal */
|
|
818
1361
|
readonly transport?: Transport;
|
|
819
1362
|
/** @internal */
|
|
820
1363
|
readonly scheduler?: Scheduler;
|
|
821
1364
|
/** @internal */
|
|
822
1365
|
readonly onFrame?: FrameHook;
|
|
1366
|
+
/**
|
|
1367
|
+
* D65: keep a bandwidth ledger for this session, readable as `room.profile`. Off by default and
|
|
1368
|
+
* free when off. A development and diagnostics surface, not something to ship enabled.
|
|
1369
|
+
*/
|
|
1370
|
+
readonly profile?: boolean;
|
|
823
1371
|
}
|
|
824
1372
|
/** `joinRelay` options: a relay room has no schema, so there is no state and no RPC. */
|
|
825
1373
|
interface JoinRelayOptions {
|
|
@@ -842,6 +1390,20 @@ interface JoinRelayOptions {
|
|
|
842
1390
|
type MessageTarget = 'all' | string | {
|
|
843
1391
|
readonly role: string;
|
|
844
1392
|
};
|
|
1393
|
+
/**
|
|
1394
|
+
* D65: the client's view of where its bytes went, by collection and field.
|
|
1395
|
+
*
|
|
1396
|
+
* A client cannot tell area-of-interest churn from a real spawn — after the encode the two are
|
|
1397
|
+
* the same bytes, and only the server knows which ops it synthesised. So every add and remove in
|
|
1398
|
+
* a `spatial-grid` collection lands under `churn` here, and surfaces that show it say
|
|
1399
|
+
* `enter/leave (incl. spawns)` rather than pretending otherwise.
|
|
1400
|
+
*/
|
|
1401
|
+
interface RoomProfile {
|
|
1402
|
+
/** Cumulative bytes since the socket opened, by row. */
|
|
1403
|
+
total(): _irtio_protocol.ProfileSnapshot;
|
|
1404
|
+
/** Bytes per second over a rolling window of about a second. */
|
|
1405
|
+
perSecond(): _irtio_protocol.ProfileSnapshot;
|
|
1406
|
+
}
|
|
845
1407
|
/**
|
|
846
1408
|
* Default D21 cap on non-owned predicted bodies per client.
|
|
847
1409
|
*
|
|
@@ -897,6 +1459,11 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
|
|
|
897
1459
|
readonly link: string;
|
|
898
1460
|
/** The last server tick this client saw. */
|
|
899
1461
|
readonly tick: number;
|
|
1462
|
+
/**
|
|
1463
|
+
* The room's configured client cap (`WELCOME`), or `0` when unknown (a relay-only server
|
|
1464
|
+
* predating this field).
|
|
1465
|
+
*/
|
|
1466
|
+
readonly maxClients: number;
|
|
900
1467
|
readonly status: Status;
|
|
901
1468
|
/** Round-trip time in ms from the last `PING`/`PONG`, or 0 before the first one. */
|
|
902
1469
|
readonly rtt: number;
|
|
@@ -920,6 +1487,12 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
|
|
|
920
1487
|
* counters the bot runtime and the demos report.
|
|
921
1488
|
*/
|
|
922
1489
|
readonly prediction?: PredictionStatus;
|
|
1490
|
+
/**
|
|
1491
|
+
* D65: this session's bandwidth ledger, present only when the join passed `profile: true`.
|
|
1492
|
+
* `total()` is cumulative since the socket opened; `perSecond()` is a rolling window of about a
|
|
1493
|
+
* second, advanced on the session's own clock each time it is read.
|
|
1494
|
+
*/
|
|
1495
|
+
readonly profile?: RoomProfile;
|
|
923
1496
|
readonly call: RoomCallProxy<S>;
|
|
924
1497
|
/** Convenience alias for `room.call.requestOwnership`. */
|
|
925
1498
|
requestOwnership(entity: string, id: string): Promise<boolean>;
|
|
@@ -934,6 +1507,11 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
|
|
|
934
1507
|
interface RelayRoom {
|
|
935
1508
|
readonly me: string;
|
|
936
1509
|
readonly id: string;
|
|
1510
|
+
/**
|
|
1511
|
+
* The room's configured client cap (`WELCOME`), or `0` when unknown (a relay-only server
|
|
1512
|
+
* predating this field).
|
|
1513
|
+
*/
|
|
1514
|
+
readonly maxClients: number;
|
|
937
1515
|
readonly link: string;
|
|
938
1516
|
readonly status: Status;
|
|
939
1517
|
/** Round-trip time in ms from the last `PING`/`PONG`, or 0 before the first one. */
|
|
@@ -987,6 +1565,130 @@ declare function roomIdFrom(roomOrLink: string): string;
|
|
|
987
1565
|
*/
|
|
988
1566
|
declare function linkForUrl(wsUrl: string, roomId: string): string;
|
|
989
1567
|
|
|
1568
|
+
/**
|
|
1569
|
+
* D52 client side: `matchRoom` — quick-match, then join.
|
|
1570
|
+
*
|
|
1571
|
+
* This is sugar over two things the SDK already does: one HTTP POST to the control plane, and
|
|
1572
|
+
* `joinRoom({ room })` with the code it answers. Nothing about the join differs from a friend
|
|
1573
|
+
* sharing a link, which is the property the whole design is built on — the room neither knows nor
|
|
1574
|
+
* cares that a matchmaker filled it.
|
|
1575
|
+
*
|
|
1576
|
+
* The wait is a long poll, so the request simply takes as long as the queue takes. A queue that
|
|
1577
|
+
* never fills answers "no match" at the deadline and `matchRoom` throws `E_NO_MATCH` rather than
|
|
1578
|
+
* silently dropping the player somewhere: a matchmaker that quietly degrades is one nobody can
|
|
1579
|
+
* debug, and a game that wants to show "still looking…" needs to be told.
|
|
1580
|
+
*/
|
|
1581
|
+
|
|
1582
|
+
declare class MatchError extends Error {
|
|
1583
|
+
readonly code: string;
|
|
1584
|
+
readonly name = "MatchError";
|
|
1585
|
+
constructor(code: string, message: string);
|
|
1586
|
+
}
|
|
1587
|
+
interface MatchOptions {
|
|
1588
|
+
/** The queue to join. Omitted uses the project's `default` queue (party of two). */
|
|
1589
|
+
readonly queue?: string;
|
|
1590
|
+
/**
|
|
1591
|
+
* D63-g: a party code from `createParty`, so this player queues with their friends.
|
|
1592
|
+
*
|
|
1593
|
+
* Every member sends the same code with their own call. Nobody sends anybody else's identity:
|
|
1594
|
+
* a device credential is a secret that never leaves the browser that minted it, so a party is
|
|
1595
|
+
* a code you read out rather than a list you assemble.
|
|
1596
|
+
*
|
|
1597
|
+
* The queue fills only when the whole party is waiting, and the members land on consecutive
|
|
1598
|
+
* seats. A party bigger than the queue is refused with `E_PARTY_TOO_BIG` at the first call.
|
|
1599
|
+
*/
|
|
1600
|
+
readonly party?: string;
|
|
1601
|
+
/**
|
|
1602
|
+
* How long to wait before giving up, in milliseconds. The control plane caps this at two
|
|
1603
|
+
* minutes and applies its own default (30 s) when omitted.
|
|
1604
|
+
*/
|
|
1605
|
+
readonly timeoutMs?: number;
|
|
1606
|
+
/** The control plane origin. Defaults to `https://irt.io`. */
|
|
1607
|
+
readonly controlUrl?: string;
|
|
1608
|
+
/** Injected in tests. */
|
|
1609
|
+
readonly fetch?: typeof fetch;
|
|
1610
|
+
}
|
|
1611
|
+
/** What the queue answered. `seat` is a 0-based index into the party in arrival order. */
|
|
1612
|
+
interface MatchTicket {
|
|
1613
|
+
readonly room: string;
|
|
1614
|
+
readonly queue: string;
|
|
1615
|
+
/**
|
|
1616
|
+
* 0-based position in the party, in arrival order — **or `-1` for a backfill answer**, where
|
|
1617
|
+
* arrival order among a running room's players is not something the matchmaker knows.
|
|
1618
|
+
*
|
|
1619
|
+
* A game that uses `seat` as an index has to handle the `-1`. It is a value that cannot be
|
|
1620
|
+
* mistaken for a position, rather than an absent field, precisely so that a game which forgets
|
|
1621
|
+
* gets an obviously wrong answer instead of a plausible one.
|
|
1622
|
+
*/
|
|
1623
|
+
readonly seat: number;
|
|
1624
|
+
readonly size: number;
|
|
1625
|
+
/**
|
|
1626
|
+
* True when this is a seat in a room that is already running rather than a fresh code.
|
|
1627
|
+
*
|
|
1628
|
+
* The room may fill before you get there — the matchmaker is working from an occupancy reading a
|
|
1629
|
+
* few seconds old — so a join off a backfill ticket can be refused with `E_ROOM_FULL`.
|
|
1630
|
+
* `matchRoom` handles that for you by queueing again, once.
|
|
1631
|
+
*/
|
|
1632
|
+
readonly backfill: boolean;
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Queues for a game and resolves with the room code when the party fills.
|
|
1636
|
+
*
|
|
1637
|
+
* The identity, when one is supplied, is what gives the queue its one-ticket-per-player rule: a
|
|
1638
|
+
* player cannot fill a queue with themselves. Without one the queue falls back to limiting by
|
|
1639
|
+
* address, which is weaker (two players behind one NAT share a bucket) and is the honest reason
|
|
1640
|
+
* to adopt identities before shipping a quick-match button.
|
|
1641
|
+
*/
|
|
1642
|
+
declare function findMatch(project: string, options?: MatchOptions & {
|
|
1643
|
+
readonly identity?: string;
|
|
1644
|
+
}): Promise<MatchTicket>;
|
|
1645
|
+
/** What `createParty` answers with. */
|
|
1646
|
+
interface PartyTicket {
|
|
1647
|
+
/** The code every member sends with their own `matchRoom` call. Readable out loud. */
|
|
1648
|
+
readonly party: string;
|
|
1649
|
+
/** How long the code stays good for. Ten minutes today. */
|
|
1650
|
+
readonly expiresInMs: number;
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Mints a party code so a group can queue together (D63-g).
|
|
1654
|
+
*
|
|
1655
|
+
* ```ts
|
|
1656
|
+
* const { party } = await createParty(schema.project, { size: 2 });
|
|
1657
|
+
* // read `party` out to your friend, then both of you:
|
|
1658
|
+
* const room = await matchRoom(schema, { queue: '2v2', party });
|
|
1659
|
+
* ```
|
|
1660
|
+
*
|
|
1661
|
+
* A code rather than a list of members, and the reason is worth knowing: an identity is a secret
|
|
1662
|
+
* that never leaves the browser that minted it, so there is no way for one player to hold
|
|
1663
|
+
* another's, and a ticket that claimed to speak for several players would be forgeable. Nobody
|
|
1664
|
+
* proves anything about anybody here — the matcher simply groups the tickets that carry the same
|
|
1665
|
+
* code — and the worst a guessed code can do is put a stranger in your game, which is what
|
|
1666
|
+
* quick-match does anyway.
|
|
1667
|
+
*
|
|
1668
|
+
* The code expires after `expiresInMs`. Expiry stops new members joining the party; members who
|
|
1669
|
+
* are already queued keep their tickets.
|
|
1670
|
+
*/
|
|
1671
|
+
declare function createParty(project: string, options: {
|
|
1672
|
+
readonly size: number;
|
|
1673
|
+
} & Pick<MatchOptions, 'controlUrl' | 'fetch'>): Promise<PartyTicket>;
|
|
1674
|
+
/**
|
|
1675
|
+
* Quick-match, then join: the one call a quick-play button needs.
|
|
1676
|
+
*
|
|
1677
|
+
* ```ts
|
|
1678
|
+
* const room = await matchRoom(schema); // two strangers, one room, no code
|
|
1679
|
+
* const room = await matchRoom(schema, { queue: '1v1', identity: true });
|
|
1680
|
+
* ```
|
|
1681
|
+
*
|
|
1682
|
+
* With `identity: true` the player is minted an anonymous persistent identity (once, ever, stored
|
|
1683
|
+
* in `localStorage`), the queue enforces one ticket per identity, and the room sees a stable
|
|
1684
|
+
* `ctx.playerId` — which is what a leaderboard needs to key on.
|
|
1685
|
+
*/
|
|
1686
|
+
declare function matchRoom<S extends AnySchema, Role extends string = RoleOf<S> & string>(schema: S, options?: MatchOptions & JoinOptions<S, Role> & {
|
|
1687
|
+
role?: RoleOf<S> & string;
|
|
1688
|
+
} & {
|
|
1689
|
+
readonly identity?: boolean;
|
|
1690
|
+
}): Promise<Room<S, Role>>;
|
|
1691
|
+
|
|
990
1692
|
/**
|
|
991
1693
|
* The default `Scheduler`. In a browser the write batcher aligns to `requestAnimationFrame` (one
|
|
992
1694
|
* `WRITE` per rendered frame, which is what a game loop produces); everywhere else — and as a
|
|
@@ -1024,32 +1726,83 @@ declare const webSocketTransport: Transport;
|
|
|
1024
1726
|
|
|
1025
1727
|
type AnyRecord$1 = Record<string, unknown>;
|
|
1026
1728
|
declare class RenderStore {
|
|
1027
|
-
|
|
1729
|
+
/** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
|
|
1730
|
+
private ext;
|
|
1028
1731
|
private readonly store;
|
|
1029
1732
|
private readonly meOf;
|
|
1030
1733
|
private readonly now;
|
|
1031
1734
|
private readonly delayMs;
|
|
1735
|
+
/** The room's tick interval from `WELCOME`; 0 means unknown (pre-week-8 server). */
|
|
1736
|
+
private readonly intervalMs;
|
|
1032
1737
|
/** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
|
|
1033
1738
|
private readonly buffers;
|
|
1034
1739
|
private readonly descs;
|
|
1740
|
+
/** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
|
|
1741
|
+
private readonly facades;
|
|
1035
1742
|
/** The object handed out as `room.render`; identity survives a resync. */
|
|
1036
1743
|
readonly view: AnyRecord$1;
|
|
1037
1744
|
/** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
|
|
1038
1745
|
starved: number;
|
|
1039
1746
|
/** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
|
|
1040
1747
|
private predictor;
|
|
1041
|
-
|
|
1748
|
+
/**
|
|
1749
|
+
* The offset between the server's tick clock and this client's `now()`: a frame for tick `n`
|
|
1750
|
+
* is stamped `base + n × intervalMs`. Undefined until the first delta of a connection.
|
|
1751
|
+
*/
|
|
1752
|
+
private base;
|
|
1753
|
+
/** `now()` at the last `base` move; bounds how far the upward drift correction may go. */
|
|
1754
|
+
private baseAt;
|
|
1755
|
+
constructor(
|
|
1756
|
+
/** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
|
|
1757
|
+
ext: AnySchema, store: ClientStore, meOf: () => string, now: () => number, delayMs: () => number,
|
|
1758
|
+
/** The room's tick interval from `WELCOME`; 0 means unknown (pre-week-8 server). */
|
|
1759
|
+
intervalMs: () => number);
|
|
1760
|
+
/**
|
|
1761
|
+
* D50: rebuild against `newExt`, keeping `view`'s identity and every collection facade behind
|
|
1762
|
+
* it, exactly as `ClientStore.swapSchema` does.
|
|
1763
|
+
*
|
|
1764
|
+
* Every interpolation buffer is dropped. A keyframe is a plain record whose fields were laid
|
|
1765
|
+
* out by the old descriptor, and `lerpRecord` walks `desc.fields` — interpolating an old
|
|
1766
|
+
* keyframe against a new one under the new field list is precisely the silent misread this
|
|
1767
|
+
* whole part exists to avoid. `seedSnapshot`, called moments later off the resync WELCOME,
|
|
1768
|
+
* re-seeds one keyframe per entity, which is the same state the buffers would have started
|
|
1769
|
+
* from on a reconnect.
|
|
1770
|
+
*/
|
|
1771
|
+
swapSchema(newExt: AnySchema): void;
|
|
1042
1772
|
/**
|
|
1043
1773
|
* Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
|
|
1044
1774
|
* the world is visible immediately (the join snapshot is the render baseline, not a change to
|
|
1045
1775
|
* ease towards).
|
|
1046
1776
|
*/
|
|
1047
1777
|
seedSnapshot(): void;
|
|
1048
|
-
/**
|
|
1778
|
+
/**
|
|
1779
|
+
* Maps `tick` onto the client clock, or returns `undefined` when the tick interval is unknown
|
|
1780
|
+
* and the caller must fall back to arrival stamping.
|
|
1781
|
+
*
|
|
1782
|
+
* The offset is the minimum of `now() − tick × intervalMs` over the connection: the least
|
|
1783
|
+
* delayed delta seen so far is the best evidence of where the server's tick clock sits, and
|
|
1784
|
+
* every later delta is that plus its own queueing delay. So a better sample is taken
|
|
1785
|
+
* immediately, a worse one only bleeds in at drift speed, and a discontinuity larger than the
|
|
1786
|
+
* render window (hibernation wake, a backgrounded tab's clock jump, a tick stall) is a new
|
|
1787
|
+
* clock rather than a late frame and snaps.
|
|
1788
|
+
*/
|
|
1789
|
+
private stampFor;
|
|
1790
|
+
private snapMs;
|
|
1791
|
+
/**
|
|
1792
|
+
* Buffers every entity a `DELTA` touched, stamped on the server's tick clock so delivery
|
|
1793
|
+
* burstiness cannot modulate the drawn velocity. Call after apply.
|
|
1794
|
+
*/
|
|
1049
1795
|
recordDelta(delta: Delta): void;
|
|
1796
|
+
/**
|
|
1797
|
+
* Appends one keyframe, keeping `frames` strictly ascending in `t` — `prune` and the lerp both
|
|
1798
|
+
* depend on it. Two constraints meet here: the hybrid spatial encode sends two DELTA frames for
|
|
1799
|
+
* a single tick, whose tick-derived stamps are equal, so the second one replaces rather than
|
|
1800
|
+
* appends; and an offset that just snapped backwards must not stamp behind what is buffered.
|
|
1801
|
+
*/
|
|
1802
|
+
private pushFrame;
|
|
1050
1803
|
private bufferFor;
|
|
1051
1804
|
private renderTime;
|
|
1052
|
-
attachPredictor(predictor:
|
|
1805
|
+
attachPredictor(predictor: Predictor): void;
|
|
1053
1806
|
/** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
|
|
1054
1807
|
get(desc: CollectionDesc, id: string): unknown;
|
|
1055
1808
|
/** Is `collection[id]` visible at the render clock? */
|
|
@@ -1061,7 +1814,8 @@ declare class RenderStore {
|
|
|
1061
1814
|
/** Drops frames the render clock has passed, keeping the newest one at-or-before `renderT`. */
|
|
1062
1815
|
private prune;
|
|
1063
1816
|
private gc;
|
|
1064
|
-
|
|
1817
|
+
/** See `ClientStore.buildViewInto`: same contract, same reasons, one object reused forever. */
|
|
1818
|
+
private buildViewInto;
|
|
1065
1819
|
}
|
|
1066
1820
|
|
|
1067
1821
|
/**
|
|
@@ -1098,35 +1852,64 @@ interface SessionOptions {
|
|
|
1098
1852
|
* resume that outlives its token would otherwise be refused with `E_TOKEN_EXPIRED`.
|
|
1099
1853
|
*/
|
|
1100
1854
|
readonly token?: string | (() => string | Promise<string>) | undefined;
|
|
1855
|
+
/**
|
|
1856
|
+
* D53: a provider for the platform identity assertion this session presents at HELLO.
|
|
1857
|
+
*
|
|
1858
|
+
* Always a function, never a string, and that is the point: an assertion lives for minutes
|
|
1859
|
+
* and a session lives for as long as the player plays, so a reconnect an hour in has to be
|
|
1860
|
+
* able to fetch a fresh one. A fixed string would be a session that dies at its first long
|
|
1861
|
+
* disconnect with `E_TOKEN_EXPIRED`.
|
|
1862
|
+
*/
|
|
1863
|
+
readonly assertion?: (() => string | Promise<string>) | undefined;
|
|
1101
1864
|
readonly role?: string | undefined;
|
|
1102
1865
|
readonly name?: string | undefined;
|
|
1103
1866
|
readonly rpc?: Readonly<Record<string, ClientImpl>> | undefined;
|
|
1104
1867
|
readonly writeIntervalMs?: number | undefined;
|
|
1105
|
-
/** Render delay for `room.render` (D20). Default `max(50,
|
|
1868
|
+
/** Render delay for `room.render` (D20). Default `max(50, 2000 / tickRate)`. */
|
|
1106
1869
|
readonly interpDelayMs?: number | undefined;
|
|
1107
1870
|
/** The shared world-builder half the client predicts with (D22 part 2). */
|
|
1108
1871
|
readonly physics?: ClientPhysicsOptions | undefined;
|
|
1872
|
+
/** The same, for a matter2d room (D57). Never both; `joinRoom` refuses that. */
|
|
1873
|
+
readonly physics2d?: ClientPhysics2dOptions | undefined;
|
|
1109
1874
|
readonly transport?: Transport | undefined;
|
|
1110
1875
|
readonly scheduler?: Scheduler | undefined;
|
|
1111
1876
|
readonly onFrame?: FrameHook | undefined;
|
|
1877
|
+
/** D65: keep a bandwidth ledger for this session. */
|
|
1878
|
+
readonly profile?: boolean | undefined;
|
|
1112
1879
|
readonly onStatus?: ((status: Status) => void) | undefined;
|
|
1113
1880
|
/** `true` in a browser with no explicit `room` option: `?room=` gets written back. */
|
|
1114
1881
|
readonly publishLocation: boolean;
|
|
1115
1882
|
}
|
|
1116
1883
|
declare class Session {
|
|
1117
1884
|
private readonly options;
|
|
1118
|
-
|
|
1885
|
+
/**
|
|
1886
|
+
* D50: not `readonly`. `swapSchema` replaces it when an additive deploy hands this session a
|
|
1887
|
+
* new descriptor mid-flight. Everything that decodes a frame reads it through `this`, so the
|
|
1888
|
+
* single assignment below is what actually moves the session onto the new schema.
|
|
1889
|
+
*/
|
|
1890
|
+
ext: AnySchema;
|
|
1119
1891
|
readonly store: ClientStore;
|
|
1120
1892
|
readonly render: RenderStore;
|
|
1121
|
-
/** `true` when the join passed
|
|
1893
|
+
/** `true` when the join passed an engine option and the schema has body-backed collections. */
|
|
1122
1894
|
readonly predictionRequested: boolean;
|
|
1123
1895
|
/**
|
|
1124
|
-
* Present once
|
|
1125
|
-
*
|
|
1896
|
+
* Present once the engine's adapter module has loaded — `./physics.js` for `physics`,
|
|
1897
|
+
* `./physics2d.js` for `physics2d`, each behind a dynamic import, so the predictor code and its
|
|
1898
|
+
* engine cost a non-physics game (or the other engine's game) zero bytes. Reads fall back to
|
|
1899
|
+
* interpolation until then.
|
|
1900
|
+
*/
|
|
1901
|
+
predictor: Predictor | undefined;
|
|
1902
|
+
/** The schema the `CALL`/`REPLY` rpc id space indexes into. D50: swappable, see `swapSchema`. */
|
|
1903
|
+
private rpcSchema;
|
|
1904
|
+
/**
|
|
1905
|
+
* The BUILDER schema this session is currently on: `options.schema` at construction, then
|
|
1906
|
+
* whatever a `SCHEMA` frame replaced it with. Read instead of `options.schema` everywhere, so a
|
|
1907
|
+
* swapped session announces its *current* hash when it reconnects rather than the one it was
|
|
1908
|
+
* born with — otherwise a resume after a swap would be refused as a mismatch.
|
|
1126
1909
|
*/
|
|
1127
|
-
|
|
1128
|
-
/**
|
|
1129
|
-
|
|
1910
|
+
private schema;
|
|
1911
|
+
/** D50: how many times this session has swapped schema. Test seam and a diagnostic. */
|
|
1912
|
+
schemaSwaps: number;
|
|
1130
1913
|
private readonly transport;
|
|
1131
1914
|
private readonly scheduler;
|
|
1132
1915
|
private readonly writeIntervalMs;
|
|
@@ -1144,7 +1927,10 @@ declare class Session {
|
|
|
1144
1927
|
*/
|
|
1145
1928
|
writeTick: number;
|
|
1146
1929
|
/** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
|
|
1930
|
+
/** Milliseconds per tick, derived from the rate in `WELCOME`. 0 when the room has no tick. */
|
|
1147
1931
|
tickIntervalMs: number;
|
|
1932
|
+
/** The room's client cap from `WELCOME`, or 0 when unknown (relay / pre-this-field server). */
|
|
1933
|
+
maxClients: number;
|
|
1148
1934
|
rtt: number;
|
|
1149
1935
|
status: Status;
|
|
1150
1936
|
private socket;
|
|
@@ -1169,6 +1955,20 @@ declare class Session {
|
|
|
1169
1955
|
private readonly pending;
|
|
1170
1956
|
private readonly listeners;
|
|
1171
1957
|
private readonly messageListeners;
|
|
1958
|
+
/**
|
|
1959
|
+
* D51: voice signaling listeners, kept in their own set rather than sharing `messageListeners`.
|
|
1960
|
+
*
|
|
1961
|
+
* The isolation clause has a client half as well as a server half. The supervisor guarantees that
|
|
1962
|
+
* a room handler never observes `{ kind: 'voice' }`; this set is what guarantees the same for a
|
|
1963
|
+
* *game*, which holds the other end of the same socket and whose `room.onMessage` would otherwise
|
|
1964
|
+
* receive every transport parameter and DTLS fingerprint in the call. Splitting the sets makes
|
|
1965
|
+
* the property structural — there is no `!== 'voice'` filter to forget somewhere in the fan-out,
|
|
1966
|
+
* and no ordering between two callbacks to get wrong.
|
|
1967
|
+
*
|
|
1968
|
+
* Nothing on the public `Room` type can reach this set. `joinVoice` gets at it through
|
|
1969
|
+
* `INTERNAL_SESSION`, the same route the D50 tests use for the session itself.
|
|
1970
|
+
*/
|
|
1971
|
+
private readonly voiceListeners;
|
|
1172
1972
|
private cancelFlush;
|
|
1173
1973
|
private cancelPing;
|
|
1174
1974
|
private cancelRetry;
|
|
@@ -1191,10 +1991,78 @@ declare class Session {
|
|
|
1191
1991
|
private fatal;
|
|
1192
1992
|
private onSocketClosed;
|
|
1193
1993
|
private stopTimers;
|
|
1994
|
+
/**
|
|
1995
|
+
* D65: the session's bandwidth ledger, present only when the join asked for one. A second
|
|
1996
|
+
* consumer of the same seam `onFrame` uses (the bots' observer is the first), so the two
|
|
1997
|
+
* compose rather than compete.
|
|
1998
|
+
*/
|
|
1999
|
+
readonly ledger: ProfileLedger | undefined;
|
|
2000
|
+
/** D65: the rolling window `room.profile.perSecond()` reads, advanced on the session clock. */
|
|
2001
|
+
private profileWindow;
|
|
2002
|
+
/**
|
|
2003
|
+
* D65: bytes per second over a rolling window of about a second, measured on the scheduler's
|
|
2004
|
+
* clock rather than `Date.now()` so a test with a fake clock gets a deterministic answer.
|
|
2005
|
+
*
|
|
2006
|
+
* The window advances only when somebody reads it: a reader at 1 Hz (the overlay) gets a
|
|
2007
|
+
* one-second window, and a reader that never calls costs nothing. A read sooner than a second
|
|
2008
|
+
* after the last one is extrapolated from the partial window rather than returning zeros.
|
|
2009
|
+
*/
|
|
2010
|
+
profilePerSecond(): ProfileSnapshot;
|
|
1194
2011
|
private send;
|
|
1195
2012
|
private onFrame;
|
|
1196
2013
|
/** A frame we could not decode or apply. Reported, never fatal: the stream may recover. */
|
|
1197
2014
|
private localError;
|
|
2015
|
+
/**
|
|
2016
|
+
* D50: a `SCHEMA` frame landed. The server sends one during an additive `migrate` deploy, after
|
|
2017
|
+
* the old worker has stopped sending and before the resync WELCOME, so this session can stay
|
|
2018
|
+
* open across a deploy instead of being closed with `E_SCHEMA_MISMATCH`.
|
|
2019
|
+
*
|
|
2020
|
+
* A decode failure here is reported as a non-fatal error and the swap is abandoned. That leaves
|
|
2021
|
+
* the session on the old schema with a resync WELCOME about to arrive under the new one, which
|
|
2022
|
+
* it will fail to decode — noisy, but noisy is the correct failure: the alternative is decoding
|
|
2023
|
+
* it anyway under the wrong descriptor, and a misdecode is silent.
|
|
2024
|
+
*/
|
|
2025
|
+
private onSchema;
|
|
2026
|
+
/**
|
|
2027
|
+
* The one entry point for a mid-session schema change. Internal: not part of the public client
|
|
2028
|
+
* API this release, and not called from anywhere but `onSchema`.
|
|
2029
|
+
*
|
|
2030
|
+
* **What is rebuilt.** Every schema-derived handle in the client, top down:
|
|
2031
|
+
*
|
|
2032
|
+
* - `schema` / `ext` / `rpcSchema` here, which is what every `decodeDelta`, `encodeDelta`,
|
|
2033
|
+
* `decodeSnapshot` and rpc-id lookup reads through;
|
|
2034
|
+
* - `descsByName`, the correction path's collection index, invalidated so it re-derives;
|
|
2035
|
+
* - `ClientStore`: `ext`, its descriptor map, `plain`, `tracked`, and the per-collection
|
|
2036
|
+
* facades behind `room.state` — retargeted, not replaced;
|
|
2037
|
+
* - `RenderStore`: the same, plus every interpolation buffer;
|
|
2038
|
+
* - `PhysicsPredictor`, when one exists: its predicted-collection list and its live bodies.
|
|
2039
|
+
*
|
|
2040
|
+
* **What is dropped, and why** (part 4 plan §1.3). Everything below is state that was laid out
|
|
2041
|
+
* by the old descriptors and has no correct reading under the new ones. The resync WELCOME
|
|
2042
|
+
* arriving immediately after re-establishes all of it, which is what makes dropping cheap:
|
|
2043
|
+
*
|
|
2044
|
+
* - the authoritative state (`plain`) and its tracked proxy tree — re-seeded by `loadSnapshot`;
|
|
2045
|
+
* - flushed-but-unjudged writes, the evicted-tick watermark and the baseline intents — a
|
|
2046
|
+
* resync already discards these (see `ClientStore.loadSnapshot`), and their field names are
|
|
2047
|
+
* indexed against descriptors that no longer exist;
|
|
2048
|
+
* - every render interpolation keyframe — `lerpRecord` walks `desc.fields`, so mixing a
|
|
2049
|
+
* pre-swap keyframe with a post-swap one is a silent misread. `seedSnapshot` re-seeds from
|
|
2050
|
+
* the WELCOME;
|
|
2051
|
+
* - every predicted body and its pose ring — `PhysicsPredictor.reset()`'s existing job.
|
|
2052
|
+
*
|
|
2053
|
+
* **What is deliberately NOT dropped: in-flight calls** (§1.2, the default rule). A `PendingCall`
|
|
2054
|
+
* captured its `returns` descriptor at the moment it was issued, and the REPLY it is waiting for
|
|
2055
|
+
* was produced by a room that had those params in hand. So a call issued before the swap keeps
|
|
2056
|
+
* decoding its reply under the schema it was issued with, and resolves normally afterwards. This
|
|
2057
|
+
* costs nothing to implement — the descriptor is already captured per call rather than looked up
|
|
2058
|
+
* at reply time — and it is the honest semantics: the call did happen, under the old contract.
|
|
2059
|
+
*
|
|
2060
|
+
* The narrow case this leaves is a REPLY whose *shape* changed additively between the two
|
|
2061
|
+
* schemas. Decoding it under the old `returns` reads the fields the caller asked for and stops,
|
|
2062
|
+
* which is exactly what an appended field means. A breaking change to an RPC never reaches here:
|
|
2063
|
+
* it classifies breaking and the session is closed instead.
|
|
2064
|
+
*/
|
|
2065
|
+
private swapSchema;
|
|
1198
2066
|
private onWelcome;
|
|
1199
2067
|
private onDelta;
|
|
1200
2068
|
private onCorrect;
|
|
@@ -1226,6 +2094,18 @@ declare class Session {
|
|
|
1226
2094
|
private rejectPending;
|
|
1227
2095
|
message(target: MessageTarget, bytes: Uint8Array): void;
|
|
1228
2096
|
onMessage(cb: (from: 'server' | string, bytes: Uint8Array) => void): Unsubscribe;
|
|
2097
|
+
/**
|
|
2098
|
+
* D51: write one voice signaling message. Internal — reached only through `INTERNAL_SESSION`,
|
|
2099
|
+
* so it is not on the `Room` type and a game cannot call it.
|
|
2100
|
+
*
|
|
2101
|
+
* This deliberately does NOT go through `message()`. `MessageTarget` has no voice member on
|
|
2102
|
+
* purpose (that is the third of the three isolation mechanisms), and widening it so that this
|
|
2103
|
+
* method could share the mapping would delete the mechanism to save four lines. Building the
|
|
2104
|
+
* `MsgTarget` here keeps `{ kind: 'voice' }` unrepresentable from anywhere a game can reach.
|
|
2105
|
+
*/
|
|
2106
|
+
sendVoice(bytes: Uint8Array): void;
|
|
2107
|
+
/** D51: subscribe to voice signaling replies. Internal, for the same reason as `sendVoice`. */
|
|
2108
|
+
onVoice(cb: (bytes: Uint8Array) => void): Unsubscribe;
|
|
1229
2109
|
get clients(): readonly PresenceRecord[];
|
|
1230
2110
|
get link(): string;
|
|
1231
2111
|
on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
|
|
@@ -1233,6 +2113,138 @@ declare class Session {
|
|
|
1233
2113
|
private setStatus;
|
|
1234
2114
|
}
|
|
1235
2115
|
|
|
2116
|
+
/**
|
|
2117
|
+
* D51: `joinVoice` — the client half of on-box voice.
|
|
2118
|
+
*
|
|
2119
|
+
* ## Why a free function and not `room.voice`
|
|
2120
|
+
*
|
|
2121
|
+
* `joinVoice(room)` mirrors `joinRoom(schema)`: a free function you call when you want the thing,
|
|
2122
|
+
* rather than a property that exists on every `Room` whether or not the game has voice. Three
|
|
2123
|
+
* reasons, in order of how much they matter.
|
|
2124
|
+
*
|
|
2125
|
+
* 1. **It keeps `mediasoup-client` off the join path.** The device library and its RTP capability
|
|
2126
|
+
* machinery are loaded by the first `joinVoice` call, dynamically. A game that never calls it
|
|
2127
|
+
* never pays for it, in bundle size or in startup work.
|
|
2128
|
+
* 2. **Voice is per-participant, not per-room.** A room can be joined by a spectator, an NPC or a
|
|
2129
|
+
* headless bot, none of which have a microphone; making voice a room property would suggest a
|
|
2130
|
+
* lifetime it does not have. The handle returned here is the lifetime.
|
|
2131
|
+
* 3. **It keeps the isolation clause structural.** Everything voice needs from the session —
|
|
2132
|
+
* `sendVoice`, `onVoice` — lives behind `INTERNAL_SESSION` and is absent from the public `Room`
|
|
2133
|
+
* type. Hanging voice off `Room` would have meant putting at least one of them within a game's
|
|
2134
|
+
* reach.
|
|
2135
|
+
*
|
|
2136
|
+
* ## Positional audio is not in this release
|
|
2137
|
+
*
|
|
2138
|
+
* The `positional` option is accepted and *ignored*. It is declared here rather than omitted so
|
|
2139
|
+
* that a game which already passes it keeps compiling and keeps working (with flat audio) rather
|
|
2140
|
+
* than failing to build, and so that turning it on later is an implementation change rather than an
|
|
2141
|
+
* API change. Passing `true` logs one warning and does nothing spatial: no panner, no listener
|
|
2142
|
+
* orientation, no distance model. See `mute` below for the one thing that *is* signalled.
|
|
2143
|
+
*
|
|
2144
|
+
* ## The handshake
|
|
2145
|
+
*
|
|
2146
|
+
* Fixed by the SFU's `dispatch`, and worth stating because the ordering is not obvious:
|
|
2147
|
+
*
|
|
2148
|
+
* join -> joined (both transports' parameters, the router's capabilities, current peers)
|
|
2149
|
+
* connect -> connected (per transport, fired lazily by mediasoup's own 'connect' event)
|
|
2150
|
+
* produce -> produced (our microphone; the SFU then tells peers via `peer-joined`)
|
|
2151
|
+
* consume -> consumed (per remote producer; the consumer starts PAUSED)
|
|
2152
|
+
* resume (no reply — the SFU acknowledges by letting packets flow)
|
|
2153
|
+
*
|
|
2154
|
+
* Consumers starting paused is mediasoup's documented sequence and the reason `resume` is a
|
|
2155
|
+
* separate message: the track is attached to its `<audio>` element before the first RTP packet
|
|
2156
|
+
* arrives, rather than racing it.
|
|
2157
|
+
*
|
|
2158
|
+
* Replies carry no request id, because the SFU's protocol has none. Correlation is therefore by
|
|
2159
|
+
* shape: `connected` by `transportId`, `consumed` by `producerId`, `joined` and `produced` by
|
|
2160
|
+
* being the only one outstanding. An `error` has nothing to correlate on at all, so it rejects
|
|
2161
|
+
* every outstanding waiter — which is what makes `E_VOICE_UNAVAILABLE` (a tenant with no SFU
|
|
2162
|
+
* configured, which is every box until O16 answers) come back as a rejected `joinVoice` rather
|
|
2163
|
+
* than a promise that hangs forever.
|
|
2164
|
+
*/
|
|
2165
|
+
|
|
2166
|
+
/**
|
|
2167
|
+
* What is known about one other participant: what *they* did to their microphone, and what *you*
|
|
2168
|
+
* did to their playback on this machine. The two are deliberately separate fields rather than one
|
|
2169
|
+
* "silent" flag, because a UI that conflated them would tell a player they had muted somebody when
|
|
2170
|
+
* in fact that person had stopped talking.
|
|
2171
|
+
*/
|
|
2172
|
+
interface VoicePeerState {
|
|
2173
|
+
/** The peer's own microphone mute, as last reported by the SFU. Not local. */
|
|
2174
|
+
readonly muted: boolean;
|
|
2175
|
+
/** This machine's local silencing of that peer. Never signalled, never seen by anyone else. */
|
|
2176
|
+
readonly mutedLocally: boolean;
|
|
2177
|
+
/** This machine's local playback volume for that peer, 0..1. */
|
|
2178
|
+
readonly volume: number;
|
|
2179
|
+
}
|
|
2180
|
+
interface VoiceHandle {
|
|
2181
|
+
/**
|
|
2182
|
+
* Mute or unmute the microphone. Signalled to the SFU rather than only stopped locally: the SFU
|
|
2183
|
+
* pauses the producer, which is what actually stops the bytes leaving the box, and an SFU that
|
|
2184
|
+
* merely saw a silent stream could not tell a muted participant from a quiet room.
|
|
2185
|
+
*/
|
|
2186
|
+
mute(muted: boolean): void;
|
|
2187
|
+
readonly muted: boolean;
|
|
2188
|
+
/** The other participants currently in this room's voice call, by room client id. */
|
|
2189
|
+
readonly peers: readonly string[];
|
|
2190
|
+
/**
|
|
2191
|
+
* Local playback volume for one peer, 0..1 (clamped). Purely local: nothing is signalled, the
|
|
2192
|
+
* peer is not told, and every other participant hears them unchanged. Remembered for the life of
|
|
2193
|
+
* the handle, so setting it for a peer whose track has not arrived yet still takes effect when
|
|
2194
|
+
* it does.
|
|
2195
|
+
*/
|
|
2196
|
+
setPeerVolume(peerId: string, volume: number): void;
|
|
2197
|
+
/**
|
|
2198
|
+
* Locally silence one peer. Distinct from the `peer-muted` event, which reports that peer's own
|
|
2199
|
+
* microphone: this one is yours, it is not signalled, and the two are reported separately by
|
|
2200
|
+
* `peerState`.
|
|
2201
|
+
*/
|
|
2202
|
+
mutePeer(peerId: string, muted: boolean): void;
|
|
2203
|
+
/** The current state of one peer, or `undefined` if nobody by that id is in the call. */
|
|
2204
|
+
peerState(peerId: string): VoicePeerState | undefined;
|
|
2205
|
+
leave(): Promise<void>;
|
|
2206
|
+
on(event: 'peer-joined' | 'peer-left' | 'peer-muted' | 'peers-changed' | 'error', cb: (v: never) => void): () => void;
|
|
2207
|
+
}
|
|
2208
|
+
interface VoiceOptions {
|
|
2209
|
+
/**
|
|
2210
|
+
* **Not implemented in this release.** Accepted so that calling code compiles and runs, ignored
|
|
2211
|
+
* at runtime; passing `true` logs one warning and produces ordinary flat audio. Kept in the type
|
|
2212
|
+
* so that shipping it later is not a breaking change.
|
|
2213
|
+
*/
|
|
2214
|
+
readonly positional?: boolean;
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* The seam `@irtio/voice-ui` reads to draw a speaking indicator, and the only thing about a voice
|
|
2218
|
+
* call that is not on the public `VoiceHandle`.
|
|
2219
|
+
*
|
|
2220
|
+
* It is a *registered* symbol rather than a module-local one (unlike `INTERNAL_SESSION`, which
|
|
2221
|
+
* never leaves this package) because the reader is a different package: two copies of
|
|
2222
|
+
* `@irtio/client` in one bundle would otherwise mint two different symbols and the panel would
|
|
2223
|
+
* silently find nothing. Registered means "internal by convention, reachable across a package
|
|
2224
|
+
* boundary", which is exactly the contract here.
|
|
2225
|
+
*
|
|
2226
|
+
* It is not public API. The shape may change in any release; `@irtio/voice-ui` treats every field
|
|
2227
|
+
* as optional and degrades to no indicator when it is absent.
|
|
2228
|
+
*/
|
|
2229
|
+
declare const INTERNAL_VOICE_TRACKS: unique symbol;
|
|
2230
|
+
/** What lives behind {@link INTERNAL_VOICE_TRACKS}. */
|
|
2231
|
+
interface VoiceTrackAccess {
|
|
2232
|
+
/** The remote track being played for one peer, if one has been attached. */
|
|
2233
|
+
peerTrack(peerId: string): MediaStreamTrack | undefined;
|
|
2234
|
+
/** This client's own microphone track, for a self speaking indicator. */
|
|
2235
|
+
micTrack(): MediaStreamTrack | undefined;
|
|
2236
|
+
}
|
|
2237
|
+
/**
|
|
2238
|
+
* Joins the voice call for a room this client is already in.
|
|
2239
|
+
*
|
|
2240
|
+
* Requires a microphone permission (the browser prompts) and a tenant with an SFU configured; a
|
|
2241
|
+
* tenant without one rejects with `E_VOICE_UNAVAILABLE` rather than hanging.
|
|
2242
|
+
*
|
|
2243
|
+
* `options.positional` is accepted and ignored — positional audio is not in this release. See the
|
|
2244
|
+
* module doc.
|
|
2245
|
+
*/
|
|
2246
|
+
declare function joinVoice<S extends AnySchema, R extends string>(room: Room<S, R>, options?: VoiceOptions): Promise<VoiceHandle>;
|
|
2247
|
+
|
|
1236
2248
|
/**
|
|
1237
2249
|
* `@irtio/client` — the browser/Node SDK.
|
|
1238
2250
|
*
|
|
@@ -1269,4 +2281,4 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
|
|
|
1269
2281
|
*/
|
|
1270
2282
|
declare function joinRelay(options?: JoinRelayOptions): Promise<RelayRoom>;
|
|
1271
2283
|
|
|
1272
|
-
export { CALL_TIMEOUT_MS, type ClientBodySpec, type ClientCollection, type ClientPhysicsOptions, type ClientRapierBody, type ClientRapierModule, type ClientRapierWorld, type ClientState, ClientStore, type ClientVector3, type Correction, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, type FrameHook, type JoinOptions, type JoinRelayOptions, MAX_PREDICTED_BODIES, type MessageTarget, PING_INTERVAL_MS, PREDICTION_EPSILON, type PredictionStats, type PredictionStatus, REGION_RE, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, SMOOTHING_HALF_LIFE_MS, SMOOTHING_SNAP_UNITS, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, defaultScheduler, joinRelay, joinRoom, linkForUrl, resolveUrl, roomIdFrom, webSocketTransport };
|
|
2284
|
+
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, 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 RoomProfile, 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 };
|