@irtio/runtime 1.0.0 → 3.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.
@@ -8,6 +8,15 @@ var RoomFullError = class extends Error {
8
8
  maxClients;
9
9
  name = "RoomFullError";
10
10
  };
11
+ var AdmissionError = class extends Error {
12
+ constructor(reason) {
13
+ super(reason);
14
+ this.reason = reason;
15
+ }
16
+ reason;
17
+ name = "AdmissionError";
18
+ };
19
+ var PHYSICS_STEP_EMA_ALPHA = 0.1;
11
20
 
12
21
  // src/core/caps.ts
13
22
  import {
@@ -212,25 +221,73 @@ function inspectState(schema, plain) {
212
221
  return out;
213
222
  }
214
223
 
215
- // src/core/rpc.ts
216
- import {
217
- ErrorCode as ErrorCode2,
218
- FrameType as FrameType2,
219
- OWNERSHIP_ASK,
220
- decodeCall,
221
- decodeReply,
222
- encodeCall,
223
- encodeFrame as encodeFrame2,
224
- encodeReply,
225
- isReservedRpcName,
226
- rpcByIdOf,
227
- rpcTable
228
- } from "@irtio/protocol";
224
+ // src/core/rpc-trace.ts
225
+ var RPC_TRACE_SIZE = 200;
226
+ var RPC_TRACE_SUMMARY_MAX = 200;
227
+ function summarize(value) {
228
+ if (value === void 0) return void 0;
229
+ let text;
230
+ try {
231
+ text = JSON.stringify(value) ?? String(value);
232
+ } catch {
233
+ text = String(value);
234
+ }
235
+ return text.length > RPC_TRACE_SUMMARY_MAX ? `${text.slice(0, RPC_TRACE_SUMMARY_MAX)}\u2026` : text;
236
+ }
237
+ var RpcTraceRing = class {
238
+ items = [];
239
+ lastByClient = /* @__PURE__ */ new Map();
240
+ seq = 0;
241
+ push(entry) {
242
+ const filed = { ...entry, seq: ++this.seq };
243
+ this.items.push(filed);
244
+ if (this.items.length > RPC_TRACE_SIZE) this.items.shift();
245
+ this.lastByClient.set(filed.clientId, {
246
+ tick: filed.tick,
247
+ clientTick: filed.clientTick,
248
+ lag: filed.clientTick === void 0 ? void 0 : filed.tick - filed.clientTick,
249
+ rpc: filed.rpc,
250
+ seq: filed.seq
251
+ });
252
+ if (this.lastByClient.size > RPC_TRACE_SIZE) {
253
+ const oldest = this.lastByClient.keys().next();
254
+ if (!oldest.done && oldest.value !== filed.clientId) this.lastByClient.delete(oldest.value);
255
+ }
256
+ return filed;
257
+ }
258
+ list() {
259
+ return this.items;
260
+ }
261
+ /** Client id → its last traced call. Survives the entry itself being evicted from the ring. */
262
+ stampLag() {
263
+ return this.lastByClient;
264
+ }
265
+ /** A client that left takes its stamp row with it; the ring entries stay. */
266
+ forget(clientId) {
267
+ this.lastByClient.delete(clientId);
268
+ }
269
+ /** JSON shape served by `/__irt/rpc-trace.json` and carried on `inspect`. */
270
+ dump() {
271
+ return {
272
+ entries: [...this.items],
273
+ stampLag: [...this.lastByClient].map(([clientId, lag]) => ({ clientId, ...lag }))
274
+ };
275
+ }
276
+ };
277
+
278
+ // src/core/views.ts
229
279
  import {
230
- SERVER_OWNER as SERVER_OWNER2,
231
- decodeFields,
232
- encodeFields,
233
- validateValue
280
+ collectionDirty,
281
+ createDirtySet,
282
+ createFieldMask,
283
+ encodeDelta,
284
+ encodeSnapshot as encodeSnapshot2,
285
+ filterDirty,
286
+ isDirtyEmpty,
287
+ markAdd,
288
+ markPath,
289
+ markRemove,
290
+ mergeDirty
234
291
  } from "@irtio/schema";
235
292
 
236
293
  // src/core/internals.ts
@@ -259,128 +316,400 @@ function deepEqual(a, b) {
259
316
  return true;
260
317
  }
261
318
 
262
- // src/core/ownership.ts
263
- import { SERVER_OWNER, defaultRecord } from "@irtio/schema";
264
- var ASK_WINDOW_MS = 5e3;
265
- var states2 = /* @__PURE__ */ new WeakMap();
266
- function stateOf2(core) {
267
- let s = states2.get(core);
268
- if (!s) {
269
- s = { holds: /* @__PURE__ */ new Map(), asks: /* @__PURE__ */ new Map(), askUnknownWarned: false };
270
- states2.set(core, s);
271
- }
272
- return s;
273
- }
274
- function keyOf(collection, id) {
275
- return `${collection} ${id}`;
276
- }
277
- function policyCollections(core) {
278
- return core.ext.collections.filter(
279
- (c) => c.kind === "entity" && c.ownership !== void 0
280
- );
319
+ // src/core/spatial.ts
320
+ function cellFor(grid, value) {
321
+ if (typeof value !== "object" || value === null) return void 0;
322
+ const record = value;
323
+ const x = record[grid.x];
324
+ const y = record[grid.y];
325
+ if (typeof x !== "number" || typeof y !== "number") return void 0;
326
+ return { x: Math.floor(x / grid.cell), y: Math.floor(y / grid.cell) };
281
327
  }
282
- function wallNow(core) {
283
- return core.wallNow();
328
+ function cellKey(cell) {
329
+ return `${cell.x},${cell.y}`;
284
330
  }
285
- function holdOn(core, collection, id) {
286
- return states2.get(core)?.holds.get(keyOf(collection, id));
331
+ function buildSpatialIndex(plain, desc) {
332
+ if (!desc.grid || desc.kind !== "entity") {
333
+ throw new Error(`${desc.name}: cannot build an index without an entity grid`);
334
+ }
335
+ const collection = plainEntity(plain, desc.name);
336
+ const buckets = /* @__PURE__ */ new Map();
337
+ const order = /* @__PURE__ */ new Map();
338
+ let n = 0;
339
+ for (const id of collection.ids()) {
340
+ order.set(id, n++);
341
+ const cell = cellFor(desc.grid, collection.get(id));
342
+ if (!cell) continue;
343
+ const key = cellKey(cell);
344
+ let ids = buckets.get(key);
345
+ if (!ids) {
346
+ ids = /* @__PURE__ */ new Set();
347
+ buckets.set(key, ids);
348
+ }
349
+ ids.add(id);
350
+ }
351
+ return { desc, buckets, collection, order };
287
352
  }
288
- function holdCount(core) {
289
- return states2.get(core)?.holds.size ?? 0;
353
+ function querySpatialIndex(index, clientId, role) {
354
+ const grid = index.desc.grid;
355
+ if ((grid.wideRoles ?? []).includes(role)) return new Set(index.collection.ids());
356
+ const anchor = index.collection.get(clientId);
357
+ const anchorCell = cellFor(grid, anchor);
358
+ if (!anchor || !anchorCell) return /* @__PURE__ */ new Set();
359
+ const candidates = /* @__PURE__ */ new Set();
360
+ for (let x = anchorCell.x - grid.radius; x <= anchorCell.x + grid.radius; x++) {
361
+ for (let y = anchorCell.y - grid.radius; y <= anchorCell.y + grid.radius; y++) {
362
+ const ids = index.buckets.get(cellKey({ x, y }));
363
+ if (ids) for (const id of ids) candidates.add(id);
364
+ }
365
+ }
366
+ candidates.add(clientId);
367
+ const ranked = [];
368
+ for (const id of candidates) if (index.order.has(id)) ranked.push(id);
369
+ ranked.sort((a, b) => index.order.get(a) - index.order.get(b));
370
+ return new Set(ranked);
290
371
  }
291
- function placeHolds(core, clientId, playerId) {
292
- const s = stateOf2(core);
293
- const now = wallNow(core);
294
- for (const c of policyCollections(core)) {
295
- const policy = c.ownership;
296
- const coll = plainEntity(core.plain, c.name);
297
- for (const id of [...coll.ids()]) {
298
- if (coll.ownerOf(id) !== clientId) continue;
299
- const hold = {
300
- collection: c.name,
301
- id,
302
- holder: playerId,
303
- deadlineMs: now + policy.holdMs,
304
- // `then` is the D77 spec's own name for the leave action; a hold is plain data nothing
305
- // ever awaits.
306
- // biome-ignore lint/suspicious/noThenProperty: see above
307
- then: policy.then
308
- };
309
- if (policy.holdMs <= 0) {
310
- act(core, hold);
311
- continue;
312
- }
313
- s.holds.set(keyOf(c.name, id), hold);
372
+ function buildSpatialIndexes(collections, plain) {
373
+ const indexes = /* @__PURE__ */ new Map();
374
+ for (const desc of collections) {
375
+ if (desc.visibility === "spatial-grid" && desc.grid) {
376
+ indexes.set(desc.name, buildSpatialIndex(plain, desc));
314
377
  }
315
378
  }
379
+ return indexes;
316
380
  }
317
- function reclaimHolds(core, clientId, playerId) {
318
- const s = states2.get(core);
319
- if (!s || s.holds.size === 0) return 0;
320
- let reclaimed = 0;
321
- for (const [key, hold] of [...s.holds]) {
322
- if (hold.holder !== playerId) continue;
323
- s.holds.delete(key);
324
- const coll = plainEntity(core.plain, hold.collection);
325
- if (!coll.has(hold.id)) continue;
326
- trackedEntity(core, hold.collection).setOwner(hold.id, clientId);
327
- reclaimed++;
381
+
382
+ // src/core/views.ts
383
+ var cache = /* @__PURE__ */ new WeakMap();
384
+ function infoOf(ext) {
385
+ let i = cache.get(ext);
386
+ if (!i) {
387
+ const scoped = /* @__PURE__ */ new Set();
388
+ for (const c of ext.collections) {
389
+ if (c.visibility === "role") for (const r of c.roles ?? []) scoped.add(r);
390
+ }
391
+ i = { scoped, names: /* @__PURE__ */ new Map() };
392
+ cache.set(ext, i);
328
393
  }
329
- return reclaimed;
394
+ return i;
330
395
  }
331
- function expireHolds(core) {
332
- const s = states2.get(core);
333
- if (!s || s.holds.size === 0) return;
334
- const now = wallNow(core);
335
- for (const [key, hold] of [...s.holds]) {
336
- if (hold.deadlineMs > now) continue;
337
- s.holds.delete(key);
338
- act(core, hold);
396
+ function isVisible(c, role) {
397
+ if (c.visibility === "server") return false;
398
+ return c.visibility !== "role" || (c.roles ?? []).includes(role);
399
+ }
400
+ function visibleTo(role) {
401
+ return (c) => isVisible(c, role);
402
+ }
403
+ function visibleNames(ext, role) {
404
+ const info = infoOf(ext);
405
+ let set = info.names.get(role);
406
+ if (!set) {
407
+ set = new Set(
408
+ ext.collections.filter((c) => isVisible(c, role)).map((c) => c.name)
409
+ );
410
+ info.names.set(role, set);
339
411
  }
412
+ return set;
340
413
  }
341
- function act(core, hold) {
342
- const coll = plainEntity(core.plain, hold.collection);
343
- if (!coll.has(hold.id)) return;
344
- const tracked = trackedEntity(core, hold.collection);
345
- switch (hold.then) {
346
- case "remove":
347
- tracked.remove(hold.id);
348
- return;
349
- case "release":
350
- tracked.setOwner(hold.id, SERVER_OWNER);
351
- return;
352
- case "nearest": {
353
- const to = nearestConnected(core, hold.collection, hold.id);
354
- tracked.setOwner(hold.id, to ?? SERVER_OWNER);
355
- return;
356
- }
357
- case "random": {
358
- const candidates = connectedClients(core);
359
- if (candidates.length === 0) {
360
- tracked.setOwner(hold.id, SERVER_OWNER);
361
- return;
362
- }
363
- const pick = candidates[Math.floor(core.rng.next() * candidates.length)];
364
- tracked.setOwner(hold.id, pick);
365
- return;
414
+ function viewKeyFor(ext, role) {
415
+ return infoOf(ext).scoped.has(role) ? role : "all";
416
+ }
417
+ function encodeViewSnapshot(ext, plain, role, tick, memberships) {
418
+ return encodeSnapshot2(
419
+ ext,
420
+ plain,
421
+ { tick },
422
+ {
423
+ collections: visibleTo(role),
424
+ entities: (c, id) => c.visibility !== "spatial-grid" || (memberships?.get(c.name)?.has(id) ?? false)
366
425
  }
367
- default:
368
- return;
369
- }
426
+ );
370
427
  }
371
- function connectedClients(core) {
372
- const out = [];
373
- for (const entry of core.clients.values()) if (entry.connected) out.push(entry.clientId);
428
+ function spatialMemberships(ext, plain, clientId, role, indexes = buildSpatialIndexes(ext.collections, plain)) {
429
+ const out = /* @__PURE__ */ new Map();
430
+ for (const [name, index] of indexes) out.set(name, querySpatialIndex(index, clientId, role));
374
431
  return out;
375
432
  }
376
- function numberAt(record, field) {
377
- if (!record) return void 0;
378
- const v = record[field];
379
- return typeof v === "number" && Number.isFinite(v) ? v : void 0;
433
+ var aoiMemo = /* @__PURE__ */ new WeakMap();
434
+ function inClientView(core, clientId, desc, id) {
435
+ const role = core.clients.get(clientId)?.role ?? "";
436
+ if (!isVisible(desc, role)) return false;
437
+ if (desc.visibility !== "spatial-grid") return true;
438
+ let memo = aoiMemo.get(core);
439
+ if (!memo || memo.tick !== core.tick) {
440
+ memo = { tick: core.tick, byClient: /* @__PURE__ */ new Map() };
441
+ aoiMemo.set(core, memo);
442
+ }
443
+ const key = `${clientId}\0${role}`;
444
+ let mine = memo.byClient.get(key);
445
+ if (!mine) {
446
+ mine = spatialMemberships(core.ext, core.plain, clientId, role);
447
+ memo.byClient.set(key, mine);
448
+ }
449
+ return mine.get(desc.name)?.has(id) ?? false;
380
450
  }
381
- function distanceTo(core, policy, object, clientId) {
382
- const position = policy.position;
383
- if (!position || !object) return void 0;
451
+ function createVisibilityPolicy(ext, plain, clientId, role) {
452
+ const memberships = spatialMemberships(ext, plain, clientId, role);
453
+ return {
454
+ memberships,
455
+ maySeeCollection: (desc) => isVisible(desc, role),
456
+ maySeeEntity: (desc, id) => isVisible(desc, role) && (desc.visibility !== "spatial-grid" || (memberships.get(desc.name)?.has(id) ?? false)),
457
+ describeSpatial: (desc, ids) => {
458
+ const grid = desc.grid;
459
+ if (desc.visibility !== "spatial-grid" || !grid) return void 0;
460
+ const coll = plainEntity(plain, desc.name);
461
+ const at = (id) => {
462
+ const cell = cellFor(grid, coll.get(id));
463
+ return cell ? `${id}@(${cell.x},${cell.y})` : `${id}@(no position)`;
464
+ };
465
+ return `viewer ${at(clientId)}, ${desc.name} ${ids.map(at).join(" ")}, radius ${grid.radius}`;
466
+ }
467
+ };
468
+ }
469
+ function sharedViewDirty(ext, dirty, role) {
470
+ return filterDirty(dirty, (name) => {
471
+ const desc = ext.collection(name);
472
+ return desc.visibility !== "spatial-grid" && isVisible(desc, role);
473
+ });
474
+ }
475
+ function aoiViewDirty(ext, dirty, previous, current) {
476
+ const out = createDirtySet();
477
+ for (const desc of ext.collections) {
478
+ if (desc.visibility !== "spatial-grid") continue;
479
+ const before = previous.get(desc.name) ?? /* @__PURE__ */ new Set();
480
+ const now = current.get(desc.name) ?? /* @__PURE__ */ new Set();
481
+ for (const id of before) if (!now.has(id)) markRemove(out, desc.name, id);
482
+ for (const id of now) if (!before.has(id)) markAdd(out, desc.name, id);
483
+ const changed = dirty.get(desc.name);
484
+ if (!changed) continue;
485
+ const visibleChanges = createDirtySet();
486
+ const collection = collectionDirty(visibleChanges, desc.name);
487
+ for (const id of changed.added) if (now.has(id)) collection.added.add(id);
488
+ for (const id of changed.removed) if (before.has(id)) collection.removed.add(id);
489
+ for (const [id, record] of changed.updated) {
490
+ if (before.has(id) && now.has(id)) collection.updated.set(id, record);
491
+ }
492
+ mergeDirty(out, visibleChanges);
493
+ }
494
+ return out;
495
+ }
496
+ function membershipSafeDirty(ext, dirty, memberships) {
497
+ const out = createDirtySet();
498
+ for (const [name, changed] of dirty) {
499
+ const desc = ext.collection(name);
500
+ if (desc.visibility !== "spatial-grid") {
501
+ out.set(name, changed);
502
+ continue;
503
+ }
504
+ const visible = memberships.get(name) ?? /* @__PURE__ */ new Set();
505
+ const kept = collectionDirty(out, name);
506
+ for (const id of changed.added) if (visible.has(id)) kept.added.add(id);
507
+ for (const id of changed.removed) if (visible.has(id)) kept.removed.add(id);
508
+ for (const [id, record] of changed.updated) if (visible.has(id)) kept.updated.set(id, record);
509
+ }
510
+ return out;
511
+ }
512
+ function encodeViewDelta(ext, plain, dirty, role, tick) {
513
+ const keep = visibleNames(ext, role);
514
+ const filtered = filterDirty(dirty, (name) => keep.has(name));
515
+ if (isDirtyEmpty(filtered)) return null;
516
+ return encodeDelta(ext, plain, filtered, { tick });
517
+ }
518
+ function stripAdds(plain, dirty, suppress) {
519
+ if (suppress.size === 0) return dirty;
520
+ const out = /* @__PURE__ */ new Map();
521
+ for (const [name, cd] of dirty) {
522
+ const captured = suppress.get(name);
523
+ if (!captured || captured.size === 0 || cd.added.size === 0) {
524
+ out.set(name, cd);
525
+ continue;
526
+ }
527
+ const coll = plainEntity(plain, name);
528
+ let added = cd.added;
529
+ for (const id of captured.keys()) {
530
+ if (!added.has(id)) continue;
531
+ const c = captured.get(id);
532
+ if (coll.ownerOf(id) !== c.owner || !deepEqual(coll.get(id), c.value)) continue;
533
+ if (added === cd.added) added = new Set(cd.added);
534
+ added.delete(id);
535
+ }
536
+ out.set(name, added === cd.added ? cd : { added, removed: cd.removed, updated: cd.updated });
537
+ }
538
+ return out;
539
+ }
540
+ function catchUpDirty(ext, plain, fromRole, toRole) {
541
+ const dirty = createDirtySet();
542
+ for (const c of ext.collections) {
543
+ const was = isVisible(c, fromRole);
544
+ const now = isVisible(c, toRole);
545
+ if (was === now) continue;
546
+ if (c.kind === "singleton") {
547
+ if (!now) continue;
548
+ const mask = createFieldMask();
549
+ for (const f of c.fields) markPath(mask, [f.index]);
550
+ collectionDirty(dirty, c.name).updated.set("", { mask, owner: false });
551
+ continue;
552
+ }
553
+ const coll = plainEntity(plain, c.name);
554
+ for (const id of [...coll.ids()]) {
555
+ if (now) markAdd(dirty, c.name, id);
556
+ else markRemove(dirty, c.name, id);
557
+ }
558
+ }
559
+ return dirty;
560
+ }
561
+
562
+ // src/core/rpc.ts
563
+ import {
564
+ ErrorCode as ErrorCode2,
565
+ FrameType as FrameType2,
566
+ OWNERSHIP_ASK,
567
+ clampErrorMessage,
568
+ decodeCall,
569
+ decodeReply,
570
+ encodeCall,
571
+ encodeFrame as encodeFrame2,
572
+ encodeReply,
573
+ isReservedRpcName,
574
+ rpcByIdOf,
575
+ rpcTable
576
+ } from "@irtio/protocol";
577
+ import {
578
+ SERVER_OWNER as SERVER_OWNER2,
579
+ decodeFields,
580
+ encodeFields,
581
+ validateValue
582
+ } from "@irtio/schema";
583
+ import { DenyError } from "@irtio/server";
584
+
585
+ // src/core/ownership.ts
586
+ import { SERVER_OWNER, defaultRecord } from "@irtio/schema";
587
+ var HOLD_ACTIONS = /* @__PURE__ */ new Set([
588
+ "release",
589
+ "nearest",
590
+ "random",
591
+ "remove"
592
+ ]);
593
+ var ASK_WINDOW_MS = 5e3;
594
+ var states2 = /* @__PURE__ */ new WeakMap();
595
+ function stateOf2(core) {
596
+ let s = states2.get(core);
597
+ if (!s) {
598
+ s = { holds: /* @__PURE__ */ new Map(), asks: /* @__PURE__ */ new Map(), askUnknownWarned: false };
599
+ states2.set(core, s);
600
+ }
601
+ return s;
602
+ }
603
+ function keyOf(collection, id) {
604
+ return `${collection} ${id}`;
605
+ }
606
+ function policyCollections(core) {
607
+ return core.ext.collections.filter(
608
+ (c) => c.kind === "entity" && c.ownership !== void 0
609
+ );
610
+ }
611
+ function wallNow(core) {
612
+ return core.wallNow();
613
+ }
614
+ function holdOn(core, collection, id) {
615
+ return states2.get(core)?.holds.get(keyOf(collection, id));
616
+ }
617
+ function holdCount(core) {
618
+ return states2.get(core)?.holds.size ?? 0;
619
+ }
620
+ function placeHolds(core, clientId, playerId) {
621
+ const s = stateOf2(core);
622
+ const now = wallNow(core);
623
+ for (const c of policyCollections(core)) {
624
+ const policy = c.ownership;
625
+ const coll = plainEntity(core.plain, c.name);
626
+ for (const id of [...coll.ids()]) {
627
+ if (coll.ownerOf(id) !== clientId) continue;
628
+ const hold = {
629
+ collection: c.name,
630
+ id,
631
+ holder: playerId,
632
+ deadlineMs: now + policy.holdMs,
633
+ // `then` is the D77 spec's own name for the leave action; a hold is plain data nothing
634
+ // ever awaits.
635
+ // biome-ignore lint/suspicious/noThenProperty: see above
636
+ then: policy.then
637
+ };
638
+ if (policy.holdMs <= 0) {
639
+ act(core, hold);
640
+ continue;
641
+ }
642
+ s.holds.set(keyOf(c.name, id), hold);
643
+ }
644
+ }
645
+ }
646
+ function reclaimHolds(core, clientId, playerId) {
647
+ const s = states2.get(core);
648
+ if (!s || s.holds.size === 0) return 0;
649
+ let reclaimed = 0;
650
+ for (const [key, hold] of [...s.holds]) {
651
+ if (hold.holder !== playerId) continue;
652
+ s.holds.delete(key);
653
+ const coll = plainEntity(core.plain, hold.collection);
654
+ if (!coll.has(hold.id)) continue;
655
+ trackedEntity(core, hold.collection).setOwner(hold.id, clientId);
656
+ reclaimed++;
657
+ }
658
+ return reclaimed;
659
+ }
660
+ function expireHolds(core) {
661
+ const s = states2.get(core);
662
+ if (!s || s.holds.size === 0) return;
663
+ const now = wallNow(core);
664
+ for (const [key, hold] of [...s.holds]) {
665
+ if (hold.deadlineMs > now) continue;
666
+ s.holds.delete(key);
667
+ act(core, hold);
668
+ }
669
+ }
670
+ function act(core, hold) {
671
+ const coll = plainEntity(core.plain, hold.collection);
672
+ if (!coll.has(hold.id)) return;
673
+ const tracked = trackedEntity(core, hold.collection);
674
+ switch (hold.then) {
675
+ case "remove":
676
+ tracked.remove(hold.id);
677
+ return;
678
+ case "release":
679
+ tracked.setOwner(hold.id, SERVER_OWNER);
680
+ return;
681
+ case "nearest": {
682
+ const to = nearestConnected(core, hold.collection, hold.id);
683
+ tracked.setOwner(hold.id, to ?? SERVER_OWNER);
684
+ return;
685
+ }
686
+ case "random": {
687
+ const candidates = connectedClients(core);
688
+ if (candidates.length === 0) {
689
+ tracked.setOwner(hold.id, SERVER_OWNER);
690
+ return;
691
+ }
692
+ const pick = candidates[Math.floor(core.rng.next() * candidates.length)];
693
+ tracked.setOwner(hold.id, pick);
694
+ return;
695
+ }
696
+ default:
697
+ return;
698
+ }
699
+ }
700
+ function connectedClients(core) {
701
+ const out = [];
702
+ for (const entry of core.clients.values()) if (entry.connected) out.push(entry.clientId);
703
+ return out;
704
+ }
705
+ function numberAt(record, field) {
706
+ if (!record) return void 0;
707
+ const v = record[field];
708
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
709
+ }
710
+ function distanceTo(core, policy, object, clientId) {
711
+ const position = policy.position;
712
+ if (!position || !object) return void 0;
384
713
  const players = plainEntity(core.plain, position.players);
385
714
  const player = players.get(clientId);
386
715
  const px = numberAt(player, position.x);
@@ -511,11 +840,20 @@ function restoreHolds(core, bytes) {
511
840
  const parsed = JSON.parse(new TextDecoder().decode(bytes));
512
841
  for (const h of parsed) {
513
842
  if (typeof h?.collection !== "string" || typeof h.id !== "string" || typeof h.holder !== "string" || typeof h.deadlineMs !== "number") {
843
+ core.log("warn", "irtio: dropping a hold that is not a hold record:", h);
514
844
  continue;
515
845
  }
516
- s.holds.set(keyOf(h.collection, h.id), h);
517
- }
518
- } catch (err) {
846
+ if (!HOLD_ACTIONS.has(h.then)) {
847
+ core.log(
848
+ "warn",
849
+ `irtio: dropping the hold on ${h.collection}.${h.id} \u2014 unknown leave action`,
850
+ h.then
851
+ );
852
+ continue;
853
+ }
854
+ s.holds.set(keyOf(h.collection, h.id), h);
855
+ }
856
+ } catch (err) {
519
857
  core.log("warn", "hold deadlines did not decode; the room wakes holding nothing:", err);
520
858
  }
521
859
  }
@@ -542,7 +880,11 @@ function errorText(code, detail) {
542
880
  return detail === void 0 ? code : `${code}: ${detail}`;
543
881
  }
544
882
  function sendReply(core, clientId, reqId, result, error) {
545
- const payload = result === null ? encodeReply({ reqId, ok: false, error: error ?? errorText("E_INTERNAL") }) : encodeReply({ reqId, ok: true, result });
883
+ const payload = result === null ? encodeReply({
884
+ reqId,
885
+ ok: false,
886
+ error: clampErrorMessage(error ?? errorText("E_INTERNAL"))
887
+ }) : encodeReply({ reqId, ok: true, result });
546
888
  core.send(clientId, encodeFrame2(FrameType2.REPLY, payload));
547
889
  }
548
890
  function handleCall(core, clientId, payload) {
@@ -581,21 +923,46 @@ function handleCall(core, clientId, payload) {
581
923
  );
582
924
  return true;
583
925
  }
926
+ const armed = core.rpcTraceArmed;
927
+ const traceEarly = armed ? (detail) => {
928
+ core.recordRpc({
929
+ clientId,
930
+ rpc: desc.name,
931
+ tick: core.tick,
932
+ outcome: "error",
933
+ result: detail
934
+ });
935
+ } : void 0;
584
936
  let params;
585
937
  try {
586
938
  params = decodeFields(desc.params, call.params);
587
939
  } catch (err) {
940
+ traceEarly?.(errorText("E_RPC_BAD_PARAMS", String(err)));
588
941
  sendReply(core, clientId, call.reqId, null, errorText("E_RPC_BAD_PARAMS", String(err)));
589
942
  return true;
590
943
  }
591
944
  for (const f of desc.params) {
592
945
  const problem = validateValue(f.type, params[f.name], f.name);
593
946
  if (problem) {
947
+ traceEarly?.(errorText("E_RPC_BAD_PARAMS", problem));
594
948
  sendReply(core, clientId, call.reqId, null, errorText("E_RPC_BAD_PARAMS", problem));
595
949
  return true;
596
950
  }
597
951
  }
598
952
  const ctx = { ...core.ctxFor(clientId), clientTick: call.clientTick || void 0 };
953
+ const trace = armed ? (outcome, result2) => {
954
+ core.recordRpc({
955
+ clientId,
956
+ rpc: desc.name,
957
+ tick: ctx.tick,
958
+ ...ctx.clientTick !== void 0 ? { clientTick: ctx.clientTick } : {},
959
+ outcome,
960
+ ...(() => {
961
+ const summary = summarize(result2);
962
+ return summary === void 0 ? {} : { result: summary };
963
+ })()
964
+ });
965
+ } : void 0;
599
966
  let result;
600
967
  if (desc.name === REQUEST_OWNERSHIP) {
601
968
  result = runRequestOwnership(core, clientId, ctx, params, call.reqId, desc);
@@ -603,27 +970,38 @@ function handleCall(core, clientId, payload) {
603
970
  } else {
604
971
  const impl = core.definition.config.rpc?.[desc.name];
605
972
  if (!impl) {
973
+ trace?.("error", "E_RPC_UNKNOWN");
606
974
  sendReply(core, clientId, call.reqId, null, errorText("E_RPC_UNKNOWN", desc.name));
607
975
  return true;
608
976
  }
609
977
  try {
610
978
  result = impl(core.anyState, params, ctx);
611
979
  } catch (err) {
980
+ if (err instanceof DenyError) {
981
+ trace?.(`deny:${err.reason}`);
982
+ sendReply(core, clientId, call.reqId, null, err.message);
983
+ return true;
984
+ }
612
985
  core.stats.handlerErrors++;
613
986
  core.log("error", `rpc.${desc.name} threw:`, err);
614
- sendReply(core, clientId, call.reqId, null, err instanceof Error ? err.message : String(err));
987
+ trace?.("error", err instanceof Error ? err.message : String(err));
988
+ sendReply(core, clientId, call.reqId, null, errorText("E_INTERNAL", `rpc ${desc.name}`));
615
989
  return true;
616
990
  }
617
991
  }
618
992
  if (!desc.returns) {
993
+ trace?.("ok");
619
994
  sendReply(core, clientId, call.reqId, new Uint8Array(0));
620
995
  return true;
621
996
  }
622
997
  try {
623
- sendReply(core, clientId, call.reqId, encodeFields(desc.returns, result ?? {}));
998
+ const bytes = encodeFields(desc.returns, result ?? {});
999
+ trace?.("ok", result ?? {});
1000
+ sendReply(core, clientId, call.reqId, bytes);
624
1001
  } catch (err) {
625
1002
  core.log("error", `rpc.${desc.name} returned a value that failed to encode:`, err);
626
1003
  core.stats.handlerErrors++;
1004
+ trace?.("error", `E_INTERNAL: ${String(err)}`);
627
1005
  sendReply(core, clientId, call.reqId, null, errorText("E_INTERNAL", `rpc ${desc.name}`));
628
1006
  }
629
1007
  return true;
@@ -633,8 +1011,11 @@ function runRequestOwnership(core, clientId, ctx, params, reqId, desc) {
633
1011
  const id = String(params.id ?? "");
634
1012
  const c = collectionDescOf(core.ext, name);
635
1013
  if (!c || c.kind !== "entity" || c.serverOwned) return refused("gone");
1014
+ if (!isVisible(c, core.clients.get(clientId)?.role ?? "")) return refused("gone");
636
1015
  const plain = plainEntity(core.plain, name);
637
1016
  if (!plain.has(id)) return refused("gone");
1017
+ if (plain.ownerOf(id) !== clientId && !inClientView(core, clientId, c, id))
1018
+ return refused("gone");
638
1019
  const handler = core.definition.config.onOwnershipRequest;
639
1020
  const tracked = trackedEntity(core, name);
640
1021
  if (!handler && c.ownership) {
@@ -891,6 +1272,10 @@ function rejectAllPending(core, reason) {
891
1272
  import { FrameType as FrameType3 } from "@irtio/protocol";
892
1273
 
893
1274
  // src/core/host-calls.ts
1275
+ import { clampErrorMessage as clampErrorMessage2 } from "@irtio/protocol";
1276
+ function refusal(text) {
1277
+ return new Error(clampErrorMessage2(text));
1278
+ }
894
1279
  var states4 = /* @__PURE__ */ new WeakMap();
895
1280
  function stateOf4(core) {
896
1281
  let s = states4.get(core);
@@ -902,6 +1287,7 @@ function stateOf4(core) {
902
1287
  }
903
1288
  function rejectAsEvent(core, err) {
904
1289
  core.scheduleContinuationFlush();
1290
+ err.message = clampErrorMessage2(err.message);
905
1291
  return Promise.reject(err);
906
1292
  }
907
1293
  function resolveAsEvent(core, value) {
@@ -910,7 +1296,7 @@ function resolveAsEvent(core, value) {
910
1296
  }
911
1297
  function startHostCall(core, call, map) {
912
1298
  if (core.stopped) {
913
- return rejectAsEvent(core, new Error(`room.${call.kind}: the room is stopped`));
1299
+ return rejectAsEvent(core, refusal(`room.${call.kind}: the room is stopped`));
914
1300
  }
915
1301
  return new Promise((resolve, reject2) => {
916
1302
  const s = stateOf4(core);
@@ -928,7 +1314,8 @@ function startHostCall(core, call, map) {
928
1314
  } catch (err) {
929
1315
  s.pending.delete(reqId);
930
1316
  core.scheduleContinuationFlush();
931
- reject2(err instanceof Error ? err : new Error(String(err)));
1317
+ if (err instanceof Error) err.message = clampErrorMessage2(err.message);
1318
+ reject2(err instanceof Error ? err : refusal(String(err)));
932
1319
  return;
933
1320
  }
934
1321
  if (core.mode === "event") {
@@ -942,7 +1329,7 @@ function completeHostCall(core, reqId, result) {
942
1329
  if (!pending) return false;
943
1330
  s.pending.delete(reqId);
944
1331
  if (result.ok) pending.resolve(pending.map(result.value));
945
- else pending.reject(new Error(`${result.code}: ${result.message}`));
1332
+ else pending.reject(refusal(`${result.code}: ${result.message}`));
946
1333
  return true;
947
1334
  }
948
1335
  function checkHostCallTimeouts(core) {
@@ -952,14 +1339,14 @@ function checkHostCallTimeouts(core) {
952
1339
  for (const [reqId, p] of [...s.pending]) {
953
1340
  if (p.deadline > now) continue;
954
1341
  s.pending.delete(reqId);
955
- p.reject(new Error(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
1342
+ p.reject(refusal(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
956
1343
  }
957
1344
  }
958
1345
  function rejectAllHostCalls(core, reason) {
959
1346
  const s = stateOf4(core);
960
1347
  for (const [reqId, p] of [...s.pending]) {
961
1348
  s.pending.delete(reqId);
962
- p.reject(new Error(reason));
1349
+ p.reject(refusal(reason));
963
1350
  }
964
1351
  }
965
1352
 
@@ -968,7 +1355,8 @@ import {
968
1355
  SERVER_OWNER as SERVER_OWNER3,
969
1356
  cloneValue,
970
1357
  decodeDelta,
971
- encodeDelta,
1358
+ defaultValue,
1359
+ encodeDelta as encodeDelta2,
972
1360
  markField,
973
1361
  normalizeRecord
974
1362
  } from "@irtio/schema";
@@ -1038,6 +1426,9 @@ function applyChanged(fields, target, prev, next) {
1038
1426
  }
1039
1427
  function correctLeaves(core, clientId, collection, id, leaves) {
1040
1428
  if (leaves.length === 0) return;
1429
+ const entry = core.clients.get(clientId);
1430
+ const desc = collectionDescOf(core.ext, collection);
1431
+ if (desc && !isVisible(desc, entry?.role ?? "")) return;
1041
1432
  const dirty = core.correctionFor(clientId);
1042
1433
  if (!dirty) return;
1043
1434
  for (const leaf of leaves) markField(dirty, collection, id, leaf.path);
@@ -1077,7 +1468,33 @@ function reject(core, clientId, c, op, reason) {
1077
1468
  if (!recordOf(core.plain, c, op.id)) return;
1078
1469
  correctLeaves(core, clientId, c.name, op.id, leafPaths(fieldList(c), op.mask));
1079
1470
  }
1471
+ function ownerMayNotWrite(c, field) {
1472
+ const simulated = c.physics;
1473
+ if (simulated) {
1474
+ if (simulated.intents.includes(field)) return void 0;
1475
+ return simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`;
1476
+ }
1477
+ if (c.serverFields?.has(field)) return `${field} (serverField)`;
1478
+ return void 0;
1479
+ }
1080
1480
  function applyOp(core, clientId, c, op) {
1481
+ const senderRole = core.clients.get(clientId)?.role ?? "";
1482
+ if (!isVisible(c, senderRole)) {
1483
+ core.recordEvent("write-rejected", clientId, `${c.name}: not visible to role`);
1484
+ core.log(
1485
+ "warn",
1486
+ `WRITE rejected (collection not visible to role ${JSON.stringify(senderRole)}) from ${clientId} on ${c.name}`
1487
+ );
1488
+ return;
1489
+ }
1490
+ if (c.kind === "entity" && c.visibility === "spatial-grid") {
1491
+ const coll = plainEntity(core.plain, c.name);
1492
+ if (coll.has(op.id) && coll.ownerOf(op.id) !== clientId && !inClientView(core, clientId, c, op.id)) {
1493
+ core.recordEvent("write-rejected", clientId, `${c.name}: ${op.id} is not in view`);
1494
+ core.log("warn", `WRITE rejected (id not in view) from ${clientId} on ${c.name}`);
1495
+ return;
1496
+ }
1497
+ }
1081
1498
  if (op.op === "add" || op.op === "remove") {
1082
1499
  if (c.kind !== "entity" || c.serverOwned || !c.clientCreate) {
1083
1500
  reject(
@@ -1119,16 +1536,14 @@ function applyOp(core, clientId, c, op) {
1119
1536
  const next = cloneValue(current);
1120
1537
  applyMaskedPatch(fields, next, op.patch, op.mask);
1121
1538
  const requested = cloneValue(next);
1122
- const simulated = c.physics;
1123
- if (simulated) {
1539
+ if (c.physics || c.serverFields) {
1124
1540
  const refused2 = [];
1125
1541
  for (const leaf of leafPaths(fields, op.mask)) {
1126
1542
  const field = leaf.names[0];
1127
- if (simulated.intents.includes(field)) continue;
1543
+ const why = ownerMayNotWrite(c, field);
1544
+ if (why === void 0) continue;
1128
1545
  next[field] = cloneValue(prev[field]);
1129
- refused2.push(
1130
- simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`
1131
- );
1546
+ refused2.push(why);
1132
1547
  }
1133
1548
  if (refused2.length > 0) {
1134
1549
  core.recordEvent("write-rejected", clientId, `${c.name}: ${refused2.join(", ")}`);
@@ -1184,8 +1599,32 @@ function applyAdd(core, clientId, c, op) {
1184
1599
  return;
1185
1600
  }
1186
1601
  if (!admitGrowth(core, clientId, maxRecordBytes(c))) return;
1602
+ let value = op.value;
1603
+ if (c.serverFields) {
1604
+ const stripped = [];
1605
+ const copy = { ...value };
1606
+ for (const f of c.fields) {
1607
+ if (!c.serverFields.has(f.name)) continue;
1608
+ const reset = defaultValue(f.type);
1609
+ if (deepEqual(copy[f.name], reset)) continue;
1610
+ copy[f.name] = reset;
1611
+ stripped.push(f.name);
1612
+ }
1613
+ if (stripped.length > 0) {
1614
+ core.recordEvent(
1615
+ "write-rejected",
1616
+ clientId,
1617
+ `${c.name}: ${stripped.join(", ")} (serverField)`
1618
+ );
1619
+ core.log(
1620
+ "warn",
1621
+ `add to ${c.name}.${op.id} from ${clientId} set server-only fields; reset to their defaults: ${stripped.join(", ")}`
1622
+ );
1623
+ }
1624
+ value = copy;
1625
+ }
1187
1626
  try {
1188
- trackedEntity(core, c.name).add(op.id, op.value, { owner: clientId });
1627
+ trackedEntity(core, c.name).add(op.id, value, { owner: clientId });
1189
1628
  } catch (err) {
1190
1629
  reject(core, clientId, c, op, `add rejected: ${String(err)}`);
1191
1630
  return;
@@ -1245,6 +1684,7 @@ function serverWinsCorrections(core, dirty) {
1245
1684
  if (owner === void 0 || owner === SERVER_OWNER3) continue;
1246
1685
  const entry = core.clients.get(owner);
1247
1686
  if (!entry || !entry.connected) continue;
1687
+ if (!isVisible(c, entry.role)) continue;
1248
1688
  const value = coll.get(id);
1249
1689
  if (value === void 0) continue;
1250
1690
  const accepted = entry.accepted.get(`${name}\0${id}`);
@@ -1268,12 +1708,13 @@ function encodeCorrection(core, dirty) {
1268
1708
  }
1269
1709
  }
1270
1710
  if (empty) return null;
1271
- return encodeDelta(core.ext, core.plain, dirty, { tick: core.tick });
1711
+ return encodeDelta2(core.ext, core.plain, dirty, { tick: core.tick });
1272
1712
  }
1273
1713
 
1274
1714
  // src/core/loop.ts
1275
1715
  var MAX_CATCHUP = 5;
1276
1716
  var CRASH_AFTER_THROWS = 3;
1717
+ var TIMER_SLEEP_GUARD_MS = 250;
1277
1718
  var Loop = class {
1278
1719
  constructor(core) {
1279
1720
  this.core = core;
@@ -1292,6 +1733,14 @@ var Loop = class {
1292
1733
  consecutiveThrows = 0;
1293
1734
  lastActivity = 0;
1294
1735
  slept = false;
1736
+ /**
1737
+ * Whether this idle stretch has already spent its one deferral for a nearly-due room timer.
1738
+ *
1739
+ * Once, deliberately. A `setInterval(100)` in an otherwise silent room must not be able to pin
1740
+ * the room resident forever — pending timers are explicitly *not* a reason to stay awake, the
1741
+ * supervisor wakes the room at the due time instead. Reset by real activity.
1742
+ */
1743
+ timerDeferred = false;
1295
1744
  get intervalMs() {
1296
1745
  return 1e3 / this.core.definition.config.tickRate;
1297
1746
  }
@@ -1336,11 +1785,40 @@ var Loop = class {
1336
1785
  this.applyFrame(f);
1337
1786
  }
1338
1787
  }
1339
- /** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
1788
+ /**
1789
+ * Applies one queued/immediate frame. Returns `false` on a malformed payload.
1790
+ *
1791
+ * The `try` is containment, not error handling, and it is deliberately *here* rather than around
1792
+ * the whole tick. Room code never throws through this line — `applyWrite` runs its validators
1793
+ * under `core.tryRun`, and `handleCall` wraps the RPC handler in a `try` of its own that does
1794
+ * the same bookkeeping by hand (`DenyError` answered as a refusal, anything else counted on
1795
+ * `core.stats.handlerErrors` and answered `E_INTERNAL`) — so a throw that reaches this frame is by
1796
+ * definition ours: a decode or an apply step that met a value it did not expect. Before this,
1797
+ * such a throw went out through `runTick`/`applyEvent` uncaught and took the worker down, which
1798
+ * meant one crafted frame could end a room (bugs.md: the non-finite `f32`, fixed at the decode
1799
+ * in `@irtio/schema`, reached `normalizeValue` from outside `applyWrite`'s own try).
1800
+ *
1801
+ * Containing it at the frame keeps the blast radius the size of the mistake: the frame is
1802
+ * *this* client's, so it is refused as malformed and the caller kicks that connection, and the
1803
+ * room — every other player in it — keeps ticking. Wrapping the tick instead would have swallowed
1804
+ * the frame's identity along with the throw and left the room running against half-applied
1805
+ * state with nobody to attribute it to.
1806
+ *
1807
+ * Nothing about how a *handler* throw is treated changes: those never arrive here.
1808
+ */
1340
1809
  applyFrame(f) {
1341
- if (f.type === FrameType3.WRITE) return applyWrite(this.core, f.clientId, f.payload).ok;
1342
- if (f.type === FrameType3.CALL) return handleCall(this.core, f.clientId, f.payload);
1343
- return true;
1810
+ try {
1811
+ if (f.type === FrameType3.WRITE) return applyWrite(this.core, f.clientId, f.payload).ok;
1812
+ if (f.type === FrameType3.CALL) return handleCall(this.core, f.clientId, f.payload);
1813
+ return true;
1814
+ } catch (err) {
1815
+ this.core.log(
1816
+ "error",
1817
+ `frame type ${f.type} from ${f.clientId} threw while being applied; refusing it:`,
1818
+ err
1819
+ );
1820
+ return false;
1821
+ }
1344
1822
  }
1345
1823
  // -------------------------------------------------------------------------
1346
1824
  // Tick mode
@@ -1401,7 +1879,12 @@ var Loop = class {
1401
1879
  if (physics) {
1402
1880
  const ran = this.core.tryRun("physics", () => {
1403
1881
  physics.reconcile();
1882
+ const stepStarted = performance.now();
1404
1883
  physics.step();
1884
+ const stepMs = performance.now() - stepStarted;
1885
+ const s = this.core.stats;
1886
+ s.physicsStepMs = stepMs;
1887
+ s.physicsStepMsAvg = s.ticks === 0 ? stepMs : s.physicsStepMsAvg + PHYSICS_STEP_EMA_ALPHA * (stepMs - s.physicsStepMsAvg);
1405
1888
  physics.sync();
1406
1889
  this.core.captureHistory();
1407
1890
  });
@@ -1450,6 +1933,7 @@ var Loop = class {
1450
1933
  noteActivity() {
1451
1934
  this.lastActivity = this.core.host.now();
1452
1935
  this.slept = false;
1936
+ this.timerDeferred = false;
1453
1937
  if (this.running && this.idleHandle === void 0) {
1454
1938
  this.armIdle(this.core.definition.config.idleMs);
1455
1939
  }
@@ -1469,6 +1953,12 @@ var Loop = class {
1469
1953
  }
1470
1954
  const waited = this.core.host.now() - this.lastActivity;
1471
1955
  if (waited >= idleMs) {
1956
+ const earliest = this.earliestTimerAt();
1957
+ if (!this.timerDeferred && earliest !== void 0 && earliest - this.core.host.now() <= TIMER_SLEEP_GUARD_MS) {
1958
+ this.timerDeferred = true;
1959
+ this.armIdle(TIMER_SLEEP_GUARD_MS);
1960
+ return;
1961
+ }
1472
1962
  if (!this.slept) {
1473
1963
  this.slept = true;
1474
1964
  this.core.host.sleep();
@@ -1490,6 +1980,7 @@ var Loop = class {
1490
1980
  periodTicks,
1491
1981
  periodMs: Math.max(0, ms),
1492
1982
  dueTick: this.core.tick + periodTicks,
1983
+ dueAtMs: this.core.host.now() + Math.max(0, ms),
1493
1984
  handle: void 0
1494
1985
  };
1495
1986
  this.timers.set(id, timer);
@@ -1501,8 +1992,10 @@ var Loop = class {
1501
1992
  timer.handle = void 0;
1502
1993
  if (this.core.stopped) return;
1503
1994
  if (!this.timers.has(timer.id)) return;
1504
- if (timer.repeat) this.armHostTimer(timer);
1505
- else this.timers.delete(timer.id);
1995
+ if (timer.repeat) {
1996
+ timer.dueAtMs = this.core.host.now() + timer.periodMs;
1997
+ this.armHostTimer(timer);
1998
+ } else this.timers.delete(timer.id);
1506
1999
  this.core.tick++;
1507
2000
  this.core.guard("room timer", timer.fn);
1508
2001
  this.core.flush();
@@ -1514,6 +2007,29 @@ var Loop = class {
1514
2007
  this.timers.delete(handle);
1515
2008
  if (t.handle !== void 0) this.core.host.clearTimeout(t.handle);
1516
2009
  }
2010
+ /**
2011
+ * The earliest pending room timer's due time on `host.now()`'s clock, or `undefined` when the
2012
+ * room has none armed.
2013
+ *
2014
+ * Read at `serialize()`, before `clearAllTimers()` drops the callbacks. The callbacks are
2015
+ * closures and cannot be persisted — but the *time* can, and handing it to the supervisor is
2016
+ * what turns "your timer is gone" into "your room is awake again by then", which is a contract
2017
+ * room code can actually catch up against in `onWake`.
2018
+ *
2019
+ * Event mode reads the absolute due time stamped on the record at arm time; tick mode derives
2020
+ * it from `dueTick`, because there a timer fires on the first tick at or after its deadline and
2021
+ * the tick counter is the clock that decides it.
2022
+ */
2023
+ earliestTimerAt() {
2024
+ if (this.timers.size === 0) return void 0;
2025
+ const now = this.core.host.now();
2026
+ let best;
2027
+ for (const t of this.timers.values()) {
2028
+ const at = this.core.mode === "event" ? t.dueAtMs : now + Math.max(0, t.dueTick - this.core.tick) * this.intervalMs;
2029
+ if (best === void 0 || at < best) best = at;
2030
+ }
2031
+ return best;
2032
+ }
1517
2033
  clearAllTimers() {
1518
2034
  for (const t of this.timers.values()) {
1519
2035
  if (t.handle !== void 0) this.core.host.clearTimeout(t.handle);
@@ -1536,139 +2052,373 @@ function hasHumanClients(core) {
1536
2052
  return false;
1537
2053
  }
1538
2054
 
1539
- // src/core/matter.ts
1540
- import {
1541
- ByteReader,
1542
- ByteWriter,
1543
- applyChannel2d,
1544
- channelOf2d
1545
- } from "@irtio/schema";
2055
+ // src/core/physics.ts
2056
+ import { ByteReader, ByteWriter } from "@irtio/schema";
1546
2057
  var engine;
1547
2058
  var loading;
1548
- async function initMatter() {
2059
+ async function initPhysics() {
1549
2060
  if (engine) return engine;
1550
2061
  loading ??= (async () => {
1551
- const mod = await import("matter-js");
2062
+ const mod = await import("@dimforge/rapier3d-compat");
1552
2063
  const ns = mod.default ?? mod;
2064
+ await ns.init();
1553
2065
  engine = ns;
1554
2066
  return ns;
1555
2067
  })();
1556
2068
  return loading;
1557
2069
  }
1558
- function loadedMatter() {
2070
+ function loadedPhysics() {
1559
2071
  return engine;
1560
2072
  }
1561
- function resetMatterForTests() {
2073
+ function resetPhysicsForTests() {
1562
2074
  engine = void 0;
1563
2075
  loading = void 0;
2076
+ rapierStepped = false;
2077
+ firstStepWaiters.length = 0;
1564
2078
  }
1565
- function encodeMatterBodies(section) {
1566
- const w = new ByteWriter(section.bodies.length * 72 + 8);
2079
+ var rapierStepped = false;
2080
+ var firstStepWaiters = [];
2081
+ function rapierHasStepped() {
2082
+ return rapierStepped;
2083
+ }
2084
+ function onFirstRapierStep(cb) {
2085
+ if (rapierStepped) {
2086
+ cb();
2087
+ return;
2088
+ }
2089
+ firstStepWaiters.push(cb);
2090
+ }
2091
+ function noteRapierStep() {
2092
+ rapierStepped = true;
2093
+ const waiters = firstStepWaiters.splice(0, firstStepWaiters.length);
2094
+ for (const cb of waiters) {
2095
+ try {
2096
+ cb();
2097
+ } catch {
2098
+ }
2099
+ }
2100
+ }
2101
+ function encodePhysicsSection(section) {
2102
+ const skipped = section.skipped ?? [];
2103
+ const unsynced = section.unsynced ?? [];
2104
+ const w = new ByteWriter(
2105
+ section.world.length + (section.bodies.length + skipped.length + unsynced.length) * 24 + 16
2106
+ );
2107
+ w.blob(section.world);
1567
2108
  w.varint(section.bodies.length);
1568
- for (const b of section.bodies) {
1569
- w.str(b.collection);
1570
- w.str(b.id);
1571
- w.f64(b.x);
1572
- w.f64(b.y);
1573
- w.f64(b.angle);
1574
- w.f64(b.vx);
1575
- w.f64(b.vy);
1576
- w.f64(b.angularVelocity);
1577
- w.u8(b.sleeping ? 1 : 0);
2109
+ for (const [name, id, handle] of section.bodies) {
2110
+ w.str(name);
2111
+ w.str(id);
2112
+ w.f64(handle);
2113
+ }
2114
+ if (skipped.length > 0 || unsynced.length > 0) {
2115
+ w.varint(skipped.length);
2116
+ for (const key of skipped) w.str(key);
2117
+ w.varint(unsynced.length);
2118
+ for (const [key, handle] of unsynced) {
2119
+ w.str(key);
2120
+ w.f64(handle);
2121
+ }
1578
2122
  }
1579
2123
  return w.finish();
1580
2124
  }
1581
- function decodeMatterBodies(bytes) {
2125
+ var MATTER_TAG = 1;
2126
+ var RAPIER2D_TAG = 2;
2127
+ function physicsSectionEngine(bytes) {
2128
+ const r = new ByteReader(bytes);
2129
+ if (r.varint() !== 0) return "rapier3d";
2130
+ const tag = r.u8();
2131
+ if (tag === MATTER_TAG) return "matter2d";
2132
+ if (tag === RAPIER2D_TAG) return "rapier2d";
2133
+ return "rapier3d";
2134
+ }
2135
+ function encodeRapier2dSectionEnvelope(payload) {
2136
+ const w = new ByteWriter(payload.length + 8);
2137
+ w.varint(0);
2138
+ w.u8(RAPIER2D_TAG);
2139
+ w.bytes(payload);
2140
+ return w.finish();
2141
+ }
2142
+ function decodeRapier2dSectionEnvelope(bytes) {
2143
+ const r = new ByteReader(bytes);
2144
+ if (r.varint() !== 0 || r.u8() !== RAPIER2D_TAG) {
2145
+ throw new Error("irtio: this physics section was not written by rapier2d");
2146
+ }
2147
+ return r.rest();
2148
+ }
2149
+ function encodeMatterSectionEnvelope(payload) {
2150
+ const w = new ByteWriter(payload.length + 8);
2151
+ w.varint(0);
2152
+ w.u8(MATTER_TAG);
2153
+ w.bytes(payload);
2154
+ return w.finish();
2155
+ }
2156
+ function decodeMatterSectionEnvelope(bytes) {
2157
+ const r = new ByteReader(bytes);
2158
+ if (r.varint() !== 0 || r.u8() !== MATTER_TAG) {
2159
+ throw new Error("irtio: this physics section was not written by matter2d");
2160
+ }
2161
+ return r.rest();
2162
+ }
2163
+ function decodePhysicsSection(bytes) {
1582
2164
  const r = new ByteReader(bytes);
2165
+ const world = r.blob().slice();
1583
2166
  const count = r.varint();
1584
2167
  const bodies = [];
1585
- for (let i = 0; i < count; i++) {
1586
- bodies.push({
1587
- collection: r.str(),
1588
- id: r.str(),
1589
- x: r.f64(),
1590
- y: r.f64(),
1591
- angle: r.f64(),
1592
- vx: r.f64(),
1593
- vy: r.f64(),
1594
- angularVelocity: r.f64(),
1595
- sleeping: r.u8() === 1
1596
- });
2168
+ for (let i = 0; i < count; i++) bodies.push([r.str(), r.str(), r.f64()]);
2169
+ if (r.eof) return { world, bodies };
2170
+ const skippedCount = r.varint();
2171
+ const skipped = [];
2172
+ for (let i = 0; i < skippedCount; i++) skipped.push(r.str());
2173
+ const unsynced = [];
2174
+ if (!r.eof) {
2175
+ const unsyncedCount = r.varint();
2176
+ for (let i = 0; i < unsyncedCount; i++) unsynced.push([r.str(), r.f64()]);
1597
2177
  }
1598
- return { bodies };
2178
+ return {
2179
+ world,
2180
+ bodies,
2181
+ ...skipped.length > 0 ? { skipped } : {},
2182
+ ...unsynced.length > 0 ? { unsynced } : {}
2183
+ };
1599
2184
  }
1600
2185
  function bodyKey(collection, id) {
1601
2186
  return `${collection}\0${id}`;
1602
2187
  }
1603
- var PLANAR_CHANNELS = /* @__PURE__ */ new Set(["x", "y", "qz", "qw", "vx", "vy", "wz"]);
1604
- var MatterRuntime = class {
1605
- engineKind = "matter2d";
1606
- /** rapier3d only; present so both runtimes satisfy one internal shape. */
1607
- rapier = void 0;
1608
- world = void 0;
1609
- matter;
1610
- engine;
1611
- /** Alias under the name `PhysicsApi` uses, so `room.physics2d` reads through one field. */
1612
- get matterEngine() {
1613
- return this.engine;
2188
+ function planarLockWarning(spec) {
2189
+ const boxed = (spec.colliders ?? []).some(
2190
+ (c) => c.shape.type === CUBOID_SHAPE || c.shape.type === ROUND_CUBOID_SHAPE
2191
+ );
2192
+ if (!boxed) return void 0;
2193
+ const b = spec.body;
2194
+ const t = [b.translationsEnabledX, b.translationsEnabledY, b.translationsEnabledZ];
2195
+ const r = [b.rotationsEnabledX, b.rotationsEnabledY, b.rotationsEnabledZ];
2196
+ const axes = ["x", "y", "z"];
2197
+ for (let i = 0; i < 3; i++) {
2198
+ if (t[i]) continue;
2199
+ if (r[(i + 1) % 3] || r[(i + 2) % 3]) continue;
2200
+ const free = axes[i] === "z" ? "enabledRotations(true, false, true)" : "one of the other two rotations";
2201
+ return `locks translation on ${axes[i]} and both rotations across that plane on a box-shaped body. That removes friction entirely: the box slides at a constant speed until it hits something. Free one out-of-plane rotation \u2014 in the xy plane that is ${free}, and it costs nothing, because geometry symmetric about the plane generates no torque about it.`;
1614
2202
  }
1615
- /** `true` when the blob carried no world at all: the caller logs it. */
2203
+ return void 0;
2204
+ }
2205
+ var CUBOID_SHAPE = 1;
2206
+ var ROUND_CUBOID_SHAPE = 12;
2207
+ function makeUnsyncedRegistry(ops) {
2208
+ const { map, build, destroy, onCreated } = ops;
2209
+ return {
2210
+ create(key, spec) {
2211
+ if (typeof key !== "string" || key.length === 0) {
2212
+ throw new Error("physics.unsynced.create: key must be a non-empty string");
2213
+ }
2214
+ const existing = map.get(key);
2215
+ if (existing !== void 0) return existing;
2216
+ const body = build(spec);
2217
+ map.set(key, body);
2218
+ onCreated?.(key, body);
2219
+ return body;
2220
+ },
2221
+ get(key) {
2222
+ return map.get(key);
2223
+ },
2224
+ has(key) {
2225
+ return map.has(key);
2226
+ },
2227
+ remove(key) {
2228
+ const body = map.get(key);
2229
+ if (body === void 0) return false;
2230
+ map.delete(key);
2231
+ destroy(body);
2232
+ return true;
2233
+ },
2234
+ get size() {
2235
+ return map.size;
2236
+ },
2237
+ keys() {
2238
+ return map.keys();
2239
+ },
2240
+ *[Symbol.iterator]() {
2241
+ for (const entry of map) yield entry;
2242
+ }
2243
+ };
2244
+ }
2245
+ var PhysicsRuntime = class {
2246
+ engineKind = "rapier3d";
2247
+ /** matter2d only; present so both runtimes satisfy one internal shape. */
2248
+ matter = void 0;
2249
+ matterEngine = void 0;
2250
+ rapier;
2251
+ world;
2252
+ /** `true` when the world was built from scratch and `setup` has to run. */
1616
2253
  rebuilt;
1617
- /**
1618
- * Always `true`. Unlike Rapier's, a matter2d world is never restored as a world — only as
1619
- * per-body state on a world the builder made — so `setup` has to run every time.
1620
- */
1621
- needsSetup = true;
2254
+ /** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
2255
+ get needsSetup() {
2256
+ return this.rebuilt;
2257
+ }
1622
2258
  core;
1623
2259
  config;
2260
+ /** Physics-backed collections, in schema (name-sorted) order. */
1624
2261
  collections;
1625
2262
  bodies = /* @__PURE__ */ new Map();
1626
- restore;
1627
- stepMs;
2263
+ /** Collections already warned about the friction-killing 2D lock recipe (bug 6). */
2264
+ warnedPlanar = /* @__PURE__ */ new Set();
2265
+ /**
2266
+ * Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
2267
+ * to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
2268
+ * tick it falls asleep, or the schema keeps a phantom residual velocity forever (and a
2269
+ * world rebuilt from schema state would wake it with a kick). Found by the drift check.
2270
+ */
1628
2271
  sleepSynced = /* @__PURE__ */ new Set();
1629
2272
  /**
1630
- * The plain record object each body was built from. See `PhysicsRuntime.sourceRecords`: `add()`
1631
- * installs a new record object, so an id removed and re-added between two reconciles (a pooled
1632
- * projectile) is identifiable by identity, and is the one case where a live body has to be
1633
- * rebuilt so the new row's spawn pose and velocity are honoured.
2273
+ * The plain record object each body was built from, by body key. `add()` always installs a
2274
+ * **new** record object (`normalizeRecord` builds one), so an id that is removed and re-added
2275
+ * between two reconciles — the pooled-projectile shape — is identifiable by identity alone,
2276
+ * which is the only way to see it from here: both edits happened before this module looked.
2277
+ * Without it the old body kept its pose and velocity and the new row's spawn values were lost.
2278
+ *
2279
+ * Unknown means adopt, never rebuild: a world restored from a hibernation blob has bodies with
2280
+ * no recorded source, and rebuilding those would throw away exactly what the blob preserved.
1634
2281
  */
1635
2282
  sourceRecords = /* @__PURE__ */ new Map();
1636
- /** `collection.field` pairs already warned about a channel a plane cannot hold. */
2283
+ /** `collection.field` pairs already warned about a channel value the engine would not take. */
1637
2284
  warnedChannels = /* @__PURE__ */ new Set();
1638
- constructor(core, matter, options) {
2285
+ // ---- M7 planned feature 3 (b): per-entity physics skip ----
2286
+ /**
2287
+ * Entities whose body the room has switched off. See {@link setSkipped} for what "off" means and
2288
+ * why it is spelled as "no body" rather than as "a body that is not stepped".
2289
+ */
2290
+ skippedKeys = /* @__PURE__ */ new Set();
2291
+ // ---- end M7 planned feature 3 (b) ----
2292
+ // ---- M7 planned feature 3 (a): physics-only unsynced bodies ----
2293
+ /**
2294
+ * Bodies with no schema row: rope anchors, dead-reckoned proxies, one-off sensors. Keyed by the
2295
+ * room's own string rather than by `collection\0id`, because there is no collection and no id.
2296
+ */
2297
+ unsyncedBodies = /* @__PURE__ */ new Map();
2298
+ unsynced;
2299
+ // ---- end M7 planned feature 3 (a) ----
2300
+ constructor(core, rapier, options) {
1639
2301
  const config = core.definition.config.physics;
1640
- if (!config) throw new Error("MatterRuntime: the room config declares no physics");
2302
+ if (!config) throw new Error("PhysicsRuntime: the room config declares no physics");
1641
2303
  this.core = core;
1642
2304
  this.config = config;
1643
- this.matter = matter;
2305
+ this.rapier = rapier;
1644
2306
  this.collections = core.ext.collections.filter(
1645
2307
  (c) => c.physics !== void 0
1646
2308
  );
1647
- this.engine = matter.Engine.create();
1648
- this.engine.gravity.x = config.gravity.x;
1649
- this.engine.gravity.y = config.gravity.y;
1650
- this.stepMs = (config.timestep ?? options.defaultTimestep) * 1e3;
1651
- this.rebuilt = options.restore === void 0;
1652
- this.restore = options.restore ? new Map(options.restore.bodies.map((b) => [bodyKey(b.collection, b.id), b])) : void 0;
2309
+ if (options.restore) {
2310
+ this.world = rapier.World.restoreSnapshot(options.restore.world);
2311
+ this.rebuilt = false;
2312
+ for (const [name, id, handle] of options.restore.bodies) {
2313
+ const body = this.world.getRigidBody(handle);
2314
+ if (body) this.bodies.set(bodyKey(name, id), body);
2315
+ }
2316
+ for (const key of options.restore.skipped ?? []) this.skippedKeys.add(key);
2317
+ for (const [key, handle] of options.restore.unsynced ?? []) {
2318
+ const body = this.world.getRigidBody(handle);
2319
+ if (body) this.unsyncedBodies.set(key, body);
2320
+ }
2321
+ } else {
2322
+ const g = config.gravity;
2323
+ this.world = new rapier.World({ x: g.x, y: g.y, z: g.z });
2324
+ this.rebuilt = true;
2325
+ }
2326
+ this.world.timestep = config.timestep ?? options.defaultTimestep;
2327
+ this.unsynced = makeUnsyncedRegistry({
2328
+ map: this.unsyncedBodies,
2329
+ build: (spec) => {
2330
+ const body = this.world.createRigidBody(spec.body);
2331
+ for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
2332
+ return body;
2333
+ },
2334
+ destroy: (body) => this.destroyBody(body)
2335
+ });
1653
2336
  }
1654
2337
  get timestep() {
1655
- return this.stepMs / 1e3;
2338
+ return this.world.timestep;
1656
2339
  }
2340
+ /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
1657
2341
  runSetup(room) {
1658
2342
  const setup = this.config.setup;
1659
2343
  if (!setup) return;
1660
- this.core.guard("physics.setup", () => setup(this.engine, this.matter, room));
2344
+ this.core.guard("physics.setup", () => setup(this.world, this.rapier, room));
1661
2345
  }
1662
2346
  free() {
1663
2347
  this.bodies.clear();
1664
- this.matter.Engine.clear(this.engine);
2348
+ this.unsyncedBodies.clear();
2349
+ this.world.free();
1665
2350
  }
1666
2351
  // -------------------------------------------------------------------------
1667
2352
  // Bodies
1668
2353
  // -------------------------------------------------------------------------
2354
+ /**
2355
+ * bugs.md #99: every teardown path goes through here rather than calling `removeRigidBody`
2356
+ * directly.
2357
+ *
2358
+ * A sleeping body is parked in an island wired through the contact graph. Pulling a body out
2359
+ * from under one leaves the sleeper resting on nothing — and, in the failure this guards, leaves
2360
+ * the island holding a body that no longer exists. Waking the contacting bodies first makes the
2361
+ * removal a plain awake-island edit, and it is also what a room wants physically: the crate the
2362
+ * floor vanished under should fall.
2363
+ *
2364
+ * The cost is one narrow-phase lookup per collider of the *victim* — `contactPairsWith` walks
2365
+ * only that collider's own contact pairs — against the alternative of waking the world, which
2366
+ * would un-sleep every dormant body in the room on every teardown. Sensors are skipped
2367
+ * deliberately: an intersection pair is not an island edge, so it cannot be the thing at risk.
2368
+ */
2369
+ destroyBody(body) {
2370
+ const colliders = body.numColliders();
2371
+ for (let i = 0; i < colliders; i++) {
2372
+ this.world.contactPairsWith(body.collider(i), (other) => {
2373
+ other.parent()?.wakeUp();
2374
+ });
2375
+ }
2376
+ this.world.removeRigidBody(body);
2377
+ }
2378
+ // ---- M7 planned feature 3 (b): per-entity physics skip ----
2379
+ /**
2380
+ * Switches one entity's body off, or back on.
2381
+ *
2382
+ * Undercity feedback 29: hundreds of dormant-by-design entities want "exists and syncs, but do
2383
+ * not step or mirror until I say so". Rapier's own sleeping approximates it and still costs a
2384
+ * broadphase entry and a sync slot per row per tick, and a sleeping body wakes on contact,
2385
+ * which a dormant spider must not.
2386
+ *
2387
+ * **Skipped means no body.** The body is destroyed on the way in and rebuilt from the schema
2388
+ * row on the way out, on the next `reconcile`. The alternative — keep the body, exclude it from
2389
+ * the step — is not something an engine offers per body without also excluding it from
2390
+ * collision, and it would leave the cost this exists to remove (a body in the world, in the
2391
+ * broadphase, walked by `sync`) exactly where it was. The row is untouched and goes on syncing
2392
+ * as ordinary state, which is what the ask asked for; a room that moves a skipped entity by
2393
+ * assigning its fields is moving it, and unskipping resumes from wherever the row now says.
2394
+ *
2395
+ * What is lost across a skip is what a body has and a row does not: contacts, and velocity
2396
+ * beyond the f32 the row carries. A skipped entity resumes at rest at its row's pose unless the
2397
+ * row's velocity channels say otherwise.
2398
+ */
2399
+ setSkipped(collection, id, on) {
2400
+ const key = bodyKey(collection, id);
2401
+ if (!on) {
2402
+ this.skippedKeys.delete(key);
2403
+ return;
2404
+ }
2405
+ this.skippedKeys.add(key);
2406
+ const existing = this.bodies.get(key);
2407
+ if (!existing) return;
2408
+ this.bodies.delete(key);
2409
+ this.sleepSynced.delete(key);
2410
+ this.sourceRecords.delete(key);
2411
+ this.destroyBody(existing);
2412
+ }
2413
+ isSkipped(collection, id) {
2414
+ return this.skippedKeys.has(bodyKey(collection, id));
2415
+ }
2416
+ // ---- end M7 planned feature 3 (b) ----
2417
+ /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
1669
2418
  bodyFor(collection, id) {
1670
2419
  const existing = this.bodies.get(bodyKey(collection, id));
1671
- if (existing) return existing.body;
2420
+ if (existing) return existing;
2421
+ if (this.skippedKeys.has(bodyKey(collection, id))) return void 0;
1672
2422
  const desc = this.collections.find((c) => c.name === collection);
1673
2423
  if (!desc) return void 0;
1674
2424
  const coll = plainEntity(this.core.plain, collection);
@@ -1684,7 +2434,7 @@ var MatterRuntime = class {
1684
2434
  }
1685
2435
  const spec = this.core.guard(
1686
2436
  `physics.bodies.${desc.name}`,
1687
- () => factory(this.matter, record, id)
2437
+ () => factory(this.rapier, record, id)
1688
2438
  );
1689
2439
  if (!spec || !spec.body) {
1690
2440
  this.core.log(
@@ -1693,83 +2443,145 @@ var MatterRuntime = class {
1693
2443
  );
1694
2444
  return void 0;
1695
2445
  }
1696
- const key = bodyKey(desc.name, id);
1697
- const constraints = spec.constraints ?? [];
1698
- this.matter.Composite.add(this.engine.world, [spec.body, ...constraints]);
1699
- this.bodies.set(key, { body: spec.body, constraints });
1700
- const saved = this.restore?.get(key);
1701
- if (saved) {
1702
- this.restore?.delete(key);
1703
- this.applyState(spec.body, saved);
1704
- } else {
1705
- this.applyRecordToBody(desc, spec.body, record);
2446
+ const planar = planarLockWarning(spec);
2447
+ if (planar !== void 0 && !this.warnedPlanar.has(desc.name)) {
2448
+ this.warnedPlanar.add(desc.name);
2449
+ this.core.log("warn", `irtio: physics.bodies.${desc.name} ${planar}`);
2450
+ }
2451
+ const body = this.world.createRigidBody(spec.body);
2452
+ for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
2453
+ this.warnLockedChannels(desc, spec, record);
2454
+ this.applyRecordToBody(desc, body, record);
2455
+ this.bodies.set(bodyKey(desc.name, id), body);
2456
+ this.sourceRecords.set(bodyKey(desc.name, id), record);
2457
+ return body;
2458
+ }
2459
+ /**
2460
+ * A spawn value the engine will not take, said once per field.
2461
+ *
2462
+ * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
2463
+ * accepts one on a fixed body and then ignores it — both read back as "the field was applied"
2464
+ * and behave as if it never was. The desc says which, before the body exists, so the check is
2465
+ * on the desc: the usual case is the 2D-in-3D recipe (`enabledTranslations(true, true, false)`)
2466
+ * meeting a row that spawns with a `vz`. Named by field, because the field is what the author
2467
+ * wrote and what they will grep for.
2468
+ */
2469
+ warnLockedChannels(desc, spec, record) {
2470
+ const physics = desc.physics;
2471
+ if (!physics) return;
2472
+ const b = spec.body;
2473
+ const frozen = b.status !== 0;
2474
+ const enabled = {
2475
+ vx: b.translationsEnabledX,
2476
+ vy: b.translationsEnabledY,
2477
+ vz: b.translationsEnabledZ,
2478
+ wx: b.rotationsEnabledX,
2479
+ wy: b.rotationsEnabledY,
2480
+ wz: b.rotationsEnabledZ
2481
+ };
2482
+ for (const [channel, field] of physics.channels) {
2483
+ const allowed = enabled[channel];
2484
+ if (allowed === void 0) continue;
2485
+ const raw = record[field];
2486
+ if (typeof raw !== "number" || raw === 0) continue;
2487
+ if (allowed && !frozen) continue;
2488
+ const key = `${desc.name}.${field}`;
2489
+ if (this.warnedChannels.has(key)) continue;
2490
+ this.warnedChannels.add(key);
2491
+ this.core.log(
2492
+ "warn",
2493
+ `irtio: ${desc.name}.${field} (body channel ${channel}) was added as ${raw}, but ` + (frozen ? `physics.bodies.${desc.name} builds a non-dynamic body, which never moves under it` : `that degree of freedom is disabled by physics.bodies.${desc.name}`) + " \u2014 the value was dropped"
2494
+ );
1706
2495
  }
1707
- this.sourceRecords.set(key, record);
1708
- return spec.body;
1709
2496
  }
1710
2497
  // ---- M6 lane F: rewind ----
1711
2498
  /**
1712
- * D72: every tracked body, in the order `sync()` walks them. The matter half of the same
1713
- * read-only accessor `PhysicsRuntime` carries; see its comment for what calls it and when.
2499
+ * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
2500
+ * order). Read-only: it hands out the live bodies and nothing else, and the map stays private.
2501
+ * The pose history calls this once per tick and the rewind scratch calls it per rewind; a room
2502
+ * that declares no `physics.history` never calls it at all.
1714
2503
  */
1715
2504
  eachTrackedBody(fn) {
1716
2505
  for (const desc of this.collections) {
1717
2506
  const plainColl = plainEntity(this.core.plain, desc.name);
1718
2507
  for (const id of plainColl.ids()) {
1719
- const attached = this.bodies.get(bodyKey(desc.name, id));
1720
- if (attached)
1721
- fn(desc.name, id, attached.body, plainColl.get(id));
2508
+ const body = this.bodies.get(bodyKey(desc.name, id));
2509
+ if (body) fn(desc.name, id, body, plainColl.get(id));
1722
2510
  }
1723
2511
  }
1724
2512
  }
1725
- applyState(body, s) {
1726
- const M = this.matter;
1727
- M.Body.setPosition(body, { x: s.x, y: s.y });
1728
- M.Body.setAngle(body, s.angle);
1729
- M.Body.setVelocity(body, { x: s.vx, y: s.vy });
1730
- M.Body.setAngularVelocity(body, s.angularVelocity);
1731
- if (s.sleeping) M.Sleeping.set(body, true);
1732
- }
1733
2513
  applyRecordToBody(desc, body, record) {
1734
2514
  const physics = desc.physics;
1735
2515
  if (!physics) return;
1736
- const t = {
1737
- x: body.position.x,
1738
- y: body.position.y,
1739
- qz: Math.sin(body.angle / 2),
1740
- qw: Math.cos(body.angle / 2),
1741
- vx: body.velocity.x,
1742
- vy: body.velocity.y,
1743
- wz: body.angularVelocity
1744
- };
2516
+ const t = { ...body.translation() };
2517
+ const r = { ...body.rotation() };
2518
+ const v = { ...body.linvel() };
2519
+ const w = { ...body.angvel() };
2520
+ let setT = false;
2521
+ let setR = false;
2522
+ let setV = false;
2523
+ let setW = false;
1745
2524
  for (const [channel, field] of physics.channels) {
1746
2525
  const raw = record[field];
1747
2526
  if (typeof raw !== "number") continue;
1748
- if (raw !== 0 && !PLANAR_CHANNELS.has(channel)) {
1749
- const key = `${desc.name}.${field}`;
1750
- if (!this.warnedChannels.has(key)) {
1751
- this.warnedChannels.add(key);
1752
- this.core.log(
1753
- "warn",
1754
- `irtio: ${desc.name}.${field} maps body channel ${channel}, which a matter2d room cannot hold (the world is a plane); the ${raw} it was added with was dropped`
1755
- );
1756
- }
1757
- continue;
2527
+ switch (channel) {
2528
+ case "x":
2529
+ case "y":
2530
+ case "z":
2531
+ t[channel] = raw;
2532
+ setT = true;
2533
+ break;
2534
+ case "qx":
2535
+ r.x = raw;
2536
+ setR = true;
2537
+ break;
2538
+ case "qy":
2539
+ r.y = raw;
2540
+ setR = true;
2541
+ break;
2542
+ case "qz":
2543
+ r.z = raw;
2544
+ setR = true;
2545
+ break;
2546
+ case "qw":
2547
+ r.w = raw;
2548
+ setR = true;
2549
+ break;
2550
+ case "vx":
2551
+ v.x = raw;
2552
+ setV = true;
2553
+ break;
2554
+ case "vy":
2555
+ v.y = raw;
2556
+ setV = true;
2557
+ break;
2558
+ case "vz":
2559
+ v.z = raw;
2560
+ setV = true;
2561
+ break;
2562
+ case "wx":
2563
+ w.x = raw;
2564
+ setW = true;
2565
+ break;
2566
+ case "wy":
2567
+ w.y = raw;
2568
+ setW = true;
2569
+ break;
2570
+ case "wz":
2571
+ w.z = raw;
2572
+ setW = true;
2573
+ break;
1758
2574
  }
1759
- applyChannel2d(channel, raw, t);
1760
2575
  }
1761
- this.applyState(body, {
1762
- collection: desc.name,
1763
- id: "",
1764
- x: t.x,
1765
- y: t.y,
1766
- angle: 2 * Math.atan2(t.qz, t.qw),
1767
- vx: t.vx,
1768
- vy: t.vy,
1769
- angularVelocity: t.wz,
1770
- sleeping: false
1771
- });
2576
+ if (setT) body.setTranslation(t, true);
2577
+ if (setR) body.setRotation(r, true);
2578
+ if (setV) body.setLinvel(v, true);
2579
+ if (setW) body.setAngvel(w, true);
1772
2580
  }
2581
+ /**
2582
+ * Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
2583
+ * tick, right before the step, in collection order then instance order.
2584
+ */
1773
2585
  reconcile() {
1774
2586
  const live = /* @__PURE__ */ new Set();
1775
2587
  for (const desc of this.collections) {
@@ -1777,6 +2589,7 @@ var MatterRuntime = class {
1777
2589
  for (const id of coll.ids()) {
1778
2590
  const key = bodyKey(desc.name, id);
1779
2591
  live.add(key);
2592
+ if (this.skippedKeys.has(key)) continue;
1780
2593
  const record = coll.get(id);
1781
2594
  const existing = this.bodies.get(key);
1782
2595
  if (existing) {
@@ -1790,25 +2603,32 @@ var MatterRuntime = class {
1790
2603
  this.bodies.delete(key);
1791
2604
  this.sleepSynced.delete(key);
1792
2605
  this.sourceRecords.delete(key);
1793
- this.matter.Composite.remove(this.engine.world, [existing.body, ...existing.constraints]);
2606
+ this.destroyBody(existing);
1794
2607
  }
1795
2608
  if (record !== void 0) this.create(desc, id, record);
1796
2609
  }
1797
2610
  }
1798
- for (const [key, attached] of [...this.bodies]) {
2611
+ for (const [key, body] of [...this.bodies]) {
1799
2612
  if (live.has(key)) continue;
1800
2613
  this.bodies.delete(key);
1801
2614
  this.sleepSynced.delete(key);
1802
2615
  this.sourceRecords.delete(key);
1803
- this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
2616
+ this.destroyBody(body);
1804
2617
  }
2618
+ for (const key of [...this.skippedKeys]) if (!live.has(key)) this.skippedKeys.delete(key);
1805
2619
  }
1806
2620
  // -------------------------------------------------------------------------
1807
2621
  // Step and sync
1808
2622
  // -------------------------------------------------------------------------
1809
2623
  step() {
1810
- this.matter.Engine.update(this.engine, this.stepMs);
2624
+ this.world.step();
2625
+ if (!rapierStepped) noteRapierStep();
1811
2626
  }
2627
+ /**
2628
+ * Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
2629
+ * Values are `Math.fround`ed for f32 fields, so what room code reads is exactly what the wire
2630
+ * carries — and so a field that has not really moved does not re-dirty every tick.
2631
+ */
1812
2632
  sync() {
1813
2633
  for (const desc of this.collections) {
1814
2634
  const physics = desc.physics;
@@ -1818,10 +2638,9 @@ var MatterRuntime = class {
1818
2638
  const rounders = roundersFor(desc);
1819
2639
  for (const id of plainColl.ids()) {
1820
2640
  const key = bodyKey(desc.name, id);
1821
- const attached = this.bodies.get(key);
1822
- if (!attached) continue;
1823
- const body = attached.body;
1824
- if (body.isSleeping) {
2641
+ const body = this.bodies.get(key);
2642
+ if (!body) continue;
2643
+ if (body.isSleeping()) {
1825
2644
  if (this.sleepSynced.has(key)) continue;
1826
2645
  this.sleepSynced.add(key);
1827
2646
  } else {
@@ -1829,16 +2648,12 @@ var MatterRuntime = class {
1829
2648
  }
1830
2649
  const record = tracked.get(id);
1831
2650
  if (!record) continue;
1832
- const state = {
1833
- x: body.position.x,
1834
- y: body.position.y,
1835
- angle: body.angle,
1836
- vx: body.velocity.x,
1837
- vy: body.velocity.y,
1838
- angularVelocity: body.angularVelocity
1839
- };
2651
+ const t = body.translation();
2652
+ const r = body.rotation();
2653
+ const v = body.linvel();
2654
+ const w = body.angvel();
1840
2655
  for (const [channel, field] of physics.channels) {
1841
- const next = (rounders[field] ?? identity)(channelOf2d(channel, state));
2656
+ const next = (rounders[field] ?? identity)(channelValue(channel, t, r, v, w));
1842
2657
  if (record[field] !== next) record[field] = next;
1843
2658
  }
1844
2659
  }
@@ -1849,22 +2664,19 @@ var MatterRuntime = class {
1849
2664
  // -------------------------------------------------------------------------
1850
2665
  serialize() {
1851
2666
  const bodies = [];
1852
- for (const [key, attached] of this.bodies) {
2667
+ for (const [key, body] of this.bodies) {
1853
2668
  const sep = key.indexOf("\0");
1854
- const b = attached.body;
1855
- bodies.push({
1856
- collection: key.slice(0, sep),
1857
- id: key.slice(sep + 1),
1858
- x: b.position.x,
1859
- y: b.position.y,
1860
- angle: b.angle,
1861
- vx: b.velocity.x,
1862
- vy: b.velocity.y,
1863
- angularVelocity: b.angularVelocity,
1864
- sleeping: b.isSleeping === true
1865
- });
2669
+ bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
1866
2670
  }
1867
- return { bodies };
2671
+ const skipped = [...this.skippedKeys];
2672
+ const unsynced = [];
2673
+ for (const [key, body] of this.unsyncedBodies) unsynced.push([key, body.handle]);
2674
+ return {
2675
+ world: this.world.takeSnapshot(),
2676
+ bodies,
2677
+ ...skipped.length > 0 ? { skipped } : {},
2678
+ ...unsynced.length > 0 ? { unsynced } : {}
2679
+ };
1868
2680
  }
1869
2681
  };
1870
2682
  function identity(v) {
@@ -1877,215 +2689,305 @@ function roundersFor(desc) {
1877
2689
  }
1878
2690
  return out;
1879
2691
  }
2692
+ function channelValue(channel, t, r, v, w) {
2693
+ switch (channel) {
2694
+ case "x":
2695
+ return t.x;
2696
+ case "y":
2697
+ return t.y;
2698
+ case "z":
2699
+ return t.z;
2700
+ case "qx":
2701
+ return r.x;
2702
+ case "qy":
2703
+ return r.y;
2704
+ case "qz":
2705
+ return r.z;
2706
+ case "qw":
2707
+ return r.w;
2708
+ case "vx":
2709
+ return v.x;
2710
+ case "vy":
2711
+ return v.y;
2712
+ case "vz":
2713
+ return v.z;
2714
+ case "wx":
2715
+ return w.x;
2716
+ case "wy":
2717
+ return w.y;
2718
+ default:
2719
+ return w.z;
2720
+ }
2721
+ }
1880
2722
 
1881
- // src/core/physics.ts
1882
- import { ByteReader as ByteReader2, ByteWriter as ByteWriter2 } from "@irtio/schema";
2723
+ // src/core/matter.ts
2724
+ import {
2725
+ ByteReader as ByteReader2,
2726
+ ByteWriter as ByteWriter2,
2727
+ applyChannel2d,
2728
+ channelOf2d
2729
+ } from "@irtio/schema";
1883
2730
  var engine2;
1884
2731
  var loading2;
1885
- async function initPhysics() {
2732
+ async function initMatter() {
1886
2733
  if (engine2) return engine2;
1887
2734
  loading2 ??= (async () => {
1888
- const mod = await import("@dimforge/rapier3d-compat");
2735
+ const mod = await import("matter-js");
1889
2736
  const ns = mod.default ?? mod;
1890
- await ns.init();
1891
2737
  engine2 = ns;
1892
2738
  return ns;
1893
2739
  })();
1894
2740
  return loading2;
1895
2741
  }
1896
- function loadedPhysics() {
2742
+ function loadedMatter() {
1897
2743
  return engine2;
1898
2744
  }
1899
- function resetPhysicsForTests() {
2745
+ function resetMatterForTests() {
1900
2746
  engine2 = void 0;
1901
2747
  loading2 = void 0;
1902
- rapierStepped = false;
1903
- firstStepWaiters.length = 0;
1904
- }
1905
- var rapierStepped = false;
1906
- var firstStepWaiters = [];
1907
- function rapierHasStepped() {
1908
- return rapierStepped;
1909
- }
1910
- function onFirstRapierStep(cb) {
1911
- if (rapierStepped) {
1912
- cb();
1913
- return;
1914
- }
1915
- firstStepWaiters.push(cb);
1916
- }
1917
- function noteRapierStep() {
1918
- rapierStepped = true;
1919
- const waiters = firstStepWaiters.splice(0, firstStepWaiters.length);
1920
- for (const cb of waiters) {
1921
- try {
1922
- cb();
1923
- } catch {
1924
- }
1925
- }
1926
2748
  }
1927
- function encodePhysicsSection(section) {
1928
- const w = new ByteWriter2(section.world.length + section.bodies.length * 24 + 8);
1929
- w.blob(section.world);
2749
+ function encodeMatterBodies(section) {
2750
+ const skipped = section.skipped ?? [];
2751
+ const unsynced = section.unsynced ?? [];
2752
+ const w = new ByteWriter2(
2753
+ section.bodies.length * 72 + skipped.length * 24 + unsynced.length * 72 + 16
2754
+ );
1930
2755
  w.varint(section.bodies.length);
1931
- for (const [name, id, handle] of section.bodies) {
1932
- w.str(name);
1933
- w.str(id);
1934
- w.f64(handle);
2756
+ for (const b of section.bodies) {
2757
+ w.str(b.collection);
2758
+ w.str(b.id);
2759
+ w.f64(b.x);
2760
+ w.f64(b.y);
2761
+ w.f64(b.angle);
2762
+ w.f64(b.vx);
2763
+ w.f64(b.vy);
2764
+ w.f64(b.angularVelocity);
2765
+ w.u8(b.sleeping ? 1 : 0);
1935
2766
  }
1936
- return w.finish();
1937
- }
1938
- var MATTER_TAG = 1;
1939
- var RAPIER2D_TAG = 2;
1940
- function physicsSectionEngine(bytes) {
1941
- const r = new ByteReader2(bytes);
1942
- if (r.varint() !== 0) return "rapier3d";
1943
- const tag = r.u8();
1944
- if (tag === MATTER_TAG) return "matter2d";
1945
- if (tag === RAPIER2D_TAG) return "rapier2d";
1946
- return "rapier3d";
1947
- }
1948
- function encodeRapier2dSectionEnvelope(payload) {
1949
- const w = new ByteWriter2(payload.length + 8);
1950
- w.varint(0);
1951
- w.u8(RAPIER2D_TAG);
1952
- w.bytes(payload);
1953
- return w.finish();
1954
- }
1955
- function decodeRapier2dSectionEnvelope(bytes) {
1956
- const r = new ByteReader2(bytes);
1957
- if (r.varint() !== 0 || r.u8() !== RAPIER2D_TAG) {
1958
- throw new Error("irtio: this physics section was not written by rapier2d");
2767
+ if (skipped.length > 0 || unsynced.length > 0) {
2768
+ w.varint(skipped.length);
2769
+ for (const key of skipped) w.str(key);
2770
+ w.varint(unsynced.length);
2771
+ for (const b of unsynced) {
2772
+ w.str(b.key);
2773
+ w.f64(b.x);
2774
+ w.f64(b.y);
2775
+ w.f64(b.angle);
2776
+ w.f64(b.vx);
2777
+ w.f64(b.vy);
2778
+ w.f64(b.angularVelocity);
2779
+ w.u8(b.sleeping ? 1 : 0);
2780
+ }
1959
2781
  }
1960
- return r.rest();
1961
- }
1962
- function encodeMatterSectionEnvelope(payload) {
1963
- const w = new ByteWriter2(payload.length + 8);
1964
- w.varint(0);
1965
- w.u8(MATTER_TAG);
1966
- w.bytes(payload);
1967
2782
  return w.finish();
1968
2783
  }
1969
- function decodeMatterSectionEnvelope(bytes) {
1970
- const r = new ByteReader2(bytes);
1971
- if (r.varint() !== 0 || r.u8() !== MATTER_TAG) {
1972
- throw new Error("irtio: this physics section was not written by matter2d");
1973
- }
1974
- return r.rest();
1975
- }
1976
- function decodePhysicsSection(bytes) {
2784
+ function decodeMatterBodies(bytes) {
1977
2785
  const r = new ByteReader2(bytes);
1978
- const world = r.blob().slice();
1979
2786
  const count = r.varint();
1980
2787
  const bodies = [];
1981
- for (let i = 0; i < count; i++) bodies.push([r.str(), r.str(), r.f64()]);
1982
- return { world, bodies };
2788
+ for (let i = 0; i < count; i++) {
2789
+ bodies.push({
2790
+ collection: r.str(),
2791
+ id: r.str(),
2792
+ x: r.f64(),
2793
+ y: r.f64(),
2794
+ angle: r.f64(),
2795
+ vx: r.f64(),
2796
+ vy: r.f64(),
2797
+ angularVelocity: r.f64(),
2798
+ sleeping: r.u8() === 1
2799
+ });
2800
+ }
2801
+ if (r.eof) return { bodies };
2802
+ const skippedCount = r.varint();
2803
+ const skipped = [];
2804
+ for (let i = 0; i < skippedCount; i++) skipped.push(r.str());
2805
+ const unsynced = [];
2806
+ if (!r.eof) {
2807
+ const unsyncedCount = r.varint();
2808
+ for (let i = 0; i < unsyncedCount; i++) {
2809
+ unsynced.push({
2810
+ key: r.str(),
2811
+ x: r.f64(),
2812
+ y: r.f64(),
2813
+ angle: r.f64(),
2814
+ vx: r.f64(),
2815
+ vy: r.f64(),
2816
+ angularVelocity: r.f64(),
2817
+ sleeping: r.u8() === 1
2818
+ });
2819
+ }
2820
+ }
2821
+ return {
2822
+ bodies,
2823
+ ...skipped.length > 0 ? { skipped } : {},
2824
+ ...unsynced.length > 0 ? { unsynced } : {}
2825
+ };
1983
2826
  }
1984
2827
  function bodyKey2(collection, id) {
1985
2828
  return `${collection}\0${id}`;
1986
2829
  }
1987
- function planarLockWarning(spec) {
1988
- const boxed = (spec.colliders ?? []).some(
1989
- (c) => c.shape.type === CUBOID_SHAPE || c.shape.type === ROUND_CUBOID_SHAPE
1990
- );
1991
- if (!boxed) return void 0;
1992
- const b = spec.body;
1993
- const t = [b.translationsEnabledX, b.translationsEnabledY, b.translationsEnabledZ];
1994
- const r = [b.rotationsEnabledX, b.rotationsEnabledY, b.rotationsEnabledZ];
1995
- const axes = ["x", "y", "z"];
1996
- for (let i = 0; i < 3; i++) {
1997
- if (t[i]) continue;
1998
- if (r[(i + 1) % 3] || r[(i + 2) % 3]) continue;
1999
- const free = axes[i] === "z" ? "enabledRotations(true, false, true)" : "one of the other two rotations";
2000
- return `locks translation on ${axes[i]} and both rotations across that plane on a box-shaped body. That removes friction entirely: the box slides at a constant speed until it hits something. Free one out-of-plane rotation \u2014 in the xy plane that is ${free}, and it costs nothing, because geometry symmetric about the plane generates no torque about it.`;
2830
+ var PLANAR_CHANNELS = /* @__PURE__ */ new Set(["x", "y", "qz", "qw", "vx", "vy", "wz"]);
2831
+ var MatterRuntime = class {
2832
+ engineKind = "matter2d";
2833
+ /** rapier3d only; present so both runtimes satisfy one internal shape. */
2834
+ rapier = void 0;
2835
+ world = void 0;
2836
+ matter;
2837
+ engine;
2838
+ /** Alias under the name `PhysicsApi` uses, so `room.physics2d` reads through one field. */
2839
+ get matterEngine() {
2840
+ return this.engine;
2001
2841
  }
2002
- return void 0;
2003
- }
2004
- var CUBOID_SHAPE = 1;
2005
- var ROUND_CUBOID_SHAPE = 12;
2006
- var PhysicsRuntime = class {
2007
- engineKind = "rapier3d";
2008
- /** matter2d only; present so both runtimes satisfy one internal shape. */
2009
- matter = void 0;
2010
- matterEngine = void 0;
2011
- rapier;
2012
- world;
2013
- /** `true` when the world was built from scratch and `setup` has to run. */
2842
+ /** `true` when the blob carried no world at all: the caller logs it. */
2014
2843
  rebuilt;
2015
- /** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
2016
- get needsSetup() {
2017
- return this.rebuilt;
2018
- }
2844
+ /**
2845
+ * Always `true`. Unlike Rapier's, a matter2d world is never restored as a world — only as
2846
+ * per-body state on a world the builder made — so `setup` has to run every time.
2847
+ */
2848
+ needsSetup = true;
2019
2849
  core;
2020
2850
  config;
2021
- /** Physics-backed collections, in schema (name-sorted) order. */
2022
2851
  collections;
2023
2852
  bodies = /* @__PURE__ */ new Map();
2024
- /** Collections already warned about the friction-killing 2D lock recipe (bug 6). */
2025
- warnedPlanar = /* @__PURE__ */ new Set();
2026
- /**
2027
- * Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
2028
- * to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
2029
- * tick it falls asleep, or the schema keeps a phantom residual velocity forever (and a
2030
- * world rebuilt from schema state would wake it with a kick). Found by the drift check.
2031
- */
2853
+ restore;
2854
+ stepMs;
2032
2855
  sleepSynced = /* @__PURE__ */ new Set();
2033
2856
  /**
2034
- * The plain record object each body was built from, by body key. `add()` always installs a
2035
- * **new** record object (`normalizeRecord` builds one), so an id that is removed and re-added
2036
- * between two reconciles — the pooled-projectile shape — is identifiable by identity alone,
2037
- * which is the only way to see it from here: both edits happened before this module looked.
2038
- * Without it the old body kept its pose and velocity and the new row's spawn values were lost.
2039
- *
2040
- * Unknown means adopt, never rebuild: a world restored from a hibernation blob has bodies with
2041
- * no recorded source, and rebuilding those would throw away exactly what the blob preserved.
2857
+ * The plain record object each body was built from. See `PhysicsRuntime.sourceRecords`: `add()`
2858
+ * installs a new record object, so an id removed and re-added between two reconciles (a pooled
2859
+ * projectile) is identifiable by identity, and is the one case where a live body has to be
2860
+ * rebuilt so the new row's spawn pose and velocity are honoured.
2042
2861
  */
2043
2862
  sourceRecords = /* @__PURE__ */ new Map();
2044
- /** `collection.field` pairs already warned about a channel value the engine would not take. */
2863
+ /** `collection.field` pairs already warned about a channel a plane cannot hold. */
2045
2864
  warnedChannels = /* @__PURE__ */ new Set();
2046
- constructor(core, rapier, options) {
2865
+ // ---- M7 planned feature 3 (b): per-entity physics skip ----
2866
+ /** See `PhysicsRuntime.skippedKeys`: entities whose body the room has switched off. */
2867
+ skippedKeys = /* @__PURE__ */ new Set();
2868
+ // ---- end M7 planned feature 3 (b) ----
2869
+ // ---- M7 planned feature 3 (a): physics-only unsynced bodies ----
2870
+ unsyncedBodies = /* @__PURE__ */ new Map();
2871
+ /** Saved unsynced state, consumed as `setup` rebuilds each body under the same key. */
2872
+ restoreUnsynced;
2873
+ /** One-shot: the first reconcile after a wake reports whatever `setup` did not rebuild. */
2874
+ reportedMissingUnsynced = false;
2875
+ unsynced;
2876
+ // ---- end M7 planned feature 3 (a) ----
2877
+ constructor(core, matter, options) {
2047
2878
  const config = core.definition.config.physics;
2048
- if (!config) throw new Error("PhysicsRuntime: the room config declares no physics");
2879
+ if (!config) throw new Error("MatterRuntime: the room config declares no physics");
2049
2880
  this.core = core;
2050
2881
  this.config = config;
2051
- this.rapier = rapier;
2882
+ this.matter = matter;
2052
2883
  this.collections = core.ext.collections.filter(
2053
2884
  (c) => c.physics !== void 0
2054
2885
  );
2055
- if (options.restore) {
2056
- this.world = rapier.World.restoreSnapshot(options.restore.world);
2057
- this.rebuilt = false;
2058
- for (const [name, id, handle] of options.restore.bodies) {
2059
- const body = this.world.getRigidBody(handle);
2060
- if (body) this.bodies.set(bodyKey2(name, id), body);
2886
+ this.engine = matter.Engine.create();
2887
+ this.engine.gravity.x = config.gravity.x;
2888
+ this.engine.gravity.y = config.gravity.y;
2889
+ this.stepMs = (config.timestep ?? options.defaultTimestep) * 1e3;
2890
+ this.rebuilt = options.restore === void 0;
2891
+ this.restore = options.restore ? new Map(options.restore.bodies.map((b) => [bodyKey2(b.collection, b.id), b])) : void 0;
2892
+ for (const key of options.restore?.skipped ?? []) this.skippedKeys.add(key);
2893
+ const savedUnsynced = options.restore?.unsynced;
2894
+ this.restoreUnsynced = savedUnsynced ? new Map(savedUnsynced.map((b) => [b.key, b])) : void 0;
2895
+ this.unsynced = makeUnsyncedRegistry({
2896
+ map: this.unsyncedBodies,
2897
+ build: (spec) => {
2898
+ const constraints = spec.constraints ?? [];
2899
+ this.matter.Composite.add(this.engine.world, [spec.body, ...constraints]);
2900
+ return { body: spec.body, constraints };
2901
+ },
2902
+ destroy: (attached) => {
2903
+ this.wakeContactsOf(attached.body);
2904
+ this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
2905
+ },
2906
+ // The matter2d half of surviving hibernation: `setup` rebuilds the body, and the saved pose
2907
+ // and velocity are put back onto it, exactly as they are for an entity-backed body.
2908
+ onCreated: (key, attached) => {
2909
+ const saved = this.restoreUnsynced?.get(key);
2910
+ if (!saved) return;
2911
+ this.restoreUnsynced?.delete(key);
2912
+ this.applyState(attached.body, {
2913
+ collection: "",
2914
+ id: key,
2915
+ x: saved.x,
2916
+ y: saved.y,
2917
+ angle: saved.angle,
2918
+ vx: saved.vx,
2919
+ vy: saved.vy,
2920
+ angularVelocity: saved.angularVelocity,
2921
+ sleeping: saved.sleeping
2922
+ });
2061
2923
  }
2062
- } else {
2063
- const g = config.gravity;
2064
- this.world = new rapier.World({ x: g.x, y: g.y, z: g.z });
2065
- this.rebuilt = true;
2066
- }
2067
- this.world.timestep = config.timestep ?? options.defaultTimestep;
2924
+ });
2068
2925
  }
2069
2926
  get timestep() {
2070
- return this.world.timestep;
2927
+ return this.stepMs / 1e3;
2071
2928
  }
2072
- /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
2073
2929
  runSetup(room) {
2074
2930
  const setup = this.config.setup;
2075
2931
  if (!setup) return;
2076
- this.core.guard("physics.setup", () => setup(this.world, this.rapier, room));
2932
+ this.core.guard("physics.setup", () => setup(this.engine, this.matter, room));
2077
2933
  }
2078
2934
  free() {
2079
2935
  this.bodies.clear();
2080
- this.world.free();
2936
+ this.unsyncedBodies.clear();
2937
+ this.matter.Engine.clear(this.engine);
2081
2938
  }
2082
2939
  // -------------------------------------------------------------------------
2083
2940
  // Bodies
2084
2941
  // -------------------------------------------------------------------------
2085
- /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
2942
+ /**
2943
+ * bugs.md #100, the matter2d half. Rapier's bindings wake a contact island themselves when a
2944
+ * member is removed (`PhysicsRuntime.destroyBody` / `Rapier2dRuntime.destroyBody`); matter.js
2945
+ * does not, so a body sleeping against a victim about to be removed is left sleeping and
2946
+ * motionless in mid-air once the victim is gone.
2947
+ *
2948
+ * `enableSleeping` is a property the room's own `setup` sets on `engine` (there is no config
2949
+ * flag for it), so this only runs the geometry query when the room has actually turned sleeping
2950
+ * on. `Matter.Query.collides` is matter's public narrow-phase check against a body's current
2951
+ * vertices, run here against every other body in the world; it costs nothing extra to compute
2952
+ * (matter.js does not expose a per-body contact list the way rapier's bindings do) but is bounded
2953
+ * by the same room body count that every reconcile already walks. Sensors are skipped, matching
2954
+ * the rapier fix: an intersection pair is not an island edge.
2955
+ */
2956
+ wakeContactsOf(victim) {
2957
+ if (!this.engine.enableSleeping) return;
2958
+ const M = this.matter;
2959
+ const others = M.Composite.allBodies(this.engine.world).filter((b) => b !== victim);
2960
+ for (const collision of M.Query.collides(victim, others)) {
2961
+ const other = collision.bodyA === victim ? collision.bodyB : collision.bodyA;
2962
+ if (other.isSensor) continue;
2963
+ if (other.isSleeping) M.Sleeping.set(other, false);
2964
+ }
2965
+ }
2966
+ // ---- M7 planned feature 3 (b): per-entity physics skip ----
2967
+ /** The matter.js half of `PhysicsRuntime.setSkipped`; its comment is the whole argument. */
2968
+ setSkipped(collection, id, on) {
2969
+ const key = bodyKey2(collection, id);
2970
+ if (!on) {
2971
+ this.skippedKeys.delete(key);
2972
+ return;
2973
+ }
2974
+ this.skippedKeys.add(key);
2975
+ const existing = this.bodies.get(key);
2976
+ if (!existing) return;
2977
+ this.bodies.delete(key);
2978
+ this.sleepSynced.delete(key);
2979
+ this.sourceRecords.delete(key);
2980
+ this.wakeContactsOf(existing.body);
2981
+ this.matter.Composite.remove(this.engine.world, [existing.body, ...existing.constraints]);
2982
+ }
2983
+ isSkipped(collection, id) {
2984
+ return this.skippedKeys.has(bodyKey2(collection, id));
2985
+ }
2986
+ // ---- end M7 planned feature 3 (b) ----
2086
2987
  bodyFor(collection, id) {
2087
2988
  const existing = this.bodies.get(bodyKey2(collection, id));
2088
- if (existing) return existing;
2989
+ if (existing) return existing.body;
2990
+ if (this.skippedKeys.has(bodyKey2(collection, id))) return void 0;
2089
2991
  const desc = this.collections.find((c) => c.name === collection);
2090
2992
  if (!desc) return void 0;
2091
2993
  const coll = plainEntity(this.core.plain, collection);
@@ -2101,7 +3003,7 @@ var PhysicsRuntime = class {
2101
3003
  }
2102
3004
  const spec = this.core.guard(
2103
3005
  `physics.bodies.${desc.name}`,
2104
- () => factory(this.rapier, record, id)
3006
+ () => factory(this.matter, record, id)
2105
3007
  );
2106
3008
  if (!spec || !spec.body) {
2107
3009
  this.core.log(
@@ -2110,152 +3012,102 @@ var PhysicsRuntime = class {
2110
3012
  );
2111
3013
  return void 0;
2112
3014
  }
2113
- const planar = planarLockWarning(spec);
2114
- if (planar !== void 0 && !this.warnedPlanar.has(desc.name)) {
2115
- this.warnedPlanar.add(desc.name);
2116
- this.core.log("warn", `irtio: physics.bodies.${desc.name} ${planar}`);
2117
- }
2118
- const body = this.world.createRigidBody(spec.body);
2119
- for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
2120
- this.warnLockedChannels(desc, spec, record);
2121
- this.applyRecordToBody(desc, body, record);
2122
- this.bodies.set(bodyKey2(desc.name, id), body);
2123
- this.sourceRecords.set(bodyKey2(desc.name, id), record);
2124
- return body;
2125
- }
2126
- /**
2127
- * A spawn value the engine will not take, said once per field.
2128
- *
2129
- * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
2130
- * accepts one on a fixed body and then ignores it — both read back as "the field was applied"
2131
- * and behave as if it never was. The desc says which, before the body exists, so the check is
2132
- * on the desc: the usual case is the 2D-in-3D recipe (`enabledTranslations(true, true, false)`)
2133
- * meeting a row that spawns with a `vz`. Named by field, because the field is what the author
2134
- * wrote and what they will grep for.
2135
- */
2136
- warnLockedChannels(desc, spec, record) {
2137
- const physics = desc.physics;
2138
- if (!physics) return;
2139
- const b = spec.body;
2140
- const frozen = b.status !== 0;
2141
- const enabled = {
2142
- vx: b.translationsEnabledX,
2143
- vy: b.translationsEnabledY,
2144
- vz: b.translationsEnabledZ,
2145
- wx: b.rotationsEnabledX,
2146
- wy: b.rotationsEnabledY,
2147
- wz: b.rotationsEnabledZ
2148
- };
2149
- for (const [channel, field] of physics.channels) {
2150
- const allowed = enabled[channel];
2151
- if (allowed === void 0) continue;
2152
- const raw = record[field];
2153
- if (typeof raw !== "number" || raw === 0) continue;
2154
- if (allowed && !frozen) continue;
2155
- const key = `${desc.name}.${field}`;
2156
- if (this.warnedChannels.has(key)) continue;
2157
- this.warnedChannels.add(key);
2158
- this.core.log(
2159
- "warn",
2160
- `irtio: ${desc.name}.${field} (body channel ${channel}) was added as ${raw}, but ` + (frozen ? `physics.bodies.${desc.name} builds a non-dynamic body, which never moves under it` : `that degree of freedom is disabled by physics.bodies.${desc.name}`) + " \u2014 the value was dropped"
2161
- );
3015
+ const key = bodyKey2(desc.name, id);
3016
+ const constraints = spec.constraints ?? [];
3017
+ this.matter.Composite.add(this.engine.world, [spec.body, ...constraints]);
3018
+ this.bodies.set(key, { body: spec.body, constraints });
3019
+ const saved = this.restore?.get(key);
3020
+ if (saved) {
3021
+ this.restore?.delete(key);
3022
+ this.applyState(spec.body, saved);
3023
+ } else {
3024
+ this.applyRecordToBody(desc, spec.body, record);
2162
3025
  }
3026
+ this.sourceRecords.set(key, record);
3027
+ return spec.body;
2163
3028
  }
2164
3029
  // ---- M6 lane F: rewind ----
2165
3030
  /**
2166
- * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
2167
- * order). Read-only: it hands out the live bodies and nothing else, and the map stays private.
2168
- * The pose history calls this once per tick and the rewind scratch calls it per rewind; a room
2169
- * that declares no `physics.history` never calls it at all.
3031
+ * D72: every tracked body, in the order `sync()` walks them. The matter half of the same
3032
+ * read-only accessor `PhysicsRuntime` carries; see its comment for what calls it and when.
2170
3033
  */
2171
3034
  eachTrackedBody(fn) {
2172
3035
  for (const desc of this.collections) {
2173
3036
  const plainColl = plainEntity(this.core.plain, desc.name);
2174
3037
  for (const id of plainColl.ids()) {
2175
- const body = this.bodies.get(bodyKey2(desc.name, id));
2176
- if (body) fn(desc.name, id, body, plainColl.get(id));
3038
+ const attached = this.bodies.get(bodyKey2(desc.name, id));
3039
+ if (attached)
3040
+ fn(desc.name, id, attached.body, plainColl.get(id));
2177
3041
  }
2178
3042
  }
2179
3043
  }
3044
+ applyState(body, s) {
3045
+ const M = this.matter;
3046
+ M.Body.setPosition(body, { x: s.x, y: s.y });
3047
+ M.Body.setAngle(body, s.angle);
3048
+ M.Body.setVelocity(body, { x: s.vx, y: s.vy });
3049
+ M.Body.setAngularVelocity(body, s.angularVelocity);
3050
+ if (s.sleeping) M.Sleeping.set(body, true);
3051
+ }
2180
3052
  applyRecordToBody(desc, body, record) {
2181
3053
  const physics = desc.physics;
2182
3054
  if (!physics) return;
2183
- const t = { ...body.translation() };
2184
- const r = { ...body.rotation() };
2185
- const v = { ...body.linvel() };
2186
- const w = { ...body.angvel() };
2187
- let setT = false;
2188
- let setR = false;
2189
- let setV = false;
2190
- let setW = false;
3055
+ const t = {
3056
+ x: body.position.x,
3057
+ y: body.position.y,
3058
+ qz: Math.sin(body.angle / 2),
3059
+ qw: Math.cos(body.angle / 2),
3060
+ vx: body.velocity.x,
3061
+ vy: body.velocity.y,
3062
+ wz: body.angularVelocity
3063
+ };
2191
3064
  for (const [channel, field] of physics.channels) {
2192
3065
  const raw = record[field];
2193
3066
  if (typeof raw !== "number") continue;
2194
- switch (channel) {
2195
- case "x":
2196
- case "y":
2197
- case "z":
2198
- t[channel] = raw;
2199
- setT = true;
2200
- break;
2201
- case "qx":
2202
- r.x = raw;
2203
- setR = true;
2204
- break;
2205
- case "qy":
2206
- r.y = raw;
2207
- setR = true;
2208
- break;
2209
- case "qz":
2210
- r.z = raw;
2211
- setR = true;
2212
- break;
2213
- case "qw":
2214
- r.w = raw;
2215
- setR = true;
2216
- break;
2217
- case "vx":
2218
- v.x = raw;
2219
- setV = true;
2220
- break;
2221
- case "vy":
2222
- v.y = raw;
2223
- setV = true;
2224
- break;
2225
- case "vz":
2226
- v.z = raw;
2227
- setV = true;
2228
- break;
2229
- case "wx":
2230
- w.x = raw;
2231
- setW = true;
2232
- break;
2233
- case "wy":
2234
- w.y = raw;
2235
- setW = true;
2236
- break;
2237
- case "wz":
2238
- w.z = raw;
2239
- setW = true;
2240
- break;
3067
+ if (raw !== 0 && !PLANAR_CHANNELS.has(channel)) {
3068
+ const key = `${desc.name}.${field}`;
3069
+ if (!this.warnedChannels.has(key)) {
3070
+ this.warnedChannels.add(key);
3071
+ this.core.log(
3072
+ "warn",
3073
+ `irtio: ${desc.name}.${field} maps body channel ${channel}, which a matter2d room cannot hold (the world is a plane); the ${raw} it was added with was dropped`
3074
+ );
3075
+ }
3076
+ continue;
2241
3077
  }
3078
+ applyChannel2d(channel, raw, t);
2242
3079
  }
2243
- if (setT) body.setTranslation(t, true);
2244
- if (setR) body.setRotation(r, true);
2245
- if (setV) body.setLinvel(v, true);
2246
- if (setW) body.setAngvel(w, true);
3080
+ this.applyState(body, {
3081
+ collection: desc.name,
3082
+ id: "",
3083
+ x: t.x,
3084
+ y: t.y,
3085
+ angle: 2 * Math.atan2(t.qz, t.qw),
3086
+ vx: t.vx,
3087
+ vy: t.vy,
3088
+ angularVelocity: t.wz,
3089
+ sleeping: false
3090
+ });
2247
3091
  }
2248
- /**
2249
- * Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
2250
- * tick, right before the step, in collection order then instance order.
2251
- */
2252
3092
  reconcile() {
3093
+ if (!this.reportedMissingUnsynced && this.restoreUnsynced) {
3094
+ this.reportedMissingUnsynced = true;
3095
+ const missing = [...this.restoreUnsynced.keys()];
3096
+ this.restoreUnsynced = void 0;
3097
+ if (missing.length > 0) {
3098
+ this.core.log(
3099
+ "warn",
3100
+ `irtio: matter2d rebuilds its world on every wake, so an unsynced body only comes back if physics.setup creates it again under the same key. These did not come back and their saved state was dropped: ${missing.join(", ")}`
3101
+ );
3102
+ }
3103
+ }
2253
3104
  const live = /* @__PURE__ */ new Set();
2254
3105
  for (const desc of this.collections) {
2255
3106
  const coll = plainEntity(this.core.plain, desc.name);
2256
3107
  for (const id of coll.ids()) {
2257
3108
  const key = bodyKey2(desc.name, id);
2258
3109
  live.add(key);
3110
+ if (this.skippedKeys.has(key)) continue;
2259
3111
  const record = coll.get(id);
2260
3112
  const existing = this.bodies.get(key);
2261
3113
  if (existing) {
@@ -2269,31 +3121,28 @@ var PhysicsRuntime = class {
2269
3121
  this.bodies.delete(key);
2270
3122
  this.sleepSynced.delete(key);
2271
3123
  this.sourceRecords.delete(key);
2272
- this.world.removeRigidBody(existing);
3124
+ this.wakeContactsOf(existing.body);
3125
+ this.matter.Composite.remove(this.engine.world, [existing.body, ...existing.constraints]);
2273
3126
  }
2274
3127
  if (record !== void 0) this.create(desc, id, record);
2275
3128
  }
2276
3129
  }
2277
- for (const [key, body] of [...this.bodies]) {
3130
+ for (const [key, attached] of [...this.bodies]) {
2278
3131
  if (live.has(key)) continue;
2279
3132
  this.bodies.delete(key);
2280
3133
  this.sleepSynced.delete(key);
2281
3134
  this.sourceRecords.delete(key);
2282
- this.world.removeRigidBody(body);
3135
+ this.wakeContactsOf(attached.body);
3136
+ this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
2283
3137
  }
3138
+ for (const key of [...this.skippedKeys]) if (!live.has(key)) this.skippedKeys.delete(key);
2284
3139
  }
2285
3140
  // -------------------------------------------------------------------------
2286
3141
  // Step and sync
2287
3142
  // -------------------------------------------------------------------------
2288
3143
  step() {
2289
- this.world.step();
2290
- if (!rapierStepped) noteRapierStep();
3144
+ this.matter.Engine.update(this.engine, this.stepMs);
2291
3145
  }
2292
- /**
2293
- * Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
2294
- * Values are `Math.fround`ed for f32 fields, so what room code reads is exactly what the wire
2295
- * carries — and so a field that has not really moved does not re-dirty every tick.
2296
- */
2297
3146
  sync() {
2298
3147
  for (const desc of this.collections) {
2299
3148
  const physics = desc.physics;
@@ -2303,9 +3152,10 @@ var PhysicsRuntime = class {
2303
3152
  const rounders = roundersFor2(desc);
2304
3153
  for (const id of plainColl.ids()) {
2305
3154
  const key = bodyKey2(desc.name, id);
2306
- const body = this.bodies.get(key);
2307
- if (!body) continue;
2308
- if (body.isSleeping()) {
3155
+ const attached = this.bodies.get(key);
3156
+ if (!attached) continue;
3157
+ const body = attached.body;
3158
+ if (body.isSleeping) {
2309
3159
  if (this.sleepSynced.has(key)) continue;
2310
3160
  this.sleepSynced.add(key);
2311
3161
  } else {
@@ -2313,12 +3163,16 @@ var PhysicsRuntime = class {
2313
3163
  }
2314
3164
  const record = tracked.get(id);
2315
3165
  if (!record) continue;
2316
- const t = body.translation();
2317
- const r = body.rotation();
2318
- const v = body.linvel();
2319
- const w = body.angvel();
3166
+ const state = {
3167
+ x: body.position.x,
3168
+ y: body.position.y,
3169
+ angle: body.angle,
3170
+ vx: body.velocity.x,
3171
+ vy: body.velocity.y,
3172
+ angularVelocity: body.angularVelocity
3173
+ };
2320
3174
  for (const [channel, field] of physics.channels) {
2321
- const next = (rounders[field] ?? identity2)(channelValue(channel, t, r, v, w));
3175
+ const next = (rounders[field] ?? identity2)(channelOf2d(channel, state));
2322
3176
  if (record[field] !== next) record[field] = next;
2323
3177
  }
2324
3178
  }
@@ -2329,52 +3183,52 @@ var PhysicsRuntime = class {
2329
3183
  // -------------------------------------------------------------------------
2330
3184
  serialize() {
2331
3185
  const bodies = [];
2332
- for (const [key, body] of this.bodies) {
3186
+ for (const [key, attached] of this.bodies) {
2333
3187
  const sep = key.indexOf("\0");
2334
- bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
2335
- }
2336
- return { world: this.world.takeSnapshot(), bodies };
2337
- }
2338
- };
2339
- function identity2(v) {
2340
- return v;
2341
- }
2342
- function roundersFor2(desc) {
2343
- const out = {};
2344
- for (const f of desc.fields) {
2345
- if (f.type.kind === "f32") out[f.name] = Math.fround;
3188
+ const b = attached.body;
3189
+ bodies.push({
3190
+ collection: key.slice(0, sep),
3191
+ id: key.slice(sep + 1),
3192
+ x: b.position.x,
3193
+ y: b.position.y,
3194
+ angle: b.angle,
3195
+ vx: b.velocity.x,
3196
+ vy: b.velocity.y,
3197
+ angularVelocity: b.angularVelocity,
3198
+ sleeping: b.isSleeping === true
3199
+ });
3200
+ }
3201
+ const skipped = [...this.skippedKeys];
3202
+ const unsynced = [];
3203
+ for (const [key, attached] of this.unsyncedBodies) {
3204
+ const b = attached.body;
3205
+ unsynced.push({
3206
+ key,
3207
+ x: b.position.x,
3208
+ y: b.position.y,
3209
+ angle: b.angle,
3210
+ vx: b.velocity.x,
3211
+ vy: b.velocity.y,
3212
+ angularVelocity: b.angularVelocity,
3213
+ sleeping: b.isSleeping === true
3214
+ });
3215
+ }
3216
+ return {
3217
+ bodies,
3218
+ ...skipped.length > 0 ? { skipped } : {},
3219
+ ...unsynced.length > 0 ? { unsynced } : {}
3220
+ };
2346
3221
  }
2347
- return out;
3222
+ };
3223
+ function identity2(v) {
3224
+ return v;
2348
3225
  }
2349
- function channelValue(channel, t, r, v, w) {
2350
- switch (channel) {
2351
- case "x":
2352
- return t.x;
2353
- case "y":
2354
- return t.y;
2355
- case "z":
2356
- return t.z;
2357
- case "qx":
2358
- return r.x;
2359
- case "qy":
2360
- return r.y;
2361
- case "qz":
2362
- return r.z;
2363
- case "qw":
2364
- return r.w;
2365
- case "vx":
2366
- return v.x;
2367
- case "vy":
2368
- return v.y;
2369
- case "vz":
2370
- return v.z;
2371
- case "wx":
2372
- return w.x;
2373
- case "wy":
2374
- return w.y;
2375
- default:
2376
- return w.z;
3226
+ function roundersFor2(desc) {
3227
+ const out = {};
3228
+ for (const f of desc.fields) {
3229
+ if (f.type.kind === "f32") out[f.name] = Math.fround;
2377
3230
  }
3231
+ return out;
2378
3232
  }
2379
3233
 
2380
3234
  // src/core/random.ts
@@ -2457,6 +3311,15 @@ var Rapier2dRuntime = class {
2457
3311
  sourceRecords = /* @__PURE__ */ new Map();
2458
3312
  /** `collection.field` pairs already warned about a channel value the engine would not take. */
2459
3313
  warnedChannels = /* @__PURE__ */ new Set();
3314
+ // ---- M7 planned feature 3 (b): per-entity physics skip ----
3315
+ /** See `PhysicsRuntime.skippedKeys`: entities whose body the room has switched off. */
3316
+ skippedKeys = /* @__PURE__ */ new Set();
3317
+ // ---- end M7 planned feature 3 (b) ----
3318
+ // ---- M7 planned feature 3 (a): physics-only unsynced bodies ----
3319
+ /** See `PhysicsRuntime.unsyncedBodies`: bodies the world steps that no schema row describes. */
3320
+ unsyncedBodies = /* @__PURE__ */ new Map();
3321
+ unsynced;
3322
+ // ---- end M7 planned feature 3 (a) ----
2460
3323
  constructor(core, rapier, options) {
2461
3324
  const config = core.definition.config.physics;
2462
3325
  if (!config) throw new Error("Rapier2dRuntime: the room config declares no physics");
@@ -2473,12 +3336,26 @@ var Rapier2dRuntime = class {
2473
3336
  const body = this.world.getRigidBody(handle);
2474
3337
  if (body) this.bodies.set(bodyKey3(name, id), body);
2475
3338
  }
3339
+ for (const key of options.restore.skipped ?? []) this.skippedKeys.add(key);
3340
+ for (const [key, handle] of options.restore.unsynced ?? []) {
3341
+ const body = this.world.getRigidBody(handle);
3342
+ if (body) this.unsyncedBodies.set(key, body);
3343
+ }
2476
3344
  } else {
2477
3345
  const g = config.gravity;
2478
3346
  this.world = new rapier.World({ x: g.x, y: g.y });
2479
3347
  this.rebuilt = true;
2480
3348
  }
2481
3349
  this.world.timestep = config.timestep ?? options.defaultTimestep;
3350
+ this.unsynced = makeUnsyncedRegistry({
3351
+ map: this.unsyncedBodies,
3352
+ build: (spec) => {
3353
+ const body = this.world.createRigidBody(spec.body);
3354
+ for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
3355
+ return body;
3356
+ },
3357
+ destroy: (body) => this.destroyBody(body)
3358
+ });
2482
3359
  }
2483
3360
  get timestep() {
2484
3361
  return this.world.timestep;
@@ -2491,15 +3368,51 @@ var Rapier2dRuntime = class {
2491
3368
  }
2492
3369
  free() {
2493
3370
  this.bodies.clear();
3371
+ this.unsyncedBodies.clear();
2494
3372
  this.world.free();
2495
3373
  }
2496
3374
  // -------------------------------------------------------------------------
2497
3375
  // Bodies
2498
3376
  // -------------------------------------------------------------------------
3377
+ /**
3378
+ * bugs.md #99, planar half: the teardown door every removal goes through. See
3379
+ * `PhysicsRuntime.destroyBody` for the argument — one narrow-phase walk of the victim's own
3380
+ * contact pairs, so a body resting on what is about to vanish is awake when it does.
3381
+ */
3382
+ destroyBody(body) {
3383
+ const colliders = body.numColliders();
3384
+ for (let i = 0; i < colliders; i++) {
3385
+ this.world.contactPairsWith(body.collider(i), (other) => {
3386
+ other.parent()?.wakeUp();
3387
+ });
3388
+ }
3389
+ this.world.removeRigidBody(body);
3390
+ }
3391
+ // ---- M7 planned feature 3 (b): per-entity physics skip ----
3392
+ /** The planar half of `PhysicsRuntime.setSkipped`; its comment is the whole argument. */
3393
+ setSkipped(collection, id, on) {
3394
+ const key = bodyKey3(collection, id);
3395
+ if (!on) {
3396
+ this.skippedKeys.delete(key);
3397
+ return;
3398
+ }
3399
+ this.skippedKeys.add(key);
3400
+ const existing = this.bodies.get(key);
3401
+ if (!existing) return;
3402
+ this.bodies.delete(key);
3403
+ this.sleepSynced.delete(key);
3404
+ this.sourceRecords.delete(key);
3405
+ this.destroyBody(existing);
3406
+ }
3407
+ isSkipped(collection, id) {
3408
+ return this.skippedKeys.has(bodyKey3(collection, id));
3409
+ }
3410
+ // ---- end M7 planned feature 3 (b) ----
2499
3411
  /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
2500
3412
  bodyFor(collection, id) {
2501
3413
  const existing = this.bodies.get(bodyKey3(collection, id));
2502
3414
  if (existing) return existing;
3415
+ if (this.skippedKeys.has(bodyKey3(collection, id))) return void 0;
2503
3416
  const desc = this.collections.find((c) => c.name === collection);
2504
3417
  if (!desc) return void 0;
2505
3418
  const coll = plainEntity(this.core.plain, collection);
@@ -2618,6 +3531,7 @@ var Rapier2dRuntime = class {
2618
3531
  for (const id of coll.ids()) {
2619
3532
  const key = bodyKey3(desc.name, id);
2620
3533
  live.add(key);
3534
+ if (this.skippedKeys.has(key)) continue;
2621
3535
  const record = coll.get(id);
2622
3536
  const existing = this.bodies.get(key);
2623
3537
  if (existing) {
@@ -2626,337 +3540,107 @@ var Rapier2dRuntime = class {
2626
3540
  if (source === void 0) {
2627
3541
  this.sourceRecords.set(key, record);
2628
3542
  continue;
2629
- }
2630
- if (source === record) continue;
2631
- this.bodies.delete(key);
2632
- this.sleepSynced.delete(key);
2633
- this.sourceRecords.delete(key);
2634
- this.world.removeRigidBody(existing);
2635
- }
2636
- if (record !== void 0) this.create(desc, id, record);
2637
- }
2638
- }
2639
- for (const [key, body] of [...this.bodies]) {
2640
- if (live.has(key)) continue;
2641
- this.bodies.delete(key);
2642
- this.sleepSynced.delete(key);
2643
- this.sourceRecords.delete(key);
2644
- this.world.removeRigidBody(body);
2645
- }
2646
- }
2647
- // -------------------------------------------------------------------------
2648
- // Step and sync
2649
- // -------------------------------------------------------------------------
2650
- step() {
2651
- this.world.step();
2652
- if (!rapierHasStepped()) noteRapierStep();
2653
- }
2654
- /**
2655
- * Body → schema, through the tracked proxies, so movement leaves the room as an ordinary delta.
2656
- * Values are `Math.fround`ed for f32 fields, so room code reads exactly what the wire carries.
2657
- */
2658
- sync() {
2659
- for (const desc of this.collections) {
2660
- const physics = desc.physics;
2661
- if (!physics) continue;
2662
- const tracked = this.core.anyState[desc.name];
2663
- const plainColl = plainEntity(this.core.plain, desc.name);
2664
- const rounders = roundersFor3(desc);
2665
- for (const id of plainColl.ids()) {
2666
- const key = bodyKey3(desc.name, id);
2667
- const body = this.bodies.get(key);
2668
- if (!body) continue;
2669
- if (body.isSleeping()) {
2670
- if (this.sleepSynced.has(key)) continue;
2671
- this.sleepSynced.add(key);
2672
- } else {
2673
- this.sleepSynced.delete(key);
2674
- }
2675
- const record = tracked.get(id);
2676
- if (!record) continue;
2677
- const t = body.translation();
2678
- const v = body.linvel();
2679
- const state = {
2680
- x: t.x,
2681
- y: t.y,
2682
- angle: body.rotation(),
2683
- vx: v.x,
2684
- vy: v.y,
2685
- angularVelocity: body.angvel()
2686
- };
2687
- for (const [channel, field] of physics.channels) {
2688
- const next = (rounders[field] ?? identity3)(channelOf2d2(channel, state));
2689
- if (record[field] !== next) record[field] = next;
2690
- }
2691
- }
2692
- }
2693
- }
2694
- // -------------------------------------------------------------------------
2695
- // Hibernation
2696
- // -------------------------------------------------------------------------
2697
- /**
2698
- * The same `PhysicsSection` the 3D runtime writes — a real engine snapshot plus the
2699
- * entity↔handle map, whose handles must ride as f64 for the reason spelled out on
2700
- * {@link PhysicsSection}. What tells the two apart on the way back in is the envelope
2701
- * `core/physics.ts` wraps this in, not anything in here.
2702
- */
2703
- serialize() {
2704
- const bodies = [];
2705
- for (const [key, body] of this.bodies) {
2706
- const sep = key.indexOf("\0");
2707
- bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
2708
- }
2709
- return { world: this.world.takeSnapshot(), bodies };
2710
- }
2711
- };
2712
- function identity3(v) {
2713
- return v;
2714
- }
2715
- function roundersFor3(desc) {
2716
- const out = {};
2717
- for (const f of desc.fields) {
2718
- if (f.type.kind === "f32") out[f.name] = Math.fround;
2719
- }
2720
- return out;
2721
- }
2722
-
2723
- // src/core/views.ts
2724
- import {
2725
- collectionDirty,
2726
- createDirtySet,
2727
- createFieldMask,
2728
- encodeDelta as encodeDelta2,
2729
- encodeSnapshot as encodeSnapshot2,
2730
- filterDirty,
2731
- isDirtyEmpty,
2732
- markAdd,
2733
- markPath,
2734
- markRemove,
2735
- mergeDirty
2736
- } from "@irtio/schema";
2737
-
2738
- // src/core/spatial.ts
2739
- function cellFor(grid, value) {
2740
- if (typeof value !== "object" || value === null) return void 0;
2741
- const record = value;
2742
- const x = record[grid.x];
2743
- const y = record[grid.y];
2744
- if (typeof x !== "number" || typeof y !== "number") return void 0;
2745
- return { x: Math.floor(x / grid.cell), y: Math.floor(y / grid.cell) };
2746
- }
2747
- function cellKey(cell) {
2748
- return `${cell.x},${cell.y}`;
2749
- }
2750
- function buildSpatialIndex(plain, desc) {
2751
- if (!desc.grid || desc.kind !== "entity") {
2752
- throw new Error(`${desc.name}: cannot build an index without an entity grid`);
2753
- }
2754
- const collection = plainEntity(plain, desc.name);
2755
- const buckets = /* @__PURE__ */ new Map();
2756
- const order = /* @__PURE__ */ new Map();
2757
- let n = 0;
2758
- for (const id of collection.ids()) {
2759
- order.set(id, n++);
2760
- const cell = cellFor(desc.grid, collection.get(id));
2761
- if (!cell) continue;
2762
- const key = cellKey(cell);
2763
- let ids = buckets.get(key);
2764
- if (!ids) {
2765
- ids = /* @__PURE__ */ new Set();
2766
- buckets.set(key, ids);
2767
- }
2768
- ids.add(id);
2769
- }
2770
- return { desc, buckets, collection, order };
2771
- }
2772
- function querySpatialIndex(index, clientId, role) {
2773
- const grid = index.desc.grid;
2774
- if ((grid.wideRoles ?? []).includes(role)) return new Set(index.collection.ids());
2775
- const anchor = index.collection.get(clientId);
2776
- const anchorCell = cellFor(grid, anchor);
2777
- if (!anchor || !anchorCell) return /* @__PURE__ */ new Set();
2778
- const candidates = /* @__PURE__ */ new Set();
2779
- for (let x = anchorCell.x - grid.radius; x <= anchorCell.x + grid.radius; x++) {
2780
- for (let y = anchorCell.y - grid.radius; y <= anchorCell.y + grid.radius; y++) {
2781
- const ids = index.buckets.get(cellKey({ x, y }));
2782
- if (ids) for (const id of ids) candidates.add(id);
2783
- }
2784
- }
2785
- candidates.add(clientId);
2786
- const ranked = [];
2787
- for (const id of candidates) if (index.order.has(id)) ranked.push(id);
2788
- ranked.sort((a, b) => index.order.get(a) - index.order.get(b));
2789
- return new Set(ranked);
2790
- }
2791
- function buildSpatialIndexes(collections, plain) {
2792
- const indexes = /* @__PURE__ */ new Map();
2793
- for (const desc of collections) {
2794
- if (desc.visibility === "spatial-grid" && desc.grid) {
2795
- indexes.set(desc.name, buildSpatialIndex(plain, desc));
2796
- }
2797
- }
2798
- return indexes;
2799
- }
2800
-
2801
- // src/core/views.ts
2802
- var cache = /* @__PURE__ */ new WeakMap();
2803
- function infoOf(ext) {
2804
- let i = cache.get(ext);
2805
- if (!i) {
2806
- const scoped = /* @__PURE__ */ new Set();
2807
- for (const c of ext.collections) {
2808
- if (c.visibility === "role") for (const r of c.roles ?? []) scoped.add(r);
2809
- }
2810
- i = { scoped, names: /* @__PURE__ */ new Map() };
2811
- cache.set(ext, i);
2812
- }
2813
- return i;
2814
- }
2815
- function isVisible(c, role) {
2816
- return c.visibility !== "role" || (c.roles ?? []).includes(role);
2817
- }
2818
- function visibleTo(role) {
2819
- return (c) => isVisible(c, role);
2820
- }
2821
- function visibleNames(ext, role) {
2822
- const info = infoOf(ext);
2823
- let set = info.names.get(role);
2824
- if (!set) {
2825
- set = new Set(
2826
- ext.collections.filter((c) => isVisible(c, role)).map((c) => c.name)
2827
- );
2828
- info.names.set(role, set);
2829
- }
2830
- return set;
2831
- }
2832
- function viewKeyFor(ext, role) {
2833
- return infoOf(ext).scoped.has(role) ? role : "all";
2834
- }
2835
- function encodeViewSnapshot(ext, plain, role, tick, memberships) {
2836
- return encodeSnapshot2(
2837
- ext,
2838
- plain,
2839
- { tick },
2840
- {
2841
- collections: visibleTo(role),
2842
- entities: (c, id) => c.visibility !== "spatial-grid" || (memberships?.get(c.name)?.has(id) ?? false)
2843
- }
2844
- );
2845
- }
2846
- function spatialMemberships(ext, plain, clientId, role, indexes = buildSpatialIndexes(ext.collections, plain)) {
2847
- const out = /* @__PURE__ */ new Map();
2848
- for (const [name, index] of indexes) out.set(name, querySpatialIndex(index, clientId, role));
2849
- return out;
2850
- }
2851
- function createVisibilityPolicy(ext, plain, clientId, role) {
2852
- const memberships = spatialMemberships(ext, plain, clientId, role);
2853
- return {
2854
- memberships,
2855
- maySeeCollection: (desc) => isVisible(desc, role),
2856
- maySeeEntity: (desc, id) => isVisible(desc, role) && (desc.visibility !== "spatial-grid" || (memberships.get(desc.name)?.has(id) ?? false)),
2857
- describeSpatial: (desc, ids) => {
2858
- const grid = desc.grid;
2859
- if (desc.visibility !== "spatial-grid" || !grid) return void 0;
2860
- const coll = plainEntity(plain, desc.name);
2861
- const at = (id) => {
2862
- const cell = cellFor(grid, coll.get(id));
2863
- return cell ? `${id}@(${cell.x},${cell.y})` : `${id}@(no position)`;
2864
- };
2865
- return `viewer ${at(clientId)}, ${desc.name} ${ids.map(at).join(" ")}, radius ${grid.radius}`;
2866
- }
2867
- };
2868
- }
2869
- function sharedViewDirty(ext, dirty, role) {
2870
- return filterDirty(dirty, (name) => {
2871
- const desc = ext.collection(name);
2872
- return desc.visibility !== "spatial-grid" && isVisible(desc, role);
2873
- });
2874
- }
2875
- function aoiViewDirty(ext, dirty, previous, current) {
2876
- const out = createDirtySet();
2877
- for (const desc of ext.collections) {
2878
- if (desc.visibility !== "spatial-grid") continue;
2879
- const before = previous.get(desc.name) ?? /* @__PURE__ */ new Set();
2880
- const now = current.get(desc.name) ?? /* @__PURE__ */ new Set();
2881
- for (const id of before) if (!now.has(id)) markRemove(out, desc.name, id);
2882
- for (const id of now) if (!before.has(id)) markAdd(out, desc.name, id);
2883
- const changed = dirty.get(desc.name);
2884
- if (!changed) continue;
2885
- const visibleChanges = createDirtySet();
2886
- const collection = collectionDirty(visibleChanges, desc.name);
2887
- for (const id of changed.added) if (now.has(id)) collection.added.add(id);
2888
- for (const id of changed.removed) if (before.has(id)) collection.removed.add(id);
2889
- for (const [id, record] of changed.updated) {
2890
- if (before.has(id) && now.has(id)) collection.updated.set(id, record);
3543
+ }
3544
+ if (source === record) continue;
3545
+ this.bodies.delete(key);
3546
+ this.sleepSynced.delete(key);
3547
+ this.sourceRecords.delete(key);
3548
+ this.destroyBody(existing);
3549
+ }
3550
+ if (record !== void 0) this.create(desc, id, record);
3551
+ }
2891
3552
  }
2892
- mergeDirty(out, visibleChanges);
2893
- }
2894
- return out;
2895
- }
2896
- function membershipSafeDirty(ext, dirty, memberships) {
2897
- const out = createDirtySet();
2898
- for (const [name, changed] of dirty) {
2899
- const desc = ext.collection(name);
2900
- if (desc.visibility !== "spatial-grid") {
2901
- out.set(name, changed);
2902
- continue;
3553
+ for (const [key, body] of [...this.bodies]) {
3554
+ if (live.has(key)) continue;
3555
+ this.bodies.delete(key);
3556
+ this.sleepSynced.delete(key);
3557
+ this.sourceRecords.delete(key);
3558
+ this.destroyBody(body);
2903
3559
  }
2904
- const visible = memberships.get(name) ?? /* @__PURE__ */ new Set();
2905
- const kept = collectionDirty(out, name);
2906
- for (const id of changed.added) if (visible.has(id)) kept.added.add(id);
2907
- for (const id of changed.removed) if (visible.has(id)) kept.removed.add(id);
2908
- for (const [id, record] of changed.updated) if (visible.has(id)) kept.updated.set(id, record);
3560
+ for (const key of [...this.skippedKeys]) if (!live.has(key)) this.skippedKeys.delete(key);
2909
3561
  }
2910
- return out;
2911
- }
2912
- function encodeViewDelta(ext, plain, dirty, role, tick) {
2913
- const keep = visibleNames(ext, role);
2914
- const filtered = filterDirty(dirty, (name) => keep.has(name));
2915
- if (isDirtyEmpty(filtered)) return null;
2916
- return encodeDelta2(ext, plain, filtered, { tick });
2917
- }
2918
- function stripAdds(plain, dirty, suppress) {
2919
- if (suppress.size === 0) return dirty;
2920
- const out = /* @__PURE__ */ new Map();
2921
- for (const [name, cd] of dirty) {
2922
- const captured = suppress.get(name);
2923
- if (!captured || captured.size === 0 || cd.added.size === 0) {
2924
- out.set(name, cd);
2925
- continue;
3562
+ // -------------------------------------------------------------------------
3563
+ // Step and sync
3564
+ // -------------------------------------------------------------------------
3565
+ step() {
3566
+ this.world.step();
3567
+ if (!rapierHasStepped()) noteRapierStep();
3568
+ }
3569
+ /**
3570
+ * Body → schema, through the tracked proxies, so movement leaves the room as an ordinary delta.
3571
+ * Values are `Math.fround`ed for f32 fields, so room code reads exactly what the wire carries.
3572
+ */
3573
+ sync() {
3574
+ for (const desc of this.collections) {
3575
+ const physics = desc.physics;
3576
+ if (!physics) continue;
3577
+ const tracked = this.core.anyState[desc.name];
3578
+ const plainColl = plainEntity(this.core.plain, desc.name);
3579
+ const rounders = roundersFor3(desc);
3580
+ for (const id of plainColl.ids()) {
3581
+ const key = bodyKey3(desc.name, id);
3582
+ const body = this.bodies.get(key);
3583
+ if (!body) continue;
3584
+ if (body.isSleeping()) {
3585
+ if (this.sleepSynced.has(key)) continue;
3586
+ this.sleepSynced.add(key);
3587
+ } else {
3588
+ this.sleepSynced.delete(key);
3589
+ }
3590
+ const record = tracked.get(id);
3591
+ if (!record) continue;
3592
+ const t = body.translation();
3593
+ const v = body.linvel();
3594
+ const state = {
3595
+ x: t.x,
3596
+ y: t.y,
3597
+ angle: body.rotation(),
3598
+ vx: v.x,
3599
+ vy: v.y,
3600
+ angularVelocity: body.angvel()
3601
+ };
3602
+ for (const [channel, field] of physics.channels) {
3603
+ const next = (rounders[field] ?? identity3)(channelOf2d2(channel, state));
3604
+ if (record[field] !== next) record[field] = next;
3605
+ }
3606
+ }
2926
3607
  }
2927
- const coll = plainEntity(plain, name);
2928
- let added = cd.added;
2929
- for (const id of captured.keys()) {
2930
- if (!added.has(id)) continue;
2931
- const c = captured.get(id);
2932
- if (coll.ownerOf(id) !== c.owner || !deepEqual(coll.get(id), c.value)) continue;
2933
- if (added === cd.added) added = new Set(cd.added);
2934
- added.delete(id);
3608
+ }
3609
+ // -------------------------------------------------------------------------
3610
+ // Hibernation
3611
+ // -------------------------------------------------------------------------
3612
+ /**
3613
+ * The same `PhysicsSection` the 3D runtime writes — a real engine snapshot plus the
3614
+ * entity↔handle map, whose handles must ride as f64 for the reason spelled out on
3615
+ * {@link PhysicsSection}. What tells the two apart on the way back in is the envelope
3616
+ * `core/physics.ts` wraps this in, not anything in here.
3617
+ */
3618
+ serialize() {
3619
+ const bodies = [];
3620
+ for (const [key, body] of this.bodies) {
3621
+ const sep = key.indexOf("\0");
3622
+ bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
2935
3623
  }
2936
- out.set(name, added === cd.added ? cd : { added, removed: cd.removed, updated: cd.updated });
3624
+ const skipped = [...this.skippedKeys];
3625
+ const unsynced = [];
3626
+ for (const [key, body] of this.unsyncedBodies) unsynced.push([key, body.handle]);
3627
+ return {
3628
+ world: this.world.takeSnapshot(),
3629
+ bodies,
3630
+ ...skipped.length > 0 ? { skipped } : {},
3631
+ ...unsynced.length > 0 ? { unsynced } : {}
3632
+ };
2937
3633
  }
2938
- return out;
3634
+ };
3635
+ function identity3(v) {
3636
+ return v;
2939
3637
  }
2940
- function catchUpDirty(ext, plain, fromRole, toRole) {
2941
- const dirty = createDirtySet();
2942
- for (const c of ext.collections) {
2943
- const was = isVisible(c, fromRole);
2944
- const now = isVisible(c, toRole);
2945
- if (was === now) continue;
2946
- if (c.kind === "singleton") {
2947
- if (!now) continue;
2948
- const mask = createFieldMask();
2949
- for (const f of c.fields) markPath(mask, [f.index]);
2950
- collectionDirty(dirty, c.name).updated.set("", { mask, owner: false });
2951
- continue;
2952
- }
2953
- const coll = plainEntity(plain, c.name);
2954
- for (const id of [...coll.ids()]) {
2955
- if (now) markAdd(dirty, c.name, id);
2956
- else markRemove(dirty, c.name, id);
2957
- }
3638
+ function roundersFor3(desc) {
3639
+ const out = {};
3640
+ for (const f of desc.fields) {
3641
+ if (f.type.kind === "f32") out[f.name] = Math.fround;
2958
3642
  }
2959
- return dirty;
3643
+ return out;
2960
3644
  }
2961
3645
 
2962
3646
  // src/core/snapshot.ts
@@ -3089,15 +3773,17 @@ import {
3089
3773
  } from "@irtio/protocol";
3090
3774
  import {
3091
3775
  cloneValue as cloneValue2,
3092
- createDirtySet as createDirtySet2,
3776
+ createDirtySet as createDirtySet3,
3093
3777
  createState,
3094
3778
  decodeSnapshot as decodeSnapshot3,
3095
3779
  encodeDelta as encodeDelta5,
3096
3780
  encodeSnapshot as encodeSnapshot4,
3781
+ filterDirty as filterDirty2,
3097
3782
  isDirtyEmpty as isDirtyEmpty4,
3098
3783
  track,
3099
3784
  validateForDeploy
3100
3785
  } from "@irtio/schema";
3786
+ import { DenyError as DenyError2 } from "@irtio/server";
3101
3787
 
3102
3788
  // src/core/history.ts
3103
3789
  var HISTORY_MAX_TICKS = 240;
@@ -4541,9 +5227,10 @@ function takeClip(core, seconds) {
4541
5227
  if (!replayArmed(core)) {
4542
5228
  return rejectAsEvent(
4543
5229
  core,
4544
- new Error(
4545
- "room.replay.clip: this room type did not declare `replay:`, so nothing is being recorded. Add `replay: { seconds: 60 }` to the room config to arm the ring."
4546
- )
5230
+ // Bug #81: written to fit the platform's 96-byte bound rather than be clamped to it. Every
5231
+ // refusal below is, for the same reason: a message the clamp cuts loses its fix line, which
5232
+ // is the half worth reading.
5233
+ new Error("room.replay.clip: this room did not declare `replay:`, so nothing is recorded")
4547
5234
  );
4548
5235
  }
4549
5236
  const state = stateOf6(core);
@@ -4558,7 +5245,7 @@ function takeClip(core, seconds) {
4558
5245
  return rejectAsEvent(
4559
5246
  core,
4560
5247
  new Error(
4561
- `E_CLIP_COOLDOWN: room.replay.clip allows bursts of ${CLIP_BURST}, refilled one every ${CLIP_COOLDOWN_MS}ms per room; this room is out of clip tokens right now`
5248
+ `E_CLIP_COOLDOWN: room.replay.clip allows ${CLIP_BURST} per room, one refilled every ${CLIP_COOLDOWN_MS}ms`
4562
5249
  )
4563
5250
  );
4564
5251
  }
@@ -4566,7 +5253,7 @@ function takeClip(core, seconds) {
4566
5253
  return rejectAsEvent(
4567
5254
  core,
4568
5255
  new Error(
4569
- `E_CLIP_DISARMED: recording stopped in this room because a single replay keyframe was larger than the ${REPLAY_RING_MAX_BYTES}-byte replay ring; see the warning on this room's log`
5256
+ `E_CLIP_DISARMED: recording stopped: a keyframe was larger than the ${REPLAY_RING_MAX_BYTES}-byte ring`
4570
5257
  )
4571
5258
  );
4572
5259
  }
@@ -4582,7 +5269,7 @@ function takeClip(core, seconds) {
4582
5269
  if (bytes === void 0) {
4583
5270
  return rejectAsEvent(
4584
5271
  core,
4585
- new Error("E_CLIP_EMPTY: the replay ring is empty \u2014 this room has not flushed a change yet")
5272
+ new Error("E_CLIP_EMPTY: the replay ring is empty; this room has not flushed a change yet")
4586
5273
  );
4587
5274
  }
4588
5275
  if (bytes.length > CLIP_MAX_BYTES) {
@@ -4605,9 +5292,7 @@ function takeExcerpt(core, options) {
4605
5292
  if (!replayArmed(core)) {
4606
5293
  return rejectAsEvent(
4607
5294
  core,
4608
- new Error(
4609
- "E_REPLAY_UNARMED: room.replay.excerpt: this room type did not declare `replay:`, so nothing is being recorded. Add `replay: { seconds: 60 }` to the room config to arm the ring."
4610
- )
5295
+ new Error("E_REPLAY_UNARMED: room.replay.excerpt: this room did not declare `replay:`")
4611
5296
  );
4612
5297
  }
4613
5298
  const seconds = options?.seconds;
@@ -4615,7 +5300,7 @@ function takeExcerpt(core, options) {
4615
5300
  return rejectAsEvent(
4616
5301
  core,
4617
5302
  new Error(
4618
- "E_EXCERPT_BAD_ARGS: room.replay.excerpt needs { seconds } as a positive number, got " + JSON.stringify(seconds)
5303
+ "E_EXCERPT_BAD_ARGS: room.replay.excerpt needs { seconds } as a positive number, got " + String(JSON.stringify(seconds)).slice(0, 8)
4619
5304
  )
4620
5305
  );
4621
5306
  }
@@ -4623,7 +5308,7 @@ function takeExcerpt(core, options) {
4623
5308
  return rejectAsEvent(
4624
5309
  core,
4625
5310
  new Error(
4626
- `E_EXCERPT_TOO_LONG: room.replay.excerpt decodes at most ${EXCERPT_MAX_SECONDS} seconds at a time and was asked for ${seconds}. An excerpt is a killcam, not a match; for a whole match use \`replay: { record: true }\`.`
5311
+ `E_EXCERPT_TOO_LONG: at most ${EXCERPT_MAX_SECONDS} seconds an excerpt, and ${seconds} was asked for`
4627
5312
  )
4628
5313
  );
4629
5314
  }
@@ -4632,9 +5317,7 @@ function takeExcerpt(core, options) {
4632
5317
  return rejectAsEvent(
4633
5318
  core,
4634
5319
  new Error(
4635
- `E_EXCERPT_BAD_ARGS: room.replay.excerpt was asked for the collection ${JSON.stringify(
4636
- badName
4637
- )}, which is not an entity collection in this room's schema. Singleton collections are not excerptable.`
5320
+ `E_EXCERPT_BAD_ARGS: ${JSON.stringify(badName)} is not an entity collection here; singletons are not excerptable`
4638
5321
  )
4639
5322
  );
4640
5323
  }
@@ -4643,7 +5326,7 @@ function takeExcerpt(core, options) {
4643
5326
  return rejectAsEvent(
4644
5327
  core,
4645
5328
  new Error(
4646
- `E_EXCERPT_DISARMED: recording stopped in this room because a single replay keyframe was larger than the ${REPLAY_RING_MAX_BYTES}-byte replay ring; see the warning on this room's log`
5329
+ `E_EXCERPT_DISARMED: recording stopped: a keyframe was larger than the ${REPLAY_RING_MAX_BYTES}-byte ring`
4647
5330
  )
4648
5331
  );
4649
5332
  }
@@ -4658,7 +5341,7 @@ function takeExcerpt(core, options) {
4658
5341
  return rejectAsEvent(
4659
5342
  core,
4660
5343
  new Error(
4661
- `E_EXCERPT_COOLDOWN: room.replay.excerpt allows bursts of ${EXCERPT_BURST}, refilled one every ${EXCERPT_COOLDOWN_MS}ms per room; this room is out of excerpt tokens right now`
5344
+ `E_EXCERPT_COOLDOWN: room.replay.excerpt allows ${EXCERPT_BURST} per room, one every ${EXCERPT_COOLDOWN_MS}ms`
4662
5345
  )
4663
5346
  );
4664
5347
  }
@@ -4672,7 +5355,7 @@ function takeExcerpt(core, options) {
4672
5355
  return rejectAsEvent(
4673
5356
  core,
4674
5357
  new Error(
4675
- `E_EXCERPT_TOO_LARGE: this excerpt would materialize about ${err.bytes} bytes of frames and the limit is ${EXCERPT_MAX_BYTES}. Ask for fewer seconds, or name the collections (and ids) you actually need in \`collections\`/\`ids\`.`
5358
+ `E_EXCERPT_TOO_LARGE: about ${err.bytes} bytes and the limit is ${EXCERPT_MAX_BYTES}; narrow collections/ids`
4676
5359
  )
4677
5360
  );
4678
5361
  }
@@ -4848,11 +5531,25 @@ function noteRecordingRepliesDropped(core) {
4848
5531
  // src/core/room-api.ts
4849
5532
  import {
4850
5533
  FrameType as FrameType5,
5534
+ HELLO_ROLE_MAX_BYTES,
4851
5535
  busChannelProblem,
4852
5536
  busPayloadProblem,
4853
5537
  encodeFrame as encodeFrame4
4854
5538
  } from "@irtio/protocol";
4855
- import { encodeDelta as encodeDelta4, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty3 } from "@irtio/schema";
5539
+ import { createDirtySet as createDirtySet2, encodeDelta as encodeDelta4, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty3, utf8Length } from "@irtio/schema";
5540
+ function skipApi(p) {
5541
+ return {
5542
+ skip(collection, id, on = true) {
5543
+ p.setSkipped(collection, id, on);
5544
+ },
5545
+ skipped(collection, id) {
5546
+ return p.isSkipped(collection, id);
5547
+ },
5548
+ // M7 pf3 (a): handed through untouched. The registry is built by the engine runtime, which is
5549
+ // the only place that knows how to build and destroy one of that engine's bodies.
5550
+ unsynced: p.unsynced
5551
+ };
5552
+ }
4856
5553
  function makePhysicsApi(p) {
4857
5554
  return {
4858
5555
  get rapier() {
@@ -4866,7 +5563,8 @@ function makePhysicsApi(p) {
4866
5563
  },
4867
5564
  body(collection, id) {
4868
5565
  return p.bodyFor(collection, id);
4869
- }
5566
+ },
5567
+ ...skipApi(p)
4870
5568
  };
4871
5569
  }
4872
5570
  function makeMatterApi(p) {
@@ -4882,7 +5580,8 @@ function makeMatterApi(p) {
4882
5580
  },
4883
5581
  body(collection, id) {
4884
5582
  return p.bodyFor(collection, id);
4885
- }
5583
+ },
5584
+ ...skipApi(p)
4886
5585
  };
4887
5586
  }
4888
5587
  function makeRapier2dApi(p) {
@@ -4898,6 +5597,17 @@ function makeRapier2dApi(p) {
4898
5597
  },
4899
5598
  body(collection, id) {
4900
5599
  return p.bodyFor(collection, id);
5600
+ },
5601
+ ...skipApi(p)
5602
+ };
5603
+ }
5604
+ function makeMetrics(core) {
5605
+ return {
5606
+ get physicsStepMs() {
5607
+ return core.stats.physicsStepMs;
5608
+ },
5609
+ get physicsStepMsAvg() {
5610
+ return core.stats.physicsStepMsAvg;
4901
5611
  }
4902
5612
  };
4903
5613
  }
@@ -5017,6 +5727,7 @@ function createRoomApi(core, publicUrl) {
5017
5727
  let physicsApi;
5018
5728
  let matterApi;
5019
5729
  let rapier2dApi;
5730
+ let metricsApi;
5020
5731
  let kvApi;
5021
5732
  let busApi;
5022
5733
  let leaderboardApi;
@@ -5076,6 +5787,43 @@ function createRoomApi(core, publicUrl) {
5076
5787
  random() {
5077
5788
  return core.rng.next();
5078
5789
  },
5790
+ // ---- M7 planned feature 3 (f): room.metrics ----
5791
+ // Undercity feedback 2: no timing has ever existed around the platform-stepped world, so a
5792
+ // room that wanted to know what its physics cost had to step its own engine outside the
5793
+ // `physics:` block and lose everything the block does for it. Live getters over `RoomStats`,
5794
+ // not a copied snapshot, so reading `room.metrics` allocates nothing on a tick loop.
5795
+ get metrics() {
5796
+ return metricsApi ?? (metricsApi = makeMetrics(core));
5797
+ },
5798
+ // ---- end M7 planned feature 3 (f) ----
5799
+ // ---- M7 planned feature 3 (g): the stale-writer query ----
5800
+ // Undercity feedback 20. A backgrounded tab stops writing and keeps its socket up, so the
5801
+ // room goes on attacking the last position it reported. Everything needed to see that is
5802
+ // already kept — `lastAppliedTick` is stamped on every judged WRITE for the correction
5803
+ // header (bug 1) — so this is a read over existing bookkeeping and not a new mechanism: no
5804
+ // callback, no config, no wire change, and no opinion about what a room should do about it.
5805
+ // The policy is the room's, and it differs per game (freeze the avatar, stop targeting it,
5806
+ // hand it to an NPC driver, kick it), which is exactly why there is no hook here.
5807
+ writerStaleness(clientId) {
5808
+ const entry = core.clients.get(clientId);
5809
+ if (!entry) return void 0;
5810
+ const since = entry.lastAppliedTick === 0 ? entry.joinedTick : entry.lastAppliedTick;
5811
+ const ticks = core.tick - since;
5812
+ return ticks > 0 ? ticks : 0;
5813
+ },
5814
+ staleWriters(ticks) {
5815
+ if (!Number.isFinite(ticks) || ticks < 0) {
5816
+ throw new Error(`room.staleWriters: ticks must be a non-negative number, got ${ticks}`);
5817
+ }
5818
+ const out = [];
5819
+ for (const entry of core.clients.values()) {
5820
+ if (!entry.connected) continue;
5821
+ const since = entry.lastAppliedTick === 0 ? entry.joinedTick : entry.lastAppliedTick;
5822
+ if (core.tick - since >= ticks) out.push(entry.clientId);
5823
+ }
5824
+ return out;
5825
+ },
5826
+ // ---- end M7 planned feature 3 (g) ----
5079
5827
  // D22. A single object, built once: `room.physics` is read on every tick of a physics room.
5080
5828
  // In a room with no `physics:` config, touching it is a mistake worth naming loudly rather
5081
5829
  // than a silent `undefined` that shows up three frames later as "cannot read world of
@@ -5140,6 +5888,11 @@ function createRoomApi(core, publicUrl) {
5140
5888
  kick(clientId, reason) {
5141
5889
  core.host.kick(clientId, "E_KICKED", reason);
5142
5890
  },
5891
+ // ---- M8 lane 5: moderation ----
5892
+ ban(clientId, options) {
5893
+ return core.banClient(clientId, options?.reason, options?.minutes);
5894
+ },
5895
+ // ---- end M8 lane 5 ----
5143
5896
  close(reason) {
5144
5897
  core.host.close(reason);
5145
5898
  },
@@ -5295,28 +6048,40 @@ function isAlarmName(core, name, where) {
5295
6048
  }
5296
6049
  return true;
5297
6050
  }
6051
+ function checkRoleValue(role) {
6052
+ const bytes = utf8Length(role);
6053
+ if (bytes > HELLO_ROLE_MAX_BYTES) {
6054
+ throw new Error(
6055
+ `room.setRole: role exceeds ${HELLO_ROLE_MAX_BYTES} UTF-8 bytes (${bytes}): ${JSON.stringify(role)}. A longer role cannot be encoded into WELCOME, so the room would fail every later wake.`
6056
+ );
6057
+ }
6058
+ for (let i = 0; i < role.length; i++) {
6059
+ const code = role.charCodeAt(i);
6060
+ if (code > 31 && code !== 127) continue;
6061
+ throw new Error(
6062
+ `room.setRole: role contains a control character (0x${code.toString(16)}): ${JSON.stringify(role)}. Roles are printed into logs, events and operator tooling.`
6063
+ );
6064
+ }
6065
+ }
5298
6066
  function setRole(core, clientId, role) {
5299
6067
  const entry = core.clients.get(clientId);
5300
6068
  if (!entry) {
5301
6069
  core.log("warn", `room.setRole: unknown client ${clientId}`);
5302
6070
  return;
5303
6071
  }
6072
+ checkRoleValue(role);
5304
6073
  const roles = core.definition.schema.roles;
5305
6074
  if (roles.length > 0 && !roles.includes(role)) {
5306
6075
  core.log("warn", `room.setRole: ${JSON.stringify(role)} is not a schema role; ignored`);
5307
6076
  return;
5308
6077
  }
5309
6078
  if (entry.role === role) return;
5310
- const dirty = catchUpDirty(core.ext, core.plain, entry.role, role);
5311
- if (!isDirtyEmpty3(dirty)) {
5312
- core.send(
5313
- clientId,
5314
- encodeFrame4(FrameType5.DELTA, encodeDelta4(core.ext, core.plain, dirty, { tick: core.tick }))
5315
- );
5316
- }
5317
- entry.role = role;
6079
+ const dirty = entry.welcomed ? catchUpDirty(core.ext, core.plain, entry.role, role) : createDirtySet2();
6080
+ const catchUp = isDirtyEmpty3(dirty) ? void 0 : encodeFrame4(FrameType5.DELTA, encodeDelta4(core.ext, core.plain, dirty, { tick: core.tick }));
5318
6081
  const presence = core.anyState.clients.get(clientId);
5319
6082
  if (presence) presence.role = role;
6083
+ entry.role = role;
6084
+ if (catchUp) core.send(clientId, catchUp);
5320
6085
  core.invalidateClients();
5321
6086
  }
5322
6087
  function checkNpcConfig(core, config) {
@@ -5367,6 +6132,7 @@ var NPC_EXAMPLE = "{ brain: { kind: 'script', script: 'chaser' } }";
5367
6132
  // src/core/room.ts
5368
6133
  var DEFAULT_PUBLIC_URL = "http://localhost/";
5369
6134
  var CONTINUATION_FLUSH_TURNS = 8;
6135
+ var ADMIT_BUDGET_MS = 50;
5370
6136
  function ownJoinCapture(plain, name, added, clientId) {
5371
6137
  const coll = plainEntity(plain, name);
5372
6138
  let mine;
@@ -5389,7 +6155,14 @@ var RoomCore = class _RoomCore {
5389
6155
  rng;
5390
6156
  clients = /* @__PURE__ */ new Map();
5391
6157
  loop;
5392
- /** D22: the Rapier world, or `undefined` in a room whose config declares no physics. */
6158
+ /**
6159
+ * D22: the Rapier world, or `undefined` in a room whose config declares no physics.
6160
+ *
6161
+ * Not `readonly`, and the reason is M7 pf3 (a): each builder installs the runtime here *before*
6162
+ * it calls `runSetup`, so `room.physics*` is readable from inside `physics.setup`. That is
6163
+ * where an unsynced body belongs — it is static geometry with a handle — and it is the only
6164
+ * place a matter2d room can put one and have it come back after a wake.
6165
+ */
5393
6166
  physics;
5394
6167
  stats = {
5395
6168
  ticks: 0,
@@ -5406,6 +6179,8 @@ var RoomCore = class _RoomCore {
5406
6179
  gridBuildMs: 0,
5407
6180
  gridQueryMs: 0,
5408
6181
  aoiEncodeMs: 0,
6182
+ physicsStepMs: 0,
6183
+ physicsStepMsAvg: 0,
5409
6184
  visibleIdsTotal: 0,
5410
6185
  membershipEnters: 0,
5411
6186
  membershipLeaves: 0,
@@ -5431,6 +6206,11 @@ var RoomCore = class _RoomCore {
5431
6206
  * end of every tick, which is the only moment in a tick where that state is settled.
5432
6207
  */
5433
6208
  recorder;
6209
+ /**
6210
+ * M7 pf4: the RPC trace ring, constructed only when `RoomCoreOptions.traceRpc` asked for one.
6211
+ * Every cost this lane has is behind this `undefined` — see `core/rpc-trace.ts`.
6212
+ */
6213
+ rpcTraceRing;
5434
6214
  /**
5435
6215
  * D65: the bandwidth ledger, present only when `RoomCoreOptions.profile` asked for one. Every
5436
6216
  * cost the profiler has is behind this `undefined`.
@@ -5456,6 +6236,7 @@ var RoomCore = class _RoomCore {
5456
6236
  this.mode = definition.config.mode;
5457
6237
  this.ext = withBuiltins2(definition.schema);
5458
6238
  this.ledger = options.profile === true ? new ProfileLedger(this.ext, definition.schema) : void 0;
6239
+ this.rpcTraceRing = options.traceRpc === true ? new RpcTraceRing() : void 0;
5459
6240
  for (const issue of validateForDeploy(this.ext)) {
5460
6241
  if (issue.level === "error") throw new Error(`RoomCore: ${issue.message}`);
5461
6242
  host.log("warn", [`irtio: ${issue.message}`]);
@@ -5469,7 +6250,13 @@ var RoomCore = class _RoomCore {
5469
6250
  restored = candidate;
5470
6251
  }
5471
6252
  if (restored) {
5472
- plain = decodeSnapshot3(this.ext, restored.snapshot).state;
6253
+ plain = decodeSnapshot3(this.ext, restored.snapshot, {
6254
+ onNonFinite: (path, value) => {
6255
+ host.log("warn", [
6256
+ `irtio: room ${options.roomId}: restored ${path} held ${value}; reset to its default`
6257
+ ]);
6258
+ }
6259
+ }).state;
5473
6260
  const presence = plainEntity(plain, PRESENCE_COLLECTION);
5474
6261
  for (const id of [...presence.ids()]) presence.remove(id);
5475
6262
  if (restored.mode !== this.mode) {
@@ -5636,6 +6423,7 @@ var RoomCore = class _RoomCore {
5636
6423
  `irtio: no physics world in this snapshot (format v${restored.version}) \u2014 rebuilding it from schema state and re-running physics.setup; contact state is not restored`
5637
6424
  ]);
5638
6425
  }
6426
+ this.physics = physics;
5639
6427
  physics.runSetup(this.room);
5640
6428
  physics.reconcile();
5641
6429
  }
@@ -5675,6 +6463,7 @@ var RoomCore = class _RoomCore {
5675
6463
  `irtio: no matter2d world in this snapshot (format v${restored.version}). Rebuilding it from schema state and re-running physics.setup; contact state is not restored.`
5676
6464
  ]);
5677
6465
  }
6466
+ this.physics = physics;
5678
6467
  physics.runSetup(this.room);
5679
6468
  physics.reconcile();
5680
6469
  return physics;
@@ -5712,6 +6501,7 @@ var RoomCore = class _RoomCore {
5712
6501
  `irtio: no rapier2d world in this snapshot (format v${restored.version}) \u2014 rebuilding it from schema state and re-running physics.setup; contact state is not restored`
5713
6502
  ]);
5714
6503
  }
6504
+ this.physics = physics;
5715
6505
  physics.runSetup(this.room);
5716
6506
  physics.reconcile();
5717
6507
  }
@@ -5781,6 +6571,25 @@ var RoomCore = class _RoomCore {
5781
6571
  captureTimeline() {
5782
6572
  this.recorder?.capture(this.tick, this.ext, this.plain);
5783
6573
  }
6574
+ /**
6575
+ * M7 pf4: file one RPC on the trace. Called from `core/rpc.ts` on every exit path a call has,
6576
+ * and a no-op — not merely a cheap one, an untaken branch — in a room nobody armed.
6577
+ */
6578
+ recordRpc(entry) {
6579
+ this.rpcTraceRing?.push(entry);
6580
+ }
6581
+ /**
6582
+ * M7 pf4: whether this room has a trace ring at all. Read **before** an entry is built, so an
6583
+ * unarmed room pays one property read per call rather than building an entry object and
6584
+ * serializing a result for `recordRpc` to drop on the floor.
6585
+ */
6586
+ get rpcTraceArmed() {
6587
+ return this.rpcTraceRing !== void 0;
6588
+ }
6589
+ /** M7 pf4: the trace so far, or `undefined` when this room was not armed. */
6590
+ rpcTrace() {
6591
+ return this.rpcTraceRing?.dump();
6592
+ }
5784
6593
  // ---- M6 lane F: rewind ----
5785
6594
  /**
5786
6595
  * D72: record this tick's body poses, right after `physics.sync()` — the poses the clients are
@@ -5879,9 +6688,23 @@ var RoomCore = class _RoomCore {
5879
6688
  // ---- end M6 lane K ----
5880
6689
  );
5881
6690
  }
6691
+ /**
6692
+ * The earliest pending room timer's due time at the last `serialize()`, on `host.now()`'s
6693
+ * clock, or `undefined` if nothing was armed.
6694
+ *
6695
+ * `serialize()` is the departure, and it drops every room timer because a callback is a closure
6696
+ * and there is nothing to persist. The *deadline* is persistable, though, and this is where the
6697
+ * worker reads it to hand to the supervisor, which arms a wake for it. The contract room code
6698
+ * gets: your callback does not survive hibernation, but your room is awake again at or shortly
6699
+ * after the time it would have fired — so keep the deadline in state and catch up in `onWake`.
6700
+ *
6701
+ * Captured before `clearAllTimers()`, which is the only ordering that could ever have worked.
6702
+ */
6703
+ lastSerializeTimerAt;
5882
6704
  serialize() {
5883
6705
  const onSleep = this.definition.config.onSleep;
5884
6706
  if (onSleep) this.guard("onSleep", () => onSleep(this.state, this.room));
6707
+ this.lastSerializeTimerAt = this.loop.earliestTimerAt();
5885
6708
  this.loop.clearAllTimers();
5886
6709
  cancelAllAsks(this.internals);
5887
6710
  rejectAllPending(this.internals, "room is hibernating");
@@ -5914,6 +6737,92 @@ var RoomCore = class _RoomCore {
5914
6737
  );
5915
6738
  return roles[0] ?? "";
5916
6739
  }
6740
+ // ---- M8 lane 5: moderation ----
6741
+ /**
6742
+ * Subjects `room.ban` has excluded, and when each stops being excluded. Values are wall-clock
6743
+ * deadlines from `host.now()`; `Number.POSITIVE_INFINITY` is the default, "until this room
6744
+ * closes".
6745
+ *
6746
+ * In memory and nowhere else, by decision. It is not in `state`, so it is not in a snapshot, so a
6747
+ * hibernation drops it and a wake starts clean — the same call relay audience membership made,
6748
+ * and for the same reason: this is a fact about a sitting, not about a save file. Exclusion that
6749
+ * outlives a wake is what the control plane's `project_bans` is for, and that one is durable
6750
+ * because it is a row in a database rather than a `Map` in a worker.
6751
+ */
6752
+ banned = /* @__PURE__ */ new Map();
6753
+ /**
6754
+ * The room's own refusal, before a seat exists. Throws {@link AdmissionError} or returns.
6755
+ *
6756
+ * Order matters and is deliberate: the cooldown is checked first, so a subject the room already
6757
+ * threw out cannot spend the room's `onAdmit` budget on every reconnect attempt.
6758
+ */
6759
+ admit(clientId, options) {
6760
+ const subject = options.subject;
6761
+ if (subject !== void 0) {
6762
+ const until = this.banned.get(subject);
6763
+ if (until !== void 0) {
6764
+ if (until > this.host.now()) {
6765
+ throw new AdmissionError("banned from this room");
6766
+ }
6767
+ this.banned.delete(subject);
6768
+ }
6769
+ }
6770
+ const onAdmit = this.definition.config.onAdmit;
6771
+ if (!onAdmit) return;
6772
+ const ctx = {
6773
+ clientId,
6774
+ subject,
6775
+ role: this.resolveRole(options.role, void 0),
6776
+ name: options.name ?? "",
6777
+ tick: this.tick,
6778
+ deny: (reason) => {
6779
+ throw new DenyError2(reason);
6780
+ },
6781
+ require: (condition, reason) => {
6782
+ if (!condition) throw new DenyError2(reason);
6783
+ }
6784
+ };
6785
+ const startedAt = this.host.now();
6786
+ let denied;
6787
+ const ran = this.tryRun("onAdmit", () => {
6788
+ try {
6789
+ onAdmit(this.state, ctx);
6790
+ } catch (err) {
6791
+ if (err instanceof DenyError2) {
6792
+ denied = err.reason;
6793
+ return;
6794
+ }
6795
+ throw err;
6796
+ }
6797
+ });
6798
+ const elapsed = this.host.now() - startedAt;
6799
+ if (elapsed > ADMIT_BUDGET_MS) {
6800
+ this.log(
6801
+ "error",
6802
+ `onAdmit took ${Math.round(elapsed)}ms (budget ${ADMIT_BUDGET_MS}ms); join refused`
6803
+ );
6804
+ throw new AdmissionError("admission timed out");
6805
+ }
6806
+ if (denied !== void 0) throw new AdmissionError(denied);
6807
+ if (!ran.ok) throw new AdmissionError("admission check failed");
6808
+ }
6809
+ /**
6810
+ * `room.ban`: kick this client and keep its subject out for a while.
6811
+ *
6812
+ * Returns the subject it excluded, or `undefined` when there was nothing durable to exclude —
6813
+ * a key join, whose only name is a client id it can mint again. The kick happens either way,
6814
+ * because a kick is a real thing to do to a session even when the exclusion cannot follow it.
6815
+ */
6816
+ banClient(clientId, reason, minutes) {
6817
+ this.host.kick(clientId, "E_KICKED", reason);
6818
+ const subject = this.clients.get(clientId)?.subject;
6819
+ if (subject === void 0) return void 0;
6820
+ const until = minutes === void 0 || !Number.isFinite(minutes) || minutes <= 0 ? Number.POSITIVE_INFINITY : this.host.now() + minutes * 6e4;
6821
+ const existing = this.banned.get(subject) ?? 0;
6822
+ this.banned.set(subject, Math.max(existing, until));
6823
+ return subject;
6824
+ }
6825
+ // ---- end M8 lane 5 ----
5917
6826
  join(clientId, options = {}) {
5918
6827
  const existing = this.clients.get(clientId);
5919
6828
  if (this.stopped) {
@@ -5933,6 +6842,8 @@ var RoomCore = class _RoomCore {
5933
6842
  }
5934
6843
  const presence = this.presence;
5935
6844
  const maxClients = this.definition.config.maxClients;
6845
+ const resuming = options.reconnecting === true || presence.has(clientId);
6846
+ if (!resuming) this.admit(clientId, options);
5936
6847
  if (!presence.has(clientId) && presence.size >= maxClients) {
5937
6848
  throw new RoomFullError(maxClients);
5938
6849
  }
@@ -5966,20 +6877,29 @@ var RoomCore = class _RoomCore {
5966
6877
  // the other tab" rather than as a line in this file.
5967
6878
  // ---- end M6 lane K ----
5968
6879
  playerId: options.playerId ?? clientId,
6880
+ // M8 lane 5: the narrower fact beside the one above. See `JoinOptions.subject`.
6881
+ subject: options.subject,
5969
6882
  npc: options.npc === true,
5970
6883
  role,
5971
6884
  name,
5972
6885
  connected: true,
6886
+ welcomed: false,
5973
6887
  correction: void 0,
5974
6888
  accepted: /* @__PURE__ */ new Map(),
5975
6889
  lastClientTick: 0,
5976
6890
  lastAppliedTick: 0,
6891
+ // M7 pf3 (g): the baseline `room.writerStaleness` measures from until this client's first
6892
+ // write lands. Without it a client that has just joined reads as `room.tick` ticks stale,
6893
+ // so in a room that has been up an hour every new joiner is instantly the worst offender.
6894
+ joinedTick: this.tick,
5977
6895
  pendingJoinAdds: void 0,
5978
6896
  spatialMembership: /* @__PURE__ */ new Map()
5979
6897
  };
5980
6898
  entry.role = role;
5981
6899
  entry.name = name;
5982
6900
  entry.connected = true;
6901
+ entry.welcomed = false;
6902
+ entry.joinedTick = this.tick;
5983
6903
  this.clients.set(clientId, entry);
5984
6904
  this.invalidateClients();
5985
6905
  reclaimHolds(this.internals, clientId, entry.playerId);
@@ -5994,6 +6914,7 @@ var RoomCore = class _RoomCore {
5994
6914
  const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
5995
6915
  entry.spatialMembership = memberships;
5996
6916
  const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
6917
+ entry.welcomed = true;
5997
6918
  this.ledger?.attributeSnapshot("out", snapshot);
5998
6919
  const pending = /* @__PURE__ */ new Map();
5999
6920
  for (const [name2, cd] of this.tracked.dirty) {
@@ -6039,6 +6960,7 @@ var RoomCore = class _RoomCore {
6039
6960
  this.presence.remove(clientId);
6040
6961
  this.recordEvent("leave", clientId, reason);
6041
6962
  rejectPendingFor(this.internals, clientId, "client disconnected");
6963
+ this.rpcTraceRing?.forget(clientId);
6042
6964
  this.loop.dropFramesFor(clientId);
6043
6965
  this.clients.delete(clientId);
6044
6966
  this.stats.bytesOutByClient.delete(clientId);
@@ -6071,7 +6993,15 @@ var RoomCore = class _RoomCore {
6071
6993
  // other entry point (join, leave, write, ownership) has no client tick by construction.
6072
6994
  clientTick: void 0,
6073
6995
  reconnecting,
6074
- room: this.room
6996
+ room: this.room,
6997
+ // M7 pf4 (a): on every ctx rather than only the RPC one, because the type says it is there.
6998
+ // `rpc.ts` is what turns the throw into an `ok: false` reply; anywhere else it is a throw.
6999
+ deny: (reason) => {
7000
+ throw new DenyError2(reason);
7001
+ },
7002
+ require: (condition, reason) => {
7003
+ if (!condition) throw new DenyError2(reason);
7004
+ }
6075
7005
  };
6076
7006
  }
6077
7007
  /**
@@ -6138,7 +7068,7 @@ var RoomCore = class _RoomCore {
6138
7068
  correctionFor(clientId) {
6139
7069
  const entry = this.clients.get(clientId);
6140
7070
  if (!entry) return void 0;
6141
- if (!entry.correction) entry.correction = createDirtySet2();
7071
+ if (!entry.correction) entry.correction = createDirtySet3();
6142
7072
  return entry.correction;
6143
7073
  }
6144
7074
  invalidateClients() {
@@ -6296,9 +7226,13 @@ var RoomCore = class _RoomCore {
6296
7226
  if (crossed) (churn ??= /* @__PURE__ */ new Map()).set(name, crossed);
6297
7227
  }
6298
7228
  if (entry.correction) {
7229
+ const keep = visibleNames(this.ext, entry.role);
6299
7230
  const payload = encodeCorrection(
6300
7231
  this.internals,
6301
- membershipSafeDirty(this.ext, entry.correction, memberships)
7232
+ filterDirty2(
7233
+ membershipSafeDirty(this.ext, entry.correction, memberships),
7234
+ (name) => keep.has(name)
7235
+ )
6302
7236
  );
6303
7237
  if (payload) {
6304
7238
  this.send(
@@ -6398,6 +7332,8 @@ function encodeSection(physics) {
6398
7332
  export {
6399
7333
  HOST_CALL_TIMEOUT_MS,
6400
7334
  RoomFullError,
7335
+ AdmissionError,
7336
+ PHYSICS_STEP_EMA_ALPHA,
6401
7337
  RELAY_STATE_BYTES,
6402
7338
  RELAY_RECORDS_PER_COLLECTION,
6403
7339
  RELAY_WRITES_PER_SECOND,
@@ -6405,14 +7341,22 @@ export {
6405
7341
  RELAY_WRITE_CAPS,
6406
7342
  EVENT_RING_SIZE,
6407
7343
  inspectState,
7344
+ HOLD_ACTIONS,
7345
+ RPC_TRACE_SIZE,
7346
+ RPC_TRACE_SUMMARY_MAX,
7347
+ summarize,
7348
+ RpcTraceRing,
7349
+ isVisible,
7350
+ visibleTo,
7351
+ visibleNames,
7352
+ viewKeyFor,
7353
+ encodeViewSnapshot,
7354
+ createVisibilityPolicy,
7355
+ encodeViewDelta,
7356
+ catchUpDirty,
6408
7357
  RPC_TIMEOUT_MS,
6409
7358
  MAX_CATCHUP,
6410
7359
  CRASH_AFTER_THROWS,
6411
- initMatter,
6412
- loadedMatter,
6413
- resetMatterForTests,
6414
- encodeMatterBodies,
6415
- decodeMatterBodies,
6416
7360
  initPhysics,
6417
7361
  loadedPhysics,
6418
7362
  resetPhysicsForTests,
@@ -6425,21 +7369,19 @@ export {
6425
7369
  encodeMatterSectionEnvelope,
6426
7370
  decodeMatterSectionEnvelope,
6427
7371
  decodePhysicsSection,
7372
+ initMatter,
7373
+ loadedMatter,
7374
+ resetMatterForTests,
7375
+ encodeMatterBodies,
7376
+ decodeMatterBodies,
6428
7377
  Mulberry32,
6429
7378
  initRapier2d,
6430
7379
  loadedRapier2d,
6431
7380
  resetRapier2dForTests,
6432
- isVisible,
6433
- visibleTo,
6434
- visibleNames,
6435
- viewKeyFor,
6436
- encodeViewSnapshot,
6437
- createVisibilityPolicy,
6438
- encodeViewDelta,
6439
- catchUpDirty,
6440
7381
  SNAPSHOT_FORMAT_VERSION,
6441
7382
  READABLE_SNAPSHOT_VERSIONS,
6442
7383
  parseHibernationBlob,
7384
+ sameSchemaHash,
6443
7385
  writeHibernationBlob,
6444
7386
  decodeSave,
6445
7387
  DEFAULT_TIMELINE_MAX_TICKS,