@irtio/runtime 4.0.0 → 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
@@ -1152,10 +1186,10 @@ function descFor(core, name) {
1152
1186
  }
1153
1187
  return found;
1154
1188
  }
1155
- function sendCall(core, clientId, desc, params) {
1189
+ function sendCall(core, clientId, desc, params, noReply = false) {
1156
1190
  const s = stateOf3(core);
1157
- const reqId = s.nextReqId;
1158
- 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;
1159
1193
  const bytes = encodeFields(desc.params, params);
1160
1194
  core.send(
1161
1195
  clientId,
@@ -1193,13 +1227,20 @@ function createCallProxy(core, clientId) {
1193
1227
  if (typeof prop !== "string") return void 0;
1194
1228
  return (params) => {
1195
1229
  const desc = descFor(core, prop);
1196
- return new Promise((resolve, reject2) => {
1230
+ const p = new Promise((resolve, reject2) => {
1197
1231
  callClient(core, clientId, desc, params ?? {}, {
1198
1232
  timeoutMs: RPC_TIMEOUT_MS,
1199
1233
  resolve,
1200
1234
  reject: reject2
1201
1235
  });
1202
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;
1203
1244
  };
1204
1245
  }
1205
1246
  }
@@ -1216,7 +1257,7 @@ function createBroadcastProxy(core, except) {
1216
1257
  for (const entry of core.clients.values()) {
1217
1258
  if (!entry.connected) continue;
1218
1259
  if (except?.has(entry.clientId)) continue;
1219
- sendCall(core, entry.clientId, desc, params ?? {});
1260
+ sendCall(core, entry.clientId, desc, params ?? {}, true);
1220
1261
  }
1221
1262
  };
1222
1263
  }
@@ -1239,6 +1280,7 @@ function handleReply(core, clientId, payload) {
1239
1280
  const key = pendingKey(clientId, reply.reqId);
1240
1281
  const pending = s.pending.get(key);
1241
1282
  if (!pending) {
1283
+ if (isNoReplyReqId(reply.reqId)) return true;
1242
1284
  core.log("warn", `REPLY from ${clientId} for unknown reqId ${reply.reqId}`);
1243
1285
  return true;
1244
1286
  }
@@ -1503,7 +1545,7 @@ function applyOp(core, clientId, c, op) {
1503
1545
  );
1504
1546
  return;
1505
1547
  }
1506
- if (c.kind === "entity" && c.visibility === "spatial-grid") {
1548
+ if (c.kind === "entity" && isPerClient(c)) {
1507
1549
  const coll = plainEntity(core.plain, c.name);
1508
1550
  if (coll.has(op.id) && coll.ownerOf(op.id) !== clientId && !inClientView(core, clientId, c, op.id)) {
1509
1551
  core.recordEvent("write-rejected", clientId, `${c.name}: ${op.id} is not in view`);
@@ -5137,7 +5179,7 @@ function stateOf6(core) {
5137
5179
  function omniscientMemberships(core) {
5138
5180
  const out = /* @__PURE__ */ new Map();
5139
5181
  for (const desc of core.ext.collections) {
5140
- if (desc.visibility !== "spatial-grid") continue;
5182
+ if (!isPerClient(desc)) continue;
5141
5183
  out.set(desc.name, new Set(plainEntity(core.plain, desc.name).ids()));
5142
5184
  }
5143
5185
  return out;
@@ -5146,9 +5188,7 @@ function hasRoleScoped(core) {
5146
5188
  return core.ext.collections.some((c) => c.visibility === "role");
5147
5189
  }
5148
5190
  function hasSpatial(core) {
5149
- return core.ext.collections.some(
5150
- (c) => c.visibility === "spatial-grid"
5151
- );
5191
+ return core.ext.collections.some(isPerClient);
5152
5192
  }
5153
5193
  function recorderSnapshot(core, state) {
5154
5194
  if (state.view === "") {
@@ -6192,6 +6232,7 @@ var RoomCore = class _RoomCore {
6192
6232
  bytesOut: 0,
6193
6233
  bytesOutByClient: /* @__PURE__ */ new Map(),
6194
6234
  handlerErrors: 0,
6235
+ rpcClientFailures: 0,
6195
6236
  encodesLastFlush: 0,
6196
6237
  aoiEncodesLastFlush: 0,
6197
6238
  gridBuildMs: 0,
@@ -6793,6 +6834,9 @@ var RoomCore = class _RoomCore {
6793
6834
  role: this.resolveRole(options.role, void 0),
6794
6835
  name: options.name ?? "",
6795
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,
6796
6840
  deny: (reason) => {
6797
6841
  throw new DenyError2(reason);
6798
6842
  },
@@ -6854,7 +6898,7 @@ var RoomCore = class _RoomCore {
6854
6898
  this.plain,
6855
6899
  role2,
6856
6900
  this.tick,
6857
- spatialMemberships(this.ext, this.plain, clientId, role2)
6901
+ viewMemberships(this.ext, this.plain, clientId, role2)
6858
6902
  )
6859
6903
  };
6860
6904
  }
@@ -6929,7 +6973,7 @@ var RoomCore = class _RoomCore {
6929
6973
  }
6930
6974
  if (!entry.npc) this.loop.noteActivity();
6931
6975
  this.recordEvent("join", clientId, reconnecting ? "reconnect" : void 0);
6932
- const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
6976
+ const memberships = viewMemberships(this.ext, this.plain, clientId, entry.role);
6933
6977
  entry.spatialMembership = memberships;
6934
6978
  const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
6935
6979
  entry.welcomed = true;
@@ -7035,6 +7079,9 @@ var RoomCore = class _RoomCore {
7035
7079
  // other entry point (join, leave, write, ownership) has no client tick by construction.
7036
7080
  clientTick: void 0,
7037
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,
7038
7085
  room: this.room,
7039
7086
  // M7 pf4 (a): on every ctx rather than only the RPC one, because the type says it is there.
7040
7087
  // `rpc.ts` is what turns the throw into an `ok: false` reply; anywhere else it is a throw.
@@ -7233,7 +7280,7 @@ var RoomCore = class _RoomCore {
7233
7280
  this.ledger?.newFlush();
7234
7281
  this.stats.encodesLastFlush = 0;
7235
7282
  this.stats.aoiEncodesLastFlush = 0;
7236
- const spatialDescs = this.ext.collections.filter((desc) => desc.visibility === "spatial-grid");
7283
+ const { spatial: spatialDescs, perClient } = perClientDescs(this.ext);
7237
7284
  const buildStarted = performance.now();
7238
7285
  const indexes = buildSpatialIndexes(spatialDescs, this.plain);
7239
7286
  this.stats.gridBuildMs += performance.now() - buildStarted;
@@ -7241,7 +7288,7 @@ var RoomCore = class _RoomCore {
7241
7288
  for (const entry of this.clients.values()) {
7242
7289
  if (entry.connected) {
7243
7290
  const queryStarted = performance.now();
7244
- const memberships = spatialMemberships(
7291
+ const memberships = viewMemberships(
7245
7292
  this.ext,
7246
7293
  this.plain,
7247
7294
  entry.clientId,
@@ -7284,7 +7331,7 @@ var RoomCore = class _RoomCore {
7284
7331
  }
7285
7332
  }
7286
7333
  let delta;
7287
- if (spatialDescs.length > 0) {
7334
+ if (perClient.length > 0) {
7288
7335
  const sourceDirty = entry.pendingJoinAdds ? stripAdds(this.plain, dirty, entry.pendingJoinAdds) : dirty;
7289
7336
  let shared;
7290
7337
  if (entry.pendingJoinAdds) {
@@ -7309,7 +7356,7 @@ var RoomCore = class _RoomCore {
7309
7356
  this.ledger ? { shared } : void 0
7310
7357
  );
7311
7358
  }
7312
- const aoiDirty = aoiViewDirty(
7359
+ const aoiDirty = perClientViewDirty(
7313
7360
  this.ext,
7314
7361
  sourceDirty,
7315
7362
  entry.spatialMembership,
@@ -7350,7 +7397,7 @@ var RoomCore = class _RoomCore {
7350
7397
  encodeFrame5(FrameType6.DELTA, delta),
7351
7398
  this.ledger ? {
7352
7399
  ...churn ? { churn } : {},
7353
- ...spatialDescs.length > 0 ? {} : { shared: delta }
7400
+ ...perClient.length > 0 ? {} : { shared: delta }
7354
7401
  } : void 0
7355
7402
  );
7356
7403
  }
@@ -7389,6 +7436,7 @@ export {
7389
7436
  summarize,
7390
7437
  RpcTraceRing,
7391
7438
  isVisible,
7439
+ isPerClient,
7392
7440
  visibleTo,
7393
7441
  visibleNames,
7394
7442
  viewKeyFor,
@@ -3,7 +3,7 @@ import {
3
3
  parseHibernationBlob,
4
4
  sameSchemaHash,
5
5
  writeHibernationBlob
6
- } from "./chunk-S4D472RL.js";
6
+ } from "./chunk-KJN62J37.js";
7
7
 
8
8
  // src/migrate.ts
9
9
  import { PRESENCE_COLLECTION, withBuiltins } from "@irtio/protocol";
@@ -604,6 +604,13 @@ interface RoomStats {
604
604
  /** Per connected client. */
605
605
  bytesOutByClient: Map<string, number>;
606
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;
607
614
  encodesLastFlush: number;
608
615
  aoiEncodesLastFlush: number;
609
616
  gridBuildMs: number;
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-CpBkqkn7.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-B9h9dc4e.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-G2XKCXRO.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-S4D472RL.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-CpBkqkn7.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. */
@@ -1,8 +1,8 @@
1
- import { R as RoomCore } from '../room-B9h9dc4e.js';
2
- export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-B9h9dc4e.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-CpBkqkn7.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
  /**
@@ -4,8 +4,9 @@ import {
4
4
  initMatter,
5
5
  initPhysics,
6
6
  initRapier2d,
7
+ isPerClient,
7
8
  visibleNames
8
- } from "../chunk-S4D472RL.js";
9
+ } from "../chunk-KJN62J37.js";
9
10
 
10
11
  // src/test/clock.ts
11
12
  var FakeClock = class {
@@ -352,6 +353,7 @@ import {
352
353
  encodeFrame,
353
354
  encodeMsg,
354
355
  encodeReply,
356
+ isNoReplyReqId,
355
357
  rpcTable
356
358
  } from "@irtio/protocol";
357
359
  import {
@@ -621,7 +623,7 @@ var FakeClientImpl = class {
621
623
  /** Flags any collection the frame named that this client's role may not see. */
622
624
  checkFrameVisibility(delta) {
623
625
  for (const dc of delta.collections) {
624
- const spatial = this.bridge.ext.collection(dc.name).visibility === "spatial-grid";
626
+ const spatial = isPerClient(this.bridge.ext.collection(dc.name));
625
627
  const leaked = dc.ops.filter((op) => {
626
628
  if (!this.bridge.visible(this.role, this.id, dc.name)) return true;
627
629
  if (!spatial) return false;
@@ -660,20 +662,26 @@ var FakeClientImpl = class {
660
662
  this.calls.push({ name: desc.name, params });
661
663
  const impl = this.impls.get(desc.name);
662
664
  if (!impl) return;
665
+ const noReply = isNoReplyReqId(call.reqId);
663
666
  let result;
664
667
  try {
665
668
  result = impl(params);
666
669
  } catch (err) {
667
- this.replyError(call.reqId, err);
670
+ if (!noReply) this.replyError(call.reqId, err);
668
671
  return;
669
672
  }
670
673
  if (isThenable(result)) {
671
674
  Promise.resolve(result).then(
672
- (v) => this.replyOk(desc, call.reqId, v),
673
- (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
+ }
674
681
  );
675
682
  return;
676
683
  }
684
+ if (noReply) return;
677
685
  this.replyOk(desc, call.reqId, result);
678
686
  }
679
687
  replyOk(desc, reqId, result) {
@@ -1010,7 +1018,7 @@ var Harness = class {
1010
1018
  const keep = visibleNames(ext, client.role);
1011
1019
  const policy = createVisibilityPolicy(ext, this.coreRef.plain, client.id, client.role);
1012
1020
  for (const c of ext.collections) {
1013
- if (c.visibility === "spatial-grid") {
1021
+ if (isPerClient(c)) {
1014
1022
  const coll = client.view[c.name];
1015
1023
  const ids = coll ? [...coll.ids()].filter((id) => !policy.maySeeEntity(c, id)) : [];
1016
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-CpBkqkn7.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-G2XKCXRO.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-S4D472RL.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": "4.0.0",
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": "^4.0.0",
40
- "@irtio/schema": "^4.0.0",
41
- "@irtio/server": "^4.0.0"
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"