@irtio/client 0.6.0 → 0.7.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.
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  MAX_PREDICTED_BODIES,
3
+ MAX_PROXY_BODIES,
3
4
  PREDICTION_EPSILON,
4
5
  RESIM_DEPTH,
5
6
  SMOOTHING_HALF_LIFE_MS,
6
7
  SMOOTHING_SNAP_UNITS
7
- } from "./chunk-6J6PKFUS.js";
8
+ } from "./chunk-XWVXZRBS.js";
8
9
 
9
10
  // src/predictor.ts
10
11
  var MAX_FREE_STEPS_PER_FRAME = 5;
@@ -134,6 +135,8 @@ var Predictor = class _Predictor {
134
135
  snaps: 0,
135
136
  suppressed: 0,
136
137
  overCap: 0,
138
+ proxies: 0,
139
+ absent: 0,
137
140
  lastResimMicros: 0,
138
141
  smoothing: 0,
139
142
  stampGap: 0,
@@ -146,11 +149,19 @@ var Predictor = class _Predictor {
146
149
  /** Seconds per step, fixed for the life of the world. */
147
150
  timestepSeconds;
148
151
  bodies = /* @__PURE__ */ new Map();
152
+ /** D71: kinematic proxies, keyed like `bodies`. The two maps are disjoint by construction. */
153
+ proxies = /* @__PURE__ */ new Map();
149
154
  /** Physics-backed entity collections, in schema order. D50: not `readonly`, see `swapSchema`. */
150
155
  collections;
151
156
  warned = /* @__PURE__ */ new Set();
152
157
  /** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
153
158
  overCapHigh = /* @__PURE__ */ new Map();
159
+ /** The same, for the proxy cap: `absent` is the count a game has to act on (D71-d). */
160
+ absentHigh = /* @__PURE__ */ new Map();
161
+ /** D71: where a proxy goes. Wired by the session; see {@link DrawnReader}. */
162
+ drawnReader;
163
+ /** `drawnReader`'s output buffer, reused: it is called once per proxy per frame. */
164
+ drawnChannels = {};
154
165
  /** Authority arrived since the last frame: rebase before free-running. */
155
166
  authorityDirty = false;
156
167
  accumulatorMs = 0;
@@ -227,8 +238,17 @@ var Predictor = class _Predictor {
227
238
  );
228
239
  for (const entry of this.bodies.values()) this.adapter.removeBody(entry.body);
229
240
  this.bodies.clear();
241
+ for (const entry of this.proxies.values()) this.adapter.removeBody(entry.body);
242
+ this.proxies.clear();
230
243
  this.reset();
231
244
  }
245
+ /**
246
+ * D71: tell the predictor where its proxies go. The session calls this once, with a reader over
247
+ * the D20 interpolation buffer; without it a proxy holds the pose it was built at.
248
+ */
249
+ setDrawnReader(reader) {
250
+ this.drawnReader = reader;
251
+ }
232
252
  get epsilon() {
233
253
  return this.tuning.epsilon ?? PREDICTION_EPSILON;
234
254
  }
@@ -260,6 +280,8 @@ var Predictor = class _Predictor {
260
280
  }
261
281
  free() {
262
282
  this.bodies.clear();
283
+ this.proxies.clear();
284
+ this.stats.proxies = 0;
263
285
  this.adapter.free();
264
286
  this.worldReady = false;
265
287
  }
@@ -330,10 +352,23 @@ var Predictor = class _Predictor {
330
352
  this.stats.lastStampTick = stampTick;
331
353
  this.stats.lastAppliedTick = appliedTick;
332
354
  }
333
- /** Does the local world currently simulate `collection[id]`? */
355
+ /**
356
+ * Does the local world currently **simulate** `collection[id]`?
357
+ *
358
+ * Deliberately still false for a proxied instance (D71). Every caller of this asks it to decide
359
+ * whether the local world is the better answer than authority — the render read path, the
360
+ * correction classifier, `room.prediction.predicts` — and for a proxy it is not: a proxy is
361
+ * authority, one interpolation delay old, put into the world so that other bodies can touch it.
362
+ * Reading it back would be a round trip through the physics engine to learn what the render
363
+ * buffer already said. {@link proxied} is the question about proxies.
364
+ */
334
365
  has(collection, id) {
335
366
  return this.bodies.has(key(collection, id));
336
367
  }
368
+ /** D71: does `collection[id]` have a kinematic proxy in the local world right now? */
369
+ hasProxy(collection, id) {
370
+ return this.proxies.has(key(collection, id));
371
+ }
337
372
  /**
338
373
  * Is a correction's every value within the suppression tolerance of the prediction it judges?
339
374
  * Position (and rotation) channels compare against `epsilon` directly; velocity and angular
@@ -630,52 +665,168 @@ var Predictor = class _Predictor {
630
665
  // Membership
631
666
  // -------------------------------------------------------------------------
632
667
  /**
633
- * Mirrors the local world's bodies onto the instances this client predicts: every physics
634
- * instance it owns, plus non-owned instances of `predicted: true` collections up to the cap
635
- * (collection order, then insertion order the same stated iteration guarantee the server
636
- * follows, so which bodies fall over the cap is deterministic).
668
+ * Mirrors the local world onto the instances this client can see: a **simulated** body for every
669
+ * physics instance it owns plus non-owned instances of `predicted: true` collections up to
670
+ * `maxPredictedBodies`, and a **kinematic proxy** (D71) for everything else that has a body
671
+ * factory and has not opted out with `proxy: false`, up to `maxProxyBodies`.
637
672
  *
638
- * Everything else non-`predicted` collections, and `predicted` instances over the cap gets
639
- * no body and no collider here. Those instances still render (the interpolation path reads
640
- * authoritative state directly), but nothing in the local world can touch them.
673
+ * Slot order is collection order then insertion order the same iteration guarantee the server
674
+ * states so which instances fall over either cap is deterministic. There is no relevance
675
+ * policy and no distance ordering: the ninth crate spawned is still the ninth crate, it is just
676
+ * now solid rather than missing.
677
+ *
678
+ * Three transitions happen here, and each is a removal before a creation so the world never holds
679
+ * two colliders for one id:
680
+ *
681
+ * - **Promotion.** A proxied instance of a predicted collection finds a free slot (a predicted
682
+ * body was removed, or the cap was raised). `createBody` applies the authority record, velocity
683
+ * channels included, so the body carries on rather than starting from rest.
684
+ * - **Demotion.** A simulated instance loses its slot to an earlier-collection newcomer. That is
685
+ * what this loop already did before proxies — it stopped marking the body live and the sweep
686
+ * below removed it — and now it becomes a proxy at the drawn pose in the same frame. It costs
687
+ * one visible pose step: the body was a lead ahead of authority and the proxy is an
688
+ * interpolation delay behind it.
689
+ * - **Removal.** An instance the server removed, or one that left this client's area of interest
690
+ * (D23), loses whichever of the two it had, the same frame.
691
+ *
692
+ * What is left over — over the proxy cap, `proxy: false`, or no factory — has no collider in the
693
+ * local world at all, is counted in `stats.absent`, and is the only case a predicted body still
694
+ * falls through.
641
695
  */
642
696
  reconcileBodies() {
643
697
  if (!this.worldReady) return;
644
698
  const me = this.meOf();
645
699
  const live = /* @__PURE__ */ new Set();
700
+ const liveProxies = /* @__PURE__ */ new Set();
646
701
  let nonOwned = 0;
647
702
  let overCap = 0;
703
+ let absent = 0;
648
704
  const overCapBy = /* @__PURE__ */ new Map();
705
+ const cappedBy = /* @__PURE__ */ new Map();
649
706
  const cap = this.tuning.maxPredictedBodies ?? MAX_PREDICTED_BODIES;
707
+ const proxyCap = this.tuning.maxProxyBodies ?? MAX_PROXY_BODIES;
650
708
  for (const desc of this.collections) {
651
709
  const coll = this.store.plainCollection(desc.name);
652
710
  for (const id of coll.ids()) {
711
+ const k = key(desc.name, id);
653
712
  const owned = coll.ownerOf(id) === me && me !== "";
654
- if (!owned) {
655
- if (!desc.predicted) continue;
656
- if (nonOwned >= cap) {
713
+ let simulate = owned;
714
+ if (!owned && desc.predicted) {
715
+ if (nonOwned < cap) {
716
+ simulate = true;
717
+ nonOwned++;
718
+ } else {
657
719
  overCap++;
658
720
  overCapBy.set(desc.name, (overCapBy.get(desc.name) ?? 0) + 1);
659
- continue;
660
721
  }
661
- nonOwned++;
662
722
  }
663
- const k = key(desc.name, id);
664
- live.add(k);
665
- if (this.bodies.has(k)) continue;
723
+ if (simulate) {
724
+ live.add(k);
725
+ if (this.bodies.has(k)) continue;
726
+ this.removeProxy(k);
727
+ const record2 = coll.get(id);
728
+ if (record2) this.createBody(desc, id, record2, owned);
729
+ continue;
730
+ }
731
+ if (!desc.proxy || !this.adapter.hasFactory(desc.name)) {
732
+ absent++;
733
+ continue;
734
+ }
735
+ if (liveProxies.size >= proxyCap) {
736
+ absent++;
737
+ cappedBy.set(desc.name, (cappedBy.get(desc.name) ?? 0) + 1);
738
+ continue;
739
+ }
740
+ liveProxies.add(k);
741
+ if (this.proxies.has(k)) continue;
742
+ this.removePredicted(k);
666
743
  const record = coll.get(id);
667
- if (record) this.createBody(desc, id, record, owned);
744
+ if (record) this.createProxy(desc, id, record);
668
745
  }
669
746
  }
670
747
  this.stats.overCap = overCap;
671
- this.warnOverCap(cap, overCapBy);
672
- for (const [k, entry] of [...this.bodies]) {
673
- if (live.has(k)) continue;
674
- this.bodies.delete(k);
675
- this.authorityTicks.delete(k);
676
- this.predictedTicks.delete(k);
677
- this.history.delete(k);
678
- this.adapter.removeBody(entry.body);
748
+ this.stats.absent = absent;
749
+ this.warnCaps(cap, proxyCap, overCapBy, cappedBy);
750
+ for (const k of [...this.bodies.keys()]) {
751
+ if (!live.has(k)) this.removePredicted(k);
752
+ }
753
+ for (const k of [...this.proxies.keys()]) {
754
+ if (!liveProxies.has(k)) this.removeProxy(k);
755
+ }
756
+ this.stats.proxies = this.proxies.size;
757
+ this.refreshProxyTargets();
758
+ }
759
+ /** Drops one simulated body and everything the loop keeps per body. Safe on a missing key. */
760
+ removePredicted(k) {
761
+ const entry = this.bodies.get(k);
762
+ if (!entry) return;
763
+ this.bodies.delete(k);
764
+ this.authorityTicks.delete(k);
765
+ this.predictedTicks.delete(k);
766
+ this.history.delete(k);
767
+ this.adapter.removeBody(entry.body);
768
+ }
769
+ /** Drops one proxy. Safe on a missing key. */
770
+ removeProxy(k) {
771
+ const entry = this.proxies.get(k);
772
+ if (!entry) return;
773
+ this.proxies.delete(k);
774
+ this.adapter.removeBody(entry.body);
775
+ }
776
+ /**
777
+ * D71: builds one kinematic proxy through the **same factory call** the simulated path uses, so
778
+ * the collider is the server's collider by construction rather than by a second description of
779
+ * it, then hands it to the adapter to be made kinematic.
780
+ *
781
+ * The authority record is applied first — a proxy appears where the server last said it was, not
782
+ * at the factory's origin — and the drawn pose takes over on the next `refreshProxyTargets`.
783
+ */
784
+ createProxy(desc, id, record) {
785
+ if (!this.worldReady) return;
786
+ const body = this.adapter.createBody(
787
+ desc,
788
+ id,
789
+ record,
790
+ (k, message) => this.warnOnce(k, message)
791
+ );
792
+ if (!body) return;
793
+ const physics = desc.physics;
794
+ if (physics) this.adapter.applyRecord(body, physics.channels, record);
795
+ this.adapter.makeKinematic(body);
796
+ const entry = { desc, id, body, target: emptyPose(), targeted: false };
797
+ this.adapter.readPose(body, entry.target);
798
+ this.proxies.set(key(desc.name, id), entry);
799
+ }
800
+ /**
801
+ * Reads every proxy's drawn pose once per frame, and only once.
802
+ *
803
+ * Once, because a rebase re-steps the world up to `MAX_LEAD` times against a render clock that
804
+ * has not moved: asking again per step would return the same answer at a real cost. And once
805
+ * *because* of D71's rule — a proxy holds one pose for every re-step of a rebase. It cannot run
806
+ * ahead of the newest delta because the reader never extrapolates, and it cannot run ahead of the
807
+ * frame because it is only ever asked here.
808
+ *
809
+ * A proxy with nothing drawn (a delta gap, an entity not yet at the render clock) keeps the pose
810
+ * it had. That is the hold, and it is the difference between a crate that stays solid through a
811
+ * stall and a crate that slides off across the level.
812
+ */
813
+ refreshProxyTargets() {
814
+ const reader = this.drawnReader;
815
+ if (!reader || this.proxies.size === 0) return;
816
+ const channels = this.drawnChannels;
817
+ for (const entry of this.proxies.values()) {
818
+ const physics = entry.desc.physics;
819
+ if (!physics) continue;
820
+ for (const [, field] of physics.channels) channels[field] = Number.NaN;
821
+ if (!reader(entry.desc, entry.id, channels)) continue;
822
+ poseFromRecord(physics.channels, channels, entry.target);
823
+ entry.targeted = true;
824
+ }
825
+ }
826
+ /** Writes every proxy to its target pose. Runs immediately before each `adapter.step()`. */
827
+ driveProxies() {
828
+ for (const entry of this.proxies.values()) {
829
+ if (entry.targeted) this.adapter.moveKinematic(entry.body, entry.target);
679
830
  }
680
831
  }
681
832
  createBody(desc, id, record, owned) {
@@ -726,17 +877,29 @@ var Predictor = class _Predictor {
726
877
  this.bodies.set(key(desc.name, id), entry);
727
878
  }
728
879
  /**
729
- * Over-cap is not a one-time tuning notice: it means those instances are missing from the local
730
- * world right now, so a predicted body walks through them. Warn on every new high-water mark
731
- * per collection a game that grows past the cap mid-session hears about it, and a count that
732
- * oscillates around one level does not turn the console into a log.
880
+ * The two cap notices, both on a new high-water mark per collection: a game that grows past a cap
881
+ * mid-session hears about it, and a count that oscillates around one level does not turn the
882
+ * console into a log.
883
+ *
884
+ * They say different things now, and only the second is an emergency (D71). Over the *prediction*
885
+ * cap an instance is still solid — it has a proxy — it just is not simulated, so a shove does
886
+ * nothing locally until the server agrees. Over the *proxy* cap it is genuinely not there, which
887
+ * is the failure `bugs.md` #3 was about, and the message names both numbers so it is obvious
888
+ * which one to raise.
733
889
  */
734
- warnOverCap(cap, overCapBy) {
890
+ warnCaps(cap, proxyCap, overCapBy, cappedBy) {
735
891
  for (const [name, count] of overCapBy) {
736
892
  if (count <= (this.overCapHigh.get(name) ?? 0)) continue;
737
893
  this.overCapHigh.set(name, count);
738
894
  this.log(
739
- `irtio: ${count} ${name} instance(s) over the ${cap}-body prediction cap. They are ABSENT from the local world, not interpolated into it: predicted bodies pass through them until the next correction snaps them back. Raise maxPredictedBodies (the cost is a world step per lead tick, not a per-body-per-frame cost), or stop predicting what nothing collides with.`
895
+ `irtio: ${count} ${name} instance(s) over the ${cap}-body prediction cap. They keep a kinematic proxy in the local world, so predicted bodies still collide with them, but they are not simulated ahead: pushing one does nothing locally until the server says so. Raise maxPredictedBodies for anything a player shoves (the cost is a world step per lead tick, not a per-body-per-frame cost); leave it for anything a player only stands on or is blocked by.`
896
+ );
897
+ }
898
+ for (const [name, count] of cappedBy) {
899
+ if (count <= (this.absentHigh.get(name) ?? 0)) continue;
900
+ this.absentHigh.set(name, count);
901
+ this.log(
902
+ `irtio: ${count} ${name} instance(s) over the ${proxyCap}-body proxy cap (maxProxyBodies ${proxyCap}, maxPredictedBodies ${cap}). They are ABSENT from the local world, not interpolated into it: predicted bodies pass through them until the next correction snaps them back. Raise maxProxyBodies, or set proxy: false on the collections nothing collides with so the budget goes to the ones that matter.`
740
903
  );
741
904
  }
742
905
  }
@@ -840,6 +1003,7 @@ var Predictor = class _Predictor {
840
1003
  }
841
1004
  return r.inForce;
842
1005
  });
1006
+ this.driveProxies();
843
1007
  this.adapter.step();
844
1008
  this.recordStep();
845
1009
  this.headTick++;
@@ -906,6 +1070,7 @@ var Predictor = class _Predictor {
906
1070
  while (this.accumulatorMs >= dtMs) {
907
1071
  this.accumulatorMs -= dtMs;
908
1072
  this.applyIntents(() => void 0);
1073
+ this.driveProxies();
909
1074
  this.adapter.step();
910
1075
  this.recordStep();
911
1076
  this.headTick++;
@@ -925,6 +1090,11 @@ var Predictor = class _Predictor {
925
1090
  * local body of a collection, owned or not, run after the whole intent pass rather than
926
1091
  * interleaved with it. It is what gives a non-owned predicted body its gravity on an engine
927
1092
  * whose world has none of its own.
1093
+ *
1094
+ * Both passes walk `this.bodies`, which is why neither ever reaches a proxy (D71): a proxy is not
1095
+ * simulated, so an intent hook or a per-step gravity force on it would be a force on a body that
1096
+ * cannot move, applied to a pose that is going to be overwritten before the next step anyway. The
1097
+ * isolation is structural rather than a filter, which is the version that cannot rot.
928
1098
  */
929
1099
  applyIntents(frameFor) {
930
1100
  for (const entry of this.bodies.values()) {
@@ -958,6 +1128,49 @@ var Predictor = class _Predictor {
958
1128
  return desc.fields[idx]?.type.kind === "f32";
959
1129
  }
960
1130
  };
1131
+ function poseFromRecord(channels, record, into) {
1132
+ for (const [channel, field] of channels) {
1133
+ const raw = record[field];
1134
+ if (typeof raw !== "number" || !Number.isFinite(raw)) continue;
1135
+ switch (channel) {
1136
+ case "x":
1137
+ case "y":
1138
+ case "z":
1139
+ into.t[channel] = raw;
1140
+ break;
1141
+ case "qx":
1142
+ into.r.x = raw;
1143
+ break;
1144
+ case "qy":
1145
+ into.r.y = raw;
1146
+ break;
1147
+ case "qz":
1148
+ into.r.z = raw;
1149
+ break;
1150
+ case "qw":
1151
+ into.r.w = raw;
1152
+ break;
1153
+ case "vx":
1154
+ into.v.x = raw;
1155
+ break;
1156
+ case "vy":
1157
+ into.v.y = raw;
1158
+ break;
1159
+ case "vz":
1160
+ into.v.z = raw;
1161
+ break;
1162
+ case "wx":
1163
+ into.w.x = raw;
1164
+ break;
1165
+ case "wy":
1166
+ into.w.y = raw;
1167
+ break;
1168
+ case "wz":
1169
+ into.w.z = raw;
1170
+ break;
1171
+ }
1172
+ }
1173
+ }
961
1174
  function channelValue(channel, t, r, v, w) {
962
1175
  switch (channel) {
963
1176
  case "x":
@@ -1,6 +1,7 @@
1
1
  // src/contract.ts
2
2
  var E_CONNECT_FAILED = "E_CONNECT_FAILED";
3
3
  var MAX_PREDICTED_BODIES = 64;
4
+ var MAX_PROXY_BODIES = 256;
4
5
  var PREDICTION_EPSILON = 0.05;
5
6
  var SMOOTHING_HALF_LIFE_MS = 70;
6
7
  var SMOOTHING_SNAP_UNITS = 4;
@@ -12,6 +13,8 @@ function emptyPredictionStats() {
12
13
  snaps: 0,
13
14
  suppressed: 0,
14
15
  overCap: 0,
16
+ proxies: 0,
17
+ absent: 0,
15
18
  lastResimMicros: 0,
16
19
  smoothing: 0,
17
20
  stampGap: 0,
@@ -620,6 +623,7 @@ var ClientStore = class {
620
623
  export {
621
624
  E_CONNECT_FAILED,
622
625
  MAX_PREDICTED_BODIES,
626
+ MAX_PROXY_BODIES,
623
627
  PREDICTION_EPSILON,
624
628
  SMOOTHING_HALF_LIFE_MS,
625
629
  SMOOTHING_SNAP_UNITS,