@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.
package/dist/index.js CHANGED
@@ -2,12 +2,13 @@ import {
2
2
  ClientStore,
3
3
  E_CONNECT_FAILED,
4
4
  MAX_PREDICTED_BODIES,
5
+ MAX_PROXY_BODIES,
5
6
  PREDICTION_EPSILON,
6
7
  RESIM_DEPTH,
7
8
  SMOOTHING_HALF_LIFE_MS,
8
9
  SMOOTHING_SNAP_UNITS,
9
10
  emptyPredictionStats
10
- } from "./chunk-6J6PKFUS.js";
11
+ } from "./chunk-XWVXZRBS.js";
11
12
 
12
13
  // src/index.ts
13
14
  import { EMPTY_PROFILE as EMPTY_PROFILE2 } from "@irtio/protocol";
@@ -495,6 +496,23 @@ async function errorFrom(res) {
495
496
  return new IdentityError(code, message);
496
497
  }
497
498
 
499
+ // src/messages.ts
500
+ import { encodeFields } from "@irtio/schema";
501
+ function buildMessages(schema, session) {
502
+ const out = {};
503
+ for (const desc of schema?.messages ?? []) {
504
+ out[desc.name] = {
505
+ send(target, value) {
506
+ session.typedMessage(desc.index, target, encodeFields(desc.fields, value));
507
+ },
508
+ on(cb) {
509
+ return session.onTypedMessage(desc.index, cb);
510
+ }
511
+ };
512
+ }
513
+ return out;
514
+ }
515
+
498
516
  // src/session.ts
499
517
  import {
500
518
  FrameType,
@@ -529,7 +547,7 @@ import {
529
547
  decodeDelta,
530
548
  decodeDeltaFrom,
531
549
  decodeFields,
532
- encodeFields,
550
+ encodeFields as encodeFields2,
533
551
  schemaFromCanonical
534
552
  } from "@irtio/schema";
535
553
 
@@ -764,6 +782,57 @@ var RenderStore = class {
764
782
  const alpha = span > 0 ? (renderT - a.t) / span : 1;
765
783
  return lerpRecord(desc, a.value, b.value, Math.min(1, Math.max(0, alpha)));
766
784
  }
785
+ /**
786
+ * M6 lane D (D71): the body-channel values the renderer is drawing for one instance right now,
787
+ * written into `into`. `true` when there is a pose to draw at all.
788
+ *
789
+ * This is what a kinematic proxy is moved to before every local step, and it deliberately does
790
+ * **not** go through `get`. `get` asks the predictor first — which would recurse, since it calls
791
+ * `frame()` — and for an owned or predicted instance it answers out of the local world, which is
792
+ * the one answer a proxy must never be given (a proxy driven by the local world is a body driving
793
+ * itself). So this is the D20 buffer and nothing else: the authoritative pose interpolated at
794
+ * `interpDelayMs` behind arrival, held at the newest delta and never extrapolated past it.
795
+ *
796
+ * Allocation-free, and only the physics channels: it runs once per proxy per frame.
797
+ *
798
+ * Two things `get` does are left out on purpose. `starved` is not counted, because that number
799
+ * belongs to the render path and a physics read landing in it would double it. And a buffer whose
800
+ * entity has aged out is not `gc`'d here, because a read that drives physics must not decide when
801
+ * the render path's buffers die.
802
+ */
803
+ drawn(desc, id, into) {
804
+ const physics = desc.physics;
805
+ if (!physics) return false;
806
+ const coll = this.store.plain[desc.name];
807
+ const fill = (value) => {
808
+ if (value === void 0) return false;
809
+ for (const [, field] of physics.channels) {
810
+ const v = value[field];
811
+ if (typeof v === "number") into[field] = v;
812
+ }
813
+ return true;
814
+ };
815
+ if (!desc.interpolate) return fill(coll.get(id));
816
+ const buffer = this.buffers.get(desc.name)?.get(id);
817
+ if (!buffer || buffer.frames.length === 0) return fill(coll.get(id));
818
+ const renderT = this.renderTime();
819
+ if (buffer.removedAt !== void 0 && renderT >= buffer.removedAt) return false;
820
+ this.prune(buffer, renderT);
821
+ const frames = buffer.frames;
822
+ const a = frames[0];
823
+ if (renderT < a.t) return false;
824
+ const b = frames[1];
825
+ if (!b) return fill(a.value);
826
+ const span = b.t - a.t;
827
+ const alpha = span > 0 ? Math.min(1, Math.max(0, (renderT - a.t) / span)) : 1;
828
+ for (const [, field] of physics.channels) {
829
+ const av = a.value[field];
830
+ if (typeof av !== "number") continue;
831
+ const bv = b.value[field];
832
+ into[field] = typeof bv === "number" ? av + (bv - av) * alpha : av;
833
+ }
834
+ return true;
835
+ }
767
836
  /** Is `collection[id]` visible at the render clock? */
768
837
  has(desc, id) {
769
838
  const coll = this.store.plain[desc.name];
@@ -1038,6 +1107,7 @@ var Session = class {
1038
1107
  if (this.left) return;
1039
1108
  this.predictor = predictor;
1040
1109
  this.render.attachPredictor(predictor);
1110
+ predictor.setDrawnReader((desc, id, into) => this.render.drawn(desc, id, into));
1041
1111
  if (this.joined) {
1042
1112
  predictor.reset();
1043
1113
  void predictor.start();
@@ -1047,7 +1117,7 @@ var Session = class {
1047
1117
  const physics2d = options.physics2d;
1048
1118
  if (physics && hasPhysics) {
1049
1119
  this.predictionRequested = true;
1050
- void import("./physics-S5LHL3HK.js").then(({ PhysicsPredictor }) => {
1120
+ void import("./physics-RT5T36P5.js").then(({ PhysicsPredictor }) => {
1051
1121
  if (this.left) return;
1052
1122
  attach(
1053
1123
  new PhysicsPredictor(
@@ -1062,7 +1132,7 @@ var Session = class {
1062
1132
  });
1063
1133
  } else if (physics2d && hasPhysics) {
1064
1134
  this.predictionRequested = true;
1065
- void import("./physics2d-ZV2IRRRZ.js").then(({ Physics2dPredictor }) => {
1135
+ void import("./physics2d-HOFMWPZV.js").then(({ Physics2dPredictor }) => {
1066
1136
  if (this.left) return;
1067
1137
  attach(
1068
1138
  new Physics2dPredictor(
@@ -1102,6 +1172,9 @@ var Session = class {
1102
1172
  * whatever a `SCHEMA` frame replaced it with. Read instead of `options.schema` everywhere, so a
1103
1173
  * swapped session announces its *current* hash when it reconnects rather than the one it was
1104
1174
  * born with — otherwise a resume after a swap would be refused as a mismatch.
1175
+ *
1176
+ * D70 made it internally readable rather than private: `buildMessages` reads the declared
1177
+ * shapes off it when the room object is built. Still not on the public `Room` type.
1105
1178
  */
1106
1179
  schema;
1107
1180
  /** D50: how many times this session has swapped schema. Test seam and a diagnostic. */
@@ -1165,6 +1238,20 @@ var Session = class {
1165
1238
  * `INTERNAL_SESSION`, the same route the D50 tests use for the session itself.
1166
1239
  */
1167
1240
  voiceListeners = /* @__PURE__ */ new Set();
1241
+ /**
1242
+ * D70: typed-message listeners, by wire index, in their own map for the same structural reason
1243
+ * `voiceListeners` is its own set: a raw `onMessage` callback must never be handed a typed
1244
+ * frame and a typed callback must never be handed raw bytes. Splitting the registries makes
1245
+ * that a fact about the shape of the code rather than a filter somewhere in the fan-out.
1246
+ */
1247
+ typedListeners = /* @__PURE__ */ new Map();
1248
+ /** D70: one warning per session for dropped typed messages — a flood must stay one line. */
1249
+ typedDropWarned = false;
1250
+ /**
1251
+ * D70: what `room.stats.messages` reads. `dropped` is the one worth watching: it counts typed
1252
+ * frames this client could not decode, which is a peer on a schema this one does not have.
1253
+ */
1254
+ messageCounts = { sent: 0, received: 0, dropped: 0 };
1168
1255
  cancelFlush;
1169
1256
  cancelPing;
1170
1257
  cancelRetry;
@@ -1628,15 +1715,62 @@ var Session = class {
1628
1715
  if (pong.serverTick > this.tick) this.tick = pong.serverTick;
1629
1716
  }
1630
1717
  onMsg(payload) {
1631
- const msg = decodeMsg(payload);
1718
+ let msg;
1719
+ try {
1720
+ msg = decodeMsg(payload);
1721
+ } catch (err) {
1722
+ this.dropTypedMessage(err);
1723
+ return;
1724
+ }
1632
1725
  const bytes = msg.payload.slice();
1633
1726
  if (msg.target.kind === "voice") {
1634
1727
  for (const cb of [...this.voiceListeners]) cb(bytes);
1635
1728
  return;
1636
1729
  }
1637
1730
  const from = msg.target.kind === "client" ? msg.target.clientId : "server";
1731
+ if (msg.typed) {
1732
+ this.deliverTyped(msg.typed.index, from, bytes);
1733
+ return;
1734
+ }
1735
+ this.messageCounts.received++;
1638
1736
  for (const cb of [...this.messageListeners]) cb(from, bytes);
1639
1737
  }
1738
+ /**
1739
+ * D70: decode one typed payload against the current schema and hand it to that shape's
1740
+ * listeners.
1741
+ *
1742
+ * Everything a hostile peer can do here ends in the same place: a counter and a dropped frame.
1743
+ * An index past the schema's list, a truncated payload, an oversize `str`, an over-max `list`,
1744
+ * an unknown enum member, or a well-formed value of the wrong shape — each is caught, none
1745
+ * throws out of the frame loop, none closes the socket, and the next frame is delivered
1746
+ * normally. The `console.warn` fires once per session so a flood stays one line.
1747
+ */
1748
+ deliverTyped(index, from, bytes) {
1749
+ const desc = this.schema?.messages?.[index];
1750
+ if (!desc) {
1751
+ this.dropTypedMessage(new Error(`no message with index ${index} in this schema`));
1752
+ return;
1753
+ }
1754
+ let value;
1755
+ try {
1756
+ value = decodeFields(desc.fields, bytes);
1757
+ } catch (err) {
1758
+ this.dropTypedMessage(err);
1759
+ return;
1760
+ }
1761
+ this.messageCounts.received++;
1762
+ const set = this.typedListeners.get(index);
1763
+ if (!set) return;
1764
+ for (const cb of [...set]) cb(from, value);
1765
+ }
1766
+ dropTypedMessage(err) {
1767
+ this.messageCounts.dropped++;
1768
+ if (this.typedDropWarned) return;
1769
+ this.typedDropWarned = true;
1770
+ console.warn(
1771
+ `irtio: dropped a typed message this client could not read (${err instanceof Error ? err.message : String(err)}). Read room.stats.messages.dropped for the count; further drops are silent.`
1772
+ );
1773
+ }
1640
1774
  // -------------------------------------------------------------------------
1641
1775
  // Timers
1642
1776
  // -------------------------------------------------------------------------
@@ -1707,7 +1841,7 @@ var Session = class {
1707
1841
  let encoded;
1708
1842
  try {
1709
1843
  desc = this.descOf(name);
1710
- encoded = encodeFields(desc.params, params);
1844
+ encoded = encodeFields2(desc.params, params);
1711
1845
  } catch (err) {
1712
1846
  return Promise.reject(err instanceof Error ? err : new Error(String(err)));
1713
1847
  }
@@ -1728,7 +1862,10 @@ var Session = class {
1728
1862
  reject,
1729
1863
  cancelTimeout
1730
1864
  });
1731
- this.send(FrameType.CALL, encodeCall({ reqId, rpcId: desc.index, params: encoded }));
1865
+ this.send(
1866
+ FrameType.CALL,
1867
+ encodeCall({ reqId, rpcId: desc.index, clientTick: this.tick, params: encoded })
1868
+ );
1732
1869
  });
1733
1870
  }
1734
1871
  async requestOwnership(entity, id) {
@@ -1802,7 +1939,7 @@ var Session = class {
1802
1939
  replyOk(desc, reqId, result) {
1803
1940
  let bytes;
1804
1941
  try {
1805
- bytes = desc.returns ? encodeFields(desc.returns, result ?? {}) : new Uint8Array(0);
1942
+ bytes = desc.returns ? encodeFields2(desc.returns, result ?? {}) : new Uint8Array(0);
1806
1943
  } catch (err) {
1807
1944
  this.replyError(reqId, err instanceof Error ? err.message : String(err));
1808
1945
  return;
@@ -1824,6 +1961,7 @@ var Session = class {
1824
1961
  // -------------------------------------------------------------------------
1825
1962
  message(target, bytes) {
1826
1963
  const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
1964
+ this.messageCounts.sent++;
1827
1965
  this.send(FrameType.MSG, encodeMsg({ target: wire, payload: bytes }));
1828
1966
  }
1829
1967
  onMessage(cb) {
@@ -1832,6 +1970,27 @@ var Session = class {
1832
1970
  this.messageListeners.delete(cb);
1833
1971
  };
1834
1972
  }
1973
+ /**
1974
+ * D70: send one typed message. `payload` is already `encodeFields`'d by the caller
1975
+ * (`buildMessages`), which is where the shape and the throw on a bad value belong.
1976
+ */
1977
+ typedMessage(index, target, payload) {
1978
+ const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
1979
+ this.messageCounts.sent++;
1980
+ this.send(FrameType.MSG, encodeMsg({ target: wire, payload, typed: { index } }));
1981
+ }
1982
+ /** D70: subscribe to one message shape by wire index. Raw listeners never see these frames. */
1983
+ onTypedMessage(index, cb) {
1984
+ let set = this.typedListeners.get(index);
1985
+ if (!set) {
1986
+ set = /* @__PURE__ */ new Set();
1987
+ this.typedListeners.set(index, set);
1988
+ }
1989
+ set.add(cb);
1990
+ return () => {
1991
+ set?.delete(cb);
1992
+ };
1993
+ }
1835
1994
  /**
1836
1995
  * D51: write one voice signaling message. Internal — reached only through `INTERNAL_SESSION`,
1837
1996
  * so it is not on the `Room` type and a game cannot call it.
@@ -2403,6 +2562,8 @@ async function joinRoom(schema, options = {}) {
2403
2562
  }
2404
2563
  function makeRoom(session) {
2405
2564
  const call = callProxy(session);
2565
+ const messages = buildMessages(session.schema, session);
2566
+ const stats = { messages: session.messageCounts };
2406
2567
  const room = {
2407
2568
  get me() {
2408
2569
  return session.me;
@@ -2446,6 +2607,8 @@ function makeRoom(session) {
2446
2607
  return session.predictor?.ready ?? false;
2447
2608
  },
2448
2609
  predicts: (collection, id) => session.predictor?.has(collection, id) ?? false,
2610
+ // ---- M6 lane D: proxies ----
2611
+ proxied: (collection, id) => session.predictor?.hasProxy(collection, id) ?? false,
2449
2612
  get stats() {
2450
2613
  return session.predictor?.stats ?? emptyPredictionStats();
2451
2614
  }
@@ -2455,6 +2618,8 @@ function makeRoom(session) {
2455
2618
  requestOwnership: (entity, id) => session.requestOwnership(entity, id),
2456
2619
  message: (target, bytes) => session.message(target, bytes),
2457
2620
  onMessage: (cb) => session.onMessage(cb),
2621
+ messages,
2622
+ stats,
2458
2623
  on: (event, cb) => session.on(event, cb),
2459
2624
  flush: () => session.flush(),
2460
2625
  leave: () => session.leave()
@@ -2468,7 +2633,11 @@ async function joinRelay(options = {}) {
2468
2633
  const fromLocation = !explicitRoom && currentLocation() !== void 0;
2469
2634
  const roomId = explicitRoom ? roomIdFrom(options.room ?? "") : fromLocation ? roomIdFromLocation() : "";
2470
2635
  const session = new Session({
2471
- schema: void 0,
2636
+ // ---- M6 lane C: typed messages ----
2637
+ // D70: a relay join may now bring the project's deployed schema. This is a type change and a
2638
+ // hash change, not a transport change: the session already chose between the builder's schema
2639
+ // and `relaySchema`, and already put `schema.hash8` or `RELAY_HASH8` in the HELLO.
2640
+ schema: options.schema,
2472
2641
  url,
2473
2642
  key: options.key ?? resolveKey(void 0, void 0, url),
2474
2643
  roomId,
@@ -2505,6 +2674,8 @@ async function joinRelay(options = {}) {
2505
2674
  },
2506
2675
  message: (target, bytes) => session.message(target, bytes),
2507
2676
  onMessage: (cb) => session.onMessage(cb),
2677
+ messages: buildMessages(session.schema, session),
2678
+ stats: { messages: session.messageCounts },
2508
2679
  on: (event, cb) => session.on(event, cb),
2509
2680
  leave: () => session.leave()
2510
2681
  };
@@ -2524,6 +2695,7 @@ export {
2524
2695
  IdentityError,
2525
2696
  MAX_IDENTITY_RETRY_WAIT_MS,
2526
2697
  MAX_PREDICTED_BODIES,
2698
+ MAX_PROXY_BODIES,
2527
2699
  MatchError,
2528
2700
  PING_INTERVAL_MS,
2529
2701
  PREDICTION_EPSILON,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Predictor
3
- } from "./chunk-TE66EDBB.js";
4
- import "./chunk-6J6PKFUS.js";
3
+ } from "./chunk-DABQDR3S.js";
4
+ import "./chunk-XWVXZRBS.js";
5
5
 
6
6
  // src/physics.ts
7
7
  function readPose(body, into) {
@@ -100,6 +100,25 @@ var RapierAdapter = class {
100
100
  removeBody(body) {
101
101
  this.world?.removeRigidBody(body);
102
102
  }
103
+ /**
104
+ * D71: a proxy is a `KinematicPositionBased` body — driven by the position it is told to be at
105
+ * next, never by a force, an impulse or a contact. Rapier derives the velocity its contacts see
106
+ * from the move itself, which is what carries a predicted body standing on a moving platform.
107
+ *
108
+ * The body is *switched* rather than built kinematic, so its collider, mass properties and axis
109
+ * locks are the ones the shared factory produced. A proxy has to be the server's body, and the
110
+ * only way to keep that true is to run the same factory call the predicted path runs.
111
+ */
112
+ makeKinematic(handle) {
113
+ const rapier = this.rapier;
114
+ if (!rapier) return;
115
+ handle.setBodyType(rapier.RigidBodyType.KinematicPositionBased, true);
116
+ }
117
+ moveKinematic(handle, pose) {
118
+ const body = handle;
119
+ body.setNextKinematicTranslation(pose.t);
120
+ body.setNextKinematicRotation(pose.r);
121
+ }
103
122
  step() {
104
123
  this.world?.step();
105
124
  }
@@ -191,6 +210,10 @@ var PhysicsPredictor = class extends Predictor {
191
210
  super(ext, store, new RapierAdapter(options), options, meOf, rttOf, tickIntervalOf, log);
192
211
  }
193
212
  };
213
+ function createRapierAdapter(options) {
214
+ return new RapierAdapter(options);
215
+ }
194
216
  export {
195
- PhysicsPredictor
217
+ PhysicsPredictor,
218
+ createRapierAdapter
196
219
  };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Predictor
3
- } from "./chunk-TE66EDBB.js";
4
- import "./chunk-6J6PKFUS.js";
3
+ } from "./chunk-DABQDR3S.js";
4
+ import "./chunk-XWVXZRBS.js";
5
5
 
6
6
  // src/physics2d.ts
7
7
  import {
@@ -47,6 +47,8 @@ var MatterAdapter = class {
47
47
  };
48
48
  /** `applyChannel2d`'s target shape, likewise reused. */
49
49
  channels = { x: 0, y: 0, qz: 0, qw: 1, vx: 0, vy: 0, wz: 0 };
50
+ /** `moveKinematic`'s position argument, reused: it runs per proxy per step (D71). */
51
+ moveTarget = { x: 0, y: 0 };
50
52
  get timestep() {
51
53
  return this.options.timestep;
52
54
  }
@@ -95,6 +97,53 @@ var MatterAdapter = class {
95
97
  matter.Composite.remove(engine2.world, pinned ? [body, ...pinned] : [body]);
96
98
  this.constraints.delete(body);
97
99
  }
100
+ /**
101
+ * D71: matter.js has no kinematic body type. Static is the only kind it never integrates and
102
+ * never moves from an impulse, so that is what a proxy is.
103
+ *
104
+ * `Body.setStatic` also overwrites the surface properties the factory set — `friction` to 1 and
105
+ * `restitution` to 0, on every part, and on a body that was *already* static it does that without
106
+ * even stashing the old values in `_original`. A proxy has to be the server's body rather than a
107
+ * stickier copy of it (matter pairs friction as the minimum of the two, so a proxy at friction 1
108
+ * is only invisible while the other body is the grippier one), so the factory's values are
109
+ * snapshotted here and put back. Mass and inertia keep the infinities that make it immovable.
110
+ */
111
+ makeKinematic(handle) {
112
+ const matter = this.matter;
113
+ if (!matter) return;
114
+ const body = handle;
115
+ const parts = body.parts;
116
+ const surfaces = parts.map((p) => ({ friction: p.friction, restitution: p.restitution }));
117
+ matter.Body.setStatic(body, true);
118
+ for (let i = 0; i < parts.length; i++) {
119
+ const part = parts[i];
120
+ const surface = surfaces[i];
121
+ part.friction = surface.friction;
122
+ part.restitution = surface.restitution;
123
+ }
124
+ }
125
+ /**
126
+ * `updateVelocity: true` is the whole mechanism. It leaves `positionPrev` exactly one move behind
127
+ * the new position, and matter's resolver reads a body's velocity as precisely that difference
128
+ * (`Resolver.solveVelocity`: `position - positionPrev`), so a predicted body resting on the proxy
129
+ * is carried by friction the way it is on the server. Given the same pose twice the difference is
130
+ * zero and the proxy is at rest, which is what holds it still through a rebase's re-steps.
131
+ *
132
+ * The third parameter is real in matter-js 0.20 (`src/body/Body.js`) and missing from
133
+ * `@types/matter-js@0.20.2` on both setters, which is what the two casts are for.
134
+ */
135
+ moveKinematic(handle, pose) {
136
+ const matter = this.matter;
137
+ if (!matter) return;
138
+ const body = handle;
139
+ const setPosition = matter.Body.setPosition;
140
+ const setAngle = matter.Body.setAngle;
141
+ const to = this.moveTarget;
142
+ to.x = pose.t.x;
143
+ to.y = pose.t.y;
144
+ setPosition(body, to, true);
145
+ setAngle(body, angleFrom2d(pose.r.z, pose.r.w), true);
146
+ }
98
147
  step() {
99
148
  if (!this.matter || !this.engine) return;
100
149
  this.matter.Engine.update(this.engine, this.stepMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/client",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "irtio client SDK: joinRoom, owned-write batching, corrections, typed RPCs, presence, reconnection",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -20,8 +20,8 @@
20
20
  ],
21
21
  "dependencies": {
22
22
  "mediasoup-client": "^3.23.1",
23
- "@irtio/protocol": "0.6.0",
24
- "@irtio/schema": "0.6.0"
23
+ "@irtio/protocol": "0.7.0",
24
+ "@irtio/schema": "0.7.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "@dimforge/rapier3d-compat": ">=0.20.0",