@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
|
@@ -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
|
+
};
|