@irtio/runtime 4.7.1 → 4.8.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.
@@ -31,6 +31,7 @@ var RELAY_STATE_BYTES = 512 * 1024;
31
31
  var RELAY_RECORDS_PER_COLLECTION = 1024;
32
32
  var RELAY_WRITES_PER_SECOND = 35;
33
33
  var DEFAULT_RECORDS_PER_COLLECTION = RELAY_RECORDS_PER_COLLECTION;
34
+ var MAX_WRITE_OPS = 4 * DEFAULT_RECORDS_PER_COLLECTION;
34
35
  var RELAY_WRITE_CAPS = {
35
36
  stateBytes: RELAY_STATE_BYTES,
36
37
  recordsPerCollection: RELAY_RECORDS_PER_COLLECTION,
@@ -67,6 +68,8 @@ function maxTypeBytes(t) {
67
68
  return optional + 5 + t.max * 4;
68
69
  case "ref":
69
70
  return optional + 5 + 64 * 4;
71
+ case "bytes":
72
+ return optional + t.n;
70
73
  case "list":
71
74
  return optional + 5 + t.max * maxTypeBytes(t.item);
72
75
  case "struct": {
@@ -197,6 +200,7 @@ function forgetWriter(core, clientId) {
197
200
  import { ByteReader, ByteWriter } from "@irtio/schema";
198
201
 
199
202
  // src/core/internals.ts
203
+ import { bytesEqual } from "@irtio/schema";
200
204
  function collectionDescOf(ext, name) {
201
205
  return ext.collections.find((c) => c.name === name);
202
206
  }
@@ -211,6 +215,9 @@ function isPlainObject(v) {
211
215
  }
212
216
  function deepEqual(a, b) {
213
217
  if (Object.is(a, b)) return true;
218
+ if (a instanceof Uint8Array || b instanceof Uint8Array) {
219
+ return a instanceof Uint8Array && b instanceof Uint8Array && bytesEqual(a, b);
220
+ }
214
221
  if (Array.isArray(a) || Array.isArray(b)) {
215
222
  if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
216
223
  for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
@@ -624,7 +631,7 @@ var PhysicsRuntime = class {
624
631
  // Bodies
625
632
  // -------------------------------------------------------------------------
626
633
  /**
627
- * bugs.md #99: every teardown path goes through here rather than calling `removeRigidBody`
634
+ * Every teardown path goes through here rather than calling `removeRigidBody`
628
635
  * directly.
629
636
  *
630
637
  * A sleeping body is parked in an island wired through the contact graph. Pulling a body out
@@ -1053,7 +1060,11 @@ function channelValue(channel, t, r, v, w) {
1053
1060
  }
1054
1061
 
1055
1062
  // src/core/inspect.ts
1056
- import { EntityCollection } from "@irtio/schema";
1063
+ import {
1064
+ EntityCollection,
1065
+ bytesToBase64,
1066
+ containsBytes
1067
+ } from "@irtio/schema";
1057
1068
  var EVENT_RING_SIZE = 100;
1058
1069
  var EventRing = class {
1059
1070
  items = [];
@@ -1069,16 +1080,35 @@ function inspectState(schema, plain) {
1069
1080
  const out = {};
1070
1081
  for (const c of schema.collections) {
1071
1082
  const v = plain[c.name];
1083
+ const hasBytes = c.fields.some((f) => containsBytes(f.type));
1084
+ const show = (value) => hasBytes ? inspectRecord(c, value) : value;
1072
1085
  if (v instanceof EntityCollection) {
1073
1086
  const recs = {};
1074
- for (const [id, value] of v) recs[id] = { owner: v.ownerOf(id), value };
1087
+ for (const [id, value] of v) recs[id] = { owner: v.ownerOf(id), value: show(value) };
1075
1088
  out[c.name] = recs;
1076
1089
  } else {
1077
- out[c.name] = v;
1090
+ out[c.name] = show(v);
1078
1091
  }
1079
1092
  }
1080
1093
  return out;
1081
1094
  }
1095
+ function inspectRecord(c, value) {
1096
+ if (value === null || typeof value !== "object") return value;
1097
+ const rec = value;
1098
+ const out = {};
1099
+ for (const f of c.fields) out[f.name] = inspectValue(f.type, rec[f.name]);
1100
+ return out;
1101
+ }
1102
+ function inspectValue(t, value) {
1103
+ if (value instanceof Uint8Array) return bytesToBase64(value);
1104
+ if (t.kind === "struct" && value !== null && typeof value === "object") {
1105
+ const rec = value;
1106
+ const out = {};
1107
+ for (const [k, ft] of Object.entries(t.fields)) out[k] = inspectValue(ft, rec[k]);
1108
+ return out;
1109
+ }
1110
+ return value;
1111
+ }
1082
1112
 
1083
1113
  // src/core/rpc-trace.ts
1084
1114
  var RPC_TRACE_SIZE = 200;
@@ -1156,7 +1186,10 @@ function cellFor(grid, value) {
1156
1186
  const x = record[grid.x];
1157
1187
  const y = record[grid.y];
1158
1188
  if (typeof x !== "number" || typeof y !== "number") return void 0;
1159
- return { x: Math.floor(x / grid.cell), y: Math.floor(y / grid.cell) };
1189
+ const cx = Math.floor(x / grid.cell);
1190
+ const cy = Math.floor(y / grid.cell);
1191
+ if (!Number.isSafeInteger(cx) || !Number.isSafeInteger(cy)) return void 0;
1192
+ return { x: cx, y: cy };
1160
1193
  }
1161
1194
  function cellKey(cell) {
1162
1195
  return `${cell.x},${cell.y}`;
@@ -1189,11 +1222,27 @@ function querySpatialIndex(index, clientId, role) {
1189
1222
  const anchor = index.collection.get(clientId);
1190
1223
  const anchorCell = cellFor(grid, anchor);
1191
1224
  if (!anchor || !anchorCell) return /* @__PURE__ */ new Set();
1225
+ const r = grid.radius;
1226
+ const ax = anchorCell.x;
1227
+ const ay = anchorCell.y;
1228
+ if (!Number.isSafeInteger(ax - r) || !Number.isSafeInteger(ax + r)) return /* @__PURE__ */ new Set();
1229
+ if (!Number.isSafeInteger(ay - r) || !Number.isSafeInteger(ay + r)) return /* @__PURE__ */ new Set();
1192
1230
  const candidates = /* @__PURE__ */ new Set();
1193
- for (let x = anchorCell.x - grid.radius; x <= anchorCell.x + grid.radius; x++) {
1194
- for (let y = anchorCell.y - grid.radius; y <= anchorCell.y + grid.radius; y++) {
1195
- const ids = index.buckets.get(cellKey({ x, y }));
1196
- if (ids) for (const id of ids) candidates.add(id);
1231
+ const span = 2 * r + 1;
1232
+ if (span * span > index.buckets.size) {
1233
+ for (const [key, ids] of index.buckets) {
1234
+ const comma = key.indexOf(",");
1235
+ const bx = Number(key.slice(0, comma));
1236
+ const by = Number(key.slice(comma + 1));
1237
+ if (Math.abs(bx - ax) <= r && Math.abs(by - ay) <= r)
1238
+ for (const id of ids) candidates.add(id);
1239
+ }
1240
+ } else {
1241
+ for (let i = 0; i < span; i++) {
1242
+ for (let j = 0; j < span; j++) {
1243
+ const ids = index.buckets.get(cellKey({ x: ax - r + i, y: ay - r + j }));
1244
+ if (ids) for (const id of ids) candidates.add(id);
1245
+ }
1197
1246
  }
1198
1247
  }
1199
1248
  candidates.add(clientId);
@@ -1519,11 +1568,22 @@ function reclaimHolds(core, clientId, playerId) {
1519
1568
  s.holds.delete(key);
1520
1569
  const coll = plainEntity(core.plain, hold.collection);
1521
1570
  if (!coll.has(hold.id)) continue;
1522
- trackedEntity(core, hold.collection).setOwner(hold.id, clientId);
1571
+ const tracked = trackedEntity(core, hold.collection);
1572
+ if (hold.id !== clientId && !coll.has(clientId) && isPerPlayer(core, hold.collection)) {
1573
+ const record = cloneValue(coll.get(hold.id));
1574
+ tracked.remove(hold.id);
1575
+ tracked.add(clientId, record, { owner: clientId });
1576
+ } else {
1577
+ tracked.setOwner(hold.id, clientId);
1578
+ }
1523
1579
  reclaimed++;
1524
1580
  }
1525
1581
  return reclaimed;
1526
1582
  }
1583
+ function isPerPlayer(core, collection) {
1584
+ const c = core.ext.collections.find((x) => x.name === collection);
1585
+ return c?.kind === "entity" && c.perPlayer === true;
1586
+ }
1527
1587
  function expireHolds(core) {
1528
1588
  const s = states2.get(core);
1529
1589
  if (!s || s.holds.size === 0) return;
@@ -2251,6 +2311,7 @@ import {
2251
2311
  SERVER_OWNER as SERVER_OWNER3,
2252
2312
  cloneValue as cloneValue2,
2253
2313
  decodeDelta,
2314
+ defaultRecord as defaultRecord2,
2254
2315
  defaultValue,
2255
2316
  encodeDelta as encodeDelta2,
2256
2317
  markField,
@@ -2337,29 +2398,60 @@ function recordOf(plain, c, id) {
2337
2398
  function applyWrite(core, clientId, payload) {
2338
2399
  let delta;
2339
2400
  try {
2340
- delta = decodeDelta(core.ext, payload);
2401
+ delta = decodeDelta(core.ext, payload, { maxOps: MAX_WRITE_OPS });
2341
2402
  } catch (err) {
2342
- core.log("warn", `WRITE from ${clientId} failed to decode:`, err);
2403
+ core.log(
2404
+ "warn",
2405
+ `WRITE from ${clientId} failed to decode: ${err instanceof Error ? err.message : String(err)}`
2406
+ );
2343
2407
  return { ok: false };
2344
2408
  }
2345
2409
  let ops = 0;
2346
2410
  for (const dc of delta.collections) ops += dc.ops.length;
2347
- if (!admitWrite(core, clientId, core.host.now(), ops)) return { ok: true };
2411
+ const admitted = admitWrite(core, clientId, core.host.now(), ops);
2348
2412
  const judged = core.clients.get(clientId);
2349
2413
  if (judged && delta.tick > judged.lastClientTick) {
2350
2414
  judged.lastClientTick = delta.tick;
2351
2415
  judged.lastAppliedTick = core.tick;
2352
2416
  }
2353
- for (const dc of delta.collections) {
2354
- const c = collectionDescOf(core.ext, dc.name);
2355
- if (!c) continue;
2356
- for (const op of dc.ops) applyOp(core, clientId, c, op);
2417
+ if (!admitted) {
2418
+ for (const dc of delta.collections) {
2419
+ const c = collectionDescOf(core.ext, dc.name);
2420
+ if (!c) continue;
2421
+ for (const op of dc.ops) {
2422
+ if (op.op !== "update" || !recordOf(core.plain, c, op.id)) continue;
2423
+ correctLeaves(core, clientId, c.name, op.id, leafPaths(fieldList(c), op.mask));
2424
+ }
2425
+ }
2426
+ return { ok: true };
2427
+ }
2428
+ const tally = { n: 0 };
2429
+ frameTally = tally;
2430
+ try {
2431
+ for (const dc of delta.collections) {
2432
+ const c = collectionDescOf(core.ext, dc.name);
2433
+ if (!c) continue;
2434
+ for (const op of dc.ops) applyOp(core, clientId, c, op);
2435
+ }
2436
+ } finally {
2437
+ frameTally = void 0;
2438
+ if (tally.n > REJECT_LOGS_PER_FRAME)
2439
+ core.log(
2440
+ "warn",
2441
+ `WRITE from ${clientId}: ${tally.n - REJECT_LOGS_PER_FRAME} more rejection lines suppressed (${tally.n} in this frame)`
2442
+ );
2357
2443
  }
2358
2444
  return { ok: true };
2359
2445
  }
2446
+ var REJECT_LOGS_PER_FRAME = 8;
2447
+ var frameTally;
2448
+ function logRejected(core, level, ...args) {
2449
+ if (frameTally && ++frameTally.n > REJECT_LOGS_PER_FRAME) return;
2450
+ core.log(level, ...args);
2451
+ }
2360
2452
  function reject(core, clientId, c, op, reason) {
2361
2453
  core.recordEvent("write-rejected", clientId, `${c.name}: ${reason}`);
2362
- core.log("warn", `WRITE rejected (${reason}) from ${clientId} on ${c.name}`);
2454
+ logRejected(core, "warn", `WRITE rejected (${reason}) from ${clientId} on ${c.name}`);
2363
2455
  if (op.op !== "update") return;
2364
2456
  if (!recordOf(core.plain, c, op.id)) return;
2365
2457
  correctLeaves(core, clientId, c.name, op.id, leafPaths(fieldList(c), op.mask));
@@ -2377,7 +2469,8 @@ function applyOp(core, clientId, c, op) {
2377
2469
  const senderRole = core.clients.get(clientId)?.role ?? "";
2378
2470
  if (!isVisible(c, senderRole)) {
2379
2471
  core.recordEvent("write-rejected", clientId, `${c.name}: not visible to role`);
2380
- core.log(
2472
+ logRejected(
2473
+ core,
2381
2474
  "warn",
2382
2475
  `WRITE rejected (collection not visible to role ${JSON.stringify(senderRole)}) from ${clientId} on ${c.name}`
2383
2476
  );
@@ -2387,7 +2480,7 @@ function applyOp(core, clientId, c, op) {
2387
2480
  const coll = plainEntity(core.plain, c.name);
2388
2481
  if (coll.has(op.id) && coll.ownerOf(op.id) !== clientId && !inClientView(core, clientId, c, op.id)) {
2389
2482
  core.recordEvent("write-rejected", clientId, `${c.name}: ${op.id} is not in view`);
2390
- core.log("warn", `WRITE rejected (id not in view) from ${clientId} on ${c.name}`);
2483
+ logRejected(core, "warn", `WRITE rejected (id not in view) from ${clientId} on ${c.name}`);
2391
2484
  return;
2392
2485
  }
2393
2486
  }
@@ -2425,6 +2518,10 @@ function applyOp(core, clientId, c, op) {
2425
2518
  reject(core, clientId, c, op, "not the owner");
2426
2519
  return;
2427
2520
  }
2521
+ if (maskHasRanges(op.mask)) {
2522
+ reject(core, clientId, c, op, "byte-range patches are not allowed in a WRITE");
2523
+ return;
2524
+ }
2428
2525
  const fields = fieldList(c);
2429
2526
  const growth = updateGrowth(c, leafPaths(fields, op.mask));
2430
2527
  if (growth > 0 && !admitGrowth(core, clientId, growth)) return;
@@ -2443,7 +2540,8 @@ function applyOp(core, clientId, c, op) {
2443
2540
  }
2444
2541
  if (refused2.length > 0) {
2445
2542
  core.recordEvent("write-rejected", clientId, `${c.name}: ${refused2.join(", ")}`);
2446
- core.log(
2543
+ logRejected(
2544
+ core,
2447
2545
  "warn",
2448
2546
  `WRITE to ${c.name}.${op.id} from ${clientId} touched fields the owner does not write: ${refused2.join(", ")}`
2449
2547
  );
@@ -2460,7 +2558,12 @@ function applyOp(core, clientId, c, op) {
2460
2558
  try {
2461
2559
  result = normalizeRecord(c, returned ?? next);
2462
2560
  } catch (err) {
2463
- core.log("error", `validate.${c.name} returned an invalid record; write rejected:`, err);
2561
+ logRejected(
2562
+ core,
2563
+ "error",
2564
+ `validate.${c.name} returned an invalid record; write rejected:`,
2565
+ err
2566
+ );
2464
2567
  result = prev;
2465
2568
  }
2466
2569
  }
@@ -2496,31 +2599,58 @@ function applyAdd(core, clientId, c, op) {
2496
2599
  }
2497
2600
  if (!admitGrowth(core, clientId, maxRecordBytes(c))) return;
2498
2601
  let value = op.value;
2499
- if (c.serverFields) {
2500
- const stripped = [];
2602
+ if (c.physics || c.serverFields) {
2603
+ const refused2 = [];
2501
2604
  const copy = { ...value };
2502
2605
  for (const f of c.fields) {
2503
- if (!c.serverFields.has(f.name)) continue;
2606
+ const why = ownerMayNotWrite(c, f.name);
2607
+ if (why === void 0) continue;
2504
2608
  const reset = defaultValue(f.type);
2505
- if (deepEqual(copy[f.name], reset)) continue;
2609
+ if (copy[f.name] === void 0 || deepEqual(copy[f.name], reset)) continue;
2506
2610
  copy[f.name] = reset;
2507
- stripped.push(f.name);
2611
+ refused2.push(why);
2508
2612
  }
2509
- if (stripped.length > 0) {
2510
- core.recordEvent(
2511
- "write-rejected",
2512
- clientId,
2513
- `${c.name}: ${stripped.join(", ")} (serverField)`
2514
- );
2515
- core.log(
2613
+ if (refused2.length > 0) {
2614
+ core.recordEvent("write-rejected", clientId, `${c.name}: ${refused2.join(", ")}`);
2615
+ logRejected(
2616
+ core,
2516
2617
  "warn",
2517
- `add to ${c.name}.${op.id} from ${clientId} set server-only fields; reset to their defaults: ${stripped.join(", ")}`
2618
+ `add to ${c.name}.${op.id} from ${clientId} set server-only fields; reset to their defaults: ${refused2.join(", ")}`
2518
2619
  );
2519
2620
  }
2520
2621
  value = copy;
2521
2622
  }
2623
+ let record;
2624
+ try {
2625
+ record = normalizeRecord(c, value);
2626
+ } catch (err) {
2627
+ reject(core, clientId, c, op, `add rejected: ${String(err)}`);
2628
+ return;
2629
+ }
2630
+ const validator = core.definition.config.validate?.[c.name];
2631
+ if (validator) {
2632
+ const prev = defaultRecord2(c);
2633
+ const ctx = core.ctxFor(clientId);
2634
+ const ran = core.tryRun(`validate.${c.name}`, () => validator(prev, record, ctx));
2635
+ if (!ran.ok) {
2636
+ reject(core, clientId, c, op, `add rejected by validate.${c.name}`);
2637
+ return;
2638
+ }
2639
+ try {
2640
+ record = normalizeRecord(c, ran.value ?? record);
2641
+ } catch (err) {
2642
+ reject(
2643
+ core,
2644
+ clientId,
2645
+ c,
2646
+ op,
2647
+ `validate.${c.name} returned an invalid record: ${String(err)}`
2648
+ );
2649
+ return;
2650
+ }
2651
+ }
2522
2652
  try {
2523
- trackedEntity(core, c.name).add(op.id, value, { owner: clientId });
2653
+ trackedEntity(core, c.name).add(op.id, record, { owner: clientId });
2524
2654
  } catch (err) {
2525
2655
  reject(core, clientId, c, op, `add rejected: ${String(err)}`);
2526
2656
  return;
@@ -2550,6 +2680,11 @@ function updateGrowth(c, leaves) {
2550
2680
  }
2551
2681
  return growth;
2552
2682
  }
2683
+ function maskHasRanges(mask) {
2684
+ if (mask.ranges && mask.ranges.size > 0) return true;
2685
+ for (const n of mask.nested.values()) if (maskHasRanges(n)) return true;
2686
+ return false;
2687
+ }
2553
2688
  function isFixedWidth(t) {
2554
2689
  switch (t.kind) {
2555
2690
  case "bool":
@@ -2560,6 +2695,7 @@ function isFixedWidth(t) {
2560
2695
  case "f32":
2561
2696
  case "f64":
2562
2697
  case "enum":
2698
+ case "bytes":
2563
2699
  return t.opt !== true;
2564
2700
  case "struct":
2565
2701
  return Object.values(t.fields).every(isFixedWidth) && t.opt !== true;
@@ -2611,6 +2747,7 @@ function encodeCorrection(core, dirty) {
2611
2747
  var MAX_CATCHUP = 5;
2612
2748
  var CRASH_AFTER_THROWS = 3;
2613
2749
  var TIMER_SLEEP_GUARD_MS = 250;
2750
+ var MAX_HOST_TIMER_MS = 2 ** 31 - 1;
2614
2751
  var Loop = class {
2615
2752
  constructor(core) {
2616
2753
  this.core = core;
@@ -2635,7 +2772,7 @@ var Loop = class {
2635
2772
  * Once, deliberately. A `setInterval(100)` in an otherwise silent room must not be able to pin
2636
2773
  * the room resident forever — pending timers are explicitly *not* a reason to stay awake. A
2637
2774
  * one-shot deadline is handed to the supervisor, which wakes the room at it; an interval is
2638
- * simply dropped, with a warning (bugs.md #142). Reset by real activity.
2775
+ * simply dropped, with a warning. Reset by real activity.
2639
2776
  */
2640
2777
  timerDeferred = false;
2641
2778
  get intervalMs() {
@@ -2869,15 +3006,24 @@ var Loop = class {
2869
3006
  // -------------------------------------------------------------------------
2870
3007
  setTimer(ms, fn, repeat) {
2871
3008
  const id = this.nextTimerId++;
2872
- const periodTicks = Math.max(1, Math.ceil(ms * this.core.definition.config.tickRate / 1e3));
3009
+ if (!Number.isFinite(ms)) {
3010
+ const where = repeat ? "room.setInterval" : "room.setTimeout";
3011
+ this.core.log("warn", `${where}: ${ms} is not a finite delay; the timer was not armed`);
3012
+ return id;
3013
+ }
3014
+ const periodMs = Math.max(0, ms);
3015
+ const periodTicks = Math.max(
3016
+ 1,
3017
+ Math.ceil(periodMs * this.core.definition.config.tickRate / 1e3)
3018
+ );
2873
3019
  const timer = {
2874
3020
  id,
2875
3021
  fn,
2876
3022
  repeat,
2877
3023
  periodTicks,
2878
- periodMs: Math.max(0, ms),
3024
+ periodMs,
2879
3025
  dueTick: this.core.tick + periodTicks,
2880
- dueAtMs: this.core.host.now() + Math.max(0, ms),
3026
+ dueAtMs: this.core.host.now() + periodMs,
2881
3027
  handle: void 0
2882
3028
  };
2883
3029
  this.timers.set(id, timer);
@@ -2885,18 +3031,27 @@ var Loop = class {
2885
3031
  return id;
2886
3032
  }
2887
3033
  armHostTimer(timer) {
2888
- timer.handle = this.core.host.setTimeout(() => {
2889
- timer.handle = void 0;
2890
- if (this.core.stopped) return;
2891
- if (!this.timers.has(timer.id)) return;
2892
- if (timer.repeat) {
2893
- timer.dueAtMs = this.core.host.now() + timer.periodMs;
2894
- this.armHostTimer(timer);
2895
- } else this.timers.delete(timer.id);
2896
- this.core.tick++;
2897
- this.core.guard("room timer", timer.fn);
2898
- this.core.flush();
2899
- }, timer.periodMs);
3034
+ const remaining = Math.max(0, timer.dueAtMs - this.core.host.now());
3035
+ const capped = remaining > MAX_HOST_TIMER_MS;
3036
+ timer.handle = this.core.host.setTimeout(
3037
+ () => {
3038
+ timer.handle = void 0;
3039
+ if (this.core.stopped) return;
3040
+ if (!this.timers.has(timer.id)) return;
3041
+ if (capped) {
3042
+ this.armHostTimer(timer);
3043
+ return;
3044
+ }
3045
+ if (timer.repeat) {
3046
+ timer.dueAtMs = this.core.host.now() + timer.periodMs;
3047
+ this.armHostTimer(timer);
3048
+ } else this.timers.delete(timer.id);
3049
+ this.core.tick++;
3050
+ this.core.guard("room timer", timer.fn);
3051
+ this.core.flush();
3052
+ },
3053
+ capped ? MAX_HOST_TIMER_MS : remaining
3054
+ );
2900
3055
  }
2901
3056
  clearTimer(handle) {
2902
3057
  const t = this.timers.get(handle);
@@ -2929,7 +3084,7 @@ var Loop = class {
2929
3084
  * gone" into "your room is awake again by then", which is a contract room code can actually
2930
3085
  * catch up against in `onWake`.
2931
3086
  *
2932
- * bugs.md #142: that contract is a statement about a **deadline**, and only a `setTimeout` has
3087
+ * That contract is a statement about a **deadline**, and only a `setTimeout` has
2933
3088
  * one. A `setInterval` has a cadence, so its next due time is one period away no matter when
2934
3089
  * you ask — including one period after the room hibernates. Arming the wake for it made an
2935
3090
  * interval mean "never let this room stay asleep": the room slept, the wake brought it back
@@ -3203,7 +3358,7 @@ var MatterRuntime = class {
3203
3358
  // Bodies
3204
3359
  // -------------------------------------------------------------------------
3205
3360
  /**
3206
- * bugs.md #100, the matter2d half. Rapier's bindings wake a contact island themselves when a
3361
+ * The matter2d half of waking a contact island on removal. Rapier's bindings wake a contact island themselves when a
3207
3362
  * member is removed (`PhysicsRuntime.destroyBody` / `Rapier2dRuntime.destroyBody`); matter.js
3208
3363
  * does not, so a body sleeping against a victim about to be removed is left sleeping and
3209
3364
  * motionless in mid-air once the victim is gone.
@@ -3680,7 +3835,7 @@ var Rapier2dRuntime = class {
3680
3835
  // Bodies
3681
3836
  // -------------------------------------------------------------------------
3682
3837
  /**
3683
- * bugs.md #99, planar half: the teardown door every removal goes through. See
3838
+ * The planar teardown door every removal goes through. See
3684
3839
  * `PhysicsRuntime.destroyBody` for the argument — one narrow-phase walk of the victim's own
3685
3840
  * contact pairs, so a body resting on what is about to vanish is awake when it does.
3686
3841
  */
@@ -4121,7 +4276,7 @@ import {
4121
4276
  decodeSnapshot as decodeSnapshot3,
4122
4277
  encodeDelta as encodeDelta5,
4123
4278
  encodeSnapshot as encodeSnapshot4,
4124
- filterDirty as filterDirty2,
4279
+ filterDirty as filterDirty3,
4125
4280
  isDirtyEmpty as isDirtyEmpty4,
4126
4281
  track,
4127
4282
  validateForDeploy
@@ -5204,6 +5359,9 @@ function lobbyConfigOf(core) {
5204
5359
  if (!schemaHasLobby(core.definition.schema)) return void 0;
5205
5360
  return declared;
5206
5361
  }
5362
+ function countsAsPlayer(config, role) {
5363
+ return config.roles === void 0 || config.roles.includes(role);
5364
+ }
5207
5365
  function stateOf5(core) {
5208
5366
  return core.anyState[LOBBY_STATE];
5209
5367
  }
@@ -5216,7 +5374,7 @@ function evaluateLobby(core) {
5216
5374
  const policy = config.start ?? "when-full";
5217
5375
  const present = [];
5218
5376
  for (const [clientId, entry] of core.clients) {
5219
- if (!entry.connected) continue;
5377
+ if (!entry.connected || !countsAsPlayer(config, entry.role)) continue;
5220
5378
  present.push(clientId);
5221
5379
  if (!members.has(clientId)) members.add(clientId, { ready: false }, { owner: clientId });
5222
5380
  else if (members.ownerOf(clientId) !== clientId) members.setOwner(clientId, clientId);
@@ -5281,7 +5439,7 @@ function makeLobby(core) {
5281
5439
  get ready() {
5282
5440
  const out = [];
5283
5441
  for (const [clientId, entry] of core.clients) {
5284
- if (!entry.connected) continue;
5442
+ if (!entry.connected || !countsAsPlayer(config, entry.role)) continue;
5285
5443
  out.push({
5286
5444
  clientId,
5287
5445
  ready: members.get(clientId)?.ready === true
@@ -5336,7 +5494,8 @@ import {
5336
5494
  encodeChatMsg,
5337
5495
  encodeFrame as encodeFrame3,
5338
5496
  encodeMsg,
5339
- isChatMsg
5497
+ isChatMsg,
5498
+ toWireTarget
5340
5499
  } from "@irtio/protocol";
5341
5500
  import { decodeFields as decodeFields2 } from "@irtio/schema";
5342
5501
  function toRoomTarget(target) {
@@ -5458,22 +5617,20 @@ function handleChat(core, clientId, payload) {
5458
5617
  deliver(core, chat.target, frame, clientId);
5459
5618
  return true;
5460
5619
  }
5461
- function wireTargetOf(target) {
5462
- return target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
5463
- }
5464
5620
  function sendMessage(core, target, bytes) {
5465
5621
  const frame = encodeFrame3(
5466
5622
  FrameType4.MSG,
5467
5623
  encodeMsg({ target: { kind: "server" }, payload: bytes })
5468
5624
  );
5469
- deliver(core, wireTargetOf(target), frame);
5625
+ deliver(core, toWireTarget(target, "room.send"), frame);
5470
5626
  }
5471
- function sendTypedMessage(core, index, target, payload) {
5627
+ function sendTypedMessage(core, index, target, payload, name = "message") {
5628
+ const wire = toWireTarget(target, `room.messages.${name}.send`);
5472
5629
  const frame = encodeFrame3(
5473
5630
  FrameType4.MSG,
5474
5631
  encodeMsg({ target: { kind: "server" }, payload, typed: { index } })
5475
5632
  );
5476
- deliver(core, wireTargetOf(target), frame);
5633
+ deliver(core, wire, frame);
5477
5634
  }
5478
5635
 
5479
5636
  // src/core/replay.ts
@@ -5489,12 +5646,14 @@ import {
5489
5646
  import {
5490
5647
  encodeDelta as encodeDelta3,
5491
5648
  encodeSnapshot as encodeSnapshot3,
5649
+ filterDirty as filterDirty2,
5492
5650
  isDirtyEmpty as isDirtyEmpty2
5493
5651
  } from "@irtio/schema";
5494
5652
 
5495
5653
  // src/core/replay-excerpt.ts
5496
5654
  import {
5497
5655
  applyDelta,
5656
+ copyBytes,
5498
5657
  decodeDelta as decodeDelta2,
5499
5658
  decodeSnapshot as decodeSnapshot2
5500
5659
  } from "@irtio/schema";
@@ -5512,6 +5671,7 @@ var ExcerptTooLargeError = class extends Error {
5512
5671
  };
5513
5672
  function estimateValue(value) {
5514
5673
  if (typeof value === "string") return value.length + 2;
5674
+ if (value instanceof Uint8Array) return value.length + 2;
5515
5675
  if (Array.isArray(value)) {
5516
5676
  let n = 2;
5517
5677
  for (const v of value) n += estimateValue(v) + 1;
@@ -5527,6 +5687,7 @@ function estimateValue(value) {
5527
5687
  return 8;
5528
5688
  }
5529
5689
  function copyValue(value) {
5690
+ if (value instanceof Uint8Array) return copyBytes(value);
5530
5691
  if (Array.isArray(value)) return value.map(copyValue);
5531
5692
  if (typeof value === "object" && value !== null) {
5532
5693
  const out = {};
@@ -5571,14 +5732,15 @@ function wantedCollections(ext, names) {
5571
5732
  const wanted = new Set(names);
5572
5733
  return all.filter((c) => wanted.has(c.name));
5573
5734
  }
5574
- function unknownCollection(ext, options) {
5735
+ function unknownCollection(ext, options, recorded) {
5575
5736
  const names = options?.collections;
5576
5737
  if (names === void 0) return void 0;
5577
5738
  const known = new Set(
5578
5739
  ext.collections.filter((c) => c.kind === "entity").map((c) => c.name)
5579
5740
  );
5580
5741
  for (const name of names) {
5581
- if (!known.has(name)) return String(name);
5742
+ if (!known.has(name)) return { kind: "unknown", name: String(name) };
5743
+ if (recorded !== void 0 && !recorded.has(name)) return { kind: "unrecorded", name };
5582
5744
  }
5583
5745
  return void 0;
5584
5746
  }
@@ -5751,6 +5913,8 @@ function stateOf6(core) {
5751
5913
  ttlMs,
5752
5914
  recExpiresAtEpochS: void 0,
5753
5915
  view: declared.view ?? "",
5916
+ mode: declared.omniscient === true ? "omniscient" : declared.view !== void 0 ? "role" : "public",
5917
+ recorded: void 0,
5754
5918
  ring: new ReplayRing(
5755
5919
  {
5756
5920
  windowTicks: Math.max(1, Math.ceil(seconds * tickRate)),
@@ -5785,13 +5949,23 @@ function stateOf6(core) {
5785
5949
  }
5786
5950
  return s;
5787
5951
  }
5788
- function omniscientMemberships(core) {
5789
- const out = /* @__PURE__ */ new Map();
5790
- for (const desc of core.ext.collections) {
5791
- if (!isPerClient(desc)) continue;
5792
- out.set(desc.name, new Set(plainEntity(core.plain, desc.name).ids()));
5793
- }
5794
- return out;
5952
+ function recorderCollections(core, state) {
5953
+ if (state.recorded !== void 0) return state.recorded;
5954
+ const keep = (c) => {
5955
+ if (c.visibility === "server") return false;
5956
+ switch (state.mode) {
5957
+ case "omniscient":
5958
+ return true;
5959
+ case "role":
5960
+ return isVisible(c, state.view) && c.visibility !== "owner";
5961
+ default:
5962
+ return c.visibility !== "role" && c.visibility !== "owner";
5963
+ }
5964
+ };
5965
+ state.recorded = new Set(
5966
+ core.ext.collections.filter(keep).map((c) => c.name)
5967
+ );
5968
+ return state.recorded;
5795
5969
  }
5796
5970
  function hasRoleScoped(core) {
5797
5971
  return core.ext.collections.some((c) => c.visibility === "role");
@@ -5800,28 +5974,30 @@ function hasSpatial(core) {
5800
5974
  return core.ext.collections.some(isPerClient);
5801
5975
  }
5802
5976
  function recorderSnapshot(core, state) {
5803
- if (state.view === "") {
5804
- return encodeSnapshot3(core.ext, core.plain, { tick: core.tick });
5805
- }
5806
- return encodeViewSnapshot(
5977
+ const names = recorderCollections(core, state);
5978
+ return encodeSnapshot3(
5807
5979
  core.ext,
5808
5980
  core.plain,
5809
- state.view,
5810
- core.tick,
5811
- omniscientMemberships(core)
5981
+ { tick: core.tick },
5982
+ { collections: (c) => names.has(c.name) }
5812
5983
  );
5813
5984
  }
5814
5985
  function recorderDelta(core, state, dirty) {
5815
- if (state.view === "") {
5816
- if (isDirtyEmpty2(dirty)) return null;
5817
- return encodeDelta3(core.ext, core.plain, dirty, { tick: core.tick });
5818
- }
5819
- return encodeViewDelta(core.ext, core.plain, dirty, state.view, core.tick);
5986
+ const names = recorderCollections(core, state);
5987
+ const filtered = filterDirty2(dirty, (n) => names.has(n));
5988
+ if (isDirtyEmpty2(filtered)) return null;
5989
+ return encodeDelta3(core.ext, core.plain, filtered, { tick: core.tick });
5820
5990
  }
5821
5991
  function cacheKeyFor(core, state) {
5822
5992
  if (hasSpatial(core)) return void 0;
5823
- if (state.view === "") return hasRoleScoped(core) ? void 0 : "all";
5824
- return viewKeyFor(core.ext, state.view);
5993
+ switch (state.mode) {
5994
+ case "public":
5995
+ return "all";
5996
+ case "role":
5997
+ return viewKeyFor(core.ext, state.view);
5998
+ default:
5999
+ return hasRoleScoped(core) ? void 0 : "all";
6000
+ }
5825
6001
  }
5826
6002
  function recordFlush(core, dirty, byView) {
5827
6003
  const state = stateOf6(core);
@@ -5979,16 +6155,19 @@ function takeExcerpt(core, options) {
5979
6155
  )
5980
6156
  );
5981
6157
  }
5982
- const badName = unknownCollection(core.ext, options);
5983
- if (badName !== void 0) {
6158
+ const state = stateOf6(core);
6159
+ const bad = unknownCollection(core.ext, options, recorderCollections(core, state));
6160
+ if (bad !== void 0) {
5984
6161
  return rejectAsEvent(
5985
6162
  core,
5986
6163
  new Error(
5987
- `E_EXCERPT_BAD_ARGS: ${JSON.stringify(badName)} is not an entity collection here; singletons are not excerptable`
6164
+ bad.kind === "unknown" ? `E_EXCERPT_BAD_ARGS: ${JSON.stringify(bad.name)} is not an entity collection here; singletons are not excerptable` : (
6165
+ // Short on purpose: `rejectAsEvent` clamps to 96 bytes, and the fix is the second half.
6166
+ `E_EXCERPT_BAD_ARGS: ${JSON.stringify(bad.name)} is not recorded (replay.view / replay.omniscient)`
6167
+ )
5988
6168
  )
5989
6169
  );
5990
6170
  }
5991
- const state = stateOf6(core);
5992
6171
  if (state.disarmed) {
5993
6172
  return rejectAsEvent(
5994
6173
  core,
@@ -6429,7 +6608,7 @@ function createRoomApi(core, publicUrl) {
6429
6608
  for (const desc of core.definition.schema.messages ?? []) {
6430
6609
  messages[desc.name] = {
6431
6610
  send(target, value) {
6432
- sendTypedMessage(core, desc.index, target, encodeFields2(desc.fields, value));
6611
+ sendTypedMessage(core, desc.index, target, encodeFields2(desc.fields, value), desc.name);
6433
6612
  }
6434
6613
  };
6435
6614
  }
@@ -6784,6 +6963,7 @@ function setRole(core, clientId, role) {
6784
6963
  entry.role = role;
6785
6964
  if (catchUp) core.send(clientId, catchUp);
6786
6965
  core.invalidateClients();
6966
+ core.host.roleChanged?.(clientId, role);
6787
6967
  }
6788
6968
  function checkNpcConfig(core, config) {
6789
6969
  if (typeof config !== "object" || config === null) {
@@ -7424,7 +7604,7 @@ var RoomCore = class _RoomCore {
7424
7604
  * after the time it would have fired — so keep the deadline in state and catch up in `onWake`.
7425
7605
  *
7426
7606
  * A repeating timer is deliberately not in here: it has a cadence rather than a deadline, and a
7427
- * wake armed for it can only ever hand the room back its own next period (bugs.md #142, and
7607
+ * wake armed for it can only ever hand the room back its own next period (see
7428
7608
  * `Loop.departureTimers` for the whole argument). The warning below is what a room holding one
7429
7609
  * gets instead.
7430
7610
  *
@@ -7604,7 +7784,7 @@ var RoomCore = class _RoomCore {
7604
7784
  }
7605
7785
  const presence = this.presence;
7606
7786
  const maxClients = this.definition.config.maxClients;
7607
- const resuming = options.reconnecting === true || presence.has(clientId);
7787
+ const resuming = presence.has(clientId);
7608
7788
  if (!resuming) this.admit(clientId, options);
7609
7789
  if (!presence.has(clientId) && presence.size >= maxClients) {
7610
7790
  throw new RoomFullError(maxClients);
@@ -7723,6 +7903,7 @@ var RoomCore = class _RoomCore {
7723
7903
  this.guard("onLeave", () => onLeave(this.state, ctx, reason));
7724
7904
  }
7725
7905
  cancelAsksFor(this.internals, clientId);
7906
+ entry.connected = false;
7726
7907
  placeHolds(this.internals, clientId, entry.playerId);
7727
7908
  forgetWriter(this.internals, clientId);
7728
7909
  this.presence.remove(clientId);
@@ -7804,7 +7985,7 @@ var RoomCore = class _RoomCore {
7804
7985
  };
7805
7986
  }
7806
7987
  /**
7807
- * Week 12: the host answering a `room.save()` / `room.kv.*`. The continuation runs here — its
7988
+ * The host answering a `room.save()` / `room.kv.*`. The continuation runs here — its
7808
7989
  * own event, between ticks, off the back of a host turn — and the flush afterwards is what
7809
7990
  * makes "state mutated in a continuation is tracked normally" true rather than aspirational.
7810
7991
  */
@@ -8028,7 +8209,7 @@ var RoomCore = class _RoomCore {
8028
8209
  const keep = visibleNames(this.ext, entry.role);
8029
8210
  const payload = encodeCorrection(
8030
8211
  this.internals,
8031
- filterDirty2(
8212
+ filterDirty3(
8032
8213
  membershipSafeDirty(this.ext, entry.correction, memberships),
8033
8214
  (name) => keep.has(name)
8034
8215
  )
@@ -3,7 +3,7 @@ import {
3
3
  parseHibernationBlob,
4
4
  sameSchemaHash,
5
5
  writeHibernationBlob
6
- } from "./chunk-RLY4VAMM.js";
6
+ } from "./chunk-53X6AWQZ.js";
7
7
 
8
8
  // src/migrate.ts
9
9
  import { PRESENCE_COLLECTION, withBuiltins } from "@irtio/protocol";
@@ -146,12 +146,12 @@ function migrateHolds(bytes, target, options) {
146
146
  return new TextEncoder().encode(JSON.stringify(kept));
147
147
  }
148
148
  function toMigrationState(schema, plain) {
149
- const out = {};
149
+ const out = /* @__PURE__ */ Object.create(null);
150
150
  for (const c of schema.collections) {
151
151
  const held = plain[c.name];
152
152
  if (c.kind === "entity") {
153
153
  const coll = held;
154
- const map = {};
154
+ const map = /* @__PURE__ */ Object.create(null);
155
155
  for (const [id, rec] of coll.records) {
156
156
  map[id] = { owner: rec.owner, value: { ...rec.value } };
157
157
  }
@@ -168,7 +168,13 @@ interface RoomEvent {
168
168
  readonly detail?: string;
169
169
  }
170
170
  declare const EVENT_RING_SIZE = 100;
171
- /** JSON-able view of a plain state: entities as `{id: {owner, value}}`, singletons as values. */
171
+ /**
172
+ * JSON-able view of a plain state: entities as `{id: {owner, value}}`, singletons as values.
173
+ *
174
+ * A `bytes(n)` value is shown as its base64 string rather than as the `{"0": .., "1": ..}` object
175
+ * JSON makes of a `Uint8Array`. Only collections that declare a bytes field are copied to do that;
176
+ * every other value is handed back as it is (callers that keep it clone it, see `timeline.ts`).
177
+ */
172
178
  declare function inspectState(schema: AnySchema, plain: PlainState): Record<string, unknown>;
173
179
  interface RoomInspection {
174
180
  readonly tick: number;
@@ -177,7 +183,7 @@ interface RoomInspection {
177
183
  }
178
184
 
179
185
  /**
180
- * M6 lane K (D77 / O28): the write core's caps, and the named refusals that enforce them.
186
+ * The write core's caps, and the named refusals that enforce them.
181
187
  *
182
188
  * ## Why these are here and not in the relay host
183
189
  *
@@ -221,7 +227,7 @@ interface RoomInspection {
221
227
  * costs. A room under half the cap pays one measure per stale window and nothing else.
222
228
  */
223
229
 
224
- /** O28's numbers, as the relay host sets them. One place, so lane J can move them once. */
230
+ /** The relay host's cap values. Kept in one place so they move together. */
225
231
  declare const RELAY_STATE_BYTES: number;
226
232
  declare const RELAY_RECORDS_PER_COLLECTION = 1024;
227
233
  declare const RELAY_WRITES_PER_SECOND = 35;
@@ -240,8 +246,7 @@ declare const RELAY_WRITE_CAPS: WriteCaps;
240
246
 
241
247
  type LogLevel = 'info' | 'warn' | 'error';
242
248
  /**
243
- * Week 12: host-side async work a room asked for — a save generation (D24) or a player-KV
244
- * operation (D25). Room handlers are synchronous and there is no way to block one on I/O, so
249
+ * Host-side async work a room asked for — a save generation or a player-KV operation. Room handlers are synchronous and there is no way to block one on I/O, so
245
250
  * every one of these is started by `RoomHost.hostCall` and *finished* by the host calling
246
251
  * `RoomCoreApi.completeHostCall` — the same way an inbound frame arrives. That is what makes
247
252
  * "the continuation runs as its own event between ticks" a structural property of the seam
@@ -274,7 +279,7 @@ type HostCall = {
274
279
  readonly board: string;
275
280
  readonly playerId: string;
276
281
  readonly score: number;
277
- /** M6 lane B (D69-c): the cohort of a bucketed board. Absent on every board that declares
282
+ /** The cohort of a bucketed board. Absent on every board that declares
278
283
  * no buckets, which is every board that existed before M6. There is deliberately no
279
284
  * `period` beside it: rotation is control's clock, never the room's. */
280
285
  readonly bucket?: string;
@@ -413,7 +418,7 @@ interface RoomHost {
413
418
  /** N consecutive tick throws; the runtime does not recover itself. */
414
419
  crashed(reason: string): void;
415
420
  /**
416
- * Week 12: start host-side async work (D24 save, D25 KV). The host **must** deliver the
421
+ * Start host-side async work (a save or a KV operation). The host **must** deliver the
417
422
  * outcome by calling `RoomCoreApi.completeHostCall(reqId, result)` from its own event loop
418
423
  * turn, never synchronously from inside this call — a handler is on the stack.
419
424
  */
@@ -435,6 +440,16 @@ interface RoomHost {
435
440
  * A host with no room list (the in-process test harness) records it and does nothing else.
436
441
  */
437
442
  setBackfill(open: boolean): void;
443
+ /**
444
+ * #217: `room.setRole` committed `role` for `clientId`. Fire-and-forget host state, in the
445
+ * style of `setBackfill`: the supervisor mirrors it onto the session so a rejoin (reconnect,
446
+ * wake, crash restart, migration) re-asserts the room's latest grant, not the join-time one.
447
+ * Only called after the role is actually applied, and never for a no-op or refused call.
448
+ *
449
+ * Optional: a host with no sessions to mirror onto (the in-process harness, the relay host)
450
+ * need not implement it.
451
+ */
452
+ roleChanged?(clientId: string, role: string): void;
438
453
  /**
439
454
  * D75: `room.lobby.setPublic(v)`, and the terminal deregistration a game start performs.
440
455
  *
@@ -512,7 +527,7 @@ interface RoomCoreOptions {
512
527
  */
513
528
  readonly traceRpc?: boolean;
514
529
  /**
515
- * D77 / O28: the write core's caps — encoded state bytes, records per collection, writes per
530
+ * The write core's caps — encoded state bytes, records per collection, writes per
516
531
  * client per second. A host that sets none gets the record cap and nothing else, which is what
517
532
  * every coded room in its own VM wants: it already has a memory ceiling and a tick budget. The
518
533
  * relay host sets all three, because it serves many tenants from one process.
@@ -553,8 +568,8 @@ interface JoinOptions {
553
568
  /**
554
569
  * D25: the identity `ctx.playerId` reports and `room.kv` is meant to be keyed by. Defaults to
555
570
  * `clientId`, which — because a resume token carries the client id across a reconnect — is
556
- * exactly the resume-token identity, and no more stable than that token. Week 13 (D27) passes
557
- * a JWT subject here instead without anything else changing.
571
+ * exactly the resume-token identity, and no more stable than that token. A JWT join passes
572
+ * the verified subject here instead without anything else changing.
558
573
  */
559
574
  readonly playerId?: string;
560
575
  /** D44: this join is a scripted NPC's loopback session. */
@@ -588,7 +603,7 @@ declare class RoomFullError extends Error {
588
603
  constructor(maxClients: number);
589
604
  }
590
605
  /**
591
- * M8 lane 5: this join was refused before it had a seat.
606
+ * This join was refused before it had a seat.
592
607
  *
593
608
  * Thrown by `RoomCore.join` for all four of the room's own refusals — a subject inside a
594
609
  * `room.ban` cooldown, an `onAdmit` that called `ctx.deny`, an `onAdmit` that never answered
@@ -703,7 +718,7 @@ interface RoomCoreApi<S extends AnySchema = AnySchema> {
703
718
  * `host.now()`'s clock, or `undefined` if there was none. The worker translates it to wall
704
719
  * clock and hands it to the supervisor, which wakes the room at it — a `room.setTimeout` loses
705
720
  * its callback to a hibernation but no longer loses the room's chance to act on the deadline.
706
- * A `room.setInterval` is not a deadline and is deliberately not reported (bugs.md #142); the
721
+ * A `room.setInterval` is not a deadline and is deliberately not reported; the
707
722
  * departure logs a warning for one instead. `undefined` until the first `serialize()`, and
708
723
  * never touched by `snapshot()`.
709
724
  */
@@ -738,7 +753,7 @@ interface RoomCoreApi<S extends AnySchema = AnySchema> {
738
753
  */
739
754
  rpcTrace(): RpcTraceDump | undefined;
740
755
  /**
741
- * Week 12: settles the promise `hostCall(reqId, …)` returned. The host calls this from its own
756
+ * Settles the promise `hostCall(reqId, …)` returned. The host calls this from its own
742
757
  * turn; the runtime runs the continuation as a discrete event and flushes afterwards, so state
743
758
  * a continuation mutated goes out exactly like state a handler mutated.
744
759
  */
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-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';
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-BQDN3oje.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-BVZiPb6s.js';
3
3
  import { CollectionDesc, AnySchema, PlainState, DirtySet } from '@irtio/schema';
4
4
  import '@irtio/protocol';
5
5
  import '@irtio/server';
@@ -137,7 +137,7 @@ interface ParsedSnapshot extends SnapshotHeader {
137
137
  readonly version: number;
138
138
  /** The encoded physics section, or `undefined` for a v1 blob / a room with no world. */
139
139
  readonly physics: Uint8Array | undefined;
140
- /** M6 lane K (D77): the hold deadlines section, or `undefined` when the room held nothing. */
140
+ /** The hold deadlines section, or `undefined` when the room held nothing. */
141
141
  readonly holds: Uint8Array | undefined;
142
142
  /** The codec snapshot, encoded under `withBuiltins(schema)`. */
143
143
  readonly snapshot: Uint8Array;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  fromMigrationState,
3
3
  migrateSnapshot,
4
4
  toMigrationState
5
- } from "./chunk-NSUQKWYO.js";
5
+ } from "./chunk-GDXHCAD6.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-RLY4VAMM.js";
62
+ } from "./chunk-53X6AWQZ.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-CcZadpC6.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-BQDN3oje.js';
5
5
 
6
6
  declare class Mulberry32 {
7
7
  /** Current internal state (u32). Survives hibernation. */
@@ -30,7 +30,7 @@ interface ClientEntry {
30
30
  */
31
31
  readonly playerId: string;
32
32
  /**
33
- * M8 lane 5: the verified identity subject behind this session, or `undefined` for a key join.
33
+ * The verified identity subject behind this session, or `undefined` for a key join.
34
34
  * Set once at the first join beside `playerId` and kept for the same reason: what a later HELLO
35
35
  * on the same seat claims must not be able to change who the seat belongs to. `room.ban` reads
36
36
  * it, and reading `undefined` is what makes "a key join cannot be banned durably" a fact of the
@@ -201,7 +201,7 @@ interface LoopApi {
201
201
  /**
202
202
  * The departure's read, taken at `serialize()` before the timers are cleared: the earliest
203
203
  * **one-shot** deadline, which the supervisor guarantees the room is awake again by, and the
204
- * number of repeating timers dropped with no such guarantee (bugs.md #142). See
204
+ * number of repeating timers dropped with no such guarantee. See
205
205
  * `Loop.departureTimers`.
206
206
  */
207
207
  departureTimers(): {
@@ -442,7 +442,7 @@ declare class Loop implements LoopApi {
442
442
  * Once, deliberately. A `setInterval(100)` in an otherwise silent room must not be able to pin
443
443
  * the room resident forever — pending timers are explicitly *not* a reason to stay awake. A
444
444
  * one-shot deadline is handed to the supervisor, which wakes the room at it; an interval is
445
- * simply dropped, with a warning (bugs.md #142). Reset by real activity.
445
+ * simply dropped, with a warning. Reset by real activity.
446
446
  */
447
447
  private timerDeferred;
448
448
  constructor(core: RoomInternals);
@@ -510,7 +510,7 @@ declare class Loop implements LoopApi {
510
510
  * gone" into "your room is awake again by then", which is a contract room code can actually
511
511
  * catch up against in `onWake`.
512
512
  *
513
- * bugs.md #142: that contract is a statement about a **deadline**, and only a `setTimeout` has
513
+ * That contract is a statement about a **deadline**, and only a `setTimeout` has
514
514
  * one. A `setInterval` has a cadence, so its next due time is one period away no matter when
515
515
  * you ask — including one period after the room hibernates. Arming the wake for it made an
516
516
  * interval mean "never let this room stay asleep": the room slept, the wake brought it back
@@ -665,7 +665,7 @@ declare class MatterRuntime {
665
665
  runSetup(room: Room): void;
666
666
  free(): void;
667
667
  /**
668
- * bugs.md #100, the matter2d half. Rapier's bindings wake a contact island themselves when a
668
+ * The matter2d half of waking a contact island on removal. Rapier's bindings wake a contact island themselves when a
669
669
  * member is removed (`PhysicsRuntime.destroyBody` / `Rapier2dRuntime.destroyBody`); matter.js
670
670
  * does not, so a body sleeping against a victim about to be removed is left sleeping and
671
671
  * motionless in mid-air once the victim is gone.
@@ -884,7 +884,7 @@ declare class PhysicsRuntime {
884
884
  runSetup(room: Room): void;
885
885
  free(): void;
886
886
  /**
887
- * bugs.md #99: every teardown path goes through here rather than calling `removeRigidBody`
887
+ * Every teardown path goes through here rather than calling `removeRigidBody`
888
888
  * directly.
889
889
  *
890
890
  * A sleeping body is parked in an island wired through the contact graph. Pulling a body out
@@ -1054,7 +1054,7 @@ declare class Rapier2dRuntime {
1054
1054
  runSetup(room: Room): void;
1055
1055
  free(): void;
1056
1056
  /**
1057
- * bugs.md #99, planar half: the teardown door every removal goes through. See
1057
+ * The planar teardown door every removal goes through. See
1058
1058
  * `PhysicsRuntime.destroyBody` for the argument — one narrow-phase walk of the victim's own
1059
1059
  * contact pairs, so a body resting on what is about to vanish is awake when it does.
1060
1060
  */
@@ -1318,7 +1318,7 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
1318
1318
  * after the time it would have fired — so keep the deadline in state and catch up in `onWake`.
1319
1319
  *
1320
1320
  * A repeating timer is deliberately not in here: it has a cadence rather than a deadline, and a
1321
- * wake armed for it can only ever hand the room back its own next period (bugs.md #142, and
1321
+ * wake armed for it can only ever hand the room back its own next period (see
1322
1322
  * `Loop.departureTimers` for the whole argument). The warning below is what a room holding one
1323
1323
  * gets instead.
1324
1324
  *
@@ -1372,7 +1372,7 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
1372
1372
  markDisconnected(clientId: string): void;
1373
1373
  ctxFor(clientId: string, reconnecting?: boolean): Ctx;
1374
1374
  /**
1375
- * Week 12: the host answering a `room.save()` / `room.kv.*`. The continuation runs here — its
1375
+ * The host answering a `room.save()` / `room.kv.*`. The continuation runs here — its
1376
1376
  * own event, between ticks, off the back of a host turn — and the flush afterwards is what
1377
1377
  * makes "state mutated in a continuation is tracked normally" true rather than aspirational.
1378
1378
  */
@@ -1,8 +1,8 @@
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';
1
+ import { R as RoomCore } from '../room-BVZiPb6s.js';
2
+ export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-BVZiPb6s.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-CcZadpC6.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-BQDN3oje.js';
6
6
  import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
7
7
 
8
8
  /**
@@ -40,7 +40,7 @@ declare class FakeClock {
40
40
  * recorded so a test can assert on it: logs, kicks, `close()`, `sleep()`, `crashed()`, and how
41
41
  * many frames/bytes went out (per client and in total).
42
42
  *
43
- * Week 12 adds the two host capabilities a room can now reach for, both modelled the way the
43
+ * It also models the two host capabilities a room can now reach for, both modelled the way the
44
44
  * real supervisor models them:
45
45
  *
46
46
  * - **Host calls** (`room.save()`, `room.kv.*`) are answered on a fake-clock timer, never
@@ -347,7 +347,7 @@ interface HarnessOptions {
347
347
  * default, exactly as a deployed room is.
348
348
  */
349
349
  readonly traceRpc?: boolean;
350
- /** D77 / O28: the write-core caps this room runs under. Default: the record cap only. */
350
+ /** The write-core caps this room runs under. Default: the record cap only. */
351
351
  readonly caps?: WriteCaps;
352
352
  /** D77: the wall clock hold deadlines are measured against. Default `Date.now`. */
353
353
  readonly wallNow?: () => number;
@@ -6,7 +6,7 @@ import {
6
6
  initRapier2d,
7
7
  isPerClient,
8
8
  visibleNames
9
- } from "../chunk-RLY4VAMM.js";
9
+ } from "../chunk-53X6AWQZ.js";
10
10
 
11
11
  // src/test/clock.ts
12
12
  var FakeClock = class {
@@ -389,7 +389,7 @@ function fieldPath(c, names) {
389
389
  return path;
390
390
  }
391
391
  function isPlainObject(v) {
392
- return typeof v === "object" && v !== null && !Array.isArray(v);
392
+ return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Uint8Array);
393
393
  }
394
394
  function mergePatch(target, patch) {
395
395
  for (const [k, v] of Object.entries(patch)) {
@@ -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-CcZadpC6.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-BQDN3oje.js';
2
2
  import { ErrorCodeName, ProfileSnapshot, PlatformNotice } from '@irtio/protocol';
3
3
  import { NpcConfig, LeaveReason } from '@irtio/server';
4
4
  import '@irtio/schema';
@@ -45,13 +45,13 @@ type ToWorker = {
45
45
  clientId: string;
46
46
  role?: string;
47
47
  name?: string;
48
- /** Week 13 (D27): the verified JWT subject — becomes `JoinOptions.playerId` and therefore
48
+ /** The verified JWT subject — becomes `JoinOptions.playerId` and therefore
49
49
  * `ctx.playerId`. Absent for key joins, whose playerId stays the client id. */
50
50
  playerId?: string;
51
51
  reconnecting?: boolean;
52
52
  /** D44: this join is a scripted NPC's loopback session. */
53
53
  npc?: boolean;
54
- /** M8 lane 5: the verified subject, absent for a key join. See `JoinOptions.subject` — it is
54
+ /** 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
57
  /** Issue #123: `JoinOptions.auth`, the credential kind. Absent means `'key'`. */
@@ -75,7 +75,7 @@ type ToWorker = {
75
75
  * the room keeps running. `false` is the departure: `onSleep` runs, timers are cleared, pending
76
76
  * work is rejected, and the worker is about to be terminated.
77
77
  *
78
- * **Required, deliberately** (bugs.md #82). It was optional, defaulting to the departure, and
78
+ * **Required, deliberately.** It was optional, defaulting to the departure, and
79
79
  * the periodic `snapshotEveryMs` snapshot of a *running* event-mode room simply omitted it — so
80
80
  * every such room ran `onSleep` and lost its armed timers every thirty seconds while it kept
81
81
  * running. Only the caller knows which of the two it meant, so the protocol makes it say. Host
@@ -115,7 +115,7 @@ type ToWorker = {
115
115
  reqId?: number;
116
116
  }
117
117
  /**
118
- * Week 12: the supervisor answering a `hostCall` (a D24 save, a D25 KV operation). It arrives
118
+ * The supervisor answering a `hostCall` (a save or a KV operation). It arrives
119
119
  * as an ordinary worker message, which is precisely what makes the room's continuation "its
120
120
  * own event between ticks" rather than something that ran inside the handler that asked.
121
121
  */
@@ -243,10 +243,22 @@ type FromWorker = {
243
243
  * clears nothing and therefore loses nothing. A `room.setInterval` never reports here: it
244
244
  * is a cadence rather than a deadline, and a wake armed for one only ever hands the room
245
245
  * its own next period, which is how an interval came to mean "this room never stays
246
- * asleep" (bugs.md #142). The supervisor arms an internal wake for what does report, so the
246
+ * asleep". The supervisor arms an internal wake for what does report, so the
247
247
  * room is up again by the time its timer would have fired.
248
248
  */
249
249
  nextTimerAt?: number;
250
+ }
251
+ /**
252
+ * The `serialize` reply when there is nothing to send: the room has no core yet, or taking the
253
+ * bytes threw (an encoder error on a bad state value). Answered rather than swallowed so the
254
+ * supervisor learns it at once instead of timing out, and can tell it apart from success: a
255
+ * room whose state could not be captured must be left running, never terminated.
256
+ */
257
+ | {
258
+ t: 'serialized';
259
+ reqId: number;
260
+ bytes?: undefined;
261
+ error: string;
250
262
  } | {
251
263
  t: 'inspected';
252
264
  reqId: number;
@@ -309,7 +321,7 @@ type FromWorker = {
309
321
  * V8 could not be sampled, which the supervisor must report as unread rather than as
310
322
  * zero, for the same reason `ticks`/`overruns` are optional.
311
323
  *
312
- * bugs.md #146: `externalBytes` is the off-heap half — `ArrayBuffer` backing stores, WASM
324
+ * `externalBytes` is the off-heap half — `ArrayBuffer` backing stores, WASM
313
325
  * memory, `Buffer`s — which neither `usedBytes` nor the old-generation cap above it covers,
314
326
  * and which is therefore the half a room can overshoot its whole budget with while every
315
327
  * number the platform was reading stays small. Optional on the wire because an older worker
@@ -336,7 +348,7 @@ type FromWorker = {
336
348
  */
337
349
  profile?: ProfileSnapshot;
338
350
  }
339
- /** Week 12: the room asked for host-side async work; the supervisor answers `hostResult`. */
351
+ /** The room asked for host-side async work; the supervisor answers `hostResult`. */
340
352
  | {
341
353
  t: 'hostCall';
342
354
  reqId: number;
@@ -360,6 +372,15 @@ type FromWorker = {
360
372
  | {
361
373
  t: 'setBackfill';
362
374
  open: boolean;
375
+ }
376
+ /**
377
+ * #217: `room.setRole` committed a new role. The supervisor mirrors it onto the session so a
378
+ * later rejoin re-asserts the room's current grant rather than the join-time one.
379
+ */
380
+ | {
381
+ t: 'role';
382
+ clientId: string;
383
+ role: string;
363
384
  } | {
364
385
  t: 'setLobby';
365
386
  public?: boolean;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  migrateSnapshot
3
- } from "../chunk-NSUQKWYO.js";
3
+ } from "../chunk-GDXHCAD6.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-RLY4VAMM.js";
13
+ } from "../chunk-53X6AWQZ.js";
14
14
 
15
15
  // src/worker/index.ts
16
16
  import { getHeapStatistics } from "v8";
@@ -81,6 +81,9 @@ function createWorkerHost(post) {
81
81
  setBackfill(open) {
82
82
  post({ t: "setBackfill", open });
83
83
  },
84
+ roleChanged(clientId, role) {
85
+ post({ t: "role", clientId, role });
86
+ },
84
87
  // ---- M6 lane H: lobby front door ----
85
88
  setLobby(update) {
86
89
  post({ t: "setLobby", ...update });
@@ -322,8 +325,19 @@ async function handleMessage(state, host, post, msg) {
322
325
  }
323
326
  case "serialize": {
324
327
  const core = state.core;
325
- if (!core) return;
326
- const bytes = ownBuffer(msg.forSave ? core.snapshot() : core.serialize());
328
+ if (!core) {
329
+ post({ t: "serialized", reqId: msg.reqId, error: "room is not initialized" });
330
+ return;
331
+ }
332
+ let bytes;
333
+ try {
334
+ bytes = ownBuffer(msg.forSave ? core.snapshot() : core.serialize());
335
+ } catch (err) {
336
+ const detail = err instanceof Error ? err.stack ?? err.message : String(err);
337
+ post({ t: "log", level: "error", args: ["worker: handling 'serialize' threw", detail] });
338
+ post({ t: "serialized", reqId: msg.reqId, error: detail });
339
+ return;
340
+ }
327
341
  const timerAt = msg.forSave ? void 0 : core.lastSerializeTimerAt;
328
342
  post(
329
343
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/runtime",
3
- "version": "4.7.1",
3
+ "version": "4.8.0",
4
4
  "description": "irtio room runtime: RoomCore, worker_threads host, in-process test harness",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,6 +12,9 @@
12
12
  "access": "public"
13
13
  },
14
14
  "type": "module",
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
15
18
  "sideEffects": false,
16
19
  "main": "./dist/index.js",
17
20
  "types": "./dist/index.d.ts",
@@ -36,9 +39,9 @@
36
39
  "@dimforge/rapier2d-compat": "0.20.0",
37
40
  "@dimforge/rapier3d-compat": "0.20.0",
38
41
  "matter-js": "0.20.0",
39
- "@irtio/protocol": "^4.7.1",
40
- "@irtio/schema": "^4.7.1",
41
- "@irtio/server": "^4.7.1"
42
+ "@irtio/protocol": "^4.8.0",
43
+ "@irtio/schema": "^4.8.0",
44
+ "@irtio/server": "^4.8.0"
42
45
  },
43
46
  "devDependencies": {
44
47
  "@types/matter-js": "0.20.2"