@irtio/runtime 3.1.1 → 4.1.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/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @irtio/runtime
2
+
3
+ Execute irtio room definitions in a custom host or an in-process harness. The runtime applies
4
+ client input, runs handlers, encodes state updates, and saves and restores rooms.
5
+
6
+ ```bash
7
+ npm install @irtio/runtime @irtio/server @irtio/schema
8
+ ```
9
+
10
+ For game development, use [`irtio dev`](https://irt.io/docs/reference/cli#irtio-dev) to run a room
11
+ and [`@irtio/testing`](https://irt.io/docs/reference/testing) to test it with real SDK clients.
12
+
13
+ ## Run a room with the low-level harness
14
+
15
+ This TypeScript example creates a room and advances one server tick:
16
+
17
+ ```ts
18
+ import { defineSchema, singleton, u32 } from '@irtio/schema';
19
+ import { defineRoom } from '@irtio/server';
20
+ import { createRoomHarness } from '@irtio/runtime/test';
21
+
22
+ const schema = defineSchema({ counter: singleton({ ticks: u32 }) });
23
+ const definition = defineRoom(schema, {
24
+ mode: 'tick',
25
+ tickRate: 20,
26
+ tick(state) {
27
+ state.counter.ticks += 1;
28
+ },
29
+ });
30
+
31
+ const harness = createRoomHarness(definition);
32
+ try {
33
+ harness.tick();
34
+ console.log(harness.state.counter.ticks); // 1
35
+ } finally {
36
+ harness.stop();
37
+ }
38
+ ```
39
+
40
+ The low-level harness uses a fake clock and protocol clients. Its `tick()` and `run(ms)` methods
41
+ are synchronous. This differs from the async methods in `@irtio/testing`.
42
+
43
+ ## Entry points
44
+
45
+ | Import | Use |
46
+ | --- | --- |
47
+ | `@irtio/runtime` | `RoomCore`, host contracts, snapshots, migrations, and inspection |
48
+ | `@irtio/runtime/test` | `createRoomHarness`, `FakeClock`, and test-host helpers |
49
+ | `@irtio/runtime/worker` | Node worker host and typed worker messages |
50
+
51
+ A custom host supplies the `RoomHost` contract, including time, scheduling, frame delivery, and
52
+ room lifecycle operations. Keep credentials and transport admission checks at your host boundary.
53
+ Initialize the appropriate physics engine before constructing a room that uses it.
54
+
55
+ See the [server room API](https://irt.io/docs/reference/room) for the behavior room handlers expect
56
+ and [hibernation](https://irt.io/docs/concepts/hibernation) for persistence and restoration rules.
@@ -397,6 +397,22 @@ function isVisible(c, role) {
397
397
  if (c.visibility === "server") return false;
398
398
  return c.visibility !== "role" || (c.roles ?? []).includes(role);
399
399
  }
400
+ function isPerClient(c) {
401
+ return c.visibility === "spatial-grid" || c.visibility === "owner";
402
+ }
403
+ var perClientCache = /* @__PURE__ */ new WeakMap();
404
+ function perClientDescs(ext) {
405
+ let hit = perClientCache.get(ext);
406
+ if (!hit) {
407
+ const all = ext.collections;
408
+ hit = {
409
+ spatial: all.filter((c) => c.visibility === "spatial-grid"),
410
+ perClient: all.filter(isPerClient)
411
+ };
412
+ perClientCache.set(ext, hit);
413
+ }
414
+ return hit;
415
+ }
400
416
  function visibleTo(role) {
401
417
  return (c) => isVisible(c, role);
402
418
  }
@@ -421,20 +437,30 @@ function encodeViewSnapshot(ext, plain, role, tick, memberships) {
421
437
  { tick },
422
438
  {
423
439
  collections: visibleTo(role),
424
- entities: (c, id) => c.visibility !== "spatial-grid" || (memberships?.get(c.name)?.has(id) ?? false)
440
+ entities: (c, id) => !isPerClient(c) || (memberships?.get(c.name)?.has(id) ?? false)
425
441
  }
426
442
  );
427
443
  }
428
- function spatialMemberships(ext, plain, clientId, role, indexes = buildSpatialIndexes(ext.collections, plain)) {
444
+ function ownedIds(plain, desc, clientId) {
445
+ const out = /* @__PURE__ */ new Set();
446
+ if (clientId === "") return out;
447
+ const coll = plainEntity(plain, desc.name);
448
+ for (const id of coll.ids()) if (coll.ownerOf(id) === clientId) out.add(id);
449
+ return out;
450
+ }
451
+ function viewMemberships(ext, plain, clientId, role, indexes = buildSpatialIndexes(ext.collections, plain)) {
429
452
  const out = /* @__PURE__ */ new Map();
430
453
  for (const [name, index] of indexes) out.set(name, querySpatialIndex(index, clientId, role));
454
+ for (const c of ext.collections) {
455
+ if (c.visibility === "owner") out.set(c.name, ownedIds(plain, c, clientId));
456
+ }
431
457
  return out;
432
458
  }
433
459
  var aoiMemo = /* @__PURE__ */ new WeakMap();
434
460
  function inClientView(core, clientId, desc, id) {
435
461
  const role = core.clients.get(clientId)?.role ?? "";
436
462
  if (!isVisible(desc, role)) return false;
437
- if (desc.visibility !== "spatial-grid") return true;
463
+ if (!isPerClient(desc)) return true;
438
464
  let memo = aoiMemo.get(core);
439
465
  if (!memo || memo.tick !== core.tick) {
440
466
  memo = { tick: core.tick, byClient: /* @__PURE__ */ new Map() };
@@ -443,18 +469,23 @@ function inClientView(core, clientId, desc, id) {
443
469
  const key = `${clientId}\0${role}`;
444
470
  let mine = memo.byClient.get(key);
445
471
  if (!mine) {
446
- mine = spatialMemberships(core.ext, core.plain, clientId, role);
472
+ mine = viewMemberships(core.ext, core.plain, clientId, role);
447
473
  memo.byClient.set(key, mine);
448
474
  }
449
475
  return mine.get(desc.name)?.has(id) ?? false;
450
476
  }
451
477
  function createVisibilityPolicy(ext, plain, clientId, role) {
452
- const memberships = spatialMemberships(ext, plain, clientId, role);
478
+ const memberships = viewMemberships(ext, plain, clientId, role);
453
479
  return {
454
480
  memberships,
455
481
  maySeeCollection: (desc) => isVisible(desc, role),
456
- maySeeEntity: (desc, id) => isVisible(desc, role) && (desc.visibility !== "spatial-grid" || (memberships.get(desc.name)?.has(id) ?? false)),
482
+ maySeeEntity: (desc, id) => isVisible(desc, role) && (!isPerClient(desc) || (memberships.get(desc.name)?.has(id) ?? false)),
457
483
  describeSpatial: (desc, ids) => {
484
+ if (desc.visibility === "owner") {
485
+ const coll2 = plainEntity(plain, desc.name);
486
+ const at2 = (id) => `${id} owned by ${coll2.ownerOf(id) || "<server>"}`;
487
+ return `viewer ${clientId}, ${desc.name} ${ids.map(at2).join(" ")}`;
488
+ }
458
489
  const grid = desc.grid;
459
490
  if (desc.visibility !== "spatial-grid" || !grid) return void 0;
460
491
  const coll = plainEntity(plain, desc.name);
@@ -469,13 +500,13 @@ function createVisibilityPolicy(ext, plain, clientId, role) {
469
500
  function sharedViewDirty(ext, dirty, role) {
470
501
  return filterDirty(dirty, (name) => {
471
502
  const desc = ext.collection(name);
472
- return desc.visibility !== "spatial-grid" && isVisible(desc, role);
503
+ return !isPerClient(desc) && isVisible(desc, role);
473
504
  });
474
505
  }
475
- function aoiViewDirty(ext, dirty, previous, current) {
506
+ function perClientViewDirty(ext, dirty, previous, current) {
476
507
  const out = createDirtySet();
477
508
  for (const desc of ext.collections) {
478
- if (desc.visibility !== "spatial-grid") continue;
509
+ if (!isPerClient(desc)) continue;
479
510
  const before = previous.get(desc.name) ?? /* @__PURE__ */ new Set();
480
511
  const now = current.get(desc.name) ?? /* @__PURE__ */ new Set();
481
512
  for (const id of before) if (!now.has(id)) markRemove(out, desc.name, id);
@@ -497,7 +528,7 @@ function membershipSafeDirty(ext, dirty, memberships) {
497
528
  const out = createDirtySet();
498
529
  for (const [name, changed] of dirty) {
499
530
  const desc = ext.collection(name);
500
- if (desc.visibility !== "spatial-grid") {
531
+ if (!isPerClient(desc)) {
501
532
  out.set(name, changed);
502
533
  continue;
503
534
  }
@@ -563,13 +594,16 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
563
594
  import {
564
595
  ErrorCode as ErrorCode2,
565
596
  FrameType as FrameType2,
597
+ NO_REPLY_REQ_ID_BIT,
566
598
  OWNERSHIP_ASK,
599
+ REQ_ID_MASK,
567
600
  clampErrorMessage,
568
601
  decodeCall,
569
602
  decodeReply,
570
603
  encodeCall,
571
604
  encodeFrame as encodeFrame2,
572
605
  encodeReply,
606
+ isNoReplyReqId,
573
607
  isReservedRpcName,
574
608
  rpcByIdOf,
575
609
  rpcTable
@@ -578,12 +612,12 @@ import {
578
612
  SERVER_OWNER as SERVER_OWNER2,
579
613
  decodeFields,
580
614
  encodeFields,
581
- validateValue
615
+ validateValue as validateValue2
582
616
  } from "@irtio/schema";
583
617
  import { DenyError } from "@irtio/server";
584
618
 
585
619
  // src/core/ownership.ts
586
- import { SERVER_OWNER, defaultRecord } from "@irtio/schema";
620
+ import { SERVER_OWNER, cloneValue, defaultRecord, validateValue } from "@irtio/schema";
587
621
  var HOLD_ACTIONS = /* @__PURE__ */ new Set([
588
622
  "release",
589
623
  "nearest",
@@ -735,7 +769,7 @@ function nearestConnected(core, collection, id) {
735
769
  }
736
770
  return best;
737
771
  }
738
- function addPerPlayerRecords(core, clientId) {
772
+ function addPerPlayerRecords(core, clientId, seeds) {
739
773
  for (const c of core.ext.collections) {
740
774
  if (c.kind !== "entity" || !c.perPlayer) continue;
741
775
  const coll = plainEntity(core.plain, c.name);
@@ -746,7 +780,23 @@ function addPerPlayerRecords(core, clientId) {
746
780
  continue;
747
781
  }
748
782
  try {
749
- trackedEntity(core, c.name).add(clientId, defaultRecord(c), { owner: clientId });
783
+ const record = defaultRecord(c);
784
+ const seed = seeds?.get(c.name);
785
+ if (seed && c.persist) {
786
+ for (const field of c.persist) {
787
+ const value = seed[field];
788
+ if (value === void 0) continue;
789
+ const desc = c.fields[c.fieldIndex.get(field) ?? -1];
790
+ if (!desc) continue;
791
+ const err = validateValue(desc.type, value, `${c.name}.${field}`);
792
+ if (err) {
793
+ core.log("warn", `persisted ${c.name}.${field} for ${clientId} dropped: ${err}`);
794
+ continue;
795
+ }
796
+ record[field] = cloneValue(value);
797
+ }
798
+ }
799
+ trackedEntity(core, c.name).add(clientId, record, { owner: clientId });
750
800
  } catch (err) {
751
801
  core.log("error", `perPlayer add on ${c.name} failed for ${clientId}:`, err);
752
802
  }
@@ -942,7 +992,7 @@ function handleCall(core, clientId, payload) {
942
992
  return true;
943
993
  }
944
994
  for (const f of desc.params) {
945
- const problem = validateValue(f.type, params[f.name], f.name);
995
+ const problem = validateValue2(f.type, params[f.name], f.name);
946
996
  if (problem) {
947
997
  traceEarly?.(errorText("E_RPC_BAD_PARAMS", problem));
948
998
  sendReply(core, clientId, call.reqId, null, errorText("E_RPC_BAD_PARAMS", problem));
@@ -1136,10 +1186,10 @@ function descFor(core, name) {
1136
1186
  }
1137
1187
  return found;
1138
1188
  }
1139
- function sendCall(core, clientId, desc, params) {
1189
+ function sendCall(core, clientId, desc, params, noReply = false) {
1140
1190
  const s = stateOf3(core);
1141
- const reqId = s.nextReqId;
1142
- s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
1191
+ const reqId = noReply ? (s.nextReqId | NO_REPLY_REQ_ID_BIT) >>> 0 : s.nextReqId;
1192
+ s.nextReqId = s.nextReqId + 1 & REQ_ID_MASK || 1;
1143
1193
  const bytes = encodeFields(desc.params, params);
1144
1194
  core.send(
1145
1195
  clientId,
@@ -1177,13 +1227,20 @@ function createCallProxy(core, clientId) {
1177
1227
  if (typeof prop !== "string") return void 0;
1178
1228
  return (params) => {
1179
1229
  const desc = descFor(core, prop);
1180
- return new Promise((resolve, reject2) => {
1230
+ const p = new Promise((resolve, reject2) => {
1181
1231
  callClient(core, clientId, desc, params ?? {}, {
1182
1232
  timeoutMs: RPC_TIMEOUT_MS,
1183
1233
  resolve,
1184
1234
  reject: reject2
1185
1235
  });
1186
1236
  });
1237
+ void p.catch((err) => {
1238
+ const message = err instanceof Error ? err.message : String(err);
1239
+ core.stats.rpcClientFailures++;
1240
+ core.log("warn", `rpc ${desc.name} to ${clientId} failed: ${message}`);
1241
+ core.recordEvent("error", clientId, `rpc ${desc.name}: ${message}`);
1242
+ });
1243
+ return p;
1187
1244
  };
1188
1245
  }
1189
1246
  }
@@ -1200,7 +1257,7 @@ function createBroadcastProxy(core, except) {
1200
1257
  for (const entry of core.clients.values()) {
1201
1258
  if (!entry.connected) continue;
1202
1259
  if (except?.has(entry.clientId)) continue;
1203
- sendCall(core, entry.clientId, desc, params ?? {});
1260
+ sendCall(core, entry.clientId, desc, params ?? {}, true);
1204
1261
  }
1205
1262
  };
1206
1263
  }
@@ -1223,6 +1280,7 @@ function handleReply(core, clientId, payload) {
1223
1280
  const key = pendingKey(clientId, reply.reqId);
1224
1281
  const pending = s.pending.get(key);
1225
1282
  if (!pending) {
1283
+ if (isNoReplyReqId(reply.reqId)) return true;
1226
1284
  core.log("warn", `REPLY from ${clientId} for unknown reqId ${reply.reqId}`);
1227
1285
  return true;
1228
1286
  }
@@ -1353,7 +1411,7 @@ function rejectAllHostCalls(core, reason) {
1353
1411
  // src/core/writes.ts
1354
1412
  import {
1355
1413
  SERVER_OWNER as SERVER_OWNER3,
1356
- cloneValue,
1414
+ cloneValue as cloneValue2,
1357
1415
  decodeDelta,
1358
1416
  defaultValue,
1359
1417
  encodeDelta as encodeDelta2,
@@ -1407,7 +1465,7 @@ function applyMaskedPatch(fields, target, patch, mask) {
1407
1465
  continue;
1408
1466
  }
1409
1467
  }
1410
- target[f.name] = cloneValue(value);
1468
+ target[f.name] = cloneValue2(value);
1411
1469
  }
1412
1470
  }
1413
1471
  function assignChanged(desc, target, key, prev, next) {
@@ -1487,7 +1545,7 @@ function applyOp(core, clientId, c, op) {
1487
1545
  );
1488
1546
  return;
1489
1547
  }
1490
- if (c.kind === "entity" && c.visibility === "spatial-grid") {
1548
+ if (c.kind === "entity" && isPerClient(c)) {
1491
1549
  const coll = plainEntity(core.plain, c.name);
1492
1550
  if (coll.has(op.id) && coll.ownerOf(op.id) !== clientId && !inClientView(core, clientId, c, op.id)) {
1493
1551
  core.recordEvent("write-rejected", clientId, `${c.name}: ${op.id} is not in view`);
@@ -1532,17 +1590,17 @@ function applyOp(core, clientId, c, op) {
1532
1590
  const fields = fieldList(c);
1533
1591
  const growth = updateGrowth(c, leafPaths(fields, op.mask));
1534
1592
  if (growth > 0 && !admitGrowth(core, clientId, growth)) return;
1535
- const prev = cloneValue(current);
1536
- const next = cloneValue(current);
1593
+ const prev = cloneValue2(current);
1594
+ const next = cloneValue2(current);
1537
1595
  applyMaskedPatch(fields, next, op.patch, op.mask);
1538
- const requested = cloneValue(next);
1596
+ const requested = cloneValue2(next);
1539
1597
  if (c.physics || c.serverFields) {
1540
1598
  const refused2 = [];
1541
1599
  for (const leaf of leafPaths(fields, op.mask)) {
1542
1600
  const field = leaf.names[0];
1543
1601
  const why = ownerMayNotWrite(c, field);
1544
1602
  if (why === void 0) continue;
1545
- next[field] = cloneValue(prev[field]);
1603
+ next[field] = cloneValue2(prev[field]);
1546
1604
  refused2.push(why);
1547
1605
  }
1548
1606
  if (refused2.length > 0) {
@@ -1580,7 +1638,7 @@ function applyOp(core, clientId, c, op) {
1580
1638
  map = /* @__PURE__ */ new Map();
1581
1639
  entry.accepted.set(key, map);
1582
1640
  }
1583
- for (const leaf of written) map.set(leafKey(leaf), cloneValue(valueAt(result, leaf.names)));
1641
+ for (const leaf of written) map.set(leafKey(leaf), cloneValue2(valueAt(result, leaf.names)));
1584
1642
  }
1585
1643
  const corrected = written.filter(
1586
1644
  (leaf) => !deepEqual(valueAt(result, leaf.names), valueAt(requested, leaf.names))
@@ -3774,7 +3832,7 @@ import {
3774
3832
  withBuiltins as withBuiltins2
3775
3833
  } from "@irtio/protocol";
3776
3834
  import {
3777
- cloneValue as cloneValue2,
3835
+ cloneValue as cloneValue3,
3778
3836
  createDirtySet as createDirtySet3,
3779
3837
  createState,
3780
3838
  decodeSnapshot as decodeSnapshot3,
@@ -5121,7 +5179,7 @@ function stateOf6(core) {
5121
5179
  function omniscientMemberships(core) {
5122
5180
  const out = /* @__PURE__ */ new Map();
5123
5181
  for (const desc of core.ext.collections) {
5124
- if (desc.visibility !== "spatial-grid") continue;
5182
+ if (!isPerClient(desc)) continue;
5125
5183
  out.set(desc.name, new Set(plainEntity(core.plain, desc.name).ids()));
5126
5184
  }
5127
5185
  return out;
@@ -5130,9 +5188,7 @@ function hasRoleScoped(core) {
5130
5188
  return core.ext.collections.some((c) => c.visibility === "role");
5131
5189
  }
5132
5190
  function hasSpatial(core) {
5133
- return core.ext.collections.some(
5134
- (c) => c.visibility === "spatial-grid"
5135
- );
5191
+ return core.ext.collections.some(isPerClient);
5136
5192
  }
5137
5193
  function recorderSnapshot(core, state) {
5138
5194
  if (state.view === "") {
@@ -6141,7 +6197,7 @@ function ownJoinCapture(plain, name, added, clientId) {
6141
6197
  for (const id of added) {
6142
6198
  const owner = coll.ownerOf(id);
6143
6199
  if (name === PRESENCE_COLLECTION ? id !== clientId : owner !== clientId) continue;
6144
- (mine ??= /* @__PURE__ */ new Map()).set(id, { value: cloneValue2(coll.get(id)), owner });
6200
+ (mine ??= /* @__PURE__ */ new Map()).set(id, { value: cloneValue3(coll.get(id)), owner });
6145
6201
  }
6146
6202
  return mine;
6147
6203
  }
@@ -6176,6 +6232,7 @@ var RoomCore = class _RoomCore {
6176
6232
  bytesOut: 0,
6177
6233
  bytesOutByClient: /* @__PURE__ */ new Map(),
6178
6234
  handlerErrors: 0,
6235
+ rpcClientFailures: 0,
6179
6236
  encodesLastFlush: 0,
6180
6237
  aoiEncodesLastFlush: 0,
6181
6238
  gridBuildMs: 0,
@@ -6777,6 +6834,9 @@ var RoomCore = class _RoomCore {
6777
6834
  role: this.resolveRole(options.role, void 0),
6778
6835
  name: options.name ?? "",
6779
6836
  tick: this.tick,
6837
+ // Bug #133: no `ClientEntry` exists yet, so the flag comes straight from the join options —
6838
+ // the same value line 1130 will stamp on the entry a moment later if this admission passes.
6839
+ npc: options.npc === true,
6780
6840
  deny: (reason) => {
6781
6841
  throw new DenyError2(reason);
6782
6842
  },
@@ -6838,7 +6898,7 @@ var RoomCore = class _RoomCore {
6838
6898
  this.plain,
6839
6899
  role2,
6840
6900
  this.tick,
6841
- spatialMemberships(this.ext, this.plain, clientId, role2)
6901
+ viewMemberships(this.ext, this.plain, clientId, role2)
6842
6902
  )
6843
6903
  };
6844
6904
  }
@@ -6905,7 +6965,7 @@ var RoomCore = class _RoomCore {
6905
6965
  this.clients.set(clientId, entry);
6906
6966
  this.invalidateClients();
6907
6967
  reclaimHolds(this.internals, clientId, entry.playerId);
6908
- addPerPlayerRecords(this.internals, clientId);
6968
+ addPerPlayerRecords(this.internals, clientId, options.persisted);
6909
6969
  const onJoin = this.definition.config.onJoin;
6910
6970
  if (onJoin) {
6911
6971
  const ctx = this.ctxFor(clientId, reconnecting);
@@ -6913,7 +6973,7 @@ var RoomCore = class _RoomCore {
6913
6973
  }
6914
6974
  if (!entry.npc) this.loop.noteActivity();
6915
6975
  this.recordEvent("join", clientId, reconnecting ? "reconnect" : void 0);
6916
- const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
6976
+ const memberships = viewMemberships(this.ext, this.plain, clientId, entry.role);
6917
6977
  entry.spatialMembership = memberships;
6918
6978
  const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
6919
6979
  entry.welcomed = true;
@@ -6928,7 +6988,7 @@ var RoomCore = class _RoomCore {
6928
6988
  const captured = mine ?? /* @__PURE__ */ new Map();
6929
6989
  for (const id of cd.added) {
6930
6990
  if (!visible.has(id) || captured.has(id)) continue;
6931
- captured.set(id, { value: cloneValue2(coll.get(id)), owner: coll.ownerOf(id) });
6991
+ captured.set(id, { value: cloneValue3(coll.get(id)), owner: coll.ownerOf(id) });
6932
6992
  }
6933
6993
  if (captured.size > 0) pending.set(name2, captured);
6934
6994
  } else if (mine) pending.set(name2, mine);
@@ -6969,6 +7029,30 @@ var RoomCore = class _RoomCore {
6969
7029
  this.invalidateClients();
6970
7030
  this.eventFlush();
6971
7031
  }
7032
+ // ---- M8 proposal 13: identity-keyed player persistence ----
7033
+ /**
7034
+ * The current values of every `persist` field this client's `perPlayer` records hold, or
7035
+ * `undefined` when the schema declares none and when the client has no records.
7036
+ *
7037
+ * Read by the relay host just before `leave`, which is the last moment the records exist: the
7038
+ * declared leave rules remove a `perPlayer` record.
7039
+ */
7040
+ persistedFieldsOf(clientId) {
7041
+ let out;
7042
+ for (const c of this.ext.collections) {
7043
+ if (c.kind !== "entity" || !c.perPlayer || !c.persist || c.persist.length === 0) continue;
7044
+ const record = plainEntity(this.plain, c.name).get(clientId);
7045
+ if (!record) continue;
7046
+ const picked = {};
7047
+ for (const field of c.persist) {
7048
+ const value = record[field];
7049
+ if (value !== void 0) picked[field] = cloneValue3(value);
7050
+ }
7051
+ if (Object.keys(picked).length > 0) (out ??= /* @__PURE__ */ new Map()).set(c.name, picked);
7052
+ }
7053
+ return out;
7054
+ }
7055
+ // ---- end M8 proposal 13 ----
6972
7056
  markDisconnected(clientId) {
6973
7057
  const entry = this.clients.get(clientId);
6974
7058
  if (!entry) {
@@ -6995,6 +7079,9 @@ var RoomCore = class _RoomCore {
6995
7079
  // other entry point (join, leave, write, ownership) has no client tick by construction.
6996
7080
  clientTick: void 0,
6997
7081
  reconnecting,
7082
+ // Bug #133: read from the seat, not from anything the session says about itself — only
7083
+ // `room.spawnNPC`'s loopback join sets it, so an RPC ctx reports it honestly too.
7084
+ npc: entry?.npc ?? false,
6998
7085
  room: this.room,
6999
7086
  // M7 pf4 (a): on every ctx rather than only the RPC one, because the type says it is there.
7000
7087
  // `rpc.ts` is what turns the throw into an `ok: false` reply; anywhere else it is a throw.
@@ -7193,7 +7280,7 @@ var RoomCore = class _RoomCore {
7193
7280
  this.ledger?.newFlush();
7194
7281
  this.stats.encodesLastFlush = 0;
7195
7282
  this.stats.aoiEncodesLastFlush = 0;
7196
- const spatialDescs = this.ext.collections.filter((desc) => desc.visibility === "spatial-grid");
7283
+ const { spatial: spatialDescs, perClient } = perClientDescs(this.ext);
7197
7284
  const buildStarted = performance.now();
7198
7285
  const indexes = buildSpatialIndexes(spatialDescs, this.plain);
7199
7286
  this.stats.gridBuildMs += performance.now() - buildStarted;
@@ -7201,7 +7288,7 @@ var RoomCore = class _RoomCore {
7201
7288
  for (const entry of this.clients.values()) {
7202
7289
  if (entry.connected) {
7203
7290
  const queryStarted = performance.now();
7204
- const memberships = spatialMemberships(
7291
+ const memberships = viewMemberships(
7205
7292
  this.ext,
7206
7293
  this.plain,
7207
7294
  entry.clientId,
@@ -7244,7 +7331,7 @@ var RoomCore = class _RoomCore {
7244
7331
  }
7245
7332
  }
7246
7333
  let delta;
7247
- if (spatialDescs.length > 0) {
7334
+ if (perClient.length > 0) {
7248
7335
  const sourceDirty = entry.pendingJoinAdds ? stripAdds(this.plain, dirty, entry.pendingJoinAdds) : dirty;
7249
7336
  let shared;
7250
7337
  if (entry.pendingJoinAdds) {
@@ -7269,7 +7356,7 @@ var RoomCore = class _RoomCore {
7269
7356
  this.ledger ? { shared } : void 0
7270
7357
  );
7271
7358
  }
7272
- const aoiDirty = aoiViewDirty(
7359
+ const aoiDirty = perClientViewDirty(
7273
7360
  this.ext,
7274
7361
  sourceDirty,
7275
7362
  entry.spatialMembership,
@@ -7310,7 +7397,7 @@ var RoomCore = class _RoomCore {
7310
7397
  encodeFrame5(FrameType6.DELTA, delta),
7311
7398
  this.ledger ? {
7312
7399
  ...churn ? { churn } : {},
7313
- ...spatialDescs.length > 0 ? {} : { shared: delta }
7400
+ ...perClient.length > 0 ? {} : { shared: delta }
7314
7401
  } : void 0
7315
7402
  );
7316
7403
  }
@@ -7349,6 +7436,7 @@ export {
7349
7436
  summarize,
7350
7437
  RpcTraceRing,
7351
7438
  isVisible,
7439
+ isPerClient,
7352
7440
  visibleTo,
7353
7441
  visibleNames,
7354
7442
  viewKeyFor,
@@ -3,7 +3,7 @@ import {
3
3
  parseHibernationBlob,
4
4
  sameSchemaHash,
5
5
  writeHibernationBlob
6
- } from "./chunk-2L3RGWXM.js";
6
+ } from "./chunk-KJN62J37.js";
7
7
 
8
8
  // src/migrate.ts
9
9
  import { PRESENCE_COLLECTION, withBuiltins } from "@irtio/protocol";
@@ -552,6 +552,12 @@ interface JoinOptions {
552
552
  * therefore the only thing `room.ban` can key a cooldown on honestly.
553
553
  */
554
554
  readonly subject?: string;
555
+ /**
556
+ * Seed values for this client's fresh `perPlayer` records, from the relay host's persistence
557
+ * blob: collection name → partial field values. Applied only where a new record is created; a
558
+ * reclaimed or reconnected record keeps what it has.
559
+ */
560
+ readonly persisted?: ReadonlyMap<string, Readonly<Record<string, unknown>>>;
555
561
  }
556
562
  interface JoinResult {
557
563
  readonly tick: number;
@@ -598,6 +604,13 @@ interface RoomStats {
598
604
  /** Per connected client. */
599
605
  bytesOutByClient: Map<string, number>;
600
606
  handlerErrors: number;
607
+ /**
608
+ * Bug #131: server→client RPCs (`room.call(id).someRpc(...)`) that rejected — the client has no
609
+ * implementation, disconnected, or did not answer within the 5 s window. Counted whether or not
610
+ * the room's own code attached a `.catch`, and deliberately *not* a `handlerErrors`: nothing the
611
+ * room's code did threw.
612
+ */
613
+ rpcClientFailures: number;
601
614
  encodesLastFlush: number;
602
615
  aoiEncodesLastFlush: number;
603
616
  gridBuildMs: number;
@@ -657,6 +670,13 @@ interface RoomCoreApi<S extends AnySchema = AnySchema> {
657
670
  leave(clientId: string, reason: LeaveReason): void;
658
671
  /** Grace window opened by the host: presence `connected = false`; frames are no longer sent. */
659
672
  markDisconnected(clientId: string): void;
673
+ /**
674
+ * M8 proposal 13: the current values of every `persist` field this client's `perPlayer` records
675
+ * hold, or `undefined` when the schema declares none and when the client has no records.
676
+ *
677
+ * Read by the relay host just before `leave`, to write the player's persistence blob.
678
+ */
679
+ persistedFieldsOf(clientId: string): ReadonlyMap<string, Record<string, unknown>> | undefined;
660
680
  /** One inbound protocol frame (`WRITE` | `CALL` | `REPLY` | `MSG`). Anything else → host.kick(E_BAD_FRAME). */
661
681
  receive(clientId: string, frame: Uint8Array): void;
662
682
  /** Full state + tick + rng for hibernation. Runs `onSleep`. Clears timers. */
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-BoCNAPZT.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-CGbiPxi3.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-DHWlURaq.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-Bpo_nqlO.js';
3
3
  import { CollectionDesc, AnySchema, PlainState, DirtySet } from '@irtio/schema';
4
4
  import '@irtio/protocol';
5
5
  import '@irtio/server';
@@ -26,6 +26,12 @@ declare const RPC_TIMEOUT_MS = 5000;
26
26
  *
27
27
  * A collection with `visibility: 'role'` is visible only to the roles it names — including its
28
28
  * adds and removes, so a client never learns such a record exists.
29
+ *
30
+ * Two modes filter *entities* rather than collections, and both are handled by the same
31
+ * per-client membership machinery: `visibility: 'spatial-grid'` (the ids inside the client's AOI)
32
+ * and `visibility: 'owner'` (the ids the client owns, per the first-class `ownerOf` ownership
33
+ * every entity record carries). A server-owned record — owner `SERVER_OWNER`, the empty string —
34
+ * is owned by no client and is therefore visible to none.
29
35
  */
30
36
 
31
37
  /**
@@ -33,6 +39,13 @@ declare const RPC_TIMEOUT_MS = 5000;
33
39
  * answer `visibility: 'role'` with an empty `roles` list has always given, said on purpose.
34
40
  */
35
41
  declare function isVisible(c: CollectionDesc, role: string): boolean;
42
+ /**
43
+ * Is `c` filtered per client rather than per role? `spatial-grid` by AOI membership, `owner` by
44
+ * record ownership. Both answer `isVisible` with `true` — from the role's side the collection
45
+ * genuinely is visible, and it is the entities that come and go — so every path that encodes or
46
+ * judges entities has to ask this question as well.
47
+ */
48
+ declare function isPerClient(c: CollectionDesc): boolean;
36
49
  /** A `SnapshotOptions.collections` predicate for one role. */
37
50
  declare function visibleTo(role: string): (c: CollectionDesc) => boolean;
38
51
  /** Names of the collections `role` sees (memoised per schema + role). */
@@ -48,7 +61,10 @@ interface VisibilityPolicy {
48
61
  readonly memberships: ReadonlyMap<string, ReadonlySet<string>>;
49
62
  maySeeCollection(desc: CollectionDesc): boolean;
50
63
  maySeeEntity(desc: CollectionDesc, id: string): boolean;
51
- /** Viewer cell, offending cells, and the radius — the diagnostics a leak report needs. */
64
+ /**
65
+ * Viewer cell, offending cells, and the radius — the diagnostics a leak report needs. For an
66
+ * `owner` collection it names the actual owner of each offending id instead.
67
+ */
52
68
  describeSpatial(desc: CollectionDesc, ids: readonly string[]): string | undefined;
53
69
  }
54
70
  /** Reusable authority-based policy for runtime, harness, and bot leak checks. */
@@ -245,4 +261,4 @@ declare function migrateSnapshot(blob: Uint8Array, chain: readonly MigrationStep
245
261
  declare function toMigrationState(schema: AnySchema, plain: PlainState): MigrationState;
246
262
  declare function fromMigrationState(schema: AnySchema, state: MigrationState, version: number): PlainState;
247
263
 
248
- export { type DecodedSave, type MigrateOptions, type MigrationEntity, type MigrationHelpers, type MigrationModule, type MigrationOutcome, type MigrationRecord, type MigrationState, type MigrationStep, type ParsedSnapshot, READABLE_SNAPSHOT_VERSIONS, RPC_TIMEOUT_MS, SNAPSHOT_FORMAT_VERSION, type SnapshotHeader, type SnapshotMode, type VisibilityPolicy, catchUpDirty, createVisibilityPolicy, decodeSave, encodeViewDelta, encodeViewSnapshot, fromMigrationState, isVisible, migrateSnapshot, parseHibernationBlob, toMigrationState, viewKeyFor, visibleNames, visibleTo, writeHibernationBlob };
264
+ export { type DecodedSave, type MigrateOptions, type MigrationEntity, type MigrationHelpers, type MigrationModule, type MigrationOutcome, type MigrationRecord, type MigrationState, type MigrationStep, type ParsedSnapshot, READABLE_SNAPSHOT_VERSIONS, RPC_TIMEOUT_MS, SNAPSHOT_FORMAT_VERSION, type SnapshotHeader, type SnapshotMode, type VisibilityPolicy, catchUpDirty, createVisibilityPolicy, decodeSave, encodeViewDelta, encodeViewSnapshot, fromMigrationState, isPerClient, isVisible, migrateSnapshot, parseHibernationBlob, toMigrationState, viewKeyFor, visibleNames, visibleTo, writeHibernationBlob };
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  fromMigrationState,
3
3
  migrateSnapshot,
4
4
  toMigrationState
5
- } from "./chunk-XPRLQCNF.js";
5
+ } from "./chunk-ZGJY4E3G.js";
6
6
  import {
7
7
  AdmissionError,
8
8
  CRASH_AFTER_THROWS,
@@ -44,6 +44,7 @@ import {
44
44
  initPhysics,
45
45
  initRapier2d,
46
46
  inspectState,
47
+ isPerClient,
47
48
  isVisible,
48
49
  loadedMatter,
49
50
  loadedPhysics,
@@ -58,7 +59,7 @@ import {
58
59
  visibleNames,
59
60
  visibleTo,
60
61
  writeHibernationBlob
61
- } from "./chunk-2L3RGWXM.js";
62
+ } from "./chunk-KJN62J37.js";
62
63
  export {
63
64
  AdmissionError,
64
65
  CRASH_AFTER_THROWS,
@@ -101,6 +102,7 @@ export {
101
102
  initPhysics,
102
103
  initRapier2d,
103
104
  inspectState,
105
+ isPerClient,
104
106
  isVisible,
105
107
  loadedMatter,
106
108
  loadedPhysics,
@@ -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, 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-BoCNAPZT.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-DHWlURaq.js';
5
5
 
6
6
  declare class Mulberry32 {
7
7
  /** Current internal state (u32). Survives hibernation. */
@@ -83,7 +83,12 @@ interface ClientEntry {
83
83
  * Every other client's delta is unaffected.
84
84
  */
85
85
  pendingJoinAdds: Map<string, ReadonlyMap<string, JoinAddCapture>> | undefined;
86
- /** Last AOI membership sent to this client, keyed by spatial collection. */
86
+ /**
87
+ * Last per-client entity membership sent to this client, keyed by collection: AOI membership
88
+ * for a `spatial-grid` collection, the ids it owns for an `owner` one. The diff against this
89
+ * flush's membership is what synthesises the adds and removes neither the dirty set nor the
90
+ * shared per-role payload can carry.
91
+ */
87
92
  spatialMembership: Map<string, Set<string>>;
88
93
  }
89
94
  /** What `join` captured for one of a client's own just-added records, for `flush`'s comparison. */
@@ -1152,6 +1157,14 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
1152
1157
  /** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
1153
1158
  private eventFlush;
1154
1159
  leave(clientId: string, reason: LeaveReason): void;
1160
+ /**
1161
+ * The current values of every `persist` field this client's `perPlayer` records hold, or
1162
+ * `undefined` when the schema declares none and when the client has no records.
1163
+ *
1164
+ * Read by the relay host just before `leave`, which is the last moment the records exist: the
1165
+ * declared leave rules remove a `perPlayer` record.
1166
+ */
1167
+ persistedFieldsOf(clientId: string): ReadonlyMap<string, Record<string, unknown>> | undefined;
1155
1168
  markDisconnected(clientId: string): void;
1156
1169
  ctxFor(clientId: string, reconnecting?: boolean): Ctx;
1157
1170
  /**
@@ -1,8 +1,8 @@
1
- import { R as RoomCore } from '../room-CGbiPxi3.js';
2
- export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-CGbiPxi3.js';
1
+ import { R as RoomCore } from '../room-Bpo_nqlO.js';
2
+ export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-Bpo_nqlO.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-BoCNAPZT.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-DHWlURaq.js';
6
6
  import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
7
7
 
8
8
  /**
@@ -20,6 +20,14 @@ declare class FakeClock {
20
20
  clearTimeout(handle: unknown): void;
21
21
  /** Number of timers still armed (a room that is ticking always has at least one). */
22
22
  get pending(): number;
23
+ /**
24
+ * The `at` of the earliest armed timer, or `undefined` when nothing is armed. Read-only: it
25
+ * changes nothing. It exists for callers that need to advance to the next timer rather than
26
+ * over it — `testRoom`'s `t.run(ms)` uses it to hop timer by timer, so a script parked on an
27
+ * `await` (whose continuation only runs once `advance` has returned and microtasks drain) can
28
+ * arm its next timer and keep going inside a single long run.
29
+ */
30
+ nextDueAt(): number | undefined;
23
31
  /**
24
32
  * Advances to `time + ms`, firing every timer that comes due in order (earliest `at` first,
25
33
  * ties broken by creation order). A timer that arms another timer inside the window fires too.
@@ -361,6 +369,11 @@ interface JoinSpec {
361
369
  * `onAdmit` that requires identity needs to be tested against.
362
370
  */
363
371
  readonly subject?: string;
372
+ /**
373
+ * The persistence blob this join arrives with: collection name → persisted field values. What
374
+ * the relay host reads from the store for an identified player, without a store or a socket.
375
+ */
376
+ readonly persisted?: ReadonlyMap<string, Readonly<Record<string, unknown>>>;
364
377
  }
365
378
  interface UntilOptions {
366
379
  /** Default 1000. */
@@ -4,8 +4,9 @@ import {
4
4
  initMatter,
5
5
  initPhysics,
6
6
  initRapier2d,
7
+ isPerClient,
7
8
  visibleNames
8
- } from "../chunk-2L3RGWXM.js";
9
+ } from "../chunk-KJN62J37.js";
9
10
 
10
11
  // src/test/clock.ts
11
12
  var FakeClock = class {
@@ -28,6 +29,22 @@ var FakeClock = class {
28
29
  get pending() {
29
30
  return this.timers.size;
30
31
  }
32
+ /**
33
+ * The `at` of the earliest armed timer, or `undefined` when nothing is armed. Read-only: it
34
+ * changes nothing. It exists for callers that need to advance to the next timer rather than
35
+ * over it — `testRoom`'s `t.run(ms)` uses it to hop timer by timer, so a script parked on an
36
+ * `await` (whose continuation only runs once `advance` has returned and microtasks drain) can
37
+ * arm its next timer and keep going inside a single long run.
38
+ */
39
+ nextDueAt() {
40
+ let earliest;
41
+ for (const t of this.timers.values()) {
42
+ if (!earliest || t.at < earliest.at || t.at === earliest.at && t.id < earliest.id) {
43
+ earliest = t;
44
+ }
45
+ }
46
+ return earliest?.at;
47
+ }
31
48
  /**
32
49
  * Advances to `time + ms`, firing every timer that comes due in order (earliest `at` first,
33
50
  * ties broken by creation order). A timer that arms another timer inside the window fires too.
@@ -336,6 +353,7 @@ import {
336
353
  encodeFrame,
337
354
  encodeMsg,
338
355
  encodeReply,
356
+ isNoReplyReqId,
339
357
  rpcTable
340
358
  } from "@irtio/protocol";
341
359
  import {
@@ -605,7 +623,7 @@ var FakeClientImpl = class {
605
623
  /** Flags any collection the frame named that this client's role may not see. */
606
624
  checkFrameVisibility(delta) {
607
625
  for (const dc of delta.collections) {
608
- const spatial = this.bridge.ext.collection(dc.name).visibility === "spatial-grid";
626
+ const spatial = isPerClient(this.bridge.ext.collection(dc.name));
609
627
  const leaked = dc.ops.filter((op) => {
610
628
  if (!this.bridge.visible(this.role, this.id, dc.name)) return true;
611
629
  if (!spatial) return false;
@@ -644,20 +662,26 @@ var FakeClientImpl = class {
644
662
  this.calls.push({ name: desc.name, params });
645
663
  const impl = this.impls.get(desc.name);
646
664
  if (!impl) return;
665
+ const noReply = isNoReplyReqId(call.reqId);
647
666
  let result;
648
667
  try {
649
668
  result = impl(params);
650
669
  } catch (err) {
651
- this.replyError(call.reqId, err);
670
+ if (!noReply) this.replyError(call.reqId, err);
652
671
  return;
653
672
  }
654
673
  if (isThenable(result)) {
655
674
  Promise.resolve(result).then(
656
- (v) => this.replyOk(desc, call.reqId, v),
657
- (err) => this.replyError(call.reqId, err)
675
+ (v) => {
676
+ if (!noReply) this.replyOk(desc, call.reqId, v);
677
+ },
678
+ (err) => {
679
+ if (!noReply) this.replyError(call.reqId, err);
680
+ }
658
681
  );
659
682
  return;
660
683
  }
684
+ if (noReply) return;
661
685
  this.replyOk(desc, call.reqId, result);
662
686
  }
663
687
  replyOk(desc, reqId, result) {
@@ -966,8 +990,11 @@ var Harness = class {
966
990
  ...spec.playerId !== void 0 ? { playerId: spec.playerId } : {},
967
991
  // ---- end M6 lane K ----
968
992
  // ---- M8 lane 5: moderation ----
969
- ...spec.subject !== void 0 ? { subject: spec.subject } : {}
993
+ ...spec.subject !== void 0 ? { subject: spec.subject } : {},
970
994
  // ---- end M8 lane 5 ----
995
+ // ---- M8 proposal 13 ----
996
+ ...spec.persisted !== void 0 ? { persisted: spec.persisted } : {}
997
+ // ---- end M8 proposal 13 ----
971
998
  };
972
999
  const client = new FakeClientImpl(this.bridge, id, options);
973
1000
  this.byId.set(id, client);
@@ -991,7 +1018,7 @@ var Harness = class {
991
1018
  const keep = visibleNames(ext, client.role);
992
1019
  const policy = createVisibilityPolicy(ext, this.coreRef.plain, client.id, client.role);
993
1020
  for (const c of ext.collections) {
994
- if (c.visibility === "spatial-grid") {
1021
+ if (isPerClient(c)) {
995
1022
  const coll = client.view[c.name];
996
1023
  const ids = coll ? [...coll.ids()].filter((id) => !policy.maySeeEntity(c, id)) : [];
997
1024
  if (ids.length > 0) {
@@ -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-BoCNAPZT.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-DHWlURaq.js';
2
2
  import { ErrorCodeName, ProfileSnapshot, PlatformNotice } from '@irtio/protocol';
3
3
  import { NpcConfig, LeaveReason } from '@irtio/server';
4
4
  import '@irtio/schema';
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  migrateSnapshot
3
- } from "../chunk-XPRLQCNF.js";
3
+ } from "../chunk-ZGJY4E3G.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-2L3RGWXM.js";
13
+ } from "../chunk-KJN62J37.js";
14
14
 
15
15
  // src/worker/index.ts
16
16
  import { getHeapStatistics } from "v8";
@@ -452,6 +452,16 @@ if (!isMainThread && parentPort) {
452
452
  const post = (msg, transfer) => {
453
453
  port.postMessage(msg, transfer ?? transferList(msg));
454
454
  };
455
+ process.on("unhandledRejection", (reason) => {
456
+ post({
457
+ t: "log",
458
+ level: "error",
459
+ args: [
460
+ "worker: unhandled rejection",
461
+ reason instanceof Error ? reason.stack ?? reason.message : reason
462
+ ]
463
+ });
464
+ });
455
465
  const host = createWorkerHost(post);
456
466
  const state = { core: void 0 };
457
467
  port.on("message", (msg) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/runtime",
3
- "version": "3.1.1",
3
+ "version": "4.1.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": "3.1.1",
40
- "@irtio/schema": "3.1.1",
41
- "@irtio/server": "3.1.1"
39
+ "@irtio/protocol": "^4.1.0",
40
+ "@irtio/schema": "^4.1.0",
41
+ "@irtio/server": "^4.1.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/matter-js": "0.20.2"