@irtio/client 0.7.0 → 0.9.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.
@@ -11,6 +11,7 @@ function emptyPredictionStats() {
11
11
  resimSteps: 0,
12
12
  rebases: 0,
13
13
  snaps: 0,
14
+ leadClamped: 0,
14
15
  suppressed: 0,
15
16
  overCap: 0,
16
17
  proxies: 0,
@@ -120,6 +121,13 @@ var ClientStore = class {
120
121
  tracked;
121
122
  descs = /* @__PURE__ */ new Map();
122
123
  frozen = /* @__PURE__ */ new WeakMap();
124
+ /**
125
+ * Per-collection add/remove subscribers (`room.onAdd` / `room.onRemove`). Kept here rather than
126
+ * in the session because this is the only place that sees a frame's ops against the state the
127
+ * frame is about to change: a removed row's last values exist for exactly as long as it takes
128
+ * `applyDelta` to run.
129
+ */
130
+ entityListeners = /* @__PURE__ */ new Map();
123
131
  /** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
124
132
  facades = /* @__PURE__ */ new Map();
125
133
  /** The object handed out as `room.state`; identity survives a resync. */
@@ -160,11 +168,13 @@ var ClientStore = class {
160
168
  }
161
169
  // -- snapshot / resync -----------------------------------------------------
162
170
  loadSnapshot(bytes) {
171
+ const before = this.captureRows();
163
172
  this.plain = decodeSnapshot(this.ext, bytes).state;
164
173
  this.tracked = track(this.ext, this.plain);
165
174
  this.pendingWrites.length = 0;
166
175
  this.evictedThroughTick = 0;
167
176
  this.baselineIntents.clear();
177
+ this.emitSnapshotDiff(before);
168
178
  }
169
179
  /**
170
180
  * Captures the field values of every pending owned write, keyed by collection then id then
@@ -293,14 +303,144 @@ var ClientStore = class {
293
303
  singletonHint(desc) {
294
304
  return `${desc.name} is a singleton and never client-owned; change it with an RPC (room.call.\u2026)`;
295
305
  }
306
+ // -- entity add/remove events ---------------------------------------------
307
+ listenersFor(name) {
308
+ let set = this.entityListeners.get(name);
309
+ if (!set) {
310
+ set = { add: /* @__PURE__ */ new Set(), remove: /* @__PURE__ */ new Set() };
311
+ this.entityListeners.set(name, set);
312
+ }
313
+ return set;
314
+ }
315
+ /**
316
+ * Subscribes to rows appearing in one entity collection. Rows that are **already present** are
317
+ * announced synchronously as the subscription is made — the join snapshot has usually landed
318
+ * before user code runs, and a listener that had to reconcile the initial set by hand would
319
+ * make the event useless for exactly the case (a spawn effect) it exists for.
320
+ */
321
+ onEntityAdd(name, cb) {
322
+ const desc = this.desc(name);
323
+ if (desc?.kind === "entity") {
324
+ const coll = entityOf(this.plain, name);
325
+ for (const id of [...coll.ids()]) cb(id, this.instance(desc, id));
326
+ }
327
+ const set = this.listenersFor(name);
328
+ set.add.add(cb);
329
+ return () => {
330
+ set.add.delete(cb);
331
+ };
332
+ }
333
+ /** Subscribes to rows disappearing from one entity collection (never fires for past removals). */
334
+ onEntityRemove(name, cb) {
335
+ const set = this.listenersFor(name);
336
+ set.remove.add(cb);
337
+ return () => {
338
+ set.remove.delete(cb);
339
+ };
340
+ }
341
+ emitAdd(name, id) {
342
+ const set = this.entityListeners.get(name);
343
+ if (!set || set.add.size === 0) return;
344
+ const desc = this.desc(name);
345
+ if (!desc) return;
346
+ const row = this.instance(desc, id);
347
+ for (const cb of [...set.add]) cb(id, row);
348
+ }
349
+ emitRemove(name, id, value, hint) {
350
+ const set = this.entityListeners.get(name);
351
+ if (!set || set.remove.size === 0) return;
352
+ const row = this.freeze(value, hint);
353
+ for (const cb of [...set.remove]) cb(id, row);
354
+ }
355
+ /**
356
+ * The adds and removes a delta is about to make, captured against the state as it stands now:
357
+ * a remove's last values are only readable before `applyDelta` drops the row.
358
+ */
359
+ captureOps(delta) {
360
+ if (this.entityListeners.size === 0) return void 0;
361
+ const adds = [];
362
+ const removes = [];
363
+ for (const dc of delta.collections) {
364
+ const set = this.entityListeners.get(dc.name);
365
+ if (!set || set.add.size === 0 && set.remove.size === 0) continue;
366
+ const desc = this.desc(dc.name);
367
+ if (!desc || desc.kind !== "entity") continue;
368
+ const coll = entityOf(this.plain, dc.name);
369
+ for (const op of dc.ops) {
370
+ if (op.op === "add") {
371
+ if (!coll.has(op.id)) adds.push([dc.name, op.id]);
372
+ } else if (op.op === "remove") {
373
+ const value = coll.get(op.id);
374
+ if (value) removes.push([dc.name, op.id, value]);
375
+ }
376
+ }
377
+ }
378
+ return adds.length === 0 && removes.length === 0 ? void 0 : { adds, removes };
379
+ }
380
+ flushOps(captured) {
381
+ if (!captured) return;
382
+ for (const [name, id, value] of captured.removes) {
383
+ const desc = this.desc(name);
384
+ this.emitRemove(
385
+ name,
386
+ id,
387
+ value,
388
+ desc ? `${desc.name}[${id}] has been removed; this is its last value` : name
389
+ );
390
+ }
391
+ for (const [name, id] of captured.adds) this.emitAdd(name, id);
392
+ }
393
+ /** Fires adds and removes for the difference a wholesale snapshot load made. */
394
+ emitSnapshotDiff(before) {
395
+ if (this.entityListeners.size === 0) return;
396
+ for (const [name, set] of this.entityListeners) {
397
+ const desc = this.desc(name);
398
+ if (!desc || desc.kind !== "entity") continue;
399
+ const prev = before.get(name) ?? /* @__PURE__ */ new Map();
400
+ const coll = entityOf(this.plain, name);
401
+ if (set.remove.size > 0) {
402
+ for (const [id, value] of prev) {
403
+ if (!coll.has(id)) {
404
+ this.emitRemove(
405
+ name,
406
+ id,
407
+ value,
408
+ `${name}[${id}] has been removed; this is its last value`
409
+ );
410
+ }
411
+ }
412
+ }
413
+ if (set.add.size > 0) {
414
+ for (const id of [...coll.ids()]) {
415
+ if (!prev.has(id)) this.emitAdd(name, id);
416
+ }
417
+ }
418
+ }
419
+ }
420
+ /** The rows every subscribed collection holds right now, for the snapshot diff. */
421
+ captureRows() {
422
+ const out = /* @__PURE__ */ new Map();
423
+ if (this.entityListeners.size === 0) return out;
424
+ for (const name of this.entityListeners.keys()) {
425
+ const desc = this.desc(name);
426
+ if (!desc || desc.kind !== "entity") continue;
427
+ const coll = entityOf(this.plain, name);
428
+ const rows = /* @__PURE__ */ new Map();
429
+ for (const [id, value] of coll) rows.set(id, value);
430
+ out.set(name, rows);
431
+ }
432
+ return out;
433
+ }
296
434
  // -- inbound frames -------------------------------------------------------
297
435
  /**
298
436
  * Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
299
437
  * (server wins only through `CORRECT`); adds, removes and owner changes always apply.
300
438
  */
301
439
  applyServerDelta(delta) {
440
+ const captured = this.captureOps(delta);
302
441
  applyDelta(this.ext, this.plain, this.withoutOwnedUpdates(delta));
303
442
  this.replayOverAddEchoes(delta);
443
+ this.flushOps(captured);
304
444
  }
305
445
  /**
306
446
  * The flushed-write half of the §7.2 in-flight own-write fix: an `add` op for an instance this
@@ -453,7 +593,9 @@ var ClientStore = class {
453
593
  });
454
594
  }
455
595
  }
596
+ const captured = this.captureOps(delta);
456
597
  applyDelta(this.ext, this.plain, delta);
598
+ this.flushOps(captured);
457
599
  let replayed = 0;
458
600
  if (replay) {
459
601
  for (const pw of [...this.pendingWrites, unflushed]) {
@@ -5,7 +5,7 @@ import {
5
5
  RESIM_DEPTH,
6
6
  SMOOTHING_HALF_LIFE_MS,
7
7
  SMOOTHING_SNAP_UNITS
8
- } from "./chunk-XWVXZRBS.js";
8
+ } from "./chunk-5OHONPVG.js";
9
9
 
10
10
  // src/predictor.ts
11
11
  var MAX_FREE_STEPS_PER_FRAME = 5;
@@ -133,6 +133,7 @@ var Predictor = class _Predictor {
133
133
  resimSteps: 0,
134
134
  rebases: 0,
135
135
  snaps: 0,
136
+ leadClamped: 0,
136
137
  suppressed: 0,
137
138
  overCap: 0,
138
139
  proxies: 0,
@@ -951,8 +952,8 @@ var Predictor = class _Predictor {
951
952
  * written, so plain state holds exactly what the server said), then re-step the world by the
952
953
  * client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
953
954
  * the newest buffered unjudged write stamped at or before it, or the baseline (the newest
954
- * judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead snaps to
955
- * authority and counts (`stats.snaps`).
955
+ * judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead re-steps that
956
+ * many and counts (`stats.leadClamped`, bugs.md #71).
956
957
  */
957
958
  rebase() {
958
959
  if (!this.worldReady) return;
@@ -966,7 +967,9 @@ var Predictor = class _Predictor {
966
967
  const k = key(entry.desc.name, entry.id);
967
968
  this.predictedTicks.set(k, Math.max(this.authorityTick, this.authorityTicks.get(k) ?? 0));
968
969
  }
969
- const lead = this.leadTicks();
970
+ const wanted = this.leadTicks();
971
+ const lead = Math.min(wanted, MAX_LEAD);
972
+ if (wanted > MAX_LEAD) this.stats.leadClamped++;
970
973
  if (this.lastLead !== void 0 && lead !== this.lastLead && this.gapMeasured) {
971
974
  const delta = lead - this.lastLead;
972
975
  this.stampGap = Math.min(RESIM_DEPTH, Math.max(0, this.stampGap + delta));
@@ -975,10 +978,6 @@ var Predictor = class _Predictor {
975
978
  }
976
979
  this.lastLead = lead;
977
980
  this.headTick = this.authorityTick;
978
- if (lead > MAX_LEAD) {
979
- this.stats.snaps++;
980
- return;
981
- }
982
981
  const replays = /* @__PURE__ */ new Map();
983
982
  for (const entry of this.bodies.values()) {
984
983
  if (!entry.owned) continue;