@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.
@@ -0,0 +1,562 @@
1
+ // src/contract.ts
2
+ var E_CONNECT_FAILED = "E_CONNECT_FAILED";
3
+ var MAX_PREDICTED_BODIES = 64;
4
+ var PREDICTION_EPSILON = 0.05;
5
+ function emptyPredictionStats() {
6
+ return {
7
+ freeSteps: 0,
8
+ resimSteps: 0,
9
+ rebases: 0,
10
+ snaps: 0,
11
+ suppressed: 0,
12
+ overCap: 0,
13
+ lastResimMicros: 0
14
+ };
15
+ }
16
+
17
+ // src/state.ts
18
+ import {
19
+ applyDelta,
20
+ collectionDirty,
21
+ createDirtySet,
22
+ createFieldMask,
23
+ createState,
24
+ decodeSnapshot,
25
+ encodeDelta,
26
+ frozenProxy,
27
+ isDirtyEmpty,
28
+ track
29
+ } from "@irtio/schema";
30
+ var RESIM_DEPTH = 20;
31
+ function entityOf(state, name) {
32
+ return state[name];
33
+ }
34
+ function subtractMask(dst, src) {
35
+ for (const idx of src.fields) {
36
+ const srcNested = src.nested.get(idx);
37
+ const dstNested = dst.nested.get(idx);
38
+ if (srcNested && dstNested) {
39
+ subtractMask(dstNested, srcNested);
40
+ if (dstNested.fields.size === 0) {
41
+ dst.fields.delete(idx);
42
+ dst.nested.delete(idx);
43
+ }
44
+ continue;
45
+ }
46
+ dst.fields.delete(idx);
47
+ dst.nested.delete(idx);
48
+ }
49
+ }
50
+ var ClientCollectionImpl = class {
51
+ constructor(store, desc) {
52
+ this.store = store;
53
+ this.desc = desc;
54
+ }
55
+ store;
56
+ desc;
57
+ get plain() {
58
+ return entityOf(this.store.plain, this.desc.name);
59
+ }
60
+ get(id) {
61
+ return this.store.instance(this.desc, id);
62
+ }
63
+ has(id) {
64
+ return this.plain.has(id);
65
+ }
66
+ ownerOf(id) {
67
+ return this.plain.ownerOf(id);
68
+ }
69
+ get size() {
70
+ return this.plain.size;
71
+ }
72
+ ids() {
73
+ return this.plain.ids();
74
+ }
75
+ *[Symbol.iterator]() {
76
+ for (const id of [...this.plain.ids()]) {
77
+ yield [id, this.store.instance(this.desc, id)];
78
+ }
79
+ }
80
+ };
81
+ function withIndexSugar(collection) {
82
+ return new Proxy(collection, {
83
+ get(target, prop, receiver) {
84
+ if (typeof prop !== "string" || prop in target) return Reflect.get(target, prop, receiver);
85
+ return target.get(prop);
86
+ },
87
+ has(target, prop) {
88
+ if (typeof prop !== "string" || prop in target) return Reflect.has(target, prop);
89
+ return target.has(prop);
90
+ }
91
+ });
92
+ }
93
+ var ClientStore = class {
94
+ constructor(ext, meOf) {
95
+ this.ext = ext;
96
+ this.meOf = meOf;
97
+ for (const c of ext.collections) this.descs.set(c.name, c);
98
+ this.plain = createState(ext);
99
+ this.tracked = track(ext, this.plain);
100
+ this.view = this.buildView();
101
+ }
102
+ ext;
103
+ meOf;
104
+ plain;
105
+ tracked;
106
+ descs = /* @__PURE__ */ new Map();
107
+ frozen = /* @__PURE__ */ new WeakMap();
108
+ /** The object handed out as `room.state`; identity survives a resync. */
109
+ view;
110
+ /** Flushed-but-unjudged writes, oldest first, at most `RESIM_DEPTH` entries (D19). */
111
+ pendingWrites = [];
112
+ /** The newest write tick evicted from `pendingWrites` — corrections older than it must snap. */
113
+ evictedThroughTick = 0;
114
+ /**
115
+ * The newest fields to leave `pendingWrites` per instance — judged into a correction, or
116
+ * evicted past the resim depth. This is the intent in force at the *start* of a resim window:
117
+ * the server holds a write's values until the next `WRITE` replaces them, so a replay that
118
+ * walks the window tick by tick starts from here, not from the newest local value.
119
+ */
120
+ baselineIntents = /* @__PURE__ */ new Map();
121
+ // -- snapshot / resync -----------------------------------------------------
122
+ loadSnapshot(bytes) {
123
+ this.plain = decodeSnapshot(this.ext, bytes).state;
124
+ this.tracked = track(this.ext, this.plain);
125
+ this.pendingWrites.length = 0;
126
+ this.evictedThroughTick = 0;
127
+ this.baselineIntents.clear();
128
+ }
129
+ /**
130
+ * Captures the field values of every pending owned write, keyed by collection then id then
131
+ * field name — call this **before** `loadSnapshot` on a resync, or the edit is gone once the
132
+ * old `plain` it lives in is replaced. Paired with `applyPendingWrites`.
133
+ */
134
+ capturePendingWrites() {
135
+ const out = /* @__PURE__ */ new Map();
136
+ const me = this.meOf();
137
+ if (me === "") return out;
138
+ for (const [name, cd] of this.tracked.dirty) {
139
+ const desc = this.desc(name);
140
+ if (!desc || desc.kind !== "entity" || desc.serverOwned) continue;
141
+ const coll = entityOf(this.plain, name);
142
+ for (const [id, rd] of cd.updated) {
143
+ if (rd.mask.fields.size === 0 || !coll.has(id) || coll.ownerOf(id) !== me) continue;
144
+ const value = coll.get(id);
145
+ const patch = {};
146
+ for (const idx of rd.mask.fields) {
147
+ const fname = desc.fields[idx]?.name;
148
+ if (fname !== void 0) patch[fname] = value[fname];
149
+ }
150
+ let byId = out.get(name);
151
+ if (!byId) {
152
+ byId = /* @__PURE__ */ new Map();
153
+ out.set(name, byId);
154
+ }
155
+ byId.set(id, patch);
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+ /**
161
+ * Writes a `capturePendingWrites` snapshot into the freshly loaded `plain` (still the raw
162
+ * decoded values — no validation, they were already validated at the time of the original
163
+ * local write) and marks every owned field dirty so the next flush resends the real edit, not
164
+ * whatever the resync snapshot happened to carry for that field.
165
+ */
166
+ applyPendingWrites(pending) {
167
+ const me = this.meOf();
168
+ if (me === "" || pending.size === 0) return;
169
+ for (const [name, byId] of pending) {
170
+ const coll = entityOf(this.plain, name);
171
+ for (const [id, patch] of byId) {
172
+ if (!coll.has(id) || coll.ownerOf(id) !== me) continue;
173
+ const rec = coll.get(id);
174
+ for (const [field, value] of Object.entries(patch)) {
175
+ rec[field] = Array.isArray(value) ? [...value] : value;
176
+ }
177
+ }
178
+ }
179
+ this.remarkOwned();
180
+ }
181
+ buildView() {
182
+ const view = {};
183
+ for (const c of this.ext.collections) {
184
+ if (c.kind === "entity") {
185
+ const facade = withIndexSugar(new ClientCollectionImpl(this, c));
186
+ Object.defineProperty(view, c.name, { get: () => facade, enumerable: true });
187
+ } else {
188
+ Object.defineProperty(view, c.name, {
189
+ get: () => this.freeze(this.plain[c.name], this.singletonHint(c)),
190
+ enumerable: true
191
+ });
192
+ }
193
+ }
194
+ return view;
195
+ }
196
+ // -- instances ------------------------------------------------------------
197
+ /** The writable tracked proxy when this client owns `id`, otherwise a frozen one. */
198
+ instance(desc, id) {
199
+ const coll = entityOf(this.plain, desc.name);
200
+ const value = coll.get(id);
201
+ if (value === void 0) return void 0;
202
+ if (!desc.serverOwned && coll.ownerOf(id) === this.meOf()) {
203
+ const trackedColl = this.tracked.state[desc.name];
204
+ return trackedColl ? trackedColl.get(id) : value;
205
+ }
206
+ return this.freeze(value, this.entityHint(desc, id, coll.ownerOf(id)));
207
+ }
208
+ freeze(value, hint) {
209
+ const cached = this.frozen.get(value);
210
+ if (cached !== void 0) return cached;
211
+ const proxy = frozenProxy(value, hint);
212
+ this.frozen.set(value, proxy);
213
+ return proxy;
214
+ }
215
+ entityHint(desc, id, owner) {
216
+ if (desc.serverOwned) {
217
+ return `${desc.name} is server-owned; change it with an RPC (room.call.\u2026)`;
218
+ }
219
+ const me = this.meOf();
220
+ if (owner === void 0 || owner === "") {
221
+ return `${desc.name}[${id}] is owned by the server; await room.requestOwnership(${JSON.stringify(desc.name)}, ${JSON.stringify(id)}) first`;
222
+ }
223
+ return `${desc.name}[${id}] is owned by ${owner}, not by you (${me}); await room.requestOwnership(${JSON.stringify(desc.name)}, ${JSON.stringify(id)}) first`;
224
+ }
225
+ singletonHint(desc) {
226
+ return `${desc.name} is a singleton and never client-owned; change it with an RPC (room.call.\u2026)`;
227
+ }
228
+ // -- inbound frames -------------------------------------------------------
229
+ /**
230
+ * Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
231
+ * (server wins only through `CORRECT`); adds, removes and owner changes always apply.
232
+ */
233
+ applyServerDelta(delta) {
234
+ applyDelta(this.ext, this.plain, this.withoutOwnedUpdates(delta));
235
+ this.replayOverAddEchoes(delta);
236
+ }
237
+ /**
238
+ * The flushed-write half of the §7.2 in-flight own-write fix: an `add` op for an instance this
239
+ * client owns replaces the whole record, so any write already *flushed* (dirty set consumed —
240
+ * `preserveLocalWrites` cannot see it) but not yet judged would be reverted by the echo.
241
+ * Re-apply the retained pending writes in flush order; the server's eventual `CORRECT` (if the
242
+ * write is clamped) still wins through the snap+replay path.
243
+ */
244
+ replayOverAddEchoes(delta) {
245
+ const me = this.meOf();
246
+ if (me === "" || this.pendingWrites.length === 0) return;
247
+ for (const dc of delta.collections) {
248
+ const desc = this.desc(dc.name);
249
+ if (!desc || desc.kind !== "entity" || desc.serverOwned) continue;
250
+ for (const op of dc.ops) {
251
+ if (op.op !== "add" || op.owner !== me) continue;
252
+ for (const pw of this.pendingWrites) this.replayWrite(pw, dc.name, op.id);
253
+ }
254
+ }
255
+ }
256
+ withoutOwnedUpdates(delta) {
257
+ const me = this.meOf();
258
+ let changed = false;
259
+ const collections = [];
260
+ for (const dc of delta.collections) {
261
+ const desc = this.desc(dc.name);
262
+ if (!desc || desc.kind !== "entity" || desc.serverOwned) {
263
+ collections.push(dc);
264
+ continue;
265
+ }
266
+ const coll = entityOf(this.plain, dc.name);
267
+ const ops = [];
268
+ for (const op of dc.ops) {
269
+ if (op.op === "add") {
270
+ const rewritten = this.preserveLocalWrites(desc, coll, op, me);
271
+ if (rewritten !== op) changed = true;
272
+ ops.push(rewritten);
273
+ continue;
274
+ }
275
+ if (op.op !== "update") {
276
+ ops.push(op);
277
+ continue;
278
+ }
279
+ const owner = op.owner ?? coll.ownerOf(op.id);
280
+ if (owner !== me) {
281
+ ops.push(op);
282
+ continue;
283
+ }
284
+ changed = true;
285
+ if (op.owner !== void 0) {
286
+ ops.push({
287
+ op: "update",
288
+ id: op.id,
289
+ owner: op.owner,
290
+ mask: createFieldMask(),
291
+ patch: {}
292
+ });
293
+ }
294
+ }
295
+ collections.push({ name: dc.name, ops });
296
+ }
297
+ return changed ? { tick: delta.tick, hash8: delta.hash8, collections } : delta;
298
+ }
299
+ /**
300
+ * An `add` for an instance this client will own, with its unflushed local field values folded
301
+ * back in. Returns `op` unchanged when there is nothing to preserve.
302
+ */
303
+ preserveLocalWrites(desc, coll, op, me) {
304
+ if (me === "" || op.owner !== me) return op;
305
+ const pending = this.tracked.dirty.get(desc.name)?.updated.get(op.id);
306
+ if (!pending || pending.mask.fields.size === 0) return op;
307
+ const local = coll.get(op.id);
308
+ if (!local) return op;
309
+ const value = { ...op.value };
310
+ for (const idx of pending.mask.fields) {
311
+ const field = desc.fields[idx]?.name;
312
+ if (field !== void 0) value[field] = local[field];
313
+ }
314
+ return { op: "add", id: op.id, owner: op.owner, value };
315
+ }
316
+ /**
317
+ * Applies a `CORRECT`: snap the named fields to the server's values, then re-apply every local
318
+ * write newer than the judged `clientTick` in order (D19 snap + replay) — flushed writes from
319
+ * the retained buffer first, the still-unflushed in-window edit last (it is the newest, and its
320
+ * dirty mark survives so the next flush re-sends the *local* value, not the server's).
321
+ *
322
+ * Without a `clientTick` (a pre-week-8 server) — or when the correction outran the
323
+ * `RESIM_DEPTH` window (`snapped`) — the pre-D19 semantics apply: the correction wins in full
324
+ * and the local dirty marks it supersedes are cleared.
325
+ */
326
+ applyCorrection(delta, clientTick) {
327
+ if (clientTick !== void 0) {
328
+ while (this.pendingWrites.length > 0 && this.pendingWrites[0].tick <= clientTick) {
329
+ this.noteBaseline(this.pendingWrites.shift());
330
+ }
331
+ }
332
+ const snapped = clientTick !== void 0 && clientTick < this.evictedThroughTick;
333
+ const replay = clientTick !== void 0 && !snapped;
334
+ const me = this.meOf();
335
+ const out = [];
336
+ const targets = [];
337
+ const unflushed = { tick: Number.MAX_SAFE_INTEGER, writes: /* @__PURE__ */ new Map() };
338
+ for (const dc of delta.collections) {
339
+ const desc = this.desc(dc.name);
340
+ if (!desc) continue;
341
+ for (const op of dc.ops) {
342
+ if (op.op !== "update") continue;
343
+ const pending = this.tracked.dirty.get(dc.name)?.updated.get(op.id);
344
+ if (pending) {
345
+ if (replay) {
346
+ const coll = entityOf(this.plain, dc.name);
347
+ const value = coll.get(op.id);
348
+ if (value && coll.ownerOf(op.id) === me && pending.mask.fields.size > 0) {
349
+ const patch = {};
350
+ for (const idx of pending.mask.fields) {
351
+ const fname = desc.fields[idx]?.name;
352
+ if (fname !== void 0) patch[fname] = value[fname];
353
+ }
354
+ let byId = unflushed.writes.get(dc.name);
355
+ if (!byId) {
356
+ byId = /* @__PURE__ */ new Map();
357
+ unflushed.writes.set(dc.name, byId);
358
+ }
359
+ byId.set(op.id, patch);
360
+ }
361
+ } else {
362
+ subtractMask(pending.mask, op.mask);
363
+ }
364
+ }
365
+ targets.push({ collection: dc.name, id: op.id });
366
+ const fields = [...op.mask.fields].map((i) => desc.fields[i]?.name).filter((n) => n !== void 0);
367
+ const beforeSnap = entityOf(this.plain, dc.name).get(op.id);
368
+ const previous = {};
369
+ if (beforeSnap) {
370
+ for (const f of fields) {
371
+ const v = beforeSnap[f];
372
+ previous[f] = Array.isArray(v) ? [...v] : v;
373
+ }
374
+ }
375
+ out.push({
376
+ collection: dc.name,
377
+ id: op.id,
378
+ fields,
379
+ patch: op.patch,
380
+ previous,
381
+ tick: delta.tick,
382
+ ...clientTick !== void 0 ? { clientTick } : {},
383
+ replayed: 0,
384
+ snapped
385
+ });
386
+ }
387
+ }
388
+ applyDelta(this.ext, this.plain, delta);
389
+ let replayed = 0;
390
+ if (replay) {
391
+ for (const pw of [...this.pendingWrites, unflushed]) {
392
+ let applied = false;
393
+ for (const t of targets) {
394
+ if (this.replayWrite(pw, t.collection, t.id)) applied = true;
395
+ }
396
+ if (applied) replayed++;
397
+ }
398
+ }
399
+ return replayed === 0 ? out : out.map((op) => ({ ...op, replayed }));
400
+ }
401
+ desc(name) {
402
+ return this.descs.get(name);
403
+ }
404
+ /** The raw plain collection — authoritative values plus local writes (`track()` writes through). */
405
+ plainCollection(name) {
406
+ return entityOf(this.plain, name);
407
+ }
408
+ /**
409
+ * The flushed-but-unjudged write patches for one instance, oldest first, each keyed by the
410
+ * tick its `WRITE` was stamped with — for a physics entity these are exactly its
411
+ * unacknowledged intent frames (only intents are writable). The predictor's rebase walks its
412
+ * resim window tick by tick and applies the newest patch stamped at or before each tick
413
+ * (D22 part 2), starting from `baselineIntent` for the ticks before the first of them.
414
+ */
415
+ pendingWritePatches(collection, id) {
416
+ const out = [];
417
+ for (const pw of this.pendingWrites) {
418
+ const patch = pw.writes.get(collection)?.get(id);
419
+ if (patch) out.push({ tick: pw.tick, patch });
420
+ }
421
+ return out;
422
+ }
423
+ /**
424
+ * The intent in force for one instance at the start of the resim window: the newest fields
425
+ * that have left the pending buffer (judged into a correction's values, or evicted past the
426
+ * resim depth). `undefined` until the instance's first write leaves the buffer.
427
+ */
428
+ baselineIntent(collection, id) {
429
+ return this.baselineIntents.get(collection)?.get(id);
430
+ }
431
+ /** Folds a write that left the pending buffer into the per-instance baseline, newest wins. */
432
+ noteBaseline(pw) {
433
+ for (const [name, byId] of pw.writes) {
434
+ let coll = this.baselineIntents.get(name);
435
+ if (!coll) {
436
+ coll = /* @__PURE__ */ new Map();
437
+ this.baselineIntents.set(name, coll);
438
+ }
439
+ for (const [id, patch] of byId) {
440
+ const prev = coll.get(id);
441
+ coll.set(id, prev ? { ...prev, ...patch } : { ...patch });
442
+ }
443
+ }
444
+ }
445
+ // -- outbound writes ------------------------------------------------------
446
+ /** Cheap check for the flush loop: has anything been written locally since the last flush? */
447
+ get hasLocalWrites() {
448
+ return !isDirtyEmpty(this.tracked.dirty);
449
+ }
450
+ /**
451
+ * The `WRITE` payload for everything dirty, or `undefined` when nothing qualifies. Consumes the
452
+ * dirty set either way (a write to an instance that has since been removed or handed away is
453
+ * dropped, not retried forever).
454
+ */
455
+ takeWrite(tick) {
456
+ const dirty = this.tracked.flush();
457
+ const owned = this.ownedDirty(dirty);
458
+ if (owned.size === 0) return void 0;
459
+ this.retainPendingWrite(tick, owned);
460
+ return encodeDelta(this.ext, this.plain, owned, { tick });
461
+ }
462
+ /**
463
+ * Captures the field values a flushed `WRITE` carried, keyed by its write tick, so a later
464
+ * `CORRECT` can re-apply exactly the writes the server had not judged yet (D19 snap + replay).
465
+ * Bounded to `RESIM_DEPTH` entries; eviction is remembered so an outrun correction snaps.
466
+ */
467
+ retainPendingWrite(tick, owned) {
468
+ const writes = /* @__PURE__ */ new Map();
469
+ for (const [name, cd] of owned) {
470
+ const desc = this.desc(name);
471
+ if (!desc) continue;
472
+ const coll = entityOf(this.plain, name);
473
+ for (const [id, rd] of cd.updated) {
474
+ const value = coll.get(id);
475
+ if (!value) continue;
476
+ const patch = {};
477
+ for (const idx of rd.mask.fields) {
478
+ const fname = desc.fields[idx]?.name;
479
+ if (fname === void 0) continue;
480
+ const v = value[fname];
481
+ patch[fname] = Array.isArray(v) ? [...v] : typeof v === "object" && v !== null ? { ...v } : v;
482
+ }
483
+ let byId = writes.get(name);
484
+ if (!byId) {
485
+ byId = /* @__PURE__ */ new Map();
486
+ writes.set(name, byId);
487
+ }
488
+ byId.set(id, patch);
489
+ }
490
+ }
491
+ if (writes.size === 0) return;
492
+ this.pendingWrites.push({ tick, writes });
493
+ while (this.pendingWrites.length > RESIM_DEPTH) {
494
+ const evicted = this.pendingWrites.shift();
495
+ if (evicted) {
496
+ this.noteBaseline(evicted);
497
+ if (evicted.tick > this.evictedThroughTick) this.evictedThroughTick = evicted.tick;
498
+ }
499
+ }
500
+ }
501
+ /** Re-applies one retained write's fields for `collection[id]` onto the plain state. */
502
+ replayWrite(pw, collection, id) {
503
+ const patch = pw.writes.get(collection)?.get(id);
504
+ if (!patch) return false;
505
+ const me = this.meOf();
506
+ const coll = entityOf(this.plain, collection);
507
+ if (!coll.has(id) || coll.ownerOf(id) !== me) return false;
508
+ const rec = coll.get(id);
509
+ for (const [field, value] of Object.entries(patch)) {
510
+ rec[field] = Array.isArray(value) ? [...value] : typeof value === "object" && value !== null ? { ...value } : value;
511
+ }
512
+ return true;
513
+ }
514
+ /**
515
+ * Re-marks every field of every instance this client owns, so a resync `WELCOME` (wake or
516
+ * worker restart) does not silently drop writes that were in flight.
517
+ */
518
+ remarkOwned() {
519
+ const me = this.meOf();
520
+ if (me === "") return;
521
+ for (const c of this.ext.collections) {
522
+ if (c.kind !== "entity" || c.serverOwned) continue;
523
+ const coll = entityOf(this.plain, c.name);
524
+ for (const id of [...coll.ids()]) {
525
+ if (coll.ownerOf(id) !== me) continue;
526
+ const rec = collectionDirty(this.tracked.dirty, c.name).updated;
527
+ const mask = createFieldMask();
528
+ for (const f of c.fields) mask.fields.add(f.index);
529
+ rec.set(id, { mask, owner: false });
530
+ }
531
+ }
532
+ }
533
+ /** Only update ops, only instances that still exist and are still mine, never the owner bit. */
534
+ ownedDirty(dirty) {
535
+ const me = this.meOf();
536
+ const out = createDirtySet();
537
+ if (me === "") return out;
538
+ for (const [name, cd] of dirty) {
539
+ const desc = this.desc(name);
540
+ if (!desc || desc.kind !== "entity" || desc.serverOwned) continue;
541
+ const coll = entityOf(this.plain, name);
542
+ for (const [id, rd] of cd.updated) {
543
+ if (rd.mask.fields.size === 0) continue;
544
+ if (!coll.has(id) || coll.ownerOf(id) !== me) continue;
545
+ collectionDirty(out, name).updated.set(id, { mask: rd.mask, owner: false });
546
+ }
547
+ }
548
+ for (const [name, cd] of [...out]) {
549
+ if (cd.updated.size === 0) out.delete(name);
550
+ }
551
+ return out;
552
+ }
553
+ };
554
+
555
+ export {
556
+ E_CONNECT_FAILED,
557
+ MAX_PREDICTED_BODIES,
558
+ PREDICTION_EPSILON,
559
+ emptyPredictionStats,
560
+ RESIM_DEPTH,
561
+ ClientStore
562
+ };