@irtio/client 0.1.0 → 0.3.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 +568 -0
- package/dist/index.d.ts +828 -137
- package/dist/index.js +450 -478
- package/dist/physics-H2VDQLAU.js +1059 -0
- package/package.json +14 -3
package/dist/index.js
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClientStore,
|
|
3
|
+
E_CONNECT_FAILED,
|
|
4
|
+
MAX_PREDICTED_BODIES,
|
|
5
|
+
PREDICTION_EPSILON,
|
|
6
|
+
RESIM_DEPTH,
|
|
7
|
+
SMOOTHING_HALF_LIFE_MS,
|
|
8
|
+
SMOOTHING_SNAP_UNITS,
|
|
9
|
+
emptyPredictionStats
|
|
10
|
+
} from "./chunk-7UTJ7RSF.js";
|
|
11
|
+
|
|
1
12
|
// src/endpoint.ts
|
|
2
13
|
var DEFAULT_REGION = "eu";
|
|
3
14
|
var DEV_PORT = 7070;
|
|
@@ -52,15 +63,19 @@ function roomIdFromLocation() {
|
|
|
52
63
|
return "";
|
|
53
64
|
}
|
|
54
65
|
}
|
|
66
|
+
var PER_CLIENT_PARAMS = ["role"];
|
|
55
67
|
function publishRoomToLocation(roomId) {
|
|
56
68
|
const loc = currentLocation();
|
|
57
69
|
const history = globalThis.history;
|
|
58
70
|
if (!loc) return void 0;
|
|
59
71
|
let href;
|
|
72
|
+
let share;
|
|
60
73
|
try {
|
|
61
74
|
const url = new URL(loc.href);
|
|
62
75
|
url.searchParams.set("room", roomId);
|
|
63
76
|
href = url.href;
|
|
77
|
+
for (const param of PER_CLIENT_PARAMS) url.searchParams.delete(param);
|
|
78
|
+
share = url.href;
|
|
64
79
|
} catch {
|
|
65
80
|
return void 0;
|
|
66
81
|
}
|
|
@@ -70,7 +85,7 @@ function publishRoomToLocation(roomId) {
|
|
|
70
85
|
} catch {
|
|
71
86
|
}
|
|
72
87
|
}
|
|
73
|
-
return
|
|
88
|
+
return share;
|
|
74
89
|
}
|
|
75
90
|
function linkForUrl(wsUrl, roomId) {
|
|
76
91
|
try {
|
|
@@ -105,6 +120,7 @@ import {
|
|
|
105
120
|
encodeReply,
|
|
106
121
|
errorByCode,
|
|
107
122
|
formatError,
|
|
123
|
+
readCorrectAppliedTick,
|
|
108
124
|
readCorrectClientTick,
|
|
109
125
|
relaySchema,
|
|
110
126
|
rpcTable,
|
|
@@ -118,513 +134,292 @@ import {
|
|
|
118
134
|
encodeFields
|
|
119
135
|
} from "@irtio/schema";
|
|
120
136
|
|
|
121
|
-
// src/
|
|
122
|
-
var
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
})).bind(globalThis);
|
|
131
|
-
return { request, cancel };
|
|
137
|
+
// src/render.ts
|
|
138
|
+
var NUMERIC = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32", "f32", "f64"]);
|
|
139
|
+
var INTEGER = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32"]);
|
|
140
|
+
function cloneRecord(value) {
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const [k, v] of Object.entries(value)) {
|
|
143
|
+
out[k] = Array.isArray(v) ? [...v] : typeof v === "object" && v !== null ? cloneRecord(v) : v;
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
132
146
|
}
|
|
133
|
-
function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
};
|
|
143
|
-
if (!raf) return base;
|
|
144
|
-
return {
|
|
145
|
-
...base,
|
|
146
|
-
frame(fn) {
|
|
147
|
-
const handle = raf.request(() => fn());
|
|
148
|
-
return () => raf.cancel(handle);
|
|
147
|
+
function lerpValue(desc, a, b, alpha) {
|
|
148
|
+
if (NUMERIC.has(desc.kind) && typeof a === "number" && typeof b === "number") {
|
|
149
|
+
const v = a + (b - a) * alpha;
|
|
150
|
+
return INTEGER.has(desc.kind) ? Math.round(v) : v;
|
|
151
|
+
}
|
|
152
|
+
if (desc.kind === "struct" && typeof a === "object" && a !== null && typeof b === "object" && b !== null && !Array.isArray(a) && !Array.isArray(b)) {
|
|
153
|
+
const out = {};
|
|
154
|
+
for (const [name, sub] of Object.entries(desc.fields)) {
|
|
155
|
+
out[name] = lerpValue(sub, a[name], b[name], alpha);
|
|
149
156
|
}
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// src/state.ts
|
|
154
|
-
import {
|
|
155
|
-
applyDelta,
|
|
156
|
-
collectionDirty,
|
|
157
|
-
createDirtySet,
|
|
158
|
-
createFieldMask,
|
|
159
|
-
createState,
|
|
160
|
-
decodeSnapshot,
|
|
161
|
-
encodeDelta,
|
|
162
|
-
frozenProxy,
|
|
163
|
-
isDirtyEmpty,
|
|
164
|
-
track
|
|
165
|
-
} from "@irtio/schema";
|
|
166
|
-
var RESIM_DEPTH = 20;
|
|
167
|
-
function entityOf(state, name) {
|
|
168
|
-
return state[name];
|
|
169
|
-
}
|
|
170
|
-
function subtractMask(dst, src) {
|
|
171
|
-
for (const idx of src.fields) {
|
|
172
|
-
const srcNested = src.nested.get(idx);
|
|
173
|
-
const dstNested = dst.nested.get(idx);
|
|
174
|
-
if (srcNested && dstNested) {
|
|
175
|
-
subtractMask(dstNested, srcNested);
|
|
176
|
-
if (dstNested.fields.size === 0) {
|
|
177
|
-
dst.fields.delete(idx);
|
|
178
|
-
dst.nested.delete(idx);
|
|
179
|
-
}
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
dst.fields.delete(idx);
|
|
183
|
-
dst.nested.delete(idx);
|
|
157
|
+
return out;
|
|
184
158
|
}
|
|
159
|
+
return Array.isArray(a) ? [...a] : a;
|
|
185
160
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
store;
|
|
192
|
-
desc;
|
|
193
|
-
get plain() {
|
|
194
|
-
return entityOf(this.store.plain, this.desc.name);
|
|
195
|
-
}
|
|
196
|
-
get(id) {
|
|
197
|
-
return this.store.instance(this.desc, id);
|
|
198
|
-
}
|
|
199
|
-
has(id) {
|
|
200
|
-
return this.plain.has(id);
|
|
201
|
-
}
|
|
202
|
-
ownerOf(id) {
|
|
203
|
-
return this.plain.ownerOf(id);
|
|
204
|
-
}
|
|
205
|
-
get size() {
|
|
206
|
-
return this.plain.size;
|
|
207
|
-
}
|
|
208
|
-
ids() {
|
|
209
|
-
return this.plain.ids();
|
|
210
|
-
}
|
|
211
|
-
*[Symbol.iterator]() {
|
|
212
|
-
for (const id of [...this.plain.ids()]) {
|
|
213
|
-
yield [id, this.store.instance(this.desc, id)];
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
};
|
|
217
|
-
function withIndexSugar(collection) {
|
|
218
|
-
return new Proxy(collection, {
|
|
219
|
-
get(target, prop, receiver) {
|
|
220
|
-
if (typeof prop !== "string" || prop in target) return Reflect.get(target, prop, receiver);
|
|
221
|
-
return target.get(prop);
|
|
222
|
-
},
|
|
223
|
-
has(target, prop) {
|
|
224
|
-
if (typeof prop !== "string" || prop in target) return Reflect.has(target, prop);
|
|
225
|
-
return target.has(prop);
|
|
226
|
-
}
|
|
227
|
-
});
|
|
161
|
+
function lerpRecord(desc, a, b, alpha) {
|
|
162
|
+
const out = {};
|
|
163
|
+
for (const f of desc.fields) out[f.name] = lerpValue(f.type, a[f.name], b[f.name], alpha);
|
|
164
|
+
return out;
|
|
228
165
|
}
|
|
229
|
-
var
|
|
230
|
-
constructor(ext, meOf) {
|
|
166
|
+
var RenderStore = class {
|
|
167
|
+
constructor(ext, store, meOf, now, delayMs) {
|
|
231
168
|
this.ext = ext;
|
|
169
|
+
this.store = store;
|
|
232
170
|
this.meOf = meOf;
|
|
171
|
+
this.now = now;
|
|
172
|
+
this.delayMs = delayMs;
|
|
233
173
|
for (const c of ext.collections) this.descs.set(c.name, c);
|
|
234
|
-
this.plain = createState(ext);
|
|
235
|
-
this.tracked = track(ext, this.plain);
|
|
236
174
|
this.view = this.buildView();
|
|
237
175
|
}
|
|
238
176
|
ext;
|
|
177
|
+
store;
|
|
239
178
|
meOf;
|
|
240
|
-
|
|
241
|
-
|
|
179
|
+
now;
|
|
180
|
+
delayMs;
|
|
181
|
+
/** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
|
|
182
|
+
buffers = /* @__PURE__ */ new Map();
|
|
242
183
|
descs = /* @__PURE__ */ new Map();
|
|
243
|
-
|
|
244
|
-
/** The object handed out as `room.state`; identity survives a resync. */
|
|
184
|
+
/** The object handed out as `room.render`; identity survives a resync. */
|
|
245
185
|
view;
|
|
246
|
-
/**
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
|
|
250
|
-
// --
|
|
251
|
-
loadSnapshot(bytes) {
|
|
252
|
-
this.plain = decodeSnapshot(this.ext, bytes).state;
|
|
253
|
-
this.tracked = track(this.ext, this.plain);
|
|
254
|
-
this.pendingWrites.length = 0;
|
|
255
|
-
this.evictedThroughTick = 0;
|
|
256
|
-
}
|
|
186
|
+
/** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
|
|
187
|
+
starved = 0;
|
|
188
|
+
/** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
|
|
189
|
+
predictor;
|
|
190
|
+
// -- ingest ---------------------------------------------------------------
|
|
257
191
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
192
|
+
* Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
|
|
193
|
+
* the world is visible immediately (the join snapshot is the render baseline, not a change to
|
|
194
|
+
* ease towards).
|
|
261
195
|
*/
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const patch = {};
|
|
274
|
-
for (const idx of rd.mask.fields) {
|
|
275
|
-
const fname = desc.fields[idx]?.name;
|
|
276
|
-
if (fname !== void 0) patch[fname] = value[fname];
|
|
277
|
-
}
|
|
278
|
-
let byId = out.get(name);
|
|
279
|
-
if (!byId) {
|
|
280
|
-
byId = /* @__PURE__ */ new Map();
|
|
281
|
-
out.set(name, byId);
|
|
282
|
-
}
|
|
283
|
-
byId.set(id, patch);
|
|
196
|
+
seedSnapshot() {
|
|
197
|
+
this.buffers.clear();
|
|
198
|
+
const t = this.now() - this.delayMs();
|
|
199
|
+
for (const c of this.ext.collections) {
|
|
200
|
+
if (c.kind !== "entity" || !c.interpolate) continue;
|
|
201
|
+
const coll = this.store.plain[c.name];
|
|
202
|
+
for (const id of [...coll.ids()]) {
|
|
203
|
+
this.bufferFor(c.name, id).frames.push({
|
|
204
|
+
t,
|
|
205
|
+
value: cloneRecord(coll.get(id))
|
|
206
|
+
});
|
|
284
207
|
}
|
|
285
208
|
}
|
|
286
|
-
return out;
|
|
287
209
|
}
|
|
288
|
-
/**
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (!coll.has(id) || coll.ownerOf(id) !== me) continue;
|
|
301
|
-
const rec = coll.get(id);
|
|
302
|
-
for (const [field, value] of Object.entries(patch)) {
|
|
303
|
-
rec[field] = Array.isArray(value) ? [...value] : value;
|
|
210
|
+
/** Buffers every entity a `DELTA` touched, stamped with its arrival time. Call after apply. */
|
|
211
|
+
recordDelta(delta) {
|
|
212
|
+
const t = this.now();
|
|
213
|
+
for (const dc of delta.collections) {
|
|
214
|
+
const desc = this.descs.get(dc.name);
|
|
215
|
+
if (!desc || desc.kind !== "entity" || !desc.interpolate) continue;
|
|
216
|
+
const coll = this.store.plain[dc.name];
|
|
217
|
+
for (const op of dc.ops) {
|
|
218
|
+
if (op.op === "remove") {
|
|
219
|
+
const buffer2 = this.buffers.get(dc.name)?.get(op.id);
|
|
220
|
+
if (buffer2 && buffer2.removedAt === void 0) buffer2.removedAt = t;
|
|
221
|
+
continue;
|
|
304
222
|
}
|
|
223
|
+
const value = coll.get(op.id);
|
|
224
|
+
if (value === void 0) continue;
|
|
225
|
+
const buffer = this.bufferFor(dc.name, op.id);
|
|
226
|
+
buffer.removedAt = void 0;
|
|
227
|
+
buffer.frames.push({ t, value: cloneRecord(value) });
|
|
305
228
|
}
|
|
306
229
|
}
|
|
307
|
-
this.remarkOwned();
|
|
308
|
-
}
|
|
309
|
-
buildView() {
|
|
310
|
-
const view = {};
|
|
311
|
-
for (const c of this.ext.collections) {
|
|
312
|
-
if (c.kind === "entity") {
|
|
313
|
-
const facade = withIndexSugar(new ClientCollectionImpl(this, c));
|
|
314
|
-
Object.defineProperty(view, c.name, { get: () => facade, enumerable: true });
|
|
315
|
-
} else {
|
|
316
|
-
Object.defineProperty(view, c.name, {
|
|
317
|
-
get: () => this.freeze(this.plain[c.name], this.singletonHint(c)),
|
|
318
|
-
enumerable: true
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
return view;
|
|
323
230
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (value === void 0) return void 0;
|
|
330
|
-
if (!desc.serverOwned && coll.ownerOf(id) === this.meOf()) {
|
|
331
|
-
const trackedColl = this.tracked.state[desc.name];
|
|
332
|
-
return trackedColl ? trackedColl.get(id) : value;
|
|
333
|
-
}
|
|
334
|
-
return this.freeze(value, this.entityHint(desc, id, coll.ownerOf(id)));
|
|
335
|
-
}
|
|
336
|
-
freeze(value, hint) {
|
|
337
|
-
const cached = this.frozen.get(value);
|
|
338
|
-
if (cached !== void 0) return cached;
|
|
339
|
-
const proxy = frozenProxy(value, hint);
|
|
340
|
-
this.frozen.set(value, proxy);
|
|
341
|
-
return proxy;
|
|
342
|
-
}
|
|
343
|
-
entityHint(desc, id, owner) {
|
|
344
|
-
if (desc.serverOwned) {
|
|
345
|
-
return `${desc.name} is server-owned; change it with an RPC (room.call.\u2026)`;
|
|
231
|
+
bufferFor(collection, id) {
|
|
232
|
+
let byId = this.buffers.get(collection);
|
|
233
|
+
if (!byId) {
|
|
234
|
+
byId = /* @__PURE__ */ new Map();
|
|
235
|
+
this.buffers.set(collection, byId);
|
|
346
236
|
}
|
|
347
|
-
|
|
348
|
-
if (
|
|
349
|
-
|
|
237
|
+
let buffer = byId.get(id);
|
|
238
|
+
if (!buffer) {
|
|
239
|
+
buffer = { frames: [], removedAt: void 0 };
|
|
240
|
+
byId.set(id, buffer);
|
|
350
241
|
}
|
|
351
|
-
return
|
|
242
|
+
return buffer;
|
|
352
243
|
}
|
|
353
|
-
|
|
354
|
-
|
|
244
|
+
// -- read -----------------------------------------------------------------
|
|
245
|
+
renderTime() {
|
|
246
|
+
return this.now() - this.delayMs();
|
|
355
247
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
* Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
|
|
359
|
-
* (server wins only through `CORRECT`); adds, removes and owner changes always apply.
|
|
360
|
-
*/
|
|
361
|
-
applyServerDelta(delta) {
|
|
362
|
-
applyDelta(this.ext, this.plain, this.withoutOwnedUpdates(delta));
|
|
363
|
-
this.replayOverAddEchoes(delta);
|
|
248
|
+
attachPredictor(predictor) {
|
|
249
|
+
this.predictor = predictor;
|
|
364
250
|
}
|
|
365
|
-
/**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const me = this.meOf();
|
|
374
|
-
if (me === "" || this.pendingWrites.length === 0) return;
|
|
375
|
-
for (const dc of delta.collections) {
|
|
376
|
-
const desc = this.desc(dc.name);
|
|
377
|
-
if (!desc || desc.kind !== "entity" || desc.serverOwned) continue;
|
|
378
|
-
for (const op of dc.ops) {
|
|
379
|
-
if (op.op !== "add" || op.owner !== me) continue;
|
|
380
|
-
for (const pw of this.pendingWrites) this.replayWrite(pw, dc.name, op.id);
|
|
251
|
+
/** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
|
|
252
|
+
get(desc, id) {
|
|
253
|
+
const coll = this.store.plain[desc.name];
|
|
254
|
+
if (this.predictor && desc.physics) {
|
|
255
|
+
this.predictor.frame(this.now());
|
|
256
|
+
if (this.predictor.has(desc.name, id)) {
|
|
257
|
+
const value = coll.get(id);
|
|
258
|
+
if (value !== void 0) return this.predictor.read(desc, id, { ...value });
|
|
381
259
|
}
|
|
382
260
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const me = this.meOf();
|
|
386
|
-
let changed = false;
|
|
387
|
-
const collections = [];
|
|
388
|
-
for (const dc of delta.collections) {
|
|
389
|
-
const desc = this.desc(dc.name);
|
|
390
|
-
if (!desc || desc.kind !== "entity" || desc.serverOwned) {
|
|
391
|
-
collections.push(dc);
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
const coll = entityOf(this.plain, dc.name);
|
|
395
|
-
const ops = [];
|
|
396
|
-
for (const op of dc.ops) {
|
|
397
|
-
if (op.op === "add") {
|
|
398
|
-
const rewritten = this.preserveLocalWrites(desc, coll, op, me);
|
|
399
|
-
if (rewritten !== op) changed = true;
|
|
400
|
-
ops.push(rewritten);
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
if (op.op !== "update") {
|
|
404
|
-
ops.push(op);
|
|
405
|
-
continue;
|
|
406
|
-
}
|
|
407
|
-
const owner = op.owner ?? coll.ownerOf(op.id);
|
|
408
|
-
if (owner !== me) {
|
|
409
|
-
ops.push(op);
|
|
410
|
-
continue;
|
|
411
|
-
}
|
|
412
|
-
changed = true;
|
|
413
|
-
if (op.owner !== void 0) {
|
|
414
|
-
ops.push({
|
|
415
|
-
op: "update",
|
|
416
|
-
id: op.id,
|
|
417
|
-
owner: op.owner,
|
|
418
|
-
mask: createFieldMask(),
|
|
419
|
-
patch: {}
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
collections.push({ name: dc.name, ops });
|
|
261
|
+
if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
|
|
262
|
+
return this.store.instance(desc, id);
|
|
424
263
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
* back in. Returns `op` unchanged when there is nothing to preserve.
|
|
430
|
-
*/
|
|
431
|
-
preserveLocalWrites(desc, coll, op, me) {
|
|
432
|
-
if (me === "" || op.owner !== me) return op;
|
|
433
|
-
const pending = this.tracked.dirty.get(desc.name)?.updated.get(op.id);
|
|
434
|
-
if (!pending || pending.mask.fields.size === 0) return op;
|
|
435
|
-
const local = coll.get(op.id);
|
|
436
|
-
if (!local) return op;
|
|
437
|
-
const value = { ...op.value };
|
|
438
|
-
for (const idx of pending.mask.fields) {
|
|
439
|
-
const field = desc.fields[idx]?.name;
|
|
440
|
-
if (field !== void 0) value[field] = local[field];
|
|
264
|
+
if (!desc.interpolate) return this.store.instance(desc, id);
|
|
265
|
+
const buffer = this.buffers.get(desc.name)?.get(id);
|
|
266
|
+
if (!buffer || buffer.frames.length === 0) {
|
|
267
|
+
return this.store.instance(desc, id);
|
|
441
268
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
* write newer than the judged `clientTick` in order (D19 snap + replay) — flushed writes from
|
|
447
|
-
* the retained buffer first, the still-unflushed in-window edit last (it is the newest, and its
|
|
448
|
-
* dirty mark survives so the next flush re-sends the *local* value, not the server's).
|
|
449
|
-
*
|
|
450
|
-
* Without a `clientTick` (a pre-week-8 server) — or when the correction outran the
|
|
451
|
-
* `RESIM_DEPTH` window (`snapped`) — the pre-D19 semantics apply: the correction wins in full
|
|
452
|
-
* and the local dirty marks it supersedes are cleared.
|
|
453
|
-
*/
|
|
454
|
-
applyCorrection(delta, clientTick) {
|
|
455
|
-
if (clientTick !== void 0) {
|
|
456
|
-
while (this.pendingWrites.length > 0 && this.pendingWrites[0].tick <= clientTick) {
|
|
457
|
-
this.pendingWrites.shift();
|
|
458
|
-
}
|
|
269
|
+
const renderT = this.renderTime();
|
|
270
|
+
if (buffer.removedAt !== void 0 && renderT >= buffer.removedAt) {
|
|
271
|
+
this.gc(desc.name, id, buffer);
|
|
272
|
+
return void 0;
|
|
459
273
|
}
|
|
460
|
-
|
|
461
|
-
const
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (!desc) continue;
|
|
469
|
-
for (const op of dc.ops) {
|
|
470
|
-
if (op.op !== "update") continue;
|
|
471
|
-
const pending = this.tracked.dirty.get(dc.name)?.updated.get(op.id);
|
|
472
|
-
if (pending) {
|
|
473
|
-
if (replay) {
|
|
474
|
-
const coll = entityOf(this.plain, dc.name);
|
|
475
|
-
const value = coll.get(op.id);
|
|
476
|
-
if (value && coll.ownerOf(op.id) === me && pending.mask.fields.size > 0) {
|
|
477
|
-
const patch = {};
|
|
478
|
-
for (const idx of pending.mask.fields) {
|
|
479
|
-
const fname = desc.fields[idx]?.name;
|
|
480
|
-
if (fname !== void 0) patch[fname] = value[fname];
|
|
481
|
-
}
|
|
482
|
-
let byId = unflushed.writes.get(dc.name);
|
|
483
|
-
if (!byId) {
|
|
484
|
-
byId = /* @__PURE__ */ new Map();
|
|
485
|
-
unflushed.writes.set(dc.name, byId);
|
|
486
|
-
}
|
|
487
|
-
byId.set(op.id, patch);
|
|
488
|
-
}
|
|
489
|
-
} else {
|
|
490
|
-
subtractMask(pending.mask, op.mask);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
targets.push({ collection: dc.name, id: op.id });
|
|
494
|
-
out.push({
|
|
495
|
-
collection: dc.name,
|
|
496
|
-
id: op.id,
|
|
497
|
-
fields: [...op.mask.fields].map((i) => desc.fields[i]?.name).filter((n) => n !== void 0),
|
|
498
|
-
patch: op.patch,
|
|
499
|
-
tick: delta.tick,
|
|
500
|
-
...clientTick !== void 0 ? { clientTick } : {},
|
|
501
|
-
replayed: 0,
|
|
502
|
-
snapped
|
|
503
|
-
});
|
|
504
|
-
}
|
|
274
|
+
this.prune(buffer, renderT);
|
|
275
|
+
const frames = buffer.frames;
|
|
276
|
+
const a = frames[0];
|
|
277
|
+
if (renderT < a.t) return void 0;
|
|
278
|
+
const b = frames[1];
|
|
279
|
+
if (!b) {
|
|
280
|
+
if (renderT > a.t) this.starved++;
|
|
281
|
+
return cloneRecord(a.value);
|
|
505
282
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
283
|
+
const alpha = (renderT - a.t) / (b.t - a.t);
|
|
284
|
+
return lerpRecord(desc, a.value, b.value, Math.min(1, Math.max(0, alpha)));
|
|
285
|
+
}
|
|
286
|
+
/** Is `collection[id]` visible at the render clock? */
|
|
287
|
+
has(desc, id) {
|
|
288
|
+
const coll = this.store.plain[desc.name];
|
|
289
|
+
if (this.predictor && desc.physics && this.predictor.has(desc.name, id)) return coll.has(id);
|
|
290
|
+
if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) return true;
|
|
291
|
+
if (!desc.interpolate) return coll.has(id);
|
|
292
|
+
const buffer = this.buffers.get(desc.name)?.get(id);
|
|
293
|
+
if (!buffer || buffer.frames.length === 0) return coll.has(id);
|
|
294
|
+
const renderT = this.renderTime();
|
|
295
|
+
if (buffer.removedAt !== void 0 && renderT >= buffer.removedAt) return false;
|
|
296
|
+
return buffer.frames[0].t <= renderT;
|
|
297
|
+
}
|
|
298
|
+
/** The authoritative owner — ownership is fact, not a rendered value. */
|
|
299
|
+
ownerOf(desc, id) {
|
|
300
|
+
return this.store.plain[desc.name].ownerOf(id);
|
|
301
|
+
}
|
|
302
|
+
/** Ids visible at the render clock: owned (predicted) plus buffered-and-appeared. */
|
|
303
|
+
*ids(desc) {
|
|
304
|
+
const coll = this.store.plain[desc.name];
|
|
305
|
+
if (!desc.interpolate) {
|
|
306
|
+
yield* coll.ids();
|
|
307
|
+
return;
|
|
516
308
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
// -- outbound writes ------------------------------------------------------
|
|
523
|
-
/** Cheap check for the flush loop: has anything been written locally since the last flush? */
|
|
524
|
-
get hasLocalWrites() {
|
|
525
|
-
return !isDirtyEmpty(this.tracked.dirty);
|
|
526
|
-
}
|
|
527
|
-
/**
|
|
528
|
-
* The `WRITE` payload for everything dirty, or `undefined` when nothing qualifies. Consumes the
|
|
529
|
-
* dirty set either way (a write to an instance that has since been removed or handed away is
|
|
530
|
-
* dropped, not retried forever).
|
|
531
|
-
*/
|
|
532
|
-
takeWrite(tick) {
|
|
533
|
-
const dirty = this.tracked.flush();
|
|
534
|
-
const owned = this.ownedDirty(dirty);
|
|
535
|
-
if (owned.size === 0) return void 0;
|
|
536
|
-
this.retainPendingWrite(tick, owned);
|
|
537
|
-
return encodeDelta(this.ext, this.plain, owned, { tick });
|
|
538
|
-
}
|
|
539
|
-
/**
|
|
540
|
-
* Captures the field values a flushed `WRITE` carried, keyed by its write tick, so a later
|
|
541
|
-
* `CORRECT` can re-apply exactly the writes the server had not judged yet (D19 snap + replay).
|
|
542
|
-
* Bounded to `RESIM_DEPTH` entries; eviction is remembered so an outrun correction snaps.
|
|
543
|
-
*/
|
|
544
|
-
retainPendingWrite(tick, owned) {
|
|
545
|
-
const writes = /* @__PURE__ */ new Map();
|
|
546
|
-
for (const [name, cd] of owned) {
|
|
547
|
-
const desc = this.desc(name);
|
|
548
|
-
if (!desc) continue;
|
|
549
|
-
const coll = entityOf(this.plain, name);
|
|
550
|
-
for (const [id, rd] of cd.updated) {
|
|
551
|
-
const value = coll.get(id);
|
|
552
|
-
if (!value) continue;
|
|
553
|
-
const patch = {};
|
|
554
|
-
for (const idx of rd.mask.fields) {
|
|
555
|
-
const fname = desc.fields[idx]?.name;
|
|
556
|
-
if (fname === void 0) continue;
|
|
557
|
-
const v = value[fname];
|
|
558
|
-
patch[fname] = Array.isArray(v) ? [...v] : typeof v === "object" && v !== null ? { ...v } : v;
|
|
559
|
-
}
|
|
560
|
-
let byId = writes.get(name);
|
|
561
|
-
if (!byId) {
|
|
562
|
-
byId = /* @__PURE__ */ new Map();
|
|
563
|
-
writes.set(name, byId);
|
|
564
|
-
}
|
|
565
|
-
byId.set(id, patch);
|
|
309
|
+
const seen = /* @__PURE__ */ new Set();
|
|
310
|
+
for (const id of [...coll.ids()]) {
|
|
311
|
+
if (this.has(desc, id)) {
|
|
312
|
+
seen.add(id);
|
|
313
|
+
yield id;
|
|
566
314
|
}
|
|
567
315
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
if (evicted && evicted.tick > this.evictedThroughTick) this.evictedThroughTick = evicted.tick;
|
|
316
|
+
const byId = this.buffers.get(desc.name);
|
|
317
|
+
if (!byId) return;
|
|
318
|
+
for (const id of [...byId.keys()]) {
|
|
319
|
+
if (!seen.has(id) && !coll.has(id) && this.has(desc, id)) yield id;
|
|
573
320
|
}
|
|
574
321
|
}
|
|
575
|
-
/**
|
|
576
|
-
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
if (!coll.has(id) || coll.ownerOf(id) !== me) return false;
|
|
582
|
-
const rec = coll.get(id);
|
|
583
|
-
for (const [field, value] of Object.entries(patch)) {
|
|
584
|
-
rec[field] = Array.isArray(value) ? [...value] : typeof value === "object" && value !== null ? { ...value } : value;
|
|
585
|
-
}
|
|
586
|
-
return true;
|
|
322
|
+
/** Drops frames the render clock has passed, keeping the newest one at-or-before `renderT`. */
|
|
323
|
+
prune(buffer, renderT) {
|
|
324
|
+
const frames = buffer.frames;
|
|
325
|
+
let drop = 0;
|
|
326
|
+
while (drop + 1 < frames.length && frames[drop + 1].t <= renderT) drop++;
|
|
327
|
+
if (drop > 0) frames.splice(0, drop);
|
|
587
328
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
329
|
+
gc(collection, id, buffer) {
|
|
330
|
+
buffer.frames.length = 0;
|
|
331
|
+
const byId = this.buffers.get(collection);
|
|
332
|
+
byId?.delete(id);
|
|
333
|
+
if (byId && byId.size === 0) this.buffers.delete(collection);
|
|
334
|
+
}
|
|
335
|
+
// -- view -----------------------------------------------------------------
|
|
336
|
+
buildView() {
|
|
337
|
+
const view = {};
|
|
595
338
|
for (const c of this.ext.collections) {
|
|
596
|
-
if (c.kind
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
339
|
+
if (c.kind === "entity") {
|
|
340
|
+
const facade = withRenderIndexSugar(new RenderCollectionImpl(this, c));
|
|
341
|
+
Object.defineProperty(view, c.name, { get: () => facade, enumerable: true });
|
|
342
|
+
} else {
|
|
343
|
+
Object.defineProperty(view, c.name, {
|
|
344
|
+
get: () => this.store.view[c.name],
|
|
345
|
+
enumerable: true
|
|
346
|
+
});
|
|
604
347
|
}
|
|
605
348
|
}
|
|
349
|
+
return view;
|
|
606
350
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
351
|
+
};
|
|
352
|
+
var RenderCollectionImpl = class {
|
|
353
|
+
constructor(render, desc) {
|
|
354
|
+
this.render = render;
|
|
355
|
+
this.desc = desc;
|
|
356
|
+
}
|
|
357
|
+
render;
|
|
358
|
+
desc;
|
|
359
|
+
get(id) {
|
|
360
|
+
return this.render.get(this.desc, id);
|
|
361
|
+
}
|
|
362
|
+
has(id) {
|
|
363
|
+
return this.render.has(this.desc, id);
|
|
364
|
+
}
|
|
365
|
+
ownerOf(id) {
|
|
366
|
+
return this.render.ownerOf(this.desc, id);
|
|
367
|
+
}
|
|
368
|
+
get size() {
|
|
369
|
+
let n = 0;
|
|
370
|
+
for (const _ of this.render.ids(this.desc)) n++;
|
|
371
|
+
return n;
|
|
372
|
+
}
|
|
373
|
+
ids() {
|
|
374
|
+
return this.render.ids(this.desc);
|
|
375
|
+
}
|
|
376
|
+
*[Symbol.iterator]() {
|
|
377
|
+
for (const id of [...this.render.ids(this.desc)]) {
|
|
378
|
+
yield [id, this.render.get(this.desc, id)];
|
|
624
379
|
}
|
|
625
|
-
return out;
|
|
626
380
|
}
|
|
627
381
|
};
|
|
382
|
+
function withRenderIndexSugar(collection) {
|
|
383
|
+
return new Proxy(collection, {
|
|
384
|
+
get(target, prop, receiver) {
|
|
385
|
+
if (typeof prop !== "string" || prop in target) return Reflect.get(target, prop, receiver);
|
|
386
|
+
return target.get(prop);
|
|
387
|
+
},
|
|
388
|
+
has(target, prop) {
|
|
389
|
+
if (typeof prop !== "string" || prop in target) return Reflect.has(target, prop);
|
|
390
|
+
return target.has(prop);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/scheduler.ts
|
|
396
|
+
function rafPair() {
|
|
397
|
+
const g = globalThis;
|
|
398
|
+
if (typeof g.requestAnimationFrame !== "function") return void 0;
|
|
399
|
+
const request = g.requestAnimationFrame.bind(globalThis);
|
|
400
|
+
const cancel = (g.cancelAnimationFrame ?? (() => {
|
|
401
|
+
})).bind(globalThis);
|
|
402
|
+
return { request, cancel };
|
|
403
|
+
}
|
|
404
|
+
function defaultScheduler() {
|
|
405
|
+
const raf = rafPair();
|
|
406
|
+
const base = {
|
|
407
|
+
now: () => Date.now(),
|
|
408
|
+
setTimeout(fn, ms) {
|
|
409
|
+
const handle = setTimeout(fn, ms);
|
|
410
|
+
handle.unref?.();
|
|
411
|
+
return () => clearTimeout(handle);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
if (!raf) return base;
|
|
415
|
+
return {
|
|
416
|
+
...base,
|
|
417
|
+
frame(fn) {
|
|
418
|
+
const handle = raf.request(() => fn());
|
|
419
|
+
return () => raf.cancel(handle);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}
|
|
628
423
|
|
|
629
424
|
// src/transport.ts
|
|
630
425
|
function globalWebSocket() {
|
|
@@ -691,7 +486,8 @@ var webSocketTransport = {
|
|
|
691
486
|
|
|
692
487
|
// src/session.ts
|
|
693
488
|
var DEFAULT_WRITE_INTERVAL_MS = 50;
|
|
694
|
-
var PING_INTERVAL_MS =
|
|
489
|
+
var PING_INTERVAL_MS = 2e3;
|
|
490
|
+
var RTT_ALPHA = 0.3;
|
|
695
491
|
var CALL_TIMEOUT_MS = 1e4;
|
|
696
492
|
var BACKOFF_START_MS = 250;
|
|
697
493
|
var BACKOFF_MAX_MS = 5e3;
|
|
@@ -717,10 +513,49 @@ var Session = class {
|
|
|
717
513
|
this.roomId = options.roomId;
|
|
718
514
|
this.role = options.role ?? "";
|
|
719
515
|
this.store = new ClientStore(this.ext, () => this.me);
|
|
516
|
+
this.render = new RenderStore(
|
|
517
|
+
this.ext,
|
|
518
|
+
this.store,
|
|
519
|
+
() => this.me,
|
|
520
|
+
() => this.scheduler.now(),
|
|
521
|
+
() => this.interpDelayMs
|
|
522
|
+
);
|
|
523
|
+
const hasPhysics = this.ext.collections.some(
|
|
524
|
+
(c) => c.physics !== void 0
|
|
525
|
+
);
|
|
526
|
+
if (options.physics && hasPhysics) {
|
|
527
|
+
const physics = options.physics;
|
|
528
|
+
this.predictionRequested = true;
|
|
529
|
+
void import("./physics-H2VDQLAU.js").then(({ PhysicsPredictor }) => {
|
|
530
|
+
if (this.left) return;
|
|
531
|
+
const predictor = new PhysicsPredictor(
|
|
532
|
+
this.ext,
|
|
533
|
+
this.store,
|
|
534
|
+
physics,
|
|
535
|
+
() => this.me,
|
|
536
|
+
() => this.rtt,
|
|
537
|
+
() => this.tickIntervalMs
|
|
538
|
+
);
|
|
539
|
+
this.predictor = predictor;
|
|
540
|
+
this.render.attachPredictor(predictor);
|
|
541
|
+
if (this.joined) {
|
|
542
|
+
predictor.reset();
|
|
543
|
+
void predictor.start();
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
720
547
|
}
|
|
721
548
|
options;
|
|
722
549
|
ext;
|
|
723
550
|
store;
|
|
551
|
+
render;
|
|
552
|
+
/** `true` when the join passed `physics` and the schema has body-backed collections. */
|
|
553
|
+
predictionRequested = false;
|
|
554
|
+
/**
|
|
555
|
+
* Present once `./physics.js` has loaded (dynamic import — the predictor code, like the
|
|
556
|
+
* engine, costs a non-physics game zero bytes). Reads fall back to interpolation until then.
|
|
557
|
+
*/
|
|
558
|
+
predictor;
|
|
724
559
|
/** The schema the `CALL`/`REPLY` rpc id space indexes into. */
|
|
725
560
|
rpcSchema;
|
|
726
561
|
transport;
|
|
@@ -732,9 +567,11 @@ var Session = class {
|
|
|
732
567
|
roomId;
|
|
733
568
|
tick = 0;
|
|
734
569
|
/**
|
|
735
|
-
* The
|
|
570
|
+
* The stamp of the newest flushed `WRITE`, carried in its delta header (D19). With physics
|
|
571
|
+
* prediction live this is the predictor's head tick + 1 — the server's tick clock, which is
|
|
572
|
+
* what lets the rebase replay pending writes by tick — and otherwise a bare counter.
|
|
736
573
|
* Monotonic for the life of the session, across reconnects — the server's `lastClientTick`
|
|
737
|
-
* for this client survives the grace window too.
|
|
574
|
+
* for this client survives the grace window too, and only ever moves forward.
|
|
738
575
|
*/
|
|
739
576
|
writeTick = 0;
|
|
740
577
|
/** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
|
|
@@ -742,6 +579,19 @@ var Session = class {
|
|
|
742
579
|
rtt = 0;
|
|
743
580
|
status = "connecting";
|
|
744
581
|
socket;
|
|
582
|
+
/**
|
|
583
|
+
* Whether `socket` has actually opened. `TransportSocket` deliberately has no `readyState` —
|
|
584
|
+
* `@irtio/testing` injects an in-process pair — so the session tracks it from `onopen`.
|
|
585
|
+
*
|
|
586
|
+
* Needed because a reconnecting session holds a socket that exists and is *not* sendable:
|
|
587
|
+
* `onSocketClosed` clears `socket` but keeps `joined` true (that is what makes resume work), and
|
|
588
|
+
* `connect()` then assigns a fresh socket that is still CONNECTING. Every send guard here used to
|
|
589
|
+
* test `this.socket` for presence alone, so the ping timer and the write flush would both reach a
|
|
590
|
+
* connecting socket and throw `InvalidStateError: Sent before connected.` out of a timer callback,
|
|
591
|
+
* where nothing catches it. Found on staging under 40+ bots, where joins are slow enough for the
|
|
592
|
+
* window to be wide; it never opens against a local dev server.
|
|
593
|
+
*/
|
|
594
|
+
socketOpen = false;
|
|
745
595
|
resumeToken;
|
|
746
596
|
joined = false;
|
|
747
597
|
left = false;
|
|
@@ -756,6 +606,14 @@ var Session = class {
|
|
|
756
606
|
linkOverride;
|
|
757
607
|
settleJoin;
|
|
758
608
|
failJoin;
|
|
609
|
+
/**
|
|
610
|
+
* The `room.render` delay (D20): the explicit option, else twice the room's tick interval
|
|
611
|
+
* (from `WELCOME`), floored at 50 ms — also the fallback when the interval is unknown.
|
|
612
|
+
*/
|
|
613
|
+
get interpDelayMs() {
|
|
614
|
+
if (this.options.interpDelayMs !== void 0) return this.options.interpDelayMs;
|
|
615
|
+
return Math.max(50, 2 * this.tickIntervalMs);
|
|
616
|
+
}
|
|
759
617
|
// -------------------------------------------------------------------------
|
|
760
618
|
// Lifecycle
|
|
761
619
|
// -------------------------------------------------------------------------
|
|
@@ -779,8 +637,11 @@ var Session = class {
|
|
|
779
637
|
return;
|
|
780
638
|
}
|
|
781
639
|
this.socket = socket;
|
|
640
|
+
this.socketOpen = false;
|
|
782
641
|
socket.onopen = () => {
|
|
783
|
-
if (this.socket
|
|
642
|
+
if (this.socket !== socket) return;
|
|
643
|
+
this.socketOpen = true;
|
|
644
|
+
void this.sendHello(socket);
|
|
784
645
|
};
|
|
785
646
|
socket.onmessage = (bytes) => {
|
|
786
647
|
if (this.socket === socket) this.onFrame(bytes);
|
|
@@ -805,11 +666,24 @@ var Session = class {
|
|
|
805
666
|
}
|
|
806
667
|
this.emit("error", { code: "E_INTERNAL", message, fatal: false });
|
|
807
668
|
}
|
|
808
|
-
sendHello() {
|
|
669
|
+
async sendHello(socket) {
|
|
670
|
+
let token;
|
|
671
|
+
const supplied = this.options.token;
|
|
672
|
+
if (supplied !== void 0) {
|
|
673
|
+
try {
|
|
674
|
+
token = typeof supplied === "function" ? await supplied() : supplied;
|
|
675
|
+
} catch (err) {
|
|
676
|
+
this.transportFailed(
|
|
677
|
+
err instanceof Error ? err : new Error(`token provider failed: ${String(err)}`)
|
|
678
|
+
);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (this.socket !== socket || !this.socketOpen || this.left) return;
|
|
682
|
+
}
|
|
809
683
|
const hash8 = this.options.schema ? this.options.schema.hash8 : RELAY_HASH8;
|
|
810
684
|
const hello = encodeHello({
|
|
811
685
|
protocolVersion: PROTOCOL_VERSION,
|
|
812
|
-
credential: { kind: "key", key: this.options.key },
|
|
686
|
+
credential: token !== void 0 ? { kind: "jwt", key: this.options.key, token } : { kind: "key", key: this.options.key },
|
|
813
687
|
roomId: this.roomId,
|
|
814
688
|
schemaHash8: hash8,
|
|
815
689
|
...this.resumeToken !== void 0 ? { resumeToken: this.resumeToken } : {},
|
|
@@ -823,19 +697,23 @@ var Session = class {
|
|
|
823
697
|
if (this.left) return;
|
|
824
698
|
this.left = true;
|
|
825
699
|
this.stopTimers();
|
|
700
|
+
this.predictor?.free();
|
|
826
701
|
this.rejectPending(new Error("irtio: left the room"));
|
|
827
702
|
this.setStatus("closed");
|
|
828
703
|
if (this.socket && this.joined) this.send(FrameType.LEAVE, new Uint8Array(0));
|
|
829
704
|
this.socket?.close();
|
|
830
705
|
this.socket = void 0;
|
|
706
|
+
this.socketOpen = false;
|
|
831
707
|
}
|
|
832
708
|
fatal(error) {
|
|
833
709
|
this.left = true;
|
|
834
710
|
this.stopTimers();
|
|
711
|
+
this.predictor?.free();
|
|
835
712
|
this.rejectPending(error);
|
|
836
713
|
this.setStatus("closed");
|
|
837
714
|
this.socket?.close();
|
|
838
715
|
this.socket = void 0;
|
|
716
|
+
this.socketOpen = false;
|
|
839
717
|
const fail = this.failJoin;
|
|
840
718
|
this.settleJoin = void 0;
|
|
841
719
|
this.failJoin = void 0;
|
|
@@ -843,6 +721,7 @@ var Session = class {
|
|
|
843
721
|
}
|
|
844
722
|
onSocketClosed(info) {
|
|
845
723
|
this.socket = void 0;
|
|
724
|
+
this.socketOpen = false;
|
|
846
725
|
this.stopTimers();
|
|
847
726
|
if (!this.joined) {
|
|
848
727
|
const detail = info?.code !== void 0 ? ` (code ${info.code}${info.reason ? `: ${info.reason}` : ""})` : "";
|
|
@@ -875,9 +754,10 @@ var Session = class {
|
|
|
875
754
|
// Frames
|
|
876
755
|
// -------------------------------------------------------------------------
|
|
877
756
|
send(type, payload) {
|
|
757
|
+
if (!this.socketOpen || !this.socket) return;
|
|
878
758
|
const frame = encodeFrame(type, payload);
|
|
879
759
|
this.options.onFrame?.("out", type, frame);
|
|
880
|
-
this.socket
|
|
760
|
+
this.socket.send(frame);
|
|
881
761
|
}
|
|
882
762
|
onFrame(bytes) {
|
|
883
763
|
let type;
|
|
@@ -941,6 +821,11 @@ var Session = class {
|
|
|
941
821
|
if (welcome.roomId !== "") this.roomId = welcome.roomId;
|
|
942
822
|
this.store.loadSnapshot(welcome.snapshot);
|
|
943
823
|
this.store.applyPendingWrites(pending);
|
|
824
|
+
this.render.seedSnapshot();
|
|
825
|
+
if (this.predictor) {
|
|
826
|
+
this.predictor.reset();
|
|
827
|
+
void this.predictor.start();
|
|
828
|
+
}
|
|
944
829
|
this.attempt = 0;
|
|
945
830
|
if (this.options.publishLocation) {
|
|
946
831
|
this.linkOverride = publishRoomToLocation(this.roomId);
|
|
@@ -957,16 +842,74 @@ var Session = class {
|
|
|
957
842
|
const delta = decodeDelta(this.ext, payload);
|
|
958
843
|
this.tick = delta.tick;
|
|
959
844
|
this.store.applyServerDelta(delta);
|
|
845
|
+
this.render.recordDelta(delta);
|
|
846
|
+
if (this.predictor && delta.collections.some((dc) => this.predictor?.predictsCollection(dc.name))) {
|
|
847
|
+
this.predictor.noteAuthority(delta.tick);
|
|
848
|
+
this.predictor.frame(this.scheduler.now());
|
|
849
|
+
}
|
|
960
850
|
}
|
|
961
851
|
onCorrect(payload) {
|
|
962
852
|
const r = new ByteReader(payload);
|
|
963
853
|
const delta = decodeDeltaFrom(this.ext, r);
|
|
964
854
|
const clientTick = readCorrectClientTick(r);
|
|
855
|
+
const appliedTick = readCorrectAppliedTick(r);
|
|
856
|
+
if (clientTick !== void 0 && appliedTick !== void 0) {
|
|
857
|
+
this.predictor?.noteWriteApplied(clientTick, appliedTick);
|
|
858
|
+
}
|
|
965
859
|
this.tick = delta.tick;
|
|
860
|
+
const predicted = this.capturePredicted(delta);
|
|
861
|
+
let sawBodyFields = false;
|
|
966
862
|
for (const op of this.store.applyCorrection(delta, clientTick)) {
|
|
967
|
-
this.
|
|
863
|
+
const desc = this.descOfCollection(op.collection);
|
|
864
|
+
const physics = desc?.physics;
|
|
865
|
+
const bodyOnly = physics !== void 0 && op.fields.length > 0 && op.fields.every((f) => physics.bodyFields.has(f));
|
|
866
|
+
if (bodyOnly) sawBodyFields = true;
|
|
867
|
+
if (bodyOnly && this.predictor?.has(op.collection, op.id)) {
|
|
868
|
+
this.predictor.noteAuthorityTick(op.collection, op.id, delta.tick);
|
|
869
|
+
}
|
|
870
|
+
const pv = predicted.get(`${op.collection} ${op.id}`);
|
|
871
|
+
if (!bodyOnly || pv === void 0) {
|
|
872
|
+
this.emit("correct", { ...op, simulation: bodyOnly, suppressed: false });
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const suppressed = this.predictor !== void 0 && desc !== void 0 ? this.predictor.withinEpsilon(desc, op.fields, op.patch, pv) : false;
|
|
876
|
+
if (suppressed && this.predictor) this.predictor.stats.suppressed++;
|
|
877
|
+
this.emit("correct", {
|
|
878
|
+
...op,
|
|
879
|
+
previous: { ...op.previous, ...pv },
|
|
880
|
+
simulation: false,
|
|
881
|
+
suppressed
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
if (sawBodyFields && this.predictor) {
|
|
885
|
+
this.predictor.noteAuthority(delta.tick);
|
|
886
|
+
this.predictor.frame(this.scheduler.now());
|
|
968
887
|
}
|
|
969
888
|
}
|
|
889
|
+
descsByName;
|
|
890
|
+
descOfCollection(name) {
|
|
891
|
+
this.descsByName ??= new Map(
|
|
892
|
+
this.ext.collections.map((c) => [c.name, c])
|
|
893
|
+
);
|
|
894
|
+
return this.descsByName.get(name);
|
|
895
|
+
}
|
|
896
|
+
/** Pre-rebase predicted values per corrected instance, keyed `collection id`. */
|
|
897
|
+
capturePredicted(delta) {
|
|
898
|
+
const out = /* @__PURE__ */ new Map();
|
|
899
|
+
const predictor = this.predictor;
|
|
900
|
+
if (!predictor || !predictor.ready) return out;
|
|
901
|
+
for (const dc of delta.collections) {
|
|
902
|
+
const desc = this.descOfCollection(dc.name);
|
|
903
|
+
if (!desc?.physics) continue;
|
|
904
|
+
for (const op of dc.ops) {
|
|
905
|
+
if (op.op !== "update" || !predictor.has(dc.name, op.id)) continue;
|
|
906
|
+
const fields = [...op.mask.fields].map((i) => desc.fields[i]?.name).filter((n) => n !== void 0);
|
|
907
|
+
const values = predictor.predictedValues(desc, op.id, fields, delta.tick);
|
|
908
|
+
if (values) out.set(`${dc.name} ${op.id}`, values);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return out;
|
|
912
|
+
}
|
|
970
913
|
onError(payload) {
|
|
971
914
|
const e = decodeErrorPayload(payload);
|
|
972
915
|
const code = nameOfCode(e.code);
|
|
@@ -985,7 +928,8 @@ var Session = class {
|
|
|
985
928
|
}
|
|
986
929
|
onPong(payload) {
|
|
987
930
|
const pong = decodePong(payload);
|
|
988
|
-
|
|
931
|
+
const sample = Math.max(1, (this.scheduler.now() >>> 0) - pong.t >>> 0);
|
|
932
|
+
this.rtt = this.rtt === 0 ? sample : Math.max(1, Math.round(this.rtt + (sample - this.rtt) * RTT_ALPHA));
|
|
989
933
|
if (pong.serverTick > this.tick) this.tick = pong.serverTick;
|
|
990
934
|
}
|
|
991
935
|
onMsg(payload) {
|
|
@@ -999,7 +943,10 @@ var Session = class {
|
|
|
999
943
|
// -------------------------------------------------------------------------
|
|
1000
944
|
startTimers() {
|
|
1001
945
|
if (!this.cancelFlush) this.armFlush();
|
|
1002
|
-
if (!this.cancelPing)
|
|
946
|
+
if (!this.cancelPing) {
|
|
947
|
+
this.sendPing();
|
|
948
|
+
this.armPing();
|
|
949
|
+
}
|
|
1003
950
|
}
|
|
1004
951
|
/**
|
|
1005
952
|
* The write batcher: one window per animation frame in a browser, or per `writeIntervalMs`
|
|
@@ -1026,21 +973,25 @@ var Session = class {
|
|
|
1026
973
|
frameCancel?.();
|
|
1027
974
|
};
|
|
1028
975
|
}
|
|
976
|
+
sendPing() {
|
|
977
|
+
if (this.left || !this.socket) return;
|
|
978
|
+
this.send(FrameType.PING, encodePing({ t: this.scheduler.now() >>> 0 }));
|
|
979
|
+
}
|
|
1029
980
|
armPing() {
|
|
1030
981
|
this.cancelPing = this.scheduler.setTimeout(() => {
|
|
1031
982
|
this.cancelPing = void 0;
|
|
1032
|
-
|
|
1033
|
-
this.
|
|
1034
|
-
this.armPing();
|
|
983
|
+
this.sendPing();
|
|
984
|
+
if (!this.left && this.socket) this.armPing();
|
|
1035
985
|
}, PING_INTERVAL_MS);
|
|
1036
986
|
}
|
|
1037
987
|
/** Sends every pending owned write now. */
|
|
1038
988
|
flush() {
|
|
1039
|
-
if (!this.
|
|
989
|
+
if (!this.socketOpen || !this.joined) return;
|
|
1040
990
|
if (!this.store.hasLocalWrites) return;
|
|
1041
|
-
const
|
|
991
|
+
const stamp = Math.max(this.writeTick + 1, this.predictor?.stampTick() ?? 0);
|
|
992
|
+
const payload = this.store.takeWrite(stamp);
|
|
1042
993
|
if (payload) {
|
|
1043
|
-
this.writeTick
|
|
994
|
+
this.writeTick = stamp;
|
|
1044
995
|
this.send(FrameType.WRITE, payload);
|
|
1045
996
|
}
|
|
1046
997
|
}
|
|
@@ -1061,7 +1012,7 @@ var Session = class {
|
|
|
1061
1012
|
} catch (err) {
|
|
1062
1013
|
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
|
1063
1014
|
}
|
|
1064
|
-
if (!this.
|
|
1015
|
+
if (!this.socketOpen || this.left) {
|
|
1065
1016
|
return Promise.reject(new Error(`irtio: ${name} failed \u2014 not connected`));
|
|
1066
1017
|
}
|
|
1067
1018
|
const reqId = ++this.reqId;
|
|
@@ -1253,8 +1204,11 @@ async function joinRoom(schema, options = {}) {
|
|
|
1253
1204
|
publishLocation: fromLocation,
|
|
1254
1205
|
role: options.role,
|
|
1255
1206
|
name: options.name,
|
|
1207
|
+
token: options.token,
|
|
1256
1208
|
rpc: options.rpc,
|
|
1257
1209
|
writeIntervalMs: options.writeIntervalMs,
|
|
1210
|
+
interpDelayMs: options.interpDelayMs,
|
|
1211
|
+
physics: options.physics,
|
|
1258
1212
|
transport: options.transport,
|
|
1259
1213
|
scheduler: options.scheduler,
|
|
1260
1214
|
onFrame: options.onFrame,
|
|
@@ -1287,9 +1241,23 @@ function makeRoom(session) {
|
|
|
1287
1241
|
get state() {
|
|
1288
1242
|
return session.store.view;
|
|
1289
1243
|
},
|
|
1244
|
+
get render() {
|
|
1245
|
+
return session.render.view;
|
|
1246
|
+
},
|
|
1290
1247
|
get clients() {
|
|
1291
1248
|
return session.clients;
|
|
1292
1249
|
},
|
|
1250
|
+
...session.predictionRequested ? {
|
|
1251
|
+
prediction: {
|
|
1252
|
+
get active() {
|
|
1253
|
+
return session.predictor?.ready ?? false;
|
|
1254
|
+
},
|
|
1255
|
+
predicts: (collection, id) => session.predictor?.has(collection, id) ?? false,
|
|
1256
|
+
get stats() {
|
|
1257
|
+
return session.predictor?.stats ?? emptyPredictionStats();
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
} : {},
|
|
1293
1261
|
call,
|
|
1294
1262
|
requestOwnership: (entity, id) => session.requestOwnership(entity, id),
|
|
1295
1263
|
message: (target, bytes) => session.message(target, bytes),
|
|
@@ -1351,8 +1319,12 @@ export {
|
|
|
1351
1319
|
DEFAULT_WRITE_INTERVAL_MS,
|
|
1352
1320
|
DEV_PORT,
|
|
1353
1321
|
E_CONNECT_FAILED,
|
|
1322
|
+
MAX_PREDICTED_BODIES,
|
|
1354
1323
|
PING_INTERVAL_MS,
|
|
1324
|
+
PREDICTION_EPSILON,
|
|
1355
1325
|
RESIM_DEPTH,
|
|
1326
|
+
SMOOTHING_HALF_LIFE_MS,
|
|
1327
|
+
SMOOTHING_SNAP_UNITS,
|
|
1356
1328
|
Session,
|
|
1357
1329
|
defaultScheduler,
|
|
1358
1330
|
joinRelay,
|