@irtio/runtime 4.5.1 → 4.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.
@@ -3,7 +3,7 @@ import {
3
3
  parseHibernationBlob,
4
4
  sameSchemaHash,
5
5
  writeHibernationBlob
6
- } from "./chunk-23TKVLTX.js";
6
+ } from "./chunk-RLY4VAMM.js";
7
7
 
8
8
  // src/migrate.ts
9
9
  import { PRESENCE_COLLECTION, withBuiltins } from "@irtio/protocol";
@@ -222,6 +222,95 @@ function deepEqual(a, b) {
222
222
  return true;
223
223
  }
224
224
 
225
+ // src/core/body-guard.ts
226
+ var MAX_BODY_RESETS = 3;
227
+ function allFinite(values) {
228
+ for (const v of values) if (!Number.isFinite(v)) return false;
229
+ return true;
230
+ }
231
+ function describeNonFinite(entries) {
232
+ const bad = [];
233
+ for (const [name, v] of Object.entries(entries)) {
234
+ if (Number.isFinite(v)) continue;
235
+ bad.push(`${name}=${v}`);
236
+ if (bad.length === 4) break;
237
+ }
238
+ return bad.length > 0 ? bad.join(", ") : "non-finite";
239
+ }
240
+ var VELOCITY_CHANNELS = /* @__PURE__ */ new Set(["vx", "vy", "vz", "wx", "wy", "wz"]);
241
+ function zeroVelocityFields(desc, record) {
242
+ for (const [channel, field] of desc.physics?.channels ?? []) {
243
+ if (VELOCITY_CHANNELS.has(channel) && record[field] !== 0) record[field] = 0;
244
+ }
245
+ }
246
+ var NUMERIC_KINDS = /* @__PURE__ */ new Set(["f32", "f64", "i32", "u8", "u16", "u32"]);
247
+ var NonFiniteGuard = class {
248
+ constructor(core) {
249
+ this.core = core;
250
+ }
251
+ core;
252
+ /** Resets so far, by body key (`collection\0id`). */
253
+ resets = /* @__PURE__ */ new Map();
254
+ /**
255
+ * Records one non-finite body and says what the engine should do with it. Also does the part
256
+ * that is the same on every engine: the log line, the room event, and the owner's intents.
257
+ */
258
+ note(desc, id, key, detail) {
259
+ const count = (this.resets.get(key) ?? 0) + 1;
260
+ this.resets.set(key, count);
261
+ const name = desc.name;
262
+ const skip = count >= MAX_BODY_RESETS;
263
+ if (skip) {
264
+ this.core.log(
265
+ "error",
266
+ `irtio: physics body ${name}[${id}] went non-finite (${detail}); body skipped after ${MAX_BODY_RESETS} resets, room.physics.skip('${name}', '${id}', false) re-enables it`
267
+ );
268
+ } else {
269
+ this.core.log(
270
+ "warn",
271
+ `irtio: physics body ${name}[${id}] went non-finite (${detail}); reset to its last synced pose with zero velocity`
272
+ );
273
+ }
274
+ this.attribute(desc, id, detail);
275
+ if (skip) this.resets.delete(key);
276
+ return skip ? "skip" : "reset";
277
+ }
278
+ /**
279
+ * Names the owner, zeroes its intents, and tells it why. The zeroing goes through the tracked
280
+ * record, so it is an ordinary server write to a client-owned field, which the server-wins pass
281
+ * turns into a `CORRECT` for the owner.
282
+ */
283
+ attribute(desc, id, detail) {
284
+ const core = this.core;
285
+ const owner = plainEntity(core.plain, desc.name).ownerOf(id);
286
+ const client = owner !== void 0 && core.clients.has(owner) ? owner : void 0;
287
+ core.recordEvent("error", client, `${desc.name}[${id}] non-finite: ${detail}`);
288
+ if (client === void 0) return;
289
+ const intents = desc.physics?.intents ?? [];
290
+ const record = core.anyState[desc.name].get(id);
291
+ if (record) {
292
+ for (const f of desc.fields) {
293
+ if (!intents.includes(f.name) || !NUMERIC_KINDS.has(f.type.kind)) continue;
294
+ if (record[f.name] !== 0) record[f.name] = 0;
295
+ }
296
+ }
297
+ refuseByName(core, client, "E_WRITE_REJECTED", {
298
+ reason: `${desc.name}[${id}] went non-finite (${detail}); its intents were zeroed and the body reset. Clamp intents in validate`
299
+ });
300
+ }
301
+ /** Drops the counts of bodies whose row is gone. */
302
+ prune(live) {
303
+ for (const key of [...this.resets.keys()]) if (!live.has(key)) this.resets.delete(key);
304
+ }
305
+ /** Drops one body's count, when its row was replaced and the body rebuilt. */
306
+ forget(key) {
307
+ this.resets.delete(key);
308
+ }
309
+ clear() {
310
+ this.resets.clear();
311
+ }
312
+ };
313
+
225
314
  // src/core/physics.ts
226
315
  var engine;
227
316
  var loading;
@@ -466,6 +555,10 @@ var PhysicsRuntime = class {
466
555
  */
467
556
  skippedKeys = /* @__PURE__ */ new Set();
468
557
  // ---- end M7 planned feature 3 (b) ----
558
+ /** bugs #116: what `sync()` does with a body that went non-finite. See `core/body-guard.ts`. */
559
+ nonFinite;
560
+ /** The mapped channel values of the body `sync()` is on, reused so the good path allocates nothing. */
561
+ nextScratch = [];
469
562
  // ---- M7 planned feature 3 (a): physics-only unsynced bodies ----
470
563
  /**
471
564
  * Bodies with no schema row: rope anchors, dead-reckoned proxies, one-off sensors. Keyed by the
@@ -480,6 +573,7 @@ var PhysicsRuntime = class {
480
573
  this.core = core;
481
574
  this.config = config;
482
575
  this.rapier = rapier;
576
+ this.nonFinite = new NonFiniteGuard(core);
483
577
  this.collections = core.ext.collections.filter(
484
578
  (c) => c.physics !== void 0
485
579
  );
@@ -522,6 +616,7 @@ var PhysicsRuntime = class {
522
616
  }
523
617
  free() {
524
618
  this.bodies.clear();
619
+ this.nonFinite.clear();
525
620
  this.unsyncedBodies.clear();
526
621
  this.world.free();
527
622
  }
@@ -780,6 +875,7 @@ var PhysicsRuntime = class {
780
875
  this.bodies.delete(key);
781
876
  this.sleepSynced.delete(key);
782
877
  this.sourceRecords.delete(key);
878
+ this.nonFinite.forget(key);
783
879
  this.destroyBody(existing);
784
880
  }
785
881
  if (record !== void 0) this.create(desc, id, record);
@@ -793,6 +889,7 @@ var PhysicsRuntime = class {
793
889
  this.destroyBody(body);
794
890
  }
795
891
  for (const key of [...this.skippedKeys]) if (!live.has(key)) this.skippedKeys.delete(key);
892
+ this.nonFinite.prune(live);
796
893
  }
797
894
  // -------------------------------------------------------------------------
798
895
  // Step and sync
@@ -829,13 +926,68 @@ var PhysicsRuntime = class {
829
926
  const r = body.rotation();
830
927
  const v = body.linvel();
831
928
  const w = body.angvel();
929
+ const nexts = this.nextScratch;
930
+ nexts.length = 0;
832
931
  for (const [channel, field] of physics.channels) {
833
- const next = (rounders[field] ?? identity)(channelValue(channel, t, r, v, w));
932
+ nexts.push((rounders[field] ?? identity)(channelValue(channel, t, r, v, w)));
933
+ }
934
+ if (!poseFinite(t, r, v, w) || !allFinite(nexts)) {
935
+ const detail = describeNonFinite({
936
+ x: t.x,
937
+ y: t.y,
938
+ z: t.z,
939
+ qx: r.x,
940
+ qy: r.y,
941
+ qz: r.z,
942
+ qw: r.w,
943
+ vx: v.x,
944
+ vy: v.y,
945
+ vz: v.z,
946
+ wx: w.x,
947
+ wy: w.y,
948
+ wz: w.z,
949
+ ...Object.fromEntries(physics.channels.map(([, field], i2) => [field, nexts[i2] ?? 0]))
950
+ });
951
+ if (this.nonFinite.note(desc, id, key, detail) === "skip") {
952
+ this.setSkipped(desc.name, id, true);
953
+ } else {
954
+ this.resetBody(desc, body, record);
955
+ }
956
+ continue;
957
+ }
958
+ let i = 0;
959
+ for (const [, field] of physics.channels) {
960
+ const next = nexts[i++];
834
961
  if (record[field] !== next) record[field] = next;
835
962
  }
836
963
  }
837
964
  }
838
965
  }
966
+ /**
967
+ * bugs #116: puts a non-finite body back at its row's pose (the last one `sync()` wrote, always
968
+ * finite), at rest, with no force left on it. Unmapped channels the engine made non-finite go to
969
+ * zero, or the identity rotation, since there is nothing better to put them back to.
970
+ */
971
+ resetBody(desc, body, record) {
972
+ const t = body.translation();
973
+ const r = body.rotation();
974
+ body.setTranslation(
975
+ {
976
+ x: Number.isFinite(t.x) ? t.x : 0,
977
+ y: Number.isFinite(t.y) ? t.y : 0,
978
+ z: Number.isFinite(t.z) ? t.z : 0
979
+ },
980
+ true
981
+ );
982
+ if (!allFinite([r.x, r.y, r.z, r.w])) body.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true);
983
+ this.applyRecordToBody(desc, body, record);
984
+ if (!body.isEnabled()) body.setEnabled(true);
985
+ body.setLinvel({ x: 0, y: 0, z: 0 }, true);
986
+ body.setAngvel({ x: 0, y: 0, z: 0 }, true);
987
+ body.resetForces(true);
988
+ body.resetTorques(true);
989
+ zeroVelocityFields(desc, record);
990
+ }
839
991
  // -------------------------------------------------------------------------
840
992
  // Hibernation
841
993
  // -------------------------------------------------------------------------
@@ -856,6 +1008,9 @@ var PhysicsRuntime = class {
856
1008
  };
857
1009
  }
858
1010
  };
1011
+ function poseFinite(t, r, v, w) {
1012
+ return Number.isFinite(t.x) && Number.isFinite(t.y) && Number.isFinite(t.z) && Number.isFinite(r.x) && Number.isFinite(r.y) && Number.isFinite(r.z) && Number.isFinite(r.w) && Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z) && Number.isFinite(w.x) && Number.isFinite(w.y) && Number.isFinite(w.z);
1013
+ }
859
1014
  function identity(v) {
860
1015
  return v;
861
1016
  }
@@ -2634,11 +2789,12 @@ var Loop = class {
2634
2789
  failed = failed === void 0 ? "the physics step" : `${failed} and the physics step`;
2635
2790
  }
2636
2791
  }
2792
+ const flushed = this.core.tryRun("flush", () => this.core.flush());
2793
+ if (!flushed.ok) failed = failed === void 0 ? "flush" : `${failed} and flush`;
2637
2794
  if (failed === void 0) this.consecutiveThrows = 0;
2638
2795
  else {
2639
2796
  this.consecutiveThrows++;
2640
2797
  if (this.consecutiveThrows >= CRASH_AFTER_THROWS) {
2641
- this.core.flush();
2642
2798
  const reason = `${failed} threw ${this.consecutiveThrows} times in a row`;
2643
2799
  this.core.stopped = true;
2644
2800
  this.stop();
@@ -2646,7 +2802,6 @@ var Loop = class {
2646
2802
  return;
2647
2803
  }
2648
2804
  }
2649
- this.core.flush();
2650
2805
  this.core.captureTimeline();
2651
2806
  const elapsed = Math.max(0, host.now() - started);
2652
2807
  const stats = this.core.stats;
@@ -2669,7 +2824,7 @@ var Loop = class {
2669
2824
  applyEvent(f) {
2670
2825
  this.core.tick++;
2671
2826
  const ok = this.applyFrame(f);
2672
- this.core.flush();
2827
+ this.core.guard("flush", () => this.core.flush());
2673
2828
  return ok;
2674
2829
  }
2675
2830
  noteActivity() {
@@ -2968,6 +3123,9 @@ var MatterRuntime = class {
2968
3123
  // ---- M7 planned feature 3 (b): per-entity physics skip ----
2969
3124
  /** See `PhysicsRuntime.skippedKeys`: entities whose body the room has switched off. */
2970
3125
  skippedKeys = /* @__PURE__ */ new Set();
3126
+ /** bugs #116: see `PhysicsRuntime.nonFinite`. */
3127
+ nonFinite;
3128
+ nextScratch = [];
2971
3129
  // ---- end M7 planned feature 3 (b) ----
2972
3130
  // ---- M7 planned feature 3 (a): physics-only unsynced bodies ----
2973
3131
  unsyncedBodies = /* @__PURE__ */ new Map();
@@ -2982,6 +3140,7 @@ var MatterRuntime = class {
2982
3140
  if (!config) throw new Error("MatterRuntime: the room config declares no physics");
2983
3141
  this.core = core;
2984
3142
  this.config = config;
3143
+ this.nonFinite = new NonFiniteGuard(core);
2985
3144
  this.matter = matter;
2986
3145
  this.collections = core.ext.collections.filter(
2987
3146
  (c) => c.physics !== void 0
@@ -3036,6 +3195,7 @@ var MatterRuntime = class {
3036
3195
  }
3037
3196
  free() {
3038
3197
  this.bodies.clear();
3198
+ this.nonFinite.clear();
3039
3199
  this.unsyncedBodies.clear();
3040
3200
  this.matter.Engine.clear(this.engine);
3041
3201
  }
@@ -3224,6 +3384,7 @@ var MatterRuntime = class {
3224
3384
  this.bodies.delete(key);
3225
3385
  this.sleepSynced.delete(key);
3226
3386
  this.sourceRecords.delete(key);
3387
+ this.nonFinite.forget(key);
3227
3388
  this.wakeContactsOf(existing.body);
3228
3389
  this.matter.Composite.remove(this.engine.world, [existing.body, ...existing.constraints]);
3229
3390
  }
@@ -3239,6 +3400,7 @@ var MatterRuntime = class {
3239
3400
  this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
3240
3401
  }
3241
3402
  for (const key of [...this.skippedKeys]) if (!live.has(key)) this.skippedKeys.delete(key);
3403
+ this.nonFinite.prune(live);
3242
3404
  }
3243
3405
  // -------------------------------------------------------------------------
3244
3406
  // Step and sync
@@ -3274,13 +3436,48 @@ var MatterRuntime = class {
3274
3436
  vy: body.velocity.y,
3275
3437
  angularVelocity: body.angularVelocity
3276
3438
  };
3439
+ const nexts = this.nextScratch;
3440
+ nexts.length = 0;
3277
3441
  for (const [channel, field] of physics.channels) {
3278
- const next = (rounders[field] ?? identity2)(channelOf2d(channel, state));
3442
+ nexts.push((rounders[field] ?? identity2)(channelOf2d(channel, state)));
3443
+ }
3444
+ if (!(Number.isFinite(state.x) && Number.isFinite(state.y) && Number.isFinite(state.angle) && Number.isFinite(state.vx) && Number.isFinite(state.vy) && Number.isFinite(state.angularVelocity)) || !allFinite(nexts)) {
3445
+ const detail = describeNonFinite({
3446
+ ...state,
3447
+ ...Object.fromEntries(physics.channels.map(([, field], i2) => [field, nexts[i2] ?? 0]))
3448
+ });
3449
+ if (this.nonFinite.note(desc, id, key, detail) === "skip") {
3450
+ this.setSkipped(desc.name, id, true);
3451
+ } else {
3452
+ this.resetBody(desc, id, key, attached, record);
3453
+ }
3454
+ continue;
3455
+ }
3456
+ let i = 0;
3457
+ for (const [, field] of physics.channels) {
3458
+ const next = nexts[i++];
3279
3459
  if (record[field] !== next) record[field] = next;
3280
3460
  }
3281
3461
  }
3282
3462
  }
3283
3463
  }
3464
+ /**
3465
+ * bugs #116: the matter2d half of `PhysicsRuntime.resetBody`, done as a rebuild rather than a
3466
+ * teleport. matter moves a body by translating its vertices by the difference between the new
3467
+ * position and the old one, and once the old one is not finite that difference is not either:
3468
+ * `Body.setPosition` cannot bring the vertices back. So the body is removed and built again
3469
+ * from its row, whose pose is the last finite one `sync()` wrote, with the row's velocity
3470
+ * fields zeroed first so the new body spawns at rest (and with no force, being new).
3471
+ */
3472
+ resetBody(desc, id, key, attached, record) {
3473
+ zeroVelocityFields(desc, record);
3474
+ this.bodies.delete(key);
3475
+ this.sleepSynced.delete(key);
3476
+ this.sourceRecords.delete(key);
3477
+ this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
3478
+ const plain = plainEntity(this.core.plain, desc.name).get(id);
3479
+ if (plain !== void 0) this.create(desc, id, plain);
3480
+ }
3284
3481
  // -------------------------------------------------------------------------
3285
3482
  // Hibernation
3286
3483
  // -------------------------------------------------------------------------
@@ -3418,6 +3615,9 @@ var Rapier2dRuntime = class {
3418
3615
  /** See `PhysicsRuntime.skippedKeys`: entities whose body the room has switched off. */
3419
3616
  skippedKeys = /* @__PURE__ */ new Set();
3420
3617
  // ---- end M7 planned feature 3 (b) ----
3618
+ /** bugs #116: see `PhysicsRuntime.nonFinite`. */
3619
+ nonFinite;
3620
+ nextScratch = [];
3421
3621
  // ---- M7 planned feature 3 (a): physics-only unsynced bodies ----
3422
3622
  /** See `PhysicsRuntime.unsyncedBodies`: bodies the world steps that no schema row describes. */
3423
3623
  unsyncedBodies = /* @__PURE__ */ new Map();
@@ -3429,6 +3629,7 @@ var Rapier2dRuntime = class {
3429
3629
  this.core = core;
3430
3630
  this.config = config;
3431
3631
  this.rapier = rapier;
3632
+ this.nonFinite = new NonFiniteGuard(core);
3432
3633
  this.collections = core.ext.collections.filter(
3433
3634
  (c) => c.physics !== void 0
3434
3635
  );
@@ -3471,6 +3672,7 @@ var Rapier2dRuntime = class {
3471
3672
  }
3472
3673
  free() {
3473
3674
  this.bodies.clear();
3675
+ this.nonFinite.clear();
3474
3676
  this.unsyncedBodies.clear();
3475
3677
  this.world.free();
3476
3678
  }
@@ -3648,6 +3850,7 @@ var Rapier2dRuntime = class {
3648
3850
  this.bodies.delete(key);
3649
3851
  this.sleepSynced.delete(key);
3650
3852
  this.sourceRecords.delete(key);
3853
+ this.nonFinite.forget(key);
3651
3854
  this.destroyBody(existing);
3652
3855
  }
3653
3856
  if (record !== void 0) this.create(desc, id, record);
@@ -3661,6 +3864,7 @@ var Rapier2dRuntime = class {
3661
3864
  this.destroyBody(body);
3662
3865
  }
3663
3866
  for (const key of [...this.skippedKeys]) if (!live.has(key)) this.skippedKeys.delete(key);
3867
+ this.nonFinite.prune(live);
3664
3868
  }
3665
3869
  // -------------------------------------------------------------------------
3666
3870
  // Step and sync
@@ -3702,13 +3906,47 @@ var Rapier2dRuntime = class {
3702
3906
  vy: v.y,
3703
3907
  angularVelocity: body.angvel()
3704
3908
  };
3909
+ const nexts = this.nextScratch;
3910
+ nexts.length = 0;
3705
3911
  for (const [channel, field] of physics.channels) {
3706
- const next = (rounders[field] ?? identity3)(channelOf2d2(channel, state));
3912
+ nexts.push((rounders[field] ?? identity3)(channelOf2d2(channel, state)));
3913
+ }
3914
+ if (!(Number.isFinite(state.x) && Number.isFinite(state.y) && Number.isFinite(state.angle) && Number.isFinite(state.vx) && Number.isFinite(state.vy) && Number.isFinite(state.angularVelocity)) || !allFinite(nexts)) {
3915
+ const detail = describeNonFinite({
3916
+ ...state,
3917
+ ...Object.fromEntries(physics.channels.map(([, field], i2) => [field, nexts[i2] ?? 0]))
3918
+ });
3919
+ if (this.nonFinite.note(desc, id, key, detail) === "skip") {
3920
+ this.setSkipped(desc.name, id, true);
3921
+ } else {
3922
+ this.resetBody(desc, body, record);
3923
+ }
3924
+ continue;
3925
+ }
3926
+ let i = 0;
3927
+ for (const [, field] of physics.channels) {
3928
+ const next = nexts[i++];
3707
3929
  if (record[field] !== next) record[field] = next;
3708
3930
  }
3709
3931
  }
3710
3932
  }
3711
3933
  }
3934
+ /** bugs #116: see `PhysicsRuntime.resetBody`. */
3935
+ resetBody(desc, body, record) {
3936
+ const t = body.translation();
3937
+ body.setTranslation(
3938
+ { x: Number.isFinite(t.x) ? t.x : 0, y: Number.isFinite(t.y) ? t.y : 0 },
3939
+ true
3940
+ );
3941
+ if (!Number.isFinite(body.rotation())) body.setRotation(0, true);
3942
+ this.applyRecordToBody(desc, body, record);
3943
+ if (!body.isEnabled()) body.setEnabled(true);
3944
+ body.setLinvel({ x: 0, y: 0 }, true);
3945
+ body.setAngvel(0, true);
3946
+ body.resetForces(true);
3947
+ body.resetTorques(true);
3948
+ zeroVelocityFields(desc, record);
3949
+ }
3712
3950
  // -------------------------------------------------------------------------
3713
3951
  // Hibernation
3714
3952
  // -------------------------------------------------------------------------
@@ -3916,6 +4154,9 @@ var CustomRuntime = class {
3916
4154
  sourceRecords = /* @__PURE__ */ new Map();
3917
4155
  skippedKeys = /* @__PURE__ */ new Set();
3918
4156
  scratch = emptyPose();
4157
+ /** bugs #116: see `PhysicsRuntime.nonFinite`. */
4158
+ nonFinite;
4159
+ nextScratch = [];
3919
4160
  stepper;
3920
4161
  /**
3921
4162
  * M7 pf3 (a): a custom room's unsynced bodies are the stepper's own — it created them, it steps
@@ -3929,6 +4170,7 @@ var CustomRuntime = class {
3929
4170
  if (!config) throw new Error("CustomRuntime: the room config declares no physics");
3930
4171
  this.core = core;
3931
4172
  this.config = config;
4173
+ this.nonFinite = new NonFiniteGuard(core);
3932
4174
  this.collections = core.ext.collections.filter(
3933
4175
  (c) => c.physics !== void 0
3934
4176
  );
@@ -3963,6 +4205,7 @@ var CustomRuntime = class {
3963
4205
  this.bodies.clear();
3964
4206
  this.unsyncedBodies.clear();
3965
4207
  this.sourceRecords.clear();
4208
+ this.nonFinite.clear();
3966
4209
  this.stepper = void 0;
3967
4210
  }
3968
4211
  /** `room.physicsCustom.stepper`. Throws rather than handing back `undefined` half a world. */
@@ -4071,6 +4314,7 @@ var CustomRuntime = class {
4071
4314
  if (source === record) continue;
4072
4315
  this.bodies.delete(key);
4073
4316
  this.sourceRecords.delete(key);
4317
+ this.nonFinite.forget(key);
4074
4318
  this.stepper?.removeBody(existing);
4075
4319
  }
4076
4320
  if (record !== void 0) this.create(desc, id, record);
@@ -4083,6 +4327,7 @@ var CustomRuntime = class {
4083
4327
  this.stepper?.removeBody(body);
4084
4328
  }
4085
4329
  for (const key of [...this.skippedKeys]) if (!live.has(key)) this.skippedKeys.delete(key);
4330
+ this.nonFinite.prune(live);
4086
4331
  }
4087
4332
  // -------------------------------------------------------------------------
4088
4333
  // Step and sync
@@ -4108,20 +4353,60 @@ var CustomRuntime = class {
4108
4353
  const plainColl = plainEntity(this.core.plain, desc.name);
4109
4354
  const rounders = roundersFor4(desc);
4110
4355
  for (const id of plainColl.ids()) {
4111
- const body = this.bodies.get(bodyKey4(desc.name, id));
4356
+ const key = bodyKey4(desc.name, id);
4357
+ const body = this.bodies.get(key);
4112
4358
  if (body === void 0) continue;
4113
4359
  const record = tracked.get(id);
4114
4360
  if (!record) continue;
4115
4361
  const pose = this.scratch;
4116
4362
  stepper.readPose(body, pose);
4363
+ const nexts = this.nextScratch;
4364
+ nexts.length = 0;
4117
4365
  for (const [channel, field] of physics.channels) {
4118
- const next = (rounders[field] ?? identity4)(pose[channel]);
4366
+ nexts.push((rounders[field] ?? identity4)(pose[channel]));
4367
+ }
4368
+ if (!poseFinite2(pose) || !allFinite(nexts)) {
4369
+ const detail = describeNonFinite({
4370
+ ...pose,
4371
+ ...Object.fromEntries(physics.channels.map(([, field], i2) => [field, nexts[i2] ?? 0]))
4372
+ });
4373
+ if (this.nonFinite.note(desc, id, key, detail) === "skip") {
4374
+ this.setSkipped(desc.name, id, true);
4375
+ } else {
4376
+ this.resetBody(stepper, desc, body, record);
4377
+ }
4378
+ continue;
4379
+ }
4380
+ let i = 0;
4381
+ for (const [, field] of physics.channels) {
4382
+ const next = nexts[i++];
4119
4383
  if (record[field] !== next) record[field] = next;
4120
4384
  }
4121
4385
  }
4122
4386
  }
4123
4387
  }
4388
+ /**
4389
+ * bugs #116: see `PhysicsRuntime.resetBody`. The pose is built from the row's mapped channels,
4390
+ * with the identity rotation and the origin for unmapped ones and zero for every velocity; a
4391
+ * stepper keeps its own forces, if it has any, where this module cannot see them.
4392
+ */
4393
+ resetBody(stepper, desc, body, record) {
4394
+ const pose = emptyPose();
4395
+ for (const [channel, field] of desc.physics?.channels ?? []) {
4396
+ if (VELOCITY_CHANNELS2.has(channel)) continue;
4397
+ const raw = record[field];
4398
+ if (typeof raw === "number" && Number.isFinite(raw)) pose[channel] = raw;
4399
+ }
4400
+ stepper.writePose(body, pose);
4401
+ zeroVelocityFields(desc, record);
4402
+ }
4124
4403
  };
4404
+ var VELOCITY_CHANNELS2 = /* @__PURE__ */ new Set(["vx", "vy", "vz", "wx", "wy", "wz"]);
4405
+ var POSE_CHANNELS = Object.keys(emptyPose());
4406
+ function poseFinite2(pose) {
4407
+ for (const channel of POSE_CHANNELS) if (!Number.isFinite(pose[channel])) return false;
4408
+ return true;
4409
+ }
4125
4410
  function identity4(v) {
4126
4411
  return v;
4127
4412
  }
@@ -7171,7 +7456,9 @@ var RoomCore = class _RoomCore {
7171
7456
  get presence() {
7172
7457
  return this.anyState[PRESENCE_COLLECTION];
7173
7458
  }
7174
- resolveRole(requested, existing) {
7459
+ /** Issue #123: clients already told that their requested role was ignored. Warn once each. */
7460
+ unverifiedRoleWarned = /* @__PURE__ */ new Set();
7461
+ resolveRole(clientId, requested, existing, verified) {
7175
7462
  const roles = this.definition.schema.roles;
7176
7463
  if (requested === void 0) return existing ?? roles[0] ?? "";
7177
7464
  if (roles.length === 0) {
@@ -7183,6 +7470,17 @@ var RoomCore = class _RoomCore {
7183
7470
  }
7184
7471
  return "";
7185
7472
  }
7473
+ if (!verified && this.definition.schema.clientRoles !== true) {
7474
+ const granted2 = existing ?? roles[0] ?? "";
7475
+ if (requested !== "" && requested !== granted2 && !this.unverifiedRoleWarned.has(clientId)) {
7476
+ this.unverifiedRoleWarned.add(clientId);
7477
+ this.log(
7478
+ "warn",
7479
+ `join: ignored the client-requested role ${JSON.stringify(requested)}; using ${JSON.stringify(granted2)}. Roles come from a JWT role claim or room.setRole; set clientRoles: true in defineSchema to let clients pick`
7480
+ );
7481
+ }
7482
+ return granted2;
7483
+ }
7186
7484
  if (roles.includes(requested)) return requested;
7187
7485
  this.log(
7188
7486
  "warn",
@@ -7225,7 +7523,10 @@ var RoomCore = class _RoomCore {
7225
7523
  const ctx = {
7226
7524
  clientId,
7227
7525
  subject,
7228
- role: this.resolveRole(options.role, void 0),
7526
+ role: this.resolveRole(clientId, options.role, void 0, options.roleVerified === true),
7527
+ // Issue #123: which credential admitted this join, so a policy can tell anonymous from
7528
+ // verified.
7529
+ auth: options.auth ?? "key",
7229
7530
  name: options.name ?? "",
7230
7531
  tick: this.tick,
7231
7532
  // Bug #133: no `ClientEntry` exists yet, so the flag comes straight from the join options —
@@ -7283,7 +7584,12 @@ var RoomCore = class _RoomCore {
7283
7584
  const existing = this.clients.get(clientId);
7284
7585
  if (this.stopped) {
7285
7586
  this.log("warn", `join(${clientId}) after stop(); ignored`);
7286
- const role2 = this.resolveRole(options.role, existing?.role);
7587
+ const role2 = this.resolveRole(
7588
+ clientId,
7589
+ options.role,
7590
+ existing?.role,
7591
+ options.roleVerified === true
7592
+ );
7287
7593
  return {
7288
7594
  tick: this.tick,
7289
7595
  role: role2,
@@ -7304,7 +7610,12 @@ var RoomCore = class _RoomCore {
7304
7610
  throw new RoomFullError(maxClients);
7305
7611
  }
7306
7612
  const reconnecting = options.reconnecting === true;
7307
- const role = this.resolveRole(options.role, existing?.role);
7613
+ const role = this.resolveRole(
7614
+ clientId,
7615
+ options.role,
7616
+ existing?.role,
7617
+ options.roleVerified === true
7618
+ );
7308
7619
  const name = options.name ?? existing?.name ?? "";
7309
7620
  const record = presence.get(clientId);
7310
7621
  if (record) {
@@ -7335,6 +7646,7 @@ var RoomCore = class _RoomCore {
7335
7646
  playerId: options.playerId ?? clientId,
7336
7647
  // M8 lane 5: the narrower fact beside the one above. See `JoinOptions.subject`.
7337
7648
  subject: options.subject,
7649
+ auth: options.auth ?? "key",
7338
7650
  npc: options.npc === true,
7339
7651
  role,
7340
7652
  name,
@@ -7419,6 +7731,7 @@ var RoomCore = class _RoomCore {
7419
7731
  this.rpcTraceRing?.forget(clientId);
7420
7732
  this.loop.dropFramesFor(clientId);
7421
7733
  this.clients.delete(clientId);
7734
+ this.unverifiedRoleWarned.delete(clientId);
7422
7735
  this.stats.bytesOutByClient.delete(clientId);
7423
7736
  this.invalidateClients();
7424
7737
  this.eventFlush();
@@ -7466,6 +7779,9 @@ var RoomCore = class _RoomCore {
7466
7779
  return {
7467
7780
  clientId,
7468
7781
  playerId: entry?.playerId ?? clientId,
7782
+ // Issue #123: read from the seat, so every ctx reports the credential the seat was taken
7783
+ // with.
7784
+ auth: entry?.auth ?? "key",
7469
7785
  role: entry?.role ?? "",
7470
7786
  name: entry?.name ?? "",
7471
7787
  tick: this.tick,
@@ -528,8 +528,25 @@ interface RoomCoreOptions {
528
528
  readonly wallNow?: () => number;
529
529
  }
530
530
  interface JoinOptions {
531
- /** Requested role; unknown/missing → `roles[0]` (logged) or `''` when the schema has no roles. */
531
+ /**
532
+ * Requested role; unknown/missing → `roles[0]` (logged) or `''` when the schema has no roles.
533
+ *
534
+ * Issue #123: honoured only when `roleVerified` is true or the schema set `clientRoles: true`.
535
+ * Otherwise an unverified request is ignored: a new seat gets `roles[0]` and a rejoining one
536
+ * keeps the role it already holds.
537
+ */
532
538
  readonly role?: string;
539
+ /**
540
+ * Issue #123: `role` was asserted by something the host verified (a JWT `role` claim, a resume
541
+ * token it signed, a role room code granted, an NPC the room spawned) rather than typed by the
542
+ * client. Absent means unverified.
543
+ */
544
+ readonly roleVerified?: boolean;
545
+ /**
546
+ * Issue #123: which credential admitted this join, surfaced as `ctx.auth`. Defaults to `'key'`,
547
+ * an anonymous project-key join.
548
+ */
549
+ readonly auth?: 'key' | 'jwt' | 'assertion';
533
550
  readonly name?: string;
534
551
  /** A resumed session: presence record kept, `ctx.reconnecting = true`. */
535
552
  readonly reconnecting?: boolean;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { A as AdmissionError, D as DEFAULT_RECORDS_PER_COLLECTION, a as DEFAULT_TIMELINE_MAX_RECORDS, b as DEFAULT_TIMELINE_MAX_TICKS, E as EVENT_RING_SIZE, H as HOST_CALL_TIMEOUT_MS, c as HostCall, d as HostCallResult, J as JoinOptions, e as JoinResult, L as LogLevel, P as PHYSICS_STEP_EMA_ALPHA, R as RELAY_RECORDS_PER_COLLECTION, f as RELAY_STATE_BYTES, g as RELAY_WRITES_PER_SECOND, h as RELAY_WRITE_CAPS, i as RPC_TRACE_SIZE, j as RPC_TRACE_SUMMARY_MAX, k as RoomCoreApi, l as RoomCoreOptions, m as RoomEvent, n as RoomEventKind, o as RoomFullError, p as RoomHost, q as RoomInspection, r as RoomStats, s as RpcOutcome, t as RpcTraceDump, u as RpcTraceEntry, v as RpcTraceRing, S as StampLag, T as TimelineDump, w as TimelineFrame, x as TimelineRecorder, y as TimelineRecorderOptions, W as WriteCaps, z as inspectState, B as summarizeRpcResult } from './contract-B72MNcCa.js';
2
- export { C as CRASH_AFTER_THROWS, M as MAX_CATCHUP, a as MatterBodyRecord, b as MatterSection, c as Mulberry32, P as PhysicsEngineTag, d as PhysicsSection, R as RoomCore, e as decodeMatterBodies, f as decodeMatterSectionEnvelope, g as decodePhysicsSection, h as decodeRapier2dSectionEnvelope, i as encodeMatterBodies, j as encodeMatterSectionEnvelope, k as encodePhysicsSection, l as encodeRapier2dSectionEnvelope, m as initMatter, n as initPhysics, o as initRapier2d, p as loadedMatter, q as loadedPhysics, r as loadedRapier2d, s as physicsSectionEngine, t as resetMatterForTests, u as resetPhysicsForTests, v as resetRapier2dForTests } from './room-DY5J0VZR.js';
1
+ export { A as AdmissionError, D as DEFAULT_RECORDS_PER_COLLECTION, a as DEFAULT_TIMELINE_MAX_RECORDS, b as DEFAULT_TIMELINE_MAX_TICKS, E as EVENT_RING_SIZE, H as HOST_CALL_TIMEOUT_MS, c as HostCall, d as HostCallResult, J as JoinOptions, e as JoinResult, L as LogLevel, P as PHYSICS_STEP_EMA_ALPHA, R as RELAY_RECORDS_PER_COLLECTION, f as RELAY_STATE_BYTES, g as RELAY_WRITES_PER_SECOND, h as RELAY_WRITE_CAPS, i as RPC_TRACE_SIZE, j as RPC_TRACE_SUMMARY_MAX, k as RoomCoreApi, l as RoomCoreOptions, m as RoomEvent, n as RoomEventKind, o as RoomFullError, p as RoomHost, q as RoomInspection, r as RoomStats, s as RpcOutcome, t as RpcTraceDump, u as RpcTraceEntry, v as RpcTraceRing, S as StampLag, T as TimelineDump, w as TimelineFrame, x as TimelineRecorder, y as TimelineRecorderOptions, W as WriteCaps, z as inspectState, B as summarizeRpcResult } from './contract-CcZadpC6.js';
2
+ export { C as CRASH_AFTER_THROWS, M as MAX_CATCHUP, a as MatterBodyRecord, b as MatterSection, c as Mulberry32, P as PhysicsEngineTag, d as PhysicsSection, R as RoomCore, e as decodeMatterBodies, f as decodeMatterSectionEnvelope, g as decodePhysicsSection, h as decodeRapier2dSectionEnvelope, i as encodeMatterBodies, j as encodeMatterSectionEnvelope, k as encodePhysicsSection, l as encodeRapier2dSectionEnvelope, m as initMatter, n as initPhysics, o as initRapier2d, p as loadedMatter, q as loadedPhysics, r as loadedRapier2d, s as physicsSectionEngine, t as resetMatterForTests, u as resetPhysicsForTests, v as resetRapier2dForTests } from './room-BW2ZSX_k.js';
3
3
  import { CollectionDesc, AnySchema, PlainState, DirtySet } from '@irtio/schema';
4
4
  import '@irtio/protocol';
5
5
  import '@irtio/server';
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  fromMigrationState,
3
3
  migrateSnapshot,
4
4
  toMigrationState
5
- } from "./chunk-VIN2SPZT.js";
5
+ } from "./chunk-NSUQKWYO.js";
6
6
  import {
7
7
  AdmissionError,
8
8
  CRASH_AFTER_THROWS,
@@ -59,7 +59,7 @@ import {
59
59
  visibleNames,
60
60
  visibleTo,
61
61
  writeHibernationBlob
62
- } from "./chunk-23TKVLTX.js";
62
+ } from "./chunk-RLY4VAMM.js";
63
63
  export {
64
64
  AdmissionError,
65
65
  CRASH_AFTER_THROWS,
@@ -1,7 +1,7 @@
1
1
  import { PlatformNotice, AttributeOptions, ProfileSnapshot } from '@irtio/protocol';
2
2
  import { DirtySet, AnySchema, PlainState, Tracked, State } from '@irtio/schema';
3
3
  import { RoomDefinition, RoomMode, Room, RewindView, Ctx, CustomStepper, CustomPose, MatterModule, MatterEngine, MatterBody, RapierModule, RapierWorld, RapierRigidBody, Rapier2dModule, Rapier2dWorld, Rapier2dRigidBody, ResolvedRoomConfig, LeaveReason } from '@irtio/server';
4
- import { p as RoomHost, r as RoomStats, u as RpcTraceEntry, L as LogLevel, k as RoomCoreApi, l as RoomCoreOptions, n as RoomEventKind, y as TimelineRecorderOptions, T as TimelineDump, t as RpcTraceDump, q as RoomInspection, J as JoinOptions, e as JoinResult, d as HostCallResult } from './contract-B72MNcCa.js';
4
+ import { p as RoomHost, r as RoomStats, u as RpcTraceEntry, L as LogLevel, k as RoomCoreApi, l as RoomCoreOptions, n as RoomEventKind, y as TimelineRecorderOptions, T as TimelineDump, t as RpcTraceDump, q as RoomInspection, J as JoinOptions, e as JoinResult, d as HostCallResult } from './contract-CcZadpC6.js';
5
5
 
6
6
  declare class Mulberry32 {
7
7
  /** Current internal state (u32). Survives hibernation. */
@@ -37,6 +37,8 @@ interface ClientEntry {
37
37
  * data rather than a caveat in a doc.
38
38
  */
39
39
  readonly subject: string | undefined;
40
+ /** Issue #123: the credential kind of the first join, kept across reconnects like `subject`. */
41
+ readonly auth: 'key' | 'jwt' | 'assertion';
40
42
  role: string;
41
43
  name: string;
42
44
  connected: boolean;
@@ -336,6 +338,9 @@ declare class CustomRuntime {
336
338
  private readonly sourceRecords;
337
339
  private readonly skippedKeys;
338
340
  private readonly scratch;
341
+ /** bugs #116: see `PhysicsRuntime.nonFinite`. */
342
+ private readonly nonFinite;
343
+ private readonly nextScratch;
339
344
  private stepper;
340
345
  /**
341
346
  * M7 pf3 (a): a custom room's unsynced bodies are the stepper's own — it created them, it steps
@@ -372,6 +377,12 @@ declare class CustomRuntime {
372
377
  step(): void;
373
378
  /** Body → schema, through the tracked proxies, `Math.fround`ed for f32 fields as Rapier's is. */
374
379
  sync(): void;
380
+ /**
381
+ * bugs #116: see `PhysicsRuntime.resetBody`. The pose is built from the row's mapped channels,
382
+ * with the identity rotation and the origin for unmapped ones and zero for every velocity; a
383
+ * stepper keeps its own forces, if it has any, where this module cannot see them.
384
+ */
385
+ private resetBody;
375
386
  }
376
387
 
377
388
  /**
@@ -640,6 +651,9 @@ declare class MatterRuntime {
640
651
  private readonly warnedChannels;
641
652
  /** See `PhysicsRuntime.skippedKeys`: entities whose body the room has switched off. */
642
653
  private readonly skippedKeys;
654
+ /** bugs #116: see `PhysicsRuntime.nonFinite`. */
655
+ private readonly nonFinite;
656
+ private readonly nextScratch;
643
657
  private readonly unsyncedBodies;
644
658
  /** Saved unsynced state, consumed as `setup` rebuilds each body under the same key. */
645
659
  private restoreUnsynced;
@@ -680,6 +694,15 @@ declare class MatterRuntime {
680
694
  reconcile(): void;
681
695
  step(): void;
682
696
  sync(): void;
697
+ /**
698
+ * bugs #116: the matter2d half of `PhysicsRuntime.resetBody`, done as a rebuild rather than a
699
+ * teleport. matter moves a body by translating its vertices by the difference between the new
700
+ * position and the old one, and once the old one is not finite that difference is not either:
701
+ * `Body.setPosition` cannot bring the vertices back. So the body is removed and built again
702
+ * from its row, whose pose is the last finite one `sync()` wrote, with the row's velocity
703
+ * fields zeroed first so the new body spawns at rest (and with no force, being new).
704
+ */
705
+ private resetBody;
683
706
  serialize(): MatterSection;
684
707
  }
685
708
 
@@ -845,6 +868,10 @@ declare class PhysicsRuntime {
845
868
  * why it is spelled as "no body" rather than as "a body that is not stepped".
846
869
  */
847
870
  private readonly skippedKeys;
871
+ /** bugs #116: what `sync()` does with a body that went non-finite. See `core/body-guard.ts`. */
872
+ private readonly nonFinite;
873
+ /** The mapped channel values of the body `sync()` is on, reused so the good path allocates nothing. */
874
+ private readonly nextScratch;
848
875
  /**
849
876
  * Bodies with no schema row: rope anchors, dead-reckoned proxies, one-off sensors. Keyed by the
850
877
  * room's own string rather than by `collection\0id`, because there is no collection and no id.
@@ -928,6 +955,12 @@ declare class PhysicsRuntime {
928
955
  * carries — and so a field that has not really moved does not re-dirty every tick.
929
956
  */
930
957
  sync(): void;
958
+ /**
959
+ * bugs #116: puts a non-finite body back at its row's pose (the last one `sync()` wrote, always
960
+ * finite), at rest, with no force left on it. Unmapped channels the engine made non-finite go to
961
+ * zero, or the identity rotation, since there is nothing better to put them back to.
962
+ */
963
+ private resetBody;
931
964
  serialize(): PhysicsSection;
932
965
  }
933
966
 
@@ -1009,6 +1042,9 @@ declare class Rapier2dRuntime {
1009
1042
  private readonly warnedChannels;
1010
1043
  /** See `PhysicsRuntime.skippedKeys`: entities whose body the room has switched off. */
1011
1044
  private readonly skippedKeys;
1045
+ /** bugs #116: see `PhysicsRuntime.nonFinite`. */
1046
+ private readonly nonFinite;
1047
+ private readonly nextScratch;
1012
1048
  /** See `PhysicsRuntime.unsyncedBodies`: bodies the world steps that no schema row describes. */
1013
1049
  private readonly unsyncedBodies;
1014
1050
  readonly unsynced: UnsyncedRegistry;
@@ -1058,6 +1094,8 @@ declare class Rapier2dRuntime {
1058
1094
  * Values are `Math.fround`ed for f32 fields, so room code reads exactly what the wire carries.
1059
1095
  */
1060
1096
  sync(): void;
1097
+ /** bugs #116: see `PhysicsRuntime.resetBody`. */
1098
+ private resetBody;
1061
1099
  /**
1062
1100
  * The same `PhysicsSection` the 3D runtime writes — a real engine snapshot plus the
1063
1101
  * entity↔handle map, whose handles must ride as f64 for the reason spelled out on
@@ -1289,6 +1327,8 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
1289
1327
  lastSerializeTimerAt: number | undefined;
1290
1328
  serialize(): Uint8Array;
1291
1329
  private get presence();
1330
+ /** Issue #123: clients already told that their requested role was ignored. Warn once each. */
1331
+ private readonly unverifiedRoleWarned;
1292
1332
  private resolveRole;
1293
1333
  /**
1294
1334
  * Subjects `room.ban` has excluded, and when each stops being excluded. Values are wall-clock
@@ -1,8 +1,8 @@
1
- import { R as RoomCore } from '../room-DY5J0VZR.js';
2
- export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-DY5J0VZR.js';
1
+ import { R as RoomCore } from '../room-BW2ZSX_k.js';
2
+ export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-BW2ZSX_k.js';
3
3
  import { ErrorCodeName, FrameType } from '@irtio/protocol';
4
4
  import { NpcConfig, LeaveReason, Room, RoomDefinition } from '@irtio/server';
5
- import { p as RoomHost, L as LogLevel, k as RoomCoreApi, c as HostCall, d as HostCallResult, W as WriteCaps, r as RoomStats } from '../contract-B72MNcCa.js';
5
+ import { p as RoomHost, L as LogLevel, k as RoomCoreApi, c as HostCall, d as HostCallResult, W as WriteCaps, r as RoomStats } from '../contract-CcZadpC6.js';
6
6
  import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
7
7
 
8
8
  /**
@@ -374,6 +374,14 @@ interface JoinSpec {
374
374
  * the relay host reads from the store for an identified player, without a store or a socket.
375
375
  */
376
376
  readonly persisted?: ReadonlyMap<string, Readonly<Record<string, unknown>>>;
377
+ /**
378
+ * Issue #123: `JoinOptions.roleVerified`. Defaults to `true` here: this harness stands where the
379
+ * host stands and its tests are about room behaviour, so `role` is a grant unless a test sets
380
+ * `false` to exercise the unverified path.
381
+ */
382
+ readonly roleVerified?: boolean;
383
+ /** Issue #123: `JoinOptions.auth`, the credential kind `ctx.auth` reports. Default `'key'`. */
384
+ readonly auth?: 'key' | 'jwt' | 'assertion';
377
385
  }
378
386
  interface UntilOptions {
379
387
  /** Default 1000. */
@@ -6,7 +6,7 @@ import {
6
6
  initRapier2d,
7
7
  isPerClient,
8
8
  visibleNames
9
- } from "../chunk-23TKVLTX.js";
9
+ } from "../chunk-RLY4VAMM.js";
10
10
 
11
11
  // src/test/clock.ts
12
12
  var FakeClock = class {
@@ -993,8 +993,10 @@ var Harness = class {
993
993
  ...spec.subject !== void 0 ? { subject: spec.subject } : {},
994
994
  // ---- end M8 lane 5 ----
995
995
  // ---- M8 proposal 13 ----
996
- ...spec.persisted !== void 0 ? { persisted: spec.persisted } : {}
996
+ ...spec.persisted !== void 0 ? { persisted: spec.persisted } : {},
997
997
  // ---- end M8 proposal 13 ----
998
+ ...spec.roleVerified !== false ? { roleVerified: true } : {},
999
+ ...spec.auth !== void 0 ? { auth: spec.auth } : {}
998
1000
  };
999
1001
  const client = new FakeClientImpl(this.bridge, id, options);
1000
1002
  this.byId.set(id, client);
@@ -1,4 +1,4 @@
1
- import { L as LogLevel, t as RpcTraceDump, T as TimelineDump, r as RoomStats, c as HostCall, d as HostCallResult, k as RoomCoreApi, p as RoomHost } from '../contract-B72MNcCa.js';
1
+ import { L as LogLevel, t as RpcTraceDump, T as TimelineDump, r as RoomStats, c as HostCall, d as HostCallResult, k as RoomCoreApi, p as RoomHost } from '../contract-CcZadpC6.js';
2
2
  import { ErrorCodeName, ProfileSnapshot, PlatformNotice } from '@irtio/protocol';
3
3
  import { NpcConfig, LeaveReason } from '@irtio/server';
4
4
  import '@irtio/schema';
@@ -54,6 +54,10 @@ type ToWorker = {
54
54
  /** M8 lane 5: the verified subject, absent for a key join. See `JoinOptions.subject` — it is
55
55
  * deliberately narrower than `playerId`, which always has a value. */
56
56
  subject?: string;
57
+ /** Issue #123: `JoinOptions.auth`, the credential kind. Absent means `'key'`. */
58
+ auth?: 'key' | 'jwt' | 'assertion';
59
+ /** Issue #123: `JoinOptions.roleVerified`. Absent means the role is a client request. */
60
+ roleVerified?: boolean;
57
61
  } | {
58
62
  t: 'leave';
59
63
  clientId: string;
@@ -304,10 +308,17 @@ type FromWorker = {
304
308
  * what cap it handed out to know how close the room is to it. Absent when the worker's
305
309
  * V8 could not be sampled, which the supervisor must report as unread rather than as
306
310
  * zero, for the same reason `ticks`/`overruns` are optional.
311
+ *
312
+ * bugs.md #146: `externalBytes` is the off-heap half — `ArrayBuffer` backing stores, WASM
313
+ * memory, `Buffer`s — which neither `usedBytes` nor the old-generation cap above it covers,
314
+ * and which is therefore the half a room can overshoot its whole budget with while every
315
+ * number the platform was reading stays small. Optional on the wire because an older worker
316
+ * does not send it, and because "V8 did not report it" is not "this room holds none".
307
317
  */
308
318
  heap?: {
309
319
  usedBytes: number;
310
320
  limitBytes: number;
321
+ externalBytes?: number;
311
322
  };
312
323
  /**
313
324
  * D56: `true` once any Rapier world in this worker process has stepped, which is the point
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  migrateSnapshot
3
- } from "../chunk-VIN2SPZT.js";
3
+ } from "../chunk-NSUQKWYO.js";
4
4
  import {
5
5
  AdmissionError,
6
6
  RoomCore,
@@ -10,7 +10,7 @@ import {
10
10
  initRapier2d,
11
11
  onFirstRapierStep,
12
12
  rapierHasStepped
13
- } from "../chunk-23TKVLTX.js";
13
+ } from "../chunk-RLY4VAMM.js";
14
14
 
15
15
  // src/worker/index.ts
16
16
  import { getHeapStatistics } from "v8";
@@ -121,7 +121,12 @@ function heapSample() {
121
121
  const s = getHeapStatistics();
122
122
  if (!Number.isFinite(s.used_heap_size) || !Number.isFinite(s.heap_size_limit)) return void 0;
123
123
  if (s.heap_size_limit <= 0) return void 0;
124
- return { usedBytes: s.used_heap_size, limitBytes: s.heap_size_limit };
124
+ const external = s.external_memory;
125
+ return {
126
+ usedBytes: s.used_heap_size,
127
+ limitBytes: s.heap_size_limit,
128
+ ...typeof external === "number" && Number.isFinite(external) && external >= 0 ? { externalBytes: external } : {}
129
+ };
125
130
  } catch {
126
131
  return void 0;
127
132
  }
@@ -265,7 +270,10 @@ async function handleMessage(state, host, post, msg) {
265
270
  ...msg.npc === true ? { npc: true } : {},
266
271
  // M8 lane 5: absent for a key join, and absent is the answer `onAdmit` and `room.ban`
267
272
  // both need to be able to see.
268
- ...msg.subject !== void 0 ? { subject: msg.subject } : {}
273
+ ...msg.subject !== void 0 ? { subject: msg.subject } : {},
274
+ // Issue #123: without these the room would treat every role as a client request.
275
+ ...msg.auth !== void 0 ? { auth: msg.auth } : {},
276
+ ...msg.roleVerified === true ? { roleVerified: true } : {}
269
277
  };
270
278
  const result = core.join(msg.clientId, joinOptions);
271
279
  const snapshot = ownBuffer(result.snapshot);
@@ -463,6 +471,15 @@ if (!isMainThread && parentPort) {
463
471
  ]
464
472
  });
465
473
  });
474
+ process.on("uncaughtException", (err) => {
475
+ post({
476
+ t: "log",
477
+ level: "error",
478
+ args: ["worker: uncaught exception", err?.stack ?? String(err)]
479
+ });
480
+ post({ t: "crashed", reason: `uncaught exception: ${err?.message ?? String(err)}` });
481
+ process.exit(1);
482
+ });
466
483
  const host = createWorkerHost(post);
467
484
  const state = { core: void 0 };
468
485
  port.on("message", (msg) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/runtime",
3
- "version": "4.5.1",
3
+ "version": "4.7.0",
4
4
  "description": "irtio room runtime: RoomCore, worker_threads host, in-process test harness",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,9 +36,9 @@
36
36
  "@dimforge/rapier2d-compat": "0.20.0",
37
37
  "@dimforge/rapier3d-compat": "0.20.0",
38
38
  "matter-js": "0.20.0",
39
- "@irtio/protocol": "^4.5.1",
40
- "@irtio/schema": "^4.5.1",
41
- "@irtio/server": "^4.5.1"
39
+ "@irtio/protocol": "^4.7.0",
40
+ "@irtio/server": "^4.7.0",
41
+ "@irtio/schema": "^4.7.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/matter-js": "0.20.2"