@irtio/client 0.1.0 → 0.2.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-AJHN3YF6.js +562 -0
- package/dist/index.d.ts +589 -134
- package/dist/index.js +432 -475
- package/dist/physics-BBFSQEYL.js +583 -0
- package/package.json +14 -3
package/dist/index.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClientStore,
|
|
3
|
+
E_CONNECT_FAILED,
|
|
4
|
+
MAX_PREDICTED_BODIES,
|
|
5
|
+
PREDICTION_EPSILON,
|
|
6
|
+
RESIM_DEPTH,
|
|
7
|
+
emptyPredictionStats
|
|
8
|
+
} from "./chunk-AJHN3YF6.js";
|
|
9
|
+
|
|
1
10
|
// src/endpoint.ts
|
|
2
11
|
var DEFAULT_REGION = "eu";
|
|
3
12
|
var DEV_PORT = 7070;
|
|
@@ -118,513 +127,292 @@ import {
|
|
|
118
127
|
encodeFields
|
|
119
128
|
} from "@irtio/schema";
|
|
120
129
|
|
|
121
|
-
// src/
|
|
122
|
-
var
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
})).bind(globalThis);
|
|
131
|
-
return { request, cancel };
|
|
130
|
+
// src/render.ts
|
|
131
|
+
var NUMERIC = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32", "f32", "f64"]);
|
|
132
|
+
var INTEGER = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32"]);
|
|
133
|
+
function cloneRecord(value) {
|
|
134
|
+
const out = {};
|
|
135
|
+
for (const [k, v] of Object.entries(value)) {
|
|
136
|
+
out[k] = Array.isArray(v) ? [...v] : typeof v === "object" && v !== null ? cloneRecord(v) : v;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
132
139
|
}
|
|
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);
|
|
140
|
+
function lerpValue(desc, a, b, alpha) {
|
|
141
|
+
if (NUMERIC.has(desc.kind) && typeof a === "number" && typeof b === "number") {
|
|
142
|
+
const v = a + (b - a) * alpha;
|
|
143
|
+
return INTEGER.has(desc.kind) ? Math.round(v) : v;
|
|
144
|
+
}
|
|
145
|
+
if (desc.kind === "struct" && typeof a === "object" && a !== null && typeof b === "object" && b !== null && !Array.isArray(a) && !Array.isArray(b)) {
|
|
146
|
+
const out = {};
|
|
147
|
+
for (const [name, sub] of Object.entries(desc.fields)) {
|
|
148
|
+
out[name] = lerpValue(sub, a[name], b[name], alpha);
|
|
149
149
|
}
|
|
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);
|
|
150
|
+
return out;
|
|
184
151
|
}
|
|
152
|
+
return Array.isArray(a) ? [...a] : a;
|
|
185
153
|
}
|
|
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
|
-
});
|
|
154
|
+
function lerpRecord(desc, a, b, alpha) {
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const f of desc.fields) out[f.name] = lerpValue(f.type, a[f.name], b[f.name], alpha);
|
|
157
|
+
return out;
|
|
228
158
|
}
|
|
229
|
-
var
|
|
230
|
-
constructor(ext, meOf) {
|
|
159
|
+
var RenderStore = class {
|
|
160
|
+
constructor(ext, store, meOf, now, delayMs) {
|
|
231
161
|
this.ext = ext;
|
|
162
|
+
this.store = store;
|
|
232
163
|
this.meOf = meOf;
|
|
164
|
+
this.now = now;
|
|
165
|
+
this.delayMs = delayMs;
|
|
233
166
|
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
167
|
this.view = this.buildView();
|
|
237
168
|
}
|
|
238
169
|
ext;
|
|
170
|
+
store;
|
|
239
171
|
meOf;
|
|
240
|
-
|
|
241
|
-
|
|
172
|
+
now;
|
|
173
|
+
delayMs;
|
|
174
|
+
/** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
|
|
175
|
+
buffers = /* @__PURE__ */ new Map();
|
|
242
176
|
descs = /* @__PURE__ */ new Map();
|
|
243
|
-
|
|
244
|
-
/** The object handed out as `room.state`; identity survives a resync. */
|
|
177
|
+
/** The object handed out as `room.render`; identity survives a resync. */
|
|
245
178
|
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
|
-
}
|
|
179
|
+
/** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
|
|
180
|
+
starved = 0;
|
|
181
|
+
/** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
|
|
182
|
+
predictor;
|
|
183
|
+
// -- ingest ---------------------------------------------------------------
|
|
257
184
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
185
|
+
* Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
|
|
186
|
+
* the world is visible immediately (the join snapshot is the render baseline, not a change to
|
|
187
|
+
* ease towards).
|
|
261
188
|
*/
|
|
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);
|
|
189
|
+
seedSnapshot() {
|
|
190
|
+
this.buffers.clear();
|
|
191
|
+
const t = this.now() - this.delayMs();
|
|
192
|
+
for (const c of this.ext.collections) {
|
|
193
|
+
if (c.kind !== "entity" || !c.interpolate) continue;
|
|
194
|
+
const coll = this.store.plain[c.name];
|
|
195
|
+
for (const id of [...coll.ids()]) {
|
|
196
|
+
this.bufferFor(c.name, id).frames.push({
|
|
197
|
+
t,
|
|
198
|
+
value: cloneRecord(coll.get(id))
|
|
199
|
+
});
|
|
284
200
|
}
|
|
285
201
|
}
|
|
286
|
-
return out;
|
|
287
202
|
}
|
|
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;
|
|
203
|
+
/** Buffers every entity a `DELTA` touched, stamped with its arrival time. Call after apply. */
|
|
204
|
+
recordDelta(delta) {
|
|
205
|
+
const t = this.now();
|
|
206
|
+
for (const dc of delta.collections) {
|
|
207
|
+
const desc = this.descs.get(dc.name);
|
|
208
|
+
if (!desc || desc.kind !== "entity" || !desc.interpolate) continue;
|
|
209
|
+
const coll = this.store.plain[dc.name];
|
|
210
|
+
for (const op of dc.ops) {
|
|
211
|
+
if (op.op === "remove") {
|
|
212
|
+
const buffer2 = this.buffers.get(dc.name)?.get(op.id);
|
|
213
|
+
if (buffer2 && buffer2.removedAt === void 0) buffer2.removedAt = t;
|
|
214
|
+
continue;
|
|
304
215
|
}
|
|
216
|
+
const value = coll.get(op.id);
|
|
217
|
+
if (value === void 0) continue;
|
|
218
|
+
const buffer = this.bufferFor(dc.name, op.id);
|
|
219
|
+
buffer.removedAt = void 0;
|
|
220
|
+
buffer.frames.push({ t, value: cloneRecord(value) });
|
|
305
221
|
}
|
|
306
222
|
}
|
|
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
223
|
}
|
|
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)`;
|
|
224
|
+
bufferFor(collection, id) {
|
|
225
|
+
let byId = this.buffers.get(collection);
|
|
226
|
+
if (!byId) {
|
|
227
|
+
byId = /* @__PURE__ */ new Map();
|
|
228
|
+
this.buffers.set(collection, byId);
|
|
346
229
|
}
|
|
347
|
-
|
|
348
|
-
if (
|
|
349
|
-
|
|
230
|
+
let buffer = byId.get(id);
|
|
231
|
+
if (!buffer) {
|
|
232
|
+
buffer = { frames: [], removedAt: void 0 };
|
|
233
|
+
byId.set(id, buffer);
|
|
350
234
|
}
|
|
351
|
-
return
|
|
235
|
+
return buffer;
|
|
352
236
|
}
|
|
353
|
-
|
|
354
|
-
|
|
237
|
+
// -- read -----------------------------------------------------------------
|
|
238
|
+
renderTime() {
|
|
239
|
+
return this.now() - this.delayMs();
|
|
355
240
|
}
|
|
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);
|
|
241
|
+
attachPredictor(predictor) {
|
|
242
|
+
this.predictor = predictor;
|
|
364
243
|
}
|
|
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);
|
|
244
|
+
/** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
|
|
245
|
+
get(desc, id) {
|
|
246
|
+
const coll = this.store.plain[desc.name];
|
|
247
|
+
if (this.predictor && desc.physics) {
|
|
248
|
+
this.predictor.frame(this.now());
|
|
249
|
+
if (this.predictor.has(desc.name, id)) {
|
|
250
|
+
const value = coll.get(id);
|
|
251
|
+
if (value !== void 0) return this.predictor.read(desc, id, { ...value });
|
|
381
252
|
}
|
|
382
253
|
}
|
|
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 });
|
|
254
|
+
if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
|
|
255
|
+
return this.store.instance(desc, id);
|
|
424
256
|
}
|
|
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];
|
|
257
|
+
if (!desc.interpolate) return this.store.instance(desc, id);
|
|
258
|
+
const buffer = this.buffers.get(desc.name)?.get(id);
|
|
259
|
+
if (!buffer || buffer.frames.length === 0) {
|
|
260
|
+
return this.store.instance(desc, id);
|
|
441
261
|
}
|
|
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
|
-
}
|
|
262
|
+
const renderT = this.renderTime();
|
|
263
|
+
if (buffer.removedAt !== void 0 && renderT >= buffer.removedAt) {
|
|
264
|
+
this.gc(desc.name, id, buffer);
|
|
265
|
+
return void 0;
|
|
459
266
|
}
|
|
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
|
-
}
|
|
267
|
+
this.prune(buffer, renderT);
|
|
268
|
+
const frames = buffer.frames;
|
|
269
|
+
const a = frames[0];
|
|
270
|
+
if (renderT < a.t) return void 0;
|
|
271
|
+
const b = frames[1];
|
|
272
|
+
if (!b) {
|
|
273
|
+
if (renderT > a.t) this.starved++;
|
|
274
|
+
return cloneRecord(a.value);
|
|
505
275
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
276
|
+
const alpha = (renderT - a.t) / (b.t - a.t);
|
|
277
|
+
return lerpRecord(desc, a.value, b.value, Math.min(1, Math.max(0, alpha)));
|
|
278
|
+
}
|
|
279
|
+
/** Is `collection[id]` visible at the render clock? */
|
|
280
|
+
has(desc, id) {
|
|
281
|
+
const coll = this.store.plain[desc.name];
|
|
282
|
+
if (this.predictor && desc.physics && this.predictor.has(desc.name, id)) return coll.has(id);
|
|
283
|
+
if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) return true;
|
|
284
|
+
if (!desc.interpolate) return coll.has(id);
|
|
285
|
+
const buffer = this.buffers.get(desc.name)?.get(id);
|
|
286
|
+
if (!buffer || buffer.frames.length === 0) return coll.has(id);
|
|
287
|
+
const renderT = this.renderTime();
|
|
288
|
+
if (buffer.removedAt !== void 0 && renderT >= buffer.removedAt) return false;
|
|
289
|
+
return buffer.frames[0].t <= renderT;
|
|
290
|
+
}
|
|
291
|
+
/** The authoritative owner — ownership is fact, not a rendered value. */
|
|
292
|
+
ownerOf(desc, id) {
|
|
293
|
+
return this.store.plain[desc.name].ownerOf(id);
|
|
294
|
+
}
|
|
295
|
+
/** Ids visible at the render clock: owned (predicted) plus buffered-and-appeared. */
|
|
296
|
+
*ids(desc) {
|
|
297
|
+
const coll = this.store.plain[desc.name];
|
|
298
|
+
if (!desc.interpolate) {
|
|
299
|
+
yield* coll.ids();
|
|
300
|
+
return;
|
|
516
301
|
}
|
|
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);
|
|
302
|
+
const seen = /* @__PURE__ */ new Set();
|
|
303
|
+
for (const id of [...coll.ids()]) {
|
|
304
|
+
if (this.has(desc, id)) {
|
|
305
|
+
seen.add(id);
|
|
306
|
+
yield id;
|
|
566
307
|
}
|
|
567
308
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
if (evicted && evicted.tick > this.evictedThroughTick) this.evictedThroughTick = evicted.tick;
|
|
309
|
+
const byId = this.buffers.get(desc.name);
|
|
310
|
+
if (!byId) return;
|
|
311
|
+
for (const id of [...byId.keys()]) {
|
|
312
|
+
if (!seen.has(id) && !coll.has(id) && this.has(desc, id)) yield id;
|
|
573
313
|
}
|
|
574
314
|
}
|
|
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;
|
|
315
|
+
/** Drops frames the render clock has passed, keeping the newest one at-or-before `renderT`. */
|
|
316
|
+
prune(buffer, renderT) {
|
|
317
|
+
const frames = buffer.frames;
|
|
318
|
+
let drop = 0;
|
|
319
|
+
while (drop + 1 < frames.length && frames[drop + 1].t <= renderT) drop++;
|
|
320
|
+
if (drop > 0) frames.splice(0, drop);
|
|
587
321
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
322
|
+
gc(collection, id, buffer) {
|
|
323
|
+
buffer.frames.length = 0;
|
|
324
|
+
const byId = this.buffers.get(collection);
|
|
325
|
+
byId?.delete(id);
|
|
326
|
+
if (byId && byId.size === 0) this.buffers.delete(collection);
|
|
327
|
+
}
|
|
328
|
+
// -- view -----------------------------------------------------------------
|
|
329
|
+
buildView() {
|
|
330
|
+
const view = {};
|
|
595
331
|
for (const c of this.ext.collections) {
|
|
596
|
-
if (c.kind
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
332
|
+
if (c.kind === "entity") {
|
|
333
|
+
const facade = withRenderIndexSugar(new RenderCollectionImpl(this, c));
|
|
334
|
+
Object.defineProperty(view, c.name, { get: () => facade, enumerable: true });
|
|
335
|
+
} else {
|
|
336
|
+
Object.defineProperty(view, c.name, {
|
|
337
|
+
get: () => this.store.view[c.name],
|
|
338
|
+
enumerable: true
|
|
339
|
+
});
|
|
604
340
|
}
|
|
605
341
|
}
|
|
342
|
+
return view;
|
|
606
343
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
344
|
+
};
|
|
345
|
+
var RenderCollectionImpl = class {
|
|
346
|
+
constructor(render, desc) {
|
|
347
|
+
this.render = render;
|
|
348
|
+
this.desc = desc;
|
|
349
|
+
}
|
|
350
|
+
render;
|
|
351
|
+
desc;
|
|
352
|
+
get(id) {
|
|
353
|
+
return this.render.get(this.desc, id);
|
|
354
|
+
}
|
|
355
|
+
has(id) {
|
|
356
|
+
return this.render.has(this.desc, id);
|
|
357
|
+
}
|
|
358
|
+
ownerOf(id) {
|
|
359
|
+
return this.render.ownerOf(this.desc, id);
|
|
360
|
+
}
|
|
361
|
+
get size() {
|
|
362
|
+
let n = 0;
|
|
363
|
+
for (const _ of this.render.ids(this.desc)) n++;
|
|
364
|
+
return n;
|
|
365
|
+
}
|
|
366
|
+
ids() {
|
|
367
|
+
return this.render.ids(this.desc);
|
|
368
|
+
}
|
|
369
|
+
*[Symbol.iterator]() {
|
|
370
|
+
for (const id of [...this.render.ids(this.desc)]) {
|
|
371
|
+
yield [id, this.render.get(this.desc, id)];
|
|
624
372
|
}
|
|
625
|
-
return out;
|
|
626
373
|
}
|
|
627
374
|
};
|
|
375
|
+
function withRenderIndexSugar(collection) {
|
|
376
|
+
return new Proxy(collection, {
|
|
377
|
+
get(target, prop, receiver) {
|
|
378
|
+
if (typeof prop !== "string" || prop in target) return Reflect.get(target, prop, receiver);
|
|
379
|
+
return target.get(prop);
|
|
380
|
+
},
|
|
381
|
+
has(target, prop) {
|
|
382
|
+
if (typeof prop !== "string" || prop in target) return Reflect.has(target, prop);
|
|
383
|
+
return target.has(prop);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/scheduler.ts
|
|
389
|
+
function rafPair() {
|
|
390
|
+
const g = globalThis;
|
|
391
|
+
if (typeof g.requestAnimationFrame !== "function") return void 0;
|
|
392
|
+
const request = g.requestAnimationFrame.bind(globalThis);
|
|
393
|
+
const cancel = (g.cancelAnimationFrame ?? (() => {
|
|
394
|
+
})).bind(globalThis);
|
|
395
|
+
return { request, cancel };
|
|
396
|
+
}
|
|
397
|
+
function defaultScheduler() {
|
|
398
|
+
const raf = rafPair();
|
|
399
|
+
const base = {
|
|
400
|
+
now: () => Date.now(),
|
|
401
|
+
setTimeout(fn, ms) {
|
|
402
|
+
const handle = setTimeout(fn, ms);
|
|
403
|
+
handle.unref?.();
|
|
404
|
+
return () => clearTimeout(handle);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
if (!raf) return base;
|
|
408
|
+
return {
|
|
409
|
+
...base,
|
|
410
|
+
frame(fn) {
|
|
411
|
+
const handle = raf.request(() => fn());
|
|
412
|
+
return () => raf.cancel(handle);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
}
|
|
628
416
|
|
|
629
417
|
// src/transport.ts
|
|
630
418
|
function globalWebSocket() {
|
|
@@ -717,10 +505,49 @@ var Session = class {
|
|
|
717
505
|
this.roomId = options.roomId;
|
|
718
506
|
this.role = options.role ?? "";
|
|
719
507
|
this.store = new ClientStore(this.ext, () => this.me);
|
|
508
|
+
this.render = new RenderStore(
|
|
509
|
+
this.ext,
|
|
510
|
+
this.store,
|
|
511
|
+
() => this.me,
|
|
512
|
+
() => this.scheduler.now(),
|
|
513
|
+
() => this.interpDelayMs
|
|
514
|
+
);
|
|
515
|
+
const hasPhysics = this.ext.collections.some(
|
|
516
|
+
(c) => c.physics !== void 0
|
|
517
|
+
);
|
|
518
|
+
if (options.physics && hasPhysics) {
|
|
519
|
+
const physics = options.physics;
|
|
520
|
+
this.predictionRequested = true;
|
|
521
|
+
void import("./physics-BBFSQEYL.js").then(({ PhysicsPredictor }) => {
|
|
522
|
+
if (this.left) return;
|
|
523
|
+
const predictor = new PhysicsPredictor(
|
|
524
|
+
this.ext,
|
|
525
|
+
this.store,
|
|
526
|
+
physics,
|
|
527
|
+
() => this.me,
|
|
528
|
+
() => this.rtt,
|
|
529
|
+
() => this.tickIntervalMs
|
|
530
|
+
);
|
|
531
|
+
this.predictor = predictor;
|
|
532
|
+
this.render.attachPredictor(predictor);
|
|
533
|
+
if (this.joined) {
|
|
534
|
+
predictor.reset();
|
|
535
|
+
void predictor.start();
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
}
|
|
720
539
|
}
|
|
721
540
|
options;
|
|
722
541
|
ext;
|
|
723
542
|
store;
|
|
543
|
+
render;
|
|
544
|
+
/** `true` when the join passed `physics` and the schema has body-backed collections. */
|
|
545
|
+
predictionRequested = false;
|
|
546
|
+
/**
|
|
547
|
+
* Present once `./physics.js` has loaded (dynamic import — the predictor code, like the
|
|
548
|
+
* engine, costs a non-physics game zero bytes). Reads fall back to interpolation until then.
|
|
549
|
+
*/
|
|
550
|
+
predictor;
|
|
724
551
|
/** The schema the `CALL`/`REPLY` rpc id space indexes into. */
|
|
725
552
|
rpcSchema;
|
|
726
553
|
transport;
|
|
@@ -732,9 +559,11 @@ var Session = class {
|
|
|
732
559
|
roomId;
|
|
733
560
|
tick = 0;
|
|
734
561
|
/**
|
|
735
|
-
* The
|
|
562
|
+
* The stamp of the newest flushed `WRITE`, carried in its delta header (D19). With physics
|
|
563
|
+
* prediction live this is the predictor's head tick + 1 — the server's tick clock, which is
|
|
564
|
+
* what lets the rebase replay pending writes by tick — and otherwise a bare counter.
|
|
736
565
|
* Monotonic for the life of the session, across reconnects — the server's `lastClientTick`
|
|
737
|
-
* for this client survives the grace window too.
|
|
566
|
+
* for this client survives the grace window too, and only ever moves forward.
|
|
738
567
|
*/
|
|
739
568
|
writeTick = 0;
|
|
740
569
|
/** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
|
|
@@ -742,6 +571,19 @@ var Session = class {
|
|
|
742
571
|
rtt = 0;
|
|
743
572
|
status = "connecting";
|
|
744
573
|
socket;
|
|
574
|
+
/**
|
|
575
|
+
* Whether `socket` has actually opened. `TransportSocket` deliberately has no `readyState` —
|
|
576
|
+
* `@irtio/testing` injects an in-process pair — so the session tracks it from `onopen`.
|
|
577
|
+
*
|
|
578
|
+
* Needed because a reconnecting session holds a socket that exists and is *not* sendable:
|
|
579
|
+
* `onSocketClosed` clears `socket` but keeps `joined` true (that is what makes resume work), and
|
|
580
|
+
* `connect()` then assigns a fresh socket that is still CONNECTING. Every send guard here used to
|
|
581
|
+
* test `this.socket` for presence alone, so the ping timer and the write flush would both reach a
|
|
582
|
+
* connecting socket and throw `InvalidStateError: Sent before connected.` out of a timer callback,
|
|
583
|
+
* where nothing catches it. Found on staging under 40+ bots, where joins are slow enough for the
|
|
584
|
+
* window to be wide; it never opens against a local dev server.
|
|
585
|
+
*/
|
|
586
|
+
socketOpen = false;
|
|
745
587
|
resumeToken;
|
|
746
588
|
joined = false;
|
|
747
589
|
left = false;
|
|
@@ -756,6 +598,14 @@ var Session = class {
|
|
|
756
598
|
linkOverride;
|
|
757
599
|
settleJoin;
|
|
758
600
|
failJoin;
|
|
601
|
+
/**
|
|
602
|
+
* The `room.render` delay (D20): the explicit option, else twice the room's tick interval
|
|
603
|
+
* (from `WELCOME`), floored at 50 ms — also the fallback when the interval is unknown.
|
|
604
|
+
*/
|
|
605
|
+
get interpDelayMs() {
|
|
606
|
+
if (this.options.interpDelayMs !== void 0) return this.options.interpDelayMs;
|
|
607
|
+
return Math.max(50, 2 * this.tickIntervalMs);
|
|
608
|
+
}
|
|
759
609
|
// -------------------------------------------------------------------------
|
|
760
610
|
// Lifecycle
|
|
761
611
|
// -------------------------------------------------------------------------
|
|
@@ -779,8 +629,11 @@ var Session = class {
|
|
|
779
629
|
return;
|
|
780
630
|
}
|
|
781
631
|
this.socket = socket;
|
|
632
|
+
this.socketOpen = false;
|
|
782
633
|
socket.onopen = () => {
|
|
783
|
-
if (this.socket
|
|
634
|
+
if (this.socket !== socket) return;
|
|
635
|
+
this.socketOpen = true;
|
|
636
|
+
void this.sendHello(socket);
|
|
784
637
|
};
|
|
785
638
|
socket.onmessage = (bytes) => {
|
|
786
639
|
if (this.socket === socket) this.onFrame(bytes);
|
|
@@ -805,11 +658,24 @@ var Session = class {
|
|
|
805
658
|
}
|
|
806
659
|
this.emit("error", { code: "E_INTERNAL", message, fatal: false });
|
|
807
660
|
}
|
|
808
|
-
sendHello() {
|
|
661
|
+
async sendHello(socket) {
|
|
662
|
+
let token;
|
|
663
|
+
const supplied = this.options.token;
|
|
664
|
+
if (supplied !== void 0) {
|
|
665
|
+
try {
|
|
666
|
+
token = typeof supplied === "function" ? await supplied() : supplied;
|
|
667
|
+
} catch (err) {
|
|
668
|
+
this.transportFailed(
|
|
669
|
+
err instanceof Error ? err : new Error(`token provider failed: ${String(err)}`)
|
|
670
|
+
);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (this.socket !== socket || !this.socketOpen || this.left) return;
|
|
674
|
+
}
|
|
809
675
|
const hash8 = this.options.schema ? this.options.schema.hash8 : RELAY_HASH8;
|
|
810
676
|
const hello = encodeHello({
|
|
811
677
|
protocolVersion: PROTOCOL_VERSION,
|
|
812
|
-
credential: { kind: "key", key: this.options.key },
|
|
678
|
+
credential: token !== void 0 ? { kind: "jwt", key: this.options.key, token } : { kind: "key", key: this.options.key },
|
|
813
679
|
roomId: this.roomId,
|
|
814
680
|
schemaHash8: hash8,
|
|
815
681
|
...this.resumeToken !== void 0 ? { resumeToken: this.resumeToken } : {},
|
|
@@ -823,19 +689,23 @@ var Session = class {
|
|
|
823
689
|
if (this.left) return;
|
|
824
690
|
this.left = true;
|
|
825
691
|
this.stopTimers();
|
|
692
|
+
this.predictor?.free();
|
|
826
693
|
this.rejectPending(new Error("irtio: left the room"));
|
|
827
694
|
this.setStatus("closed");
|
|
828
695
|
if (this.socket && this.joined) this.send(FrameType.LEAVE, new Uint8Array(0));
|
|
829
696
|
this.socket?.close();
|
|
830
697
|
this.socket = void 0;
|
|
698
|
+
this.socketOpen = false;
|
|
831
699
|
}
|
|
832
700
|
fatal(error) {
|
|
833
701
|
this.left = true;
|
|
834
702
|
this.stopTimers();
|
|
703
|
+
this.predictor?.free();
|
|
835
704
|
this.rejectPending(error);
|
|
836
705
|
this.setStatus("closed");
|
|
837
706
|
this.socket?.close();
|
|
838
707
|
this.socket = void 0;
|
|
708
|
+
this.socketOpen = false;
|
|
839
709
|
const fail = this.failJoin;
|
|
840
710
|
this.settleJoin = void 0;
|
|
841
711
|
this.failJoin = void 0;
|
|
@@ -843,6 +713,7 @@ var Session = class {
|
|
|
843
713
|
}
|
|
844
714
|
onSocketClosed(info) {
|
|
845
715
|
this.socket = void 0;
|
|
716
|
+
this.socketOpen = false;
|
|
846
717
|
this.stopTimers();
|
|
847
718
|
if (!this.joined) {
|
|
848
719
|
const detail = info?.code !== void 0 ? ` (code ${info.code}${info.reason ? `: ${info.reason}` : ""})` : "";
|
|
@@ -875,9 +746,10 @@ var Session = class {
|
|
|
875
746
|
// Frames
|
|
876
747
|
// -------------------------------------------------------------------------
|
|
877
748
|
send(type, payload) {
|
|
749
|
+
if (!this.socketOpen || !this.socket) return;
|
|
878
750
|
const frame = encodeFrame(type, payload);
|
|
879
751
|
this.options.onFrame?.("out", type, frame);
|
|
880
|
-
this.socket
|
|
752
|
+
this.socket.send(frame);
|
|
881
753
|
}
|
|
882
754
|
onFrame(bytes) {
|
|
883
755
|
let type;
|
|
@@ -941,6 +813,11 @@ var Session = class {
|
|
|
941
813
|
if (welcome.roomId !== "") this.roomId = welcome.roomId;
|
|
942
814
|
this.store.loadSnapshot(welcome.snapshot);
|
|
943
815
|
this.store.applyPendingWrites(pending);
|
|
816
|
+
this.render.seedSnapshot();
|
|
817
|
+
if (this.predictor) {
|
|
818
|
+
this.predictor.reset();
|
|
819
|
+
void this.predictor.start();
|
|
820
|
+
}
|
|
944
821
|
this.attempt = 0;
|
|
945
822
|
if (this.options.publishLocation) {
|
|
946
823
|
this.linkOverride = publishRoomToLocation(this.roomId);
|
|
@@ -957,16 +834,70 @@ var Session = class {
|
|
|
957
834
|
const delta = decodeDelta(this.ext, payload);
|
|
958
835
|
this.tick = delta.tick;
|
|
959
836
|
this.store.applyServerDelta(delta);
|
|
837
|
+
this.render.recordDelta(delta);
|
|
838
|
+
if (this.predictor && delta.collections.some((dc) => this.predictor?.predictsCollection(dc.name))) {
|
|
839
|
+
this.predictor.noteAuthority();
|
|
840
|
+
this.predictor.frame(this.scheduler.now());
|
|
841
|
+
}
|
|
960
842
|
}
|
|
961
843
|
onCorrect(payload) {
|
|
962
844
|
const r = new ByteReader(payload);
|
|
963
845
|
const delta = decodeDeltaFrom(this.ext, r);
|
|
964
846
|
const clientTick = readCorrectClientTick(r);
|
|
965
847
|
this.tick = delta.tick;
|
|
848
|
+
const predicted = this.capturePredicted(delta);
|
|
849
|
+
let sawBodyFields = false;
|
|
966
850
|
for (const op of this.store.applyCorrection(delta, clientTick)) {
|
|
967
|
-
this.
|
|
851
|
+
const desc = this.descOfCollection(op.collection);
|
|
852
|
+
const physics = desc?.physics;
|
|
853
|
+
const bodyOnly = physics !== void 0 && op.fields.length > 0 && op.fields.every((f) => physics.bodyFields.has(f));
|
|
854
|
+
if (bodyOnly) sawBodyFields = true;
|
|
855
|
+
if (bodyOnly && this.predictor?.has(op.collection, op.id)) {
|
|
856
|
+
this.predictor.noteAuthorityTick(op.collection, op.id, delta.tick);
|
|
857
|
+
}
|
|
858
|
+
const pv = predicted.get(`${op.collection} ${op.id}`);
|
|
859
|
+
if (!bodyOnly || pv === void 0) {
|
|
860
|
+
this.emit("correct", { ...op, simulation: bodyOnly, suppressed: false });
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
const suppressed = this.predictor !== void 0 && desc !== void 0 ? this.predictor.withinEpsilon(desc, op.fields, op.patch, pv) : false;
|
|
864
|
+
if (suppressed && this.predictor) this.predictor.stats.suppressed++;
|
|
865
|
+
this.emit("correct", {
|
|
866
|
+
...op,
|
|
867
|
+
previous: { ...op.previous, ...pv },
|
|
868
|
+
simulation: false,
|
|
869
|
+
suppressed
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
if (sawBodyFields && this.predictor) {
|
|
873
|
+
this.predictor.noteAuthority();
|
|
874
|
+
this.predictor.frame(this.scheduler.now());
|
|
968
875
|
}
|
|
969
876
|
}
|
|
877
|
+
descsByName;
|
|
878
|
+
descOfCollection(name) {
|
|
879
|
+
this.descsByName ??= new Map(
|
|
880
|
+
this.ext.collections.map((c) => [c.name, c])
|
|
881
|
+
);
|
|
882
|
+
return this.descsByName.get(name);
|
|
883
|
+
}
|
|
884
|
+
/** Pre-rebase predicted values per corrected instance, keyed `collection id`. */
|
|
885
|
+
capturePredicted(delta) {
|
|
886
|
+
const out = /* @__PURE__ */ new Map();
|
|
887
|
+
const predictor = this.predictor;
|
|
888
|
+
if (!predictor || !predictor.ready) return out;
|
|
889
|
+
for (const dc of delta.collections) {
|
|
890
|
+
const desc = this.descOfCollection(dc.name);
|
|
891
|
+
if (!desc?.physics) continue;
|
|
892
|
+
for (const op of dc.ops) {
|
|
893
|
+
if (op.op !== "update" || !predictor.has(dc.name, op.id)) continue;
|
|
894
|
+
const fields = [...op.mask.fields].map((i) => desc.fields[i]?.name).filter((n) => n !== void 0);
|
|
895
|
+
const values = predictor.predictedValues(desc, op.id, fields, delta.tick);
|
|
896
|
+
if (values) out.set(`${dc.name} ${op.id}`, values);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return out;
|
|
900
|
+
}
|
|
970
901
|
onError(payload) {
|
|
971
902
|
const e = decodeErrorPayload(payload);
|
|
972
903
|
const code = nameOfCode(e.code);
|
|
@@ -999,7 +930,10 @@ var Session = class {
|
|
|
999
930
|
// -------------------------------------------------------------------------
|
|
1000
931
|
startTimers() {
|
|
1001
932
|
if (!this.cancelFlush) this.armFlush();
|
|
1002
|
-
if (!this.cancelPing)
|
|
933
|
+
if (!this.cancelPing) {
|
|
934
|
+
this.sendPing();
|
|
935
|
+
this.armPing();
|
|
936
|
+
}
|
|
1003
937
|
}
|
|
1004
938
|
/**
|
|
1005
939
|
* The write batcher: one window per animation frame in a browser, or per `writeIntervalMs`
|
|
@@ -1026,21 +960,25 @@ var Session = class {
|
|
|
1026
960
|
frameCancel?.();
|
|
1027
961
|
};
|
|
1028
962
|
}
|
|
963
|
+
sendPing() {
|
|
964
|
+
if (this.left || !this.socket) return;
|
|
965
|
+
this.send(FrameType.PING, encodePing({ t: this.scheduler.now() >>> 0 }));
|
|
966
|
+
}
|
|
1029
967
|
armPing() {
|
|
1030
968
|
this.cancelPing = this.scheduler.setTimeout(() => {
|
|
1031
969
|
this.cancelPing = void 0;
|
|
1032
|
-
|
|
1033
|
-
this.
|
|
1034
|
-
this.armPing();
|
|
970
|
+
this.sendPing();
|
|
971
|
+
if (!this.left && this.socket) this.armPing();
|
|
1035
972
|
}, PING_INTERVAL_MS);
|
|
1036
973
|
}
|
|
1037
974
|
/** Sends every pending owned write now. */
|
|
1038
975
|
flush() {
|
|
1039
|
-
if (!this.
|
|
976
|
+
if (!this.socketOpen || !this.joined) return;
|
|
1040
977
|
if (!this.store.hasLocalWrites) return;
|
|
1041
|
-
const
|
|
978
|
+
const stamp = Math.max(this.writeTick + 1, this.predictor?.stampTick() ?? 0);
|
|
979
|
+
const payload = this.store.takeWrite(stamp);
|
|
1042
980
|
if (payload) {
|
|
1043
|
-
this.writeTick
|
|
981
|
+
this.writeTick = stamp;
|
|
1044
982
|
this.send(FrameType.WRITE, payload);
|
|
1045
983
|
}
|
|
1046
984
|
}
|
|
@@ -1061,7 +999,7 @@ var Session = class {
|
|
|
1061
999
|
} catch (err) {
|
|
1062
1000
|
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
|
1063
1001
|
}
|
|
1064
|
-
if (!this.
|
|
1002
|
+
if (!this.socketOpen || this.left) {
|
|
1065
1003
|
return Promise.reject(new Error(`irtio: ${name} failed \u2014 not connected`));
|
|
1066
1004
|
}
|
|
1067
1005
|
const reqId = ++this.reqId;
|
|
@@ -1253,8 +1191,11 @@ async function joinRoom(schema, options = {}) {
|
|
|
1253
1191
|
publishLocation: fromLocation,
|
|
1254
1192
|
role: options.role,
|
|
1255
1193
|
name: options.name,
|
|
1194
|
+
token: options.token,
|
|
1256
1195
|
rpc: options.rpc,
|
|
1257
1196
|
writeIntervalMs: options.writeIntervalMs,
|
|
1197
|
+
interpDelayMs: options.interpDelayMs,
|
|
1198
|
+
physics: options.physics,
|
|
1258
1199
|
transport: options.transport,
|
|
1259
1200
|
scheduler: options.scheduler,
|
|
1260
1201
|
onFrame: options.onFrame,
|
|
@@ -1287,9 +1228,23 @@ function makeRoom(session) {
|
|
|
1287
1228
|
get state() {
|
|
1288
1229
|
return session.store.view;
|
|
1289
1230
|
},
|
|
1231
|
+
get render() {
|
|
1232
|
+
return session.render.view;
|
|
1233
|
+
},
|
|
1290
1234
|
get clients() {
|
|
1291
1235
|
return session.clients;
|
|
1292
1236
|
},
|
|
1237
|
+
...session.predictionRequested ? {
|
|
1238
|
+
prediction: {
|
|
1239
|
+
get active() {
|
|
1240
|
+
return session.predictor?.ready ?? false;
|
|
1241
|
+
},
|
|
1242
|
+
predicts: (collection, id) => session.predictor?.has(collection, id) ?? false,
|
|
1243
|
+
get stats() {
|
|
1244
|
+
return session.predictor?.stats ?? emptyPredictionStats();
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
} : {},
|
|
1293
1248
|
call,
|
|
1294
1249
|
requestOwnership: (entity, id) => session.requestOwnership(entity, id),
|
|
1295
1250
|
message: (target, bytes) => session.message(target, bytes),
|
|
@@ -1351,7 +1306,9 @@ export {
|
|
|
1351
1306
|
DEFAULT_WRITE_INTERVAL_MS,
|
|
1352
1307
|
DEV_PORT,
|
|
1353
1308
|
E_CONNECT_FAILED,
|
|
1309
|
+
MAX_PREDICTED_BODIES,
|
|
1354
1310
|
PING_INTERVAL_MS,
|
|
1311
|
+
PREDICTION_EPSILON,
|
|
1355
1312
|
RESIM_DEPTH,
|
|
1356
1313
|
Session,
|
|
1357
1314
|
defaultScheduler,
|