@irtio/runtime 0.1.0 → 0.3.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.
@@ -1,4 +1,5 @@
1
1
  // src/contract.ts
2
+ var HOST_CALL_TIMEOUT_MS = 1e4;
2
3
  var RoomFullError = class extends Error {
3
4
  constructor(maxClients) {
4
5
  super(`room is full (maxClients=${maxClients})`);
@@ -420,7 +421,10 @@ function applyWrite(core, clientId, payload) {
420
421
  return { ok: false };
421
422
  }
422
423
  const judged = core.clients.get(clientId);
423
- if (judged && delta.tick > judged.lastClientTick) judged.lastClientTick = delta.tick;
424
+ if (judged && delta.tick > judged.lastClientTick) {
425
+ judged.lastClientTick = delta.tick;
426
+ judged.lastAppliedTick = core.tick;
427
+ }
424
428
  for (const dc of delta.collections) {
425
429
  const c = collectionDescOf(core.ext, dc.name);
426
430
  if (!c) continue;
@@ -463,6 +467,26 @@ function applyOp(core, clientId, c, op) {
463
467
  const prev = cloneValue(current);
464
468
  const next = cloneValue(current);
465
469
  applyMaskedPatch(fields, next, op.patch, op.mask);
470
+ const requested = cloneValue(next);
471
+ const simulated = c.physics;
472
+ if (simulated) {
473
+ const refused = [];
474
+ for (const leaf of leafPaths(fields, op.mask)) {
475
+ const field = leaf.names[0];
476
+ if (simulated.intents.includes(field)) continue;
477
+ next[field] = cloneValue(prev[field]);
478
+ refused.push(
479
+ simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`
480
+ );
481
+ }
482
+ if (refused.length > 0) {
483
+ core.recordEvent("write-rejected", clientId, `${c.name}: ${refused.join(", ")}`);
484
+ core.log(
485
+ "warn",
486
+ `WRITE to ${c.name}.${op.id} from ${clientId} touched fields the owner does not write: ${refused.join(", ")}`
487
+ );
488
+ }
489
+ }
466
490
  const validator = core.definition.config.validate?.[c.name];
467
491
  let result = next;
468
492
  if (validator) {
@@ -493,7 +517,7 @@ function applyOp(core, clientId, c, op) {
493
517
  for (const leaf of written) map.set(leafKey(leaf), cloneValue(valueAt(result, leaf.names)));
494
518
  }
495
519
  const corrected = written.filter(
496
- (leaf) => !deepEqual(valueAt(result, leaf.names), valueAt(next, leaf.names))
520
+ (leaf) => !deepEqual(valueAt(result, leaf.names), valueAt(requested, leaf.names))
497
521
  );
498
522
  correctLeaves(core, clientId, c.name, op.id, corrected);
499
523
  }
@@ -567,7 +591,7 @@ var Loop = class {
567
591
  this.lastActivity = this.lastWake;
568
592
  this.accumulator = 0;
569
593
  if (this.core.mode === "tick") this.scheduleTick();
570
- else this.armIdle(this.core.definition.config.idleMs);
594
+ this.armIdle(this.core.definition.config.idleMs);
571
595
  }
572
596
  stop() {
573
597
  this.running = false;
@@ -642,7 +666,7 @@ var Loop = class {
642
666
  }
643
667
  if (this.running && !this.core.stopped) this.scheduleTick();
644
668
  }
645
- /** One tick: inbound → room timers → `tick(state, dt, room)` → flush. */
669
+ /** One tick: inbound → room timers → `tick(state, dt, room)` → physics step → flush. */
646
670
  runTick() {
647
671
  const host = this.core.host;
648
672
  const started = host.now();
@@ -651,6 +675,7 @@ var Loop = class {
651
675
  this.fireDueTimers();
652
676
  checkTimeouts(this.core);
653
677
  const config = this.core.definition.config;
678
+ let failed;
654
679
  if (config.tick) {
655
680
  const fn = config.tick;
656
681
  const dt = 1 / config.tickRate;
@@ -658,17 +683,29 @@ var Loop = class {
658
683
  "tick",
659
684
  () => fn(this.core.anyState, dt, this.core.room)
660
685
  );
661
- if (ran.ok) this.consecutiveThrows = 0;
662
- else {
663
- this.consecutiveThrows++;
664
- if (this.consecutiveThrows >= CRASH_AFTER_THROWS) {
665
- this.core.flush();
666
- const reason = `tick() threw ${this.consecutiveThrows} times in a row`;
667
- this.core.stopped = true;
668
- this.stop();
669
- host.crashed(reason);
670
- return;
671
- }
686
+ if (!ran.ok) failed = "tick()";
687
+ }
688
+ const physics = this.core.physics;
689
+ if (physics) {
690
+ const ran = this.core.tryRun("physics", () => {
691
+ physics.reconcile();
692
+ physics.step();
693
+ physics.sync();
694
+ });
695
+ if (!ran.ok) {
696
+ failed = failed === void 0 ? "the physics step" : `${failed} and the physics step`;
697
+ }
698
+ }
699
+ if (failed === void 0) this.consecutiveThrows = 0;
700
+ else {
701
+ this.consecutiveThrows++;
702
+ if (this.consecutiveThrows >= CRASH_AFTER_THROWS) {
703
+ this.core.flush();
704
+ const reason = `${failed} threw ${this.consecutiveThrows} times in a row`;
705
+ this.core.stopped = true;
706
+ this.stop();
707
+ host.crashed(reason);
708
+ return;
672
709
  }
673
710
  }
674
711
  this.core.flush();
@@ -697,7 +734,6 @@ var Loop = class {
697
734
  return ok;
698
735
  }
699
736
  noteActivity() {
700
- if (this.core.mode !== "event") return;
701
737
  this.lastActivity = this.core.host.now();
702
738
  this.slept = false;
703
739
  if (this.running && this.idleHandle === void 0) {
@@ -711,6 +747,12 @@ var Loop = class {
711
747
  this.idleHandle = void 0;
712
748
  if (!this.running || this.core.stopped) return;
713
749
  const idleMs = this.core.definition.config.idleMs;
750
+ if (idleMs <= 0) return;
751
+ if (this.core.mode === "tick" && this.core.clients.size > 0) {
752
+ this.lastActivity = this.core.host.now();
753
+ this.armIdle(idleMs);
754
+ return;
755
+ }
714
756
  const waited = this.core.host.now() - this.lastActivity;
715
757
  if (waited >= idleMs) {
716
758
  if (!this.slept) {
@@ -774,6 +816,351 @@ var Loop = class {
774
816
  }
775
817
  };
776
818
 
819
+ // src/core/physics.ts
820
+ import { ByteReader, ByteWriter } from "@irtio/schema";
821
+ var engine;
822
+ var loading;
823
+ async function initPhysics() {
824
+ if (engine) return engine;
825
+ loading ??= (async () => {
826
+ const mod = await import("@dimforge/rapier3d-compat");
827
+ const ns = mod.default ?? mod;
828
+ await ns.init();
829
+ engine = ns;
830
+ return ns;
831
+ })();
832
+ return loading;
833
+ }
834
+ function loadedPhysics() {
835
+ return engine;
836
+ }
837
+ function resetPhysicsForTests() {
838
+ engine = void 0;
839
+ loading = void 0;
840
+ }
841
+ function encodePhysicsSection(section) {
842
+ const w = new ByteWriter(section.world.length + section.bodies.length * 24 + 8);
843
+ w.blob(section.world);
844
+ w.varint(section.bodies.length);
845
+ for (const [name, id, handle] of section.bodies) {
846
+ w.str(name);
847
+ w.str(id);
848
+ w.f64(handle);
849
+ }
850
+ return w.finish();
851
+ }
852
+ function decodePhysicsSection(bytes) {
853
+ const r = new ByteReader(bytes);
854
+ const world = r.blob().slice();
855
+ const count = r.varint();
856
+ const bodies = [];
857
+ for (let i = 0; i < count; i++) bodies.push([r.str(), r.str(), r.f64()]);
858
+ return { world, bodies };
859
+ }
860
+ function bodyKey(collection, id) {
861
+ return `${collection}\0${id}`;
862
+ }
863
+ function planarLockWarning(spec) {
864
+ const boxed = (spec.colliders ?? []).some(
865
+ (c) => c.shape.type === CUBOID_SHAPE || c.shape.type === ROUND_CUBOID_SHAPE
866
+ );
867
+ if (!boxed) return void 0;
868
+ const b = spec.body;
869
+ const t = [b.translationsEnabledX, b.translationsEnabledY, b.translationsEnabledZ];
870
+ const r = [b.rotationsEnabledX, b.rotationsEnabledY, b.rotationsEnabledZ];
871
+ const axes = ["x", "y", "z"];
872
+ for (let i = 0; i < 3; i++) {
873
+ if (t[i]) continue;
874
+ if (r[(i + 1) % 3] || r[(i + 2) % 3]) continue;
875
+ const free = axes[i] === "z" ? "enabledRotations(true, false, true)" : "one of the other two rotations";
876
+ 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.`;
877
+ }
878
+ return void 0;
879
+ }
880
+ var CUBOID_SHAPE = 1;
881
+ var ROUND_CUBOID_SHAPE = 12;
882
+ var PhysicsRuntime = class {
883
+ rapier;
884
+ world;
885
+ /** `true` when the world was built from scratch and `setup` has to run. */
886
+ rebuilt;
887
+ core;
888
+ config;
889
+ /** Physics-backed collections, in schema (name-sorted) order. */
890
+ collections;
891
+ bodies = /* @__PURE__ */ new Map();
892
+ /** Collections already warned about the friction-killing 2D lock recipe (bug 6). */
893
+ warnedPlanar = /* @__PURE__ */ new Set();
894
+ /**
895
+ * Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
896
+ * to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
897
+ * tick it falls asleep, or the schema keeps a phantom residual velocity forever (and a
898
+ * world rebuilt from schema state would wake it with a kick). Found by the drift check.
899
+ */
900
+ sleepSynced = /* @__PURE__ */ new Set();
901
+ constructor(core, rapier, options) {
902
+ const config = core.definition.config.physics;
903
+ if (!config) throw new Error("PhysicsRuntime: the room config declares no physics");
904
+ this.core = core;
905
+ this.config = config;
906
+ this.rapier = rapier;
907
+ this.collections = core.ext.collections.filter(
908
+ (c) => c.physics !== void 0
909
+ );
910
+ if (options.restore) {
911
+ this.world = rapier.World.restoreSnapshot(options.restore.world);
912
+ this.rebuilt = false;
913
+ for (const [name, id, handle] of options.restore.bodies) {
914
+ const body = this.world.getRigidBody(handle);
915
+ if (body) this.bodies.set(bodyKey(name, id), body);
916
+ }
917
+ } else {
918
+ const g = config.gravity;
919
+ this.world = new rapier.World({ x: g.x, y: g.y, z: g.z });
920
+ this.rebuilt = true;
921
+ }
922
+ this.world.timestep = config.timestep ?? options.defaultTimestep;
923
+ }
924
+ get timestep() {
925
+ return this.world.timestep;
926
+ }
927
+ /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
928
+ runSetup(room) {
929
+ const setup = this.config.setup;
930
+ if (!setup) return;
931
+ this.core.guard("physics.setup", () => setup(this.world, this.rapier, room));
932
+ }
933
+ free() {
934
+ this.bodies.clear();
935
+ this.world.free();
936
+ }
937
+ // -------------------------------------------------------------------------
938
+ // Bodies
939
+ // -------------------------------------------------------------------------
940
+ /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
941
+ bodyFor(collection, id) {
942
+ const existing = this.bodies.get(bodyKey(collection, id));
943
+ if (existing) return existing;
944
+ const desc = this.collections.find((c) => c.name === collection);
945
+ if (!desc) return void 0;
946
+ const coll = plainEntity(this.core.plain, collection);
947
+ const record = coll.get(id);
948
+ if (record === void 0) return void 0;
949
+ return this.create(desc, id, record);
950
+ }
951
+ create(desc, id, record) {
952
+ const factory = this.config.bodies?.[desc.name];
953
+ if (!factory) {
954
+ this.core.log("error", `irtio: physics.bodies.${desc.name} is missing; no body created`);
955
+ return void 0;
956
+ }
957
+ const spec = this.core.guard(
958
+ `physics.bodies.${desc.name}`,
959
+ () => factory(this.rapier, record, id)
960
+ );
961
+ if (!spec || !spec.body) {
962
+ this.core.log(
963
+ "error",
964
+ `irtio: physics.bodies.${desc.name} returned no { body } for ${JSON.stringify(id)}`
965
+ );
966
+ return void 0;
967
+ }
968
+ const planar = planarLockWarning(spec);
969
+ if (planar !== void 0 && !this.warnedPlanar.has(desc.name)) {
970
+ this.warnedPlanar.add(desc.name);
971
+ this.core.log("warn", `irtio: physics.bodies.${desc.name} ${planar}`);
972
+ }
973
+ const body = this.world.createRigidBody(spec.body);
974
+ for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
975
+ this.applyRecordToBody(desc, body, record);
976
+ this.bodies.set(bodyKey(desc.name, id), body);
977
+ return body;
978
+ }
979
+ applyRecordToBody(desc, body, record) {
980
+ const physics = desc.physics;
981
+ if (!physics) return;
982
+ const t = { ...body.translation() };
983
+ const r = { ...body.rotation() };
984
+ const v = { ...body.linvel() };
985
+ const w = { ...body.angvel() };
986
+ let setT = false;
987
+ let setR = false;
988
+ let setV = false;
989
+ let setW = false;
990
+ for (const [channel, field] of physics.channels) {
991
+ const raw = record[field];
992
+ if (typeof raw !== "number") continue;
993
+ switch (channel) {
994
+ case "x":
995
+ case "y":
996
+ case "z":
997
+ t[channel] = raw;
998
+ setT = true;
999
+ break;
1000
+ case "qx":
1001
+ r.x = raw;
1002
+ setR = true;
1003
+ break;
1004
+ case "qy":
1005
+ r.y = raw;
1006
+ setR = true;
1007
+ break;
1008
+ case "qz":
1009
+ r.z = raw;
1010
+ setR = true;
1011
+ break;
1012
+ case "qw":
1013
+ r.w = raw;
1014
+ setR = true;
1015
+ break;
1016
+ case "vx":
1017
+ v.x = raw;
1018
+ setV = true;
1019
+ break;
1020
+ case "vy":
1021
+ v.y = raw;
1022
+ setV = true;
1023
+ break;
1024
+ case "vz":
1025
+ v.z = raw;
1026
+ setV = true;
1027
+ break;
1028
+ case "wx":
1029
+ w.x = raw;
1030
+ setW = true;
1031
+ break;
1032
+ case "wy":
1033
+ w.y = raw;
1034
+ setW = true;
1035
+ break;
1036
+ case "wz":
1037
+ w.z = raw;
1038
+ setW = true;
1039
+ break;
1040
+ }
1041
+ }
1042
+ if (setT) body.setTranslation(t, true);
1043
+ if (setR) body.setRotation(r, true);
1044
+ if (setV) body.setLinvel(v, true);
1045
+ if (setW) body.setAngvel(w, true);
1046
+ }
1047
+ /**
1048
+ * Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
1049
+ * tick, right before the step, in collection order then instance order.
1050
+ */
1051
+ reconcile() {
1052
+ const live = /* @__PURE__ */ new Set();
1053
+ for (const desc of this.collections) {
1054
+ const coll = plainEntity(this.core.plain, desc.name);
1055
+ for (const id of coll.ids()) {
1056
+ const key = bodyKey(desc.name, id);
1057
+ live.add(key);
1058
+ if (this.bodies.has(key)) continue;
1059
+ const record = coll.get(id);
1060
+ if (record !== void 0) this.create(desc, id, record);
1061
+ }
1062
+ }
1063
+ for (const [key, body] of [...this.bodies]) {
1064
+ if (live.has(key)) continue;
1065
+ this.bodies.delete(key);
1066
+ this.sleepSynced.delete(key);
1067
+ this.world.removeRigidBody(body);
1068
+ }
1069
+ }
1070
+ // -------------------------------------------------------------------------
1071
+ // Step and sync
1072
+ // -------------------------------------------------------------------------
1073
+ step() {
1074
+ this.world.step();
1075
+ }
1076
+ /**
1077
+ * Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
1078
+ * Values are `Math.fround`ed for f32 fields, so what room code reads is exactly what the wire
1079
+ * carries — and so a field that has not really moved does not re-dirty every tick.
1080
+ */
1081
+ sync() {
1082
+ for (const desc of this.collections) {
1083
+ const physics = desc.physics;
1084
+ if (!physics) continue;
1085
+ const tracked = this.core.anyState[desc.name];
1086
+ const plainColl = plainEntity(this.core.plain, desc.name);
1087
+ const rounders = roundersFor(desc);
1088
+ for (const id of plainColl.ids()) {
1089
+ const key = bodyKey(desc.name, id);
1090
+ const body = this.bodies.get(key);
1091
+ if (!body) continue;
1092
+ if (body.isSleeping()) {
1093
+ if (this.sleepSynced.has(key)) continue;
1094
+ this.sleepSynced.add(key);
1095
+ } else {
1096
+ this.sleepSynced.delete(key);
1097
+ }
1098
+ const record = tracked.get(id);
1099
+ if (!record) continue;
1100
+ const t = body.translation();
1101
+ const r = body.rotation();
1102
+ const v = body.linvel();
1103
+ const w = body.angvel();
1104
+ for (const [channel, field] of physics.channels) {
1105
+ const next = (rounders[field] ?? identity)(channelValue(channel, t, r, v, w));
1106
+ if (record[field] !== next) record[field] = next;
1107
+ }
1108
+ }
1109
+ }
1110
+ }
1111
+ // -------------------------------------------------------------------------
1112
+ // Hibernation
1113
+ // -------------------------------------------------------------------------
1114
+ serialize() {
1115
+ const bodies = [];
1116
+ for (const [key, body] of this.bodies) {
1117
+ const sep = key.indexOf("\0");
1118
+ bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
1119
+ }
1120
+ return { world: this.world.takeSnapshot(), bodies };
1121
+ }
1122
+ };
1123
+ function identity(v) {
1124
+ return v;
1125
+ }
1126
+ function roundersFor(desc) {
1127
+ const out = {};
1128
+ for (const f of desc.fields) {
1129
+ if (f.type.kind === "f32") out[f.name] = Math.fround;
1130
+ }
1131
+ return out;
1132
+ }
1133
+ function channelValue(channel, t, r, v, w) {
1134
+ switch (channel) {
1135
+ case "x":
1136
+ return t.x;
1137
+ case "y":
1138
+ return t.y;
1139
+ case "z":
1140
+ return t.z;
1141
+ case "qx":
1142
+ return r.x;
1143
+ case "qy":
1144
+ return r.y;
1145
+ case "qz":
1146
+ return r.z;
1147
+ case "qw":
1148
+ return r.w;
1149
+ case "vx":
1150
+ return v.x;
1151
+ case "vy":
1152
+ return v.y;
1153
+ case "vz":
1154
+ return v.z;
1155
+ case "wx":
1156
+ return w.x;
1157
+ case "wy":
1158
+ return w.y;
1159
+ default:
1160
+ return w.z;
1161
+ }
1162
+ }
1163
+
777
1164
  // src/core/random.ts
778
1165
  var Mulberry32 = class {
779
1166
  /** Current internal state (u32). Survives hibernation. */
@@ -802,8 +1189,74 @@ import {
802
1189
  isDirtyEmpty,
803
1190
  markAdd,
804
1191
  markPath,
805
- markRemove
1192
+ markRemove,
1193
+ mergeDirty
806
1194
  } from "@irtio/schema";
1195
+
1196
+ // src/core/spatial.ts
1197
+ function cellFor(grid, value) {
1198
+ if (typeof value !== "object" || value === null) return void 0;
1199
+ const record = value;
1200
+ const x = record[grid.x];
1201
+ const y = record[grid.y];
1202
+ if (typeof x !== "number" || typeof y !== "number") return void 0;
1203
+ return { x: Math.floor(x / grid.cell), y: Math.floor(y / grid.cell) };
1204
+ }
1205
+ function cellKey(cell) {
1206
+ return `${cell.x},${cell.y}`;
1207
+ }
1208
+ function buildSpatialIndex(plain, desc) {
1209
+ if (!desc.grid || desc.kind !== "entity") {
1210
+ throw new Error(`${desc.name}: cannot build an index without an entity grid`);
1211
+ }
1212
+ const collection = plainEntity(plain, desc.name);
1213
+ const buckets = /* @__PURE__ */ new Map();
1214
+ const order = /* @__PURE__ */ new Map();
1215
+ let n = 0;
1216
+ for (const id of collection.ids()) {
1217
+ order.set(id, n++);
1218
+ const cell = cellFor(desc.grid, collection.get(id));
1219
+ if (!cell) continue;
1220
+ const key = cellKey(cell);
1221
+ let ids = buckets.get(key);
1222
+ if (!ids) {
1223
+ ids = /* @__PURE__ */ new Set();
1224
+ buckets.set(key, ids);
1225
+ }
1226
+ ids.add(id);
1227
+ }
1228
+ return { desc, buckets, collection, order };
1229
+ }
1230
+ function querySpatialIndex(index, clientId, role) {
1231
+ const grid = index.desc.grid;
1232
+ if ((grid.wideRoles ?? []).includes(role)) return new Set(index.collection.ids());
1233
+ const anchor = index.collection.get(clientId);
1234
+ const anchorCell = cellFor(grid, anchor);
1235
+ if (!anchor || !anchorCell) return /* @__PURE__ */ new Set();
1236
+ const candidates = /* @__PURE__ */ new Set();
1237
+ for (let x = anchorCell.x - grid.radius; x <= anchorCell.x + grid.radius; x++) {
1238
+ for (let y = anchorCell.y - grid.radius; y <= anchorCell.y + grid.radius; y++) {
1239
+ const ids = index.buckets.get(cellKey({ x, y }));
1240
+ if (ids) for (const id of ids) candidates.add(id);
1241
+ }
1242
+ }
1243
+ candidates.add(clientId);
1244
+ const ranked = [];
1245
+ for (const id of candidates) if (index.order.has(id)) ranked.push(id);
1246
+ ranked.sort((a, b) => index.order.get(a) - index.order.get(b));
1247
+ return new Set(ranked);
1248
+ }
1249
+ function buildSpatialIndexes(collections, plain) {
1250
+ const indexes = /* @__PURE__ */ new Map();
1251
+ for (const desc of collections) {
1252
+ if (desc.visibility === "spatial-grid" && desc.grid) {
1253
+ indexes.set(desc.name, buildSpatialIndex(plain, desc));
1254
+ }
1255
+ }
1256
+ return indexes;
1257
+ }
1258
+
1259
+ // src/core/views.ts
807
1260
  var cache = /* @__PURE__ */ new WeakMap();
808
1261
  function infoOf(ext) {
809
1262
  let i = cache.get(ext);
@@ -837,8 +1290,82 @@ function visibleNames(ext, role) {
837
1290
  function viewKeyFor(ext, role) {
838
1291
  return infoOf(ext).scoped.has(role) ? role : "all";
839
1292
  }
840
- function encodeViewSnapshot(ext, plain, role, tick) {
841
- return encodeSnapshot(ext, plain, { tick }, { collections: visibleTo(role) });
1293
+ function encodeViewSnapshot(ext, plain, role, tick, memberships) {
1294
+ return encodeSnapshot(
1295
+ ext,
1296
+ plain,
1297
+ { tick },
1298
+ {
1299
+ collections: visibleTo(role),
1300
+ entities: (c, id) => c.visibility !== "spatial-grid" || (memberships?.get(c.name)?.has(id) ?? false)
1301
+ }
1302
+ );
1303
+ }
1304
+ function spatialMemberships(ext, plain, clientId, role, indexes = buildSpatialIndexes(ext.collections, plain)) {
1305
+ const out = /* @__PURE__ */ new Map();
1306
+ for (const [name, index] of indexes) out.set(name, querySpatialIndex(index, clientId, role));
1307
+ return out;
1308
+ }
1309
+ function createVisibilityPolicy(ext, plain, clientId, role) {
1310
+ const memberships = spatialMemberships(ext, plain, clientId, role);
1311
+ return {
1312
+ memberships,
1313
+ maySeeCollection: (desc) => isVisible(desc, role),
1314
+ maySeeEntity: (desc, id) => isVisible(desc, role) && (desc.visibility !== "spatial-grid" || (memberships.get(desc.name)?.has(id) ?? false)),
1315
+ describeSpatial: (desc, ids) => {
1316
+ const grid = desc.grid;
1317
+ if (desc.visibility !== "spatial-grid" || !grid) return void 0;
1318
+ const coll = plainEntity(plain, desc.name);
1319
+ const at = (id) => {
1320
+ const cell = cellFor(grid, coll.get(id));
1321
+ return cell ? `${id}@(${cell.x},${cell.y})` : `${id}@(no position)`;
1322
+ };
1323
+ return `viewer ${at(clientId)}, ${desc.name} ${ids.map(at).join(" ")}, radius ${grid.radius}`;
1324
+ }
1325
+ };
1326
+ }
1327
+ function sharedViewDirty(ext, dirty, role) {
1328
+ return filterDirty(dirty, (name) => {
1329
+ const desc = ext.collection(name);
1330
+ return desc.visibility !== "spatial-grid" && isVisible(desc, role);
1331
+ });
1332
+ }
1333
+ function aoiViewDirty(ext, dirty, previous, current) {
1334
+ const out = createDirtySet();
1335
+ for (const desc of ext.collections) {
1336
+ if (desc.visibility !== "spatial-grid") continue;
1337
+ const before = previous.get(desc.name) ?? /* @__PURE__ */ new Set();
1338
+ const now = current.get(desc.name) ?? /* @__PURE__ */ new Set();
1339
+ for (const id of before) if (!now.has(id)) markRemove(out, desc.name, id);
1340
+ for (const id of now) if (!before.has(id)) markAdd(out, desc.name, id);
1341
+ const changed = dirty.get(desc.name);
1342
+ if (!changed) continue;
1343
+ const visibleChanges = createDirtySet();
1344
+ const collection = collectionDirty(visibleChanges, desc.name);
1345
+ for (const id of changed.added) if (now.has(id)) collection.added.add(id);
1346
+ for (const id of changed.removed) if (before.has(id)) collection.removed.add(id);
1347
+ for (const [id, record] of changed.updated) {
1348
+ if (before.has(id) && now.has(id)) collection.updated.set(id, record);
1349
+ }
1350
+ mergeDirty(out, visibleChanges);
1351
+ }
1352
+ return out;
1353
+ }
1354
+ function membershipSafeDirty(ext, dirty, memberships) {
1355
+ const out = createDirtySet();
1356
+ for (const [name, changed] of dirty) {
1357
+ const desc = ext.collection(name);
1358
+ if (desc.visibility !== "spatial-grid") {
1359
+ out.set(name, changed);
1360
+ continue;
1361
+ }
1362
+ const visible = memberships.get(name) ?? /* @__PURE__ */ new Set();
1363
+ const kept = collectionDirty(out, name);
1364
+ for (const id of changed.added) if (visible.has(id)) kept.added.add(id);
1365
+ for (const id of changed.removed) if (visible.has(id)) kept.removed.add(id);
1366
+ for (const [id, record] of changed.updated) if (visible.has(id)) kept.updated.set(id, record);
1367
+ }
1368
+ return out;
842
1369
  }
843
1370
  function encodeViewDelta(ext, plain, dirty, role, tick) {
844
1371
  const keep = visibleNames(ext, role);
@@ -891,27 +1418,35 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
891
1418
  }
892
1419
 
893
1420
  // src/core/snapshot.ts
894
- import { ByteReader, ByteWriter } from "@irtio/schema";
895
- var SNAPSHOT_FORMAT_VERSION = 1;
1421
+ import { ByteReader as ByteReader2, ByteWriter as ByteWriter2 } from "@irtio/schema";
1422
+ var SNAPSHOT_FORMAT_VERSION = 2;
1423
+ var READABLE_SNAPSHOT_VERSIONS = [1, 2];
896
1424
  function parseHibernationBlob(bytes) {
897
- const r = new ByteReader(bytes);
1425
+ const r = new ByteReader2(bytes);
898
1426
  const version = r.u8();
899
- if (version !== SNAPSHOT_FORMAT_VERSION) {
1427
+ if (!READABLE_SNAPSHOT_VERSIONS.includes(version)) {
900
1428
  throw new Error(`RoomCore.restore: unsupported snapshot format version ${version}`);
901
1429
  }
902
1430
  const seed = r.u32();
903
1431
  const rngState = r.u32();
904
1432
  const tick = r.u32();
905
1433
  const mode = r.u8() === 1 ? "event" : "tick";
906
- return { seed, rngState, tick, mode, snapshot: r.rest() };
1434
+ let physics;
1435
+ if (version >= 2) {
1436
+ const section = r.blob();
1437
+ if (section.length > 0) physics = section;
1438
+ }
1439
+ return { version, seed, rngState, tick, mode, physics, snapshot: r.rest() };
907
1440
  }
908
- function writeHibernationBlob(header, snapshot) {
909
- const w = new ByteWriter(snapshot.length + 16);
910
- w.u8(SNAPSHOT_FORMAT_VERSION);
1441
+ function writeHibernationBlob(header, snapshot, physics) {
1442
+ const version = physics ? 2 : 1;
1443
+ const w = new ByteWriter2(snapshot.length + (physics?.length ?? 0) + 24);
1444
+ w.u8(version);
911
1445
  w.u32(header.seed >>> 0);
912
1446
  w.u32(header.rngState >>> 0);
913
1447
  w.u32(header.tick >>> 0);
914
1448
  w.u8(header.mode === "event" ? 1 : 0);
1449
+ if (physics) w.blob(physics);
915
1450
  w.bytes(snapshot);
916
1451
  return w.finish();
917
1452
  }
@@ -930,12 +1465,78 @@ import {
930
1465
  createDirtySet as createDirtySet2,
931
1466
  createState,
932
1467
  decodeSnapshot,
1468
+ encodeDelta as encodeDelta4,
933
1469
  encodeSnapshot as encodeSnapshot2,
934
1470
  isDirtyEmpty as isDirtyEmpty3,
935
1471
  track,
936
1472
  validateForDeploy
937
1473
  } from "@irtio/schema";
938
1474
 
1475
+ // src/core/host-calls.ts
1476
+ var states2 = /* @__PURE__ */ new WeakMap();
1477
+ function stateOf2(core) {
1478
+ let s = states2.get(core);
1479
+ if (!s) {
1480
+ s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
1481
+ states2.set(core, s);
1482
+ }
1483
+ return s;
1484
+ }
1485
+ function startHostCall(core, call, map) {
1486
+ return new Promise((resolve, reject2) => {
1487
+ if (core.stopped) {
1488
+ reject2(new Error(`room.${call.kind}: the room is stopped`));
1489
+ return;
1490
+ }
1491
+ const s = stateOf2(core);
1492
+ const reqId = s.nextReqId;
1493
+ s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
1494
+ s.pending.set(reqId, {
1495
+ kind: call.kind,
1496
+ deadline: core.host.now() + HOST_CALL_TIMEOUT_MS,
1497
+ map,
1498
+ resolve,
1499
+ reject: reject2
1500
+ });
1501
+ try {
1502
+ core.host.hostCall(reqId, call);
1503
+ } catch (err) {
1504
+ s.pending.delete(reqId);
1505
+ reject2(err instanceof Error ? err : new Error(String(err)));
1506
+ return;
1507
+ }
1508
+ if (core.mode === "event") {
1509
+ core.loop.after(HOST_CALL_TIMEOUT_MS, () => checkHostCallTimeouts(core));
1510
+ }
1511
+ });
1512
+ }
1513
+ function completeHostCall(core, reqId, result) {
1514
+ const s = stateOf2(core);
1515
+ const pending = s.pending.get(reqId);
1516
+ if (!pending) return false;
1517
+ s.pending.delete(reqId);
1518
+ if (result.ok) pending.resolve(pending.map(result.value));
1519
+ else pending.reject(new Error(`${result.code}: ${result.message}`));
1520
+ return true;
1521
+ }
1522
+ function checkHostCallTimeouts(core) {
1523
+ const s = stateOf2(core);
1524
+ if (s.pending.size === 0) return;
1525
+ const now = core.host.now();
1526
+ for (const [reqId, p] of [...s.pending]) {
1527
+ if (p.deadline > now) continue;
1528
+ s.pending.delete(reqId);
1529
+ p.reject(new Error(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
1530
+ }
1531
+ }
1532
+ function rejectAllHostCalls(core, reason) {
1533
+ const s = stateOf2(core);
1534
+ for (const [reqId, p] of [...s.pending]) {
1535
+ s.pending.delete(reqId);
1536
+ p.reject(new Error(reason));
1537
+ }
1538
+ }
1539
+
939
1540
  // src/core/messages.ts
940
1541
  import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
941
1542
  function toRoomTarget(target) {
@@ -1002,9 +1603,40 @@ function sendMessage(core, target, bytes) {
1002
1603
  // src/core/room-api.ts
1003
1604
  import { FrameType as FrameType4, encodeFrame as encodeFrame3 } from "@irtio/protocol";
1004
1605
  import { encodeDelta as encodeDelta3, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
1606
+ function makePhysicsApi(p) {
1607
+ return {
1608
+ get rapier() {
1609
+ return p.rapier;
1610
+ },
1611
+ get world() {
1612
+ return p.world;
1613
+ },
1614
+ get timestep() {
1615
+ return p.timestep;
1616
+ },
1617
+ body(collection, id) {
1618
+ return p.bodyFor(collection, id);
1619
+ }
1620
+ };
1621
+ }
1622
+ function makeKv(core) {
1623
+ return {
1624
+ get(playerId, key) {
1625
+ return startHostCall(core, { kind: "kvGet", playerId, key }, (v) => v);
1626
+ },
1627
+ set(playerId, key, value) {
1628
+ return startHostCall(core, { kind: "kvSet", playerId, key, value }, () => void 0);
1629
+ },
1630
+ delete(playerId, key) {
1631
+ return startHostCall(core, { kind: "kvDelete", playerId, key }, () => void 0);
1632
+ }
1633
+ };
1634
+ }
1005
1635
  function createRoomApi(core, publicUrl) {
1006
1636
  let cached;
1007
1637
  let lastNow = Number.NEGATIVE_INFINITY;
1638
+ let physicsApi;
1639
+ let kvApi;
1008
1640
  const link = () => {
1009
1641
  const sep = publicUrl.includes("?") ? "&" : "?";
1010
1642
  return `${publicUrl}${sep}room=${encodeURIComponent(core.roomId)}`;
@@ -1038,6 +1670,19 @@ function createRoomApi(core, publicUrl) {
1038
1670
  random() {
1039
1671
  return core.rng.next();
1040
1672
  },
1673
+ // D22. A single object, built once: `room.physics` is read on every tick of a physics room.
1674
+ // In a room with no `physics:` config, touching it is a mistake worth naming loudly rather
1675
+ // than a silent `undefined` that shows up three frames later as "cannot read world of
1676
+ // undefined". The property itself is safe to *hold*; only reading through it throws.
1677
+ get physics() {
1678
+ const p = core.physics;
1679
+ if (!p) {
1680
+ throw new Error(
1681
+ "room.physics: this room has no physics \u2014 add physics: { engine: 'rapier3d', gravity, bodies } to defineRoom(...)"
1682
+ );
1683
+ }
1684
+ return physicsApi ?? (physicsApi = makePhysicsApi(p));
1685
+ },
1041
1686
  send(target, bytes) {
1042
1687
  sendMessage(core, target, bytes);
1043
1688
  },
@@ -1073,6 +1718,27 @@ function createRoomApi(core, publicUrl) {
1073
1718
  clearInterval(handle) {
1074
1719
  core.loop.clearTimer(handle);
1075
1720
  },
1721
+ save() {
1722
+ return startHostCall(core, { kind: "save" }, (id) => id ?? "");
1723
+ },
1724
+ get kv() {
1725
+ return kvApi ?? (kvApi = makeKv(core));
1726
+ },
1727
+ alarm(name, atMs) {
1728
+ if (!isAlarmName(core, name, "room.alarm")) return;
1729
+ if (!Number.isFinite(atMs)) {
1730
+ core.log("warn", `room.alarm: ${JSON.stringify(name)} needs a finite time; ignored`);
1731
+ return;
1732
+ }
1733
+ core.host.setAlarm(name, atMs);
1734
+ },
1735
+ cancelAlarm(name) {
1736
+ if (typeof name !== "string" || name === "") {
1737
+ core.log("warn", "room.cancelAlarm: a name is required; ignored");
1738
+ return;
1739
+ }
1740
+ core.host.setAlarm(name, void 0);
1741
+ },
1076
1742
  call(clientId) {
1077
1743
  return createCallProxy(core, clientId);
1078
1744
  },
@@ -1088,6 +1754,21 @@ function createRoomApi(core, publicUrl) {
1088
1754
  }
1089
1755
  };
1090
1756
  }
1757
+ function isAlarmName(core, name, where) {
1758
+ if (typeof name !== "string" || name === "") {
1759
+ core.log("warn", `${where}: a name is required; ignored`);
1760
+ return false;
1761
+ }
1762
+ const alarms = core.definition.config.alarms;
1763
+ if (!alarms || typeof alarms[name] !== "function") {
1764
+ core.log(
1765
+ "warn",
1766
+ `${where}: no handler named ${JSON.stringify(name)} \u2014 add it to the room's \`alarms: { \u2026 }\` config; the alarm was not armed`
1767
+ );
1768
+ return false;
1769
+ }
1770
+ return true;
1771
+ }
1091
1772
  function setRole(core, clientId, role) {
1092
1773
  const entry = core.clients.get(clientId);
1093
1774
  if (!entry) {
@@ -1115,6 +1796,7 @@ function setRole(core, clientId, role) {
1115
1796
 
1116
1797
  // src/core/room.ts
1117
1798
  var DEFAULT_PUBLIC_URL = "http://localhost/";
1799
+ var CONTINUATION_FLUSH_TURNS = 8;
1118
1800
  function ownJoinCapture(plain, name, added, clientId) {
1119
1801
  const coll = plainEntity(plain, name);
1120
1802
  let mine;
@@ -1137,6 +1819,8 @@ var RoomCore = class _RoomCore {
1137
1819
  rng;
1138
1820
  clients = /* @__PURE__ */ new Map();
1139
1821
  loop;
1822
+ /** D22: the Rapier world, or `undefined` in a room whose config declares no physics. */
1823
+ physics;
1140
1824
  stats = {
1141
1825
  ticks: 0,
1142
1826
  lastTickMs: 0,
@@ -1148,14 +1832,30 @@ var RoomCore = class _RoomCore {
1148
1832
  bytesOutByClient: /* @__PURE__ */ new Map(),
1149
1833
  handlerErrors: 0,
1150
1834
  encodesLastFlush: 0,
1835
+ aoiEncodesLastFlush: 0,
1836
+ gridBuildMs: 0,
1837
+ gridQueryMs: 0,
1838
+ aoiEncodeMs: 0,
1839
+ visibleIdsTotal: 0,
1840
+ membershipEnters: 0,
1841
+ membershipLeaves: 0,
1151
1842
  corrections: 0
1152
1843
  };
1153
1844
  tick = 0;
1154
1845
  stopped = false;
1846
+ /**
1847
+ * Called for every handler throw `tryRun` swallows, before it is logged. A test harness sets
1848
+ * this so a room that breaks fails the test that broke it (bug 2): the runtime's job is to
1849
+ * keep the room up in production, but under test that same guarding turns a broken handler
1850
+ * into a timeout in an unrelated assertion ten seconds later. Unset in production.
1851
+ */
1852
+ onHandlerError;
1155
1853
  seed;
1156
1854
  api;
1157
1855
  internals;
1158
1856
  started = false;
1857
+ /** One pending continuation flush at a time; concurrent completions coalesce into it. */
1858
+ continuationFlushPending = false;
1159
1859
  constructor(definition, host, options) {
1160
1860
  this.definition = definition;
1161
1861
  this.host = host;
@@ -1191,6 +1891,7 @@ var RoomCore = class _RoomCore {
1191
1891
  this.internals = self;
1192
1892
  this.loop = new Loop(self);
1193
1893
  this.api = createRoomApi(self, options.publicUrl ?? DEFAULT_PUBLIC_URL);
1894
+ this.physics = this.buildPhysics(restored);
1194
1895
  if (restored) {
1195
1896
  const onWake = definition.config.onWake;
1196
1897
  if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
@@ -1199,6 +1900,42 @@ var RoomCore = class _RoomCore {
1199
1900
  if (onCreate) this.guard("onCreate", () => onCreate(this.state, this.room));
1200
1901
  }
1201
1902
  }
1903
+ /**
1904
+ * D22: builds the world, or returns `undefined` for a room with no `physics:` config.
1905
+ *
1906
+ * A v2 blob restores the world from its own bytes and `setup` does **not** run — the static
1907
+ * geometry is already in there. Anything else (a fresh room, a v1 blob written before this
1908
+ * room had physics, a migrated snapshot whose world was deliberately dropped) builds a world,
1909
+ * runs `setup`, and lets the first tick's `reconcile` rebuild the bodies from schema state:
1910
+ * positions and velocities live in schema fields, so the rebuild is faithful to what the state
1911
+ * says. Transient contact state — resting contacts, accumulated impulses — is not in the schema
1912
+ * and is lost; a stack of boxes may settle again with a small visible jolt.
1913
+ */
1914
+ buildPhysics(restored) {
1915
+ const config = this.definition.config.physics;
1916
+ if (!config) return void 0;
1917
+ const engine2 = loadedPhysics();
1918
+ if (!engine2) {
1919
+ throw new Error(
1920
+ "RoomCore: this room declares physics but the engine is not initialized \u2014 the host must `await initPhysics()` (from '@irtio/runtime') before constructing the room; handlers are synchronous, so the WASM cannot be loaded later"
1921
+ );
1922
+ }
1923
+ const section = restored?.physics ? decodePhysicsSection(restored.physics) : void 0;
1924
+ const physics = new PhysicsRuntime(this.internals, engine2, {
1925
+ ...section ? { restore: section } : {},
1926
+ defaultTimestep: 1 / this.definition.config.tickRate
1927
+ });
1928
+ if (physics.rebuilt) {
1929
+ if (restored) {
1930
+ this.host.log("info", [
1931
+ `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`
1932
+ ]);
1933
+ }
1934
+ physics.runSetup(this.room);
1935
+ physics.reconcile();
1936
+ }
1937
+ return physics;
1938
+ }
1202
1939
  /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
1203
1940
  static restore(definition, bytes, host, options) {
1204
1941
  return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
@@ -1229,6 +1966,10 @@ var RoomCore = class _RoomCore {
1229
1966
  `${name}: ${err instanceof Error ? err.message : String(err)}`
1230
1967
  );
1231
1968
  this.host.log("error", [`irtio: ${name} threw`, err]);
1969
+ try {
1970
+ this.onHandlerError?.(name, err);
1971
+ } catch {
1972
+ }
1232
1973
  return { ok: false };
1233
1974
  }
1234
1975
  }
@@ -1275,16 +2016,35 @@ var RoomCore = class _RoomCore {
1275
2016
  this.started = false;
1276
2017
  this.loop.stop();
1277
2018
  rejectAllPending(this.internals, "room stopped");
2019
+ rejectAllHostCalls(this.internals, "room stopped");
2020
+ this.physics?.free();
2021
+ }
2022
+ /**
2023
+ * The hibernation blob's bytes, and **nothing else** — no `onSleep`, no timers cleared, no
2024
+ * pending work rejected. A save (D24) is a *copy* of the room; hibernation is the room
2025
+ * *leaving*. They want identical bytes and opposite side effects, so the bytes live here and
2026
+ * the departure lives in `serialize()`.
2027
+ *
2028
+ * Conflating the two is not hypothetical: the first cut of `room.save()` routed through
2029
+ * `serialize()`, which rejected every pending host call — including the `save()` that had just
2030
+ * asked for it. The room waited out its own 10 s deadline for a save that had already been
2031
+ * written.
2032
+ */
2033
+ snapshot() {
2034
+ const physics = this.physics ? encodePhysicsSection(this.physics.serialize()) : void 0;
2035
+ return writeHibernationBlob(
2036
+ { seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
2037
+ encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
2038
+ physics
2039
+ );
1278
2040
  }
1279
2041
  serialize() {
1280
2042
  const onSleep = this.definition.config.onSleep;
1281
2043
  if (onSleep) this.guard("onSleep", () => onSleep(this.state, this.room));
1282
2044
  this.loop.clearAllTimers();
1283
2045
  rejectAllPending(this.internals, "room is hibernating");
1284
- return writeHibernationBlob(
1285
- { seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
1286
- encodeSnapshot2(this.ext, this.plain, { tick: this.tick })
1287
- );
2046
+ rejectAllHostCalls(this.internals, "room is hibernating");
2047
+ return this.snapshot();
1288
2048
  }
1289
2049
  // -------------------------------------------------------------------------
1290
2050
  // Clients
@@ -1319,7 +2079,13 @@ var RoomCore = class _RoomCore {
1319
2079
  return {
1320
2080
  tick: this.tick,
1321
2081
  role: role2,
1322
- snapshot: encodeViewSnapshot(this.ext, this.plain, role2, this.tick)
2082
+ snapshot: encodeViewSnapshot(
2083
+ this.ext,
2084
+ this.plain,
2085
+ role2,
2086
+ this.tick,
2087
+ spatialMemberships(this.ext, this.plain, clientId, role2)
2088
+ )
1323
2089
  };
1324
2090
  }
1325
2091
  const presence = this.presence;
@@ -1340,13 +2106,20 @@ var RoomCore = class _RoomCore {
1340
2106
  }
1341
2107
  const entry = existing ?? {
1342
2108
  clientId,
2109
+ // D25: defaults to the client id, which a resume token carries across a reconnect — so for
2110
+ // a key join `ctx.playerId` is exactly as durable as the resume token and no more. A JWT
2111
+ // join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
2112
+ // and nothing else here changes.
2113
+ playerId: options.playerId ?? clientId,
1343
2114
  role,
1344
2115
  name,
1345
2116
  connected: true,
1346
2117
  correction: void 0,
1347
2118
  accepted: /* @__PURE__ */ new Map(),
1348
2119
  lastClientTick: 0,
1349
- pendingJoinAdds: void 0
2120
+ lastAppliedTick: 0,
2121
+ pendingJoinAdds: void 0,
2122
+ spatialMembership: /* @__PURE__ */ new Map()
1350
2123
  };
1351
2124
  entry.role = role;
1352
2125
  entry.name = name;
@@ -1360,12 +2133,23 @@ var RoomCore = class _RoomCore {
1360
2133
  }
1361
2134
  this.loop.noteActivity();
1362
2135
  this.recordEvent("join", clientId, reconnecting ? "reconnect" : void 0);
1363
- const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick);
2136
+ const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
2137
+ entry.spatialMembership = memberships;
2138
+ const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
1364
2139
  const pending = /* @__PURE__ */ new Map();
1365
2140
  for (const [name2, cd] of this.tracked.dirty) {
1366
2141
  if (cd.added.size === 0) continue;
1367
2142
  const mine = ownJoinCapture(this.plain, name2, cd.added, clientId);
1368
- if (mine) pending.set(name2, mine);
2143
+ const visible = memberships.get(name2);
2144
+ if (visible) {
2145
+ const coll = plainEntity(this.plain, name2);
2146
+ const captured = mine ?? /* @__PURE__ */ new Map();
2147
+ for (const id of cd.added) {
2148
+ if (!visible.has(id) || captured.has(id)) continue;
2149
+ captured.set(id, { value: cloneValue2(coll.get(id)), owner: coll.ownerOf(id) });
2150
+ }
2151
+ if (captured.size > 0) pending.set(name2, captured);
2152
+ } else if (mine) pending.set(name2, mine);
1369
2153
  }
1370
2154
  entry.pendingJoinAdds = pending.size > 0 ? pending : void 0;
1371
2155
  this.eventFlush();
@@ -1415,6 +2199,7 @@ var RoomCore = class _RoomCore {
1415
2199
  const entry = this.clients.get(clientId);
1416
2200
  return {
1417
2201
  clientId,
2202
+ playerId: entry?.playerId ?? clientId,
1418
2203
  role: entry?.role ?? "",
1419
2204
  name: entry?.name ?? "",
1420
2205
  tick: this.tick,
@@ -1422,6 +2207,63 @@ var RoomCore = class _RoomCore {
1422
2207
  room: this.room
1423
2208
  };
1424
2209
  }
2210
+ /**
2211
+ * Week 12: the host answering a `room.save()` / `room.kv.*`. The continuation runs here — its
2212
+ * own event, between ticks, off the back of a host turn — and the flush afterwards is what
2213
+ * makes "state mutated in a continuation is tracked normally" true rather than aspirational.
2214
+ */
2215
+ completeHostCall(reqId, result) {
2216
+ if (this.stopped) return;
2217
+ if (!completeHostCall(this.internals, reqId, result)) {
2218
+ this.log("warn", `completeHostCall: nothing is waiting on reqId ${reqId}`);
2219
+ return;
2220
+ }
2221
+ checkHostCallTimeouts(this.internals);
2222
+ this.scheduleContinuationFlush();
2223
+ }
2224
+ /**
2225
+ * Flushes whatever a promise continuation wrote, as its own event, once the microtask queue
2226
+ * that continuation lives on has drained.
2227
+ *
2228
+ * The subtlety this exists for: `resolve()` does not run the room's `.then` — it *queues* it,
2229
+ * and every promise link between the resolve and the room's callback costs another microtask
2230
+ * turn. A single `queueMicrotask(flush)` therefore only ever catches a continuation exactly one
2231
+ * link deep, and silently drops the state written by anything the room chained further out.
2232
+ * Draining a bounded number of turns first covers the chains rooms actually write, coalesces
2233
+ * concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
2234
+ * the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
2235
+ */
2236
+ scheduleContinuationFlush() {
2237
+ if (this.mode !== "event" || this.continuationFlushPending) return;
2238
+ this.continuationFlushPending = true;
2239
+ let turns = 0;
2240
+ const drain = () => {
2241
+ if (++turns < CONTINUATION_FLUSH_TURNS) {
2242
+ queueMicrotask(drain);
2243
+ return;
2244
+ }
2245
+ this.continuationFlushPending = false;
2246
+ if (!this.stopped) this.eventFlush();
2247
+ };
2248
+ queueMicrotask(drain);
2249
+ }
2250
+ /**
2251
+ * D26: one durable alarm firing. Same scheduling class as an RPC — a discrete event between
2252
+ * ticks — so a tick-mode room never sees a `tick` run half-alarmed, and a handler that re-arms
2253
+ * its own name is the supported way to build a repeating timer.
2254
+ */
2255
+ fireAlarm(name) {
2256
+ if (this.stopped) return;
2257
+ const alarms = this.definition.config.alarms;
2258
+ const handler = alarms?.[name];
2259
+ if (!handler) {
2260
+ this.log("warn", `alarm ${JSON.stringify(name)} fired but the room has no handler for it`);
2261
+ return;
2262
+ }
2263
+ this.recordEvent("alarm", void 0, name);
2264
+ this.guard(`alarms.${name}`, () => handler(this.state, this.room));
2265
+ this.eventFlush();
2266
+ }
1425
2267
  correctionFor(clientId) {
1426
2268
  const entry = this.clients.get(clientId);
1427
2269
  if (!entry) return void 0;
@@ -1486,12 +2328,8 @@ var RoomCore = class _RoomCore {
1486
2328
  case FrameType5.REPLY: {
1487
2329
  if (!handleReply(this.internals, clientId, payload)) {
1488
2330
  this.badFrame(clientId, "malformed REPLY payload");
1489
- } else if (this.mode === "event") {
1490
- queueMicrotask(() => {
1491
- if (this.stopped || isDirtyEmpty3(this.tracked.dirty)) return;
1492
- this.tick++;
1493
- this.flush();
1494
- });
2331
+ } else {
2332
+ this.scheduleContinuationFlush();
1495
2333
  }
1496
2334
  return;
1497
2335
  }
@@ -1515,15 +2353,77 @@ var RoomCore = class _RoomCore {
1515
2353
  const dirty = this.tracked.flush();
1516
2354
  serverWinsCorrections(this.internals, dirty);
1517
2355
  this.stats.encodesLastFlush = 0;
2356
+ this.stats.aoiEncodesLastFlush = 0;
2357
+ const spatialDescs = this.ext.collections.filter((desc) => desc.visibility === "spatial-grid");
2358
+ const buildStarted = performance.now();
2359
+ const indexes = buildSpatialIndexes(spatialDescs, this.plain);
2360
+ this.stats.gridBuildMs += performance.now() - buildStarted;
1518
2361
  const byView = /* @__PURE__ */ new Map();
1519
2362
  for (const entry of this.clients.values()) {
1520
2363
  if (entry.connected) {
2364
+ const queryStarted = performance.now();
2365
+ const memberships = spatialMemberships(
2366
+ this.ext,
2367
+ this.plain,
2368
+ entry.clientId,
2369
+ entry.role,
2370
+ indexes
2371
+ );
2372
+ this.stats.gridQueryMs += performance.now() - queryStarted;
2373
+ for (const [name, ids] of memberships) {
2374
+ const before = entry.spatialMembership.get(name) ?? /* @__PURE__ */ new Set();
2375
+ this.stats.visibleIdsTotal += ids.size;
2376
+ for (const id of ids) if (!before.has(id)) this.stats.membershipEnters++;
2377
+ for (const id of before) if (!ids.has(id)) this.stats.membershipLeaves++;
2378
+ }
1521
2379
  if (entry.correction) {
1522
- const payload = encodeCorrection(this.internals, entry.correction);
1523
- if (payload) this.send(entry.clientId, encodeCorrectFrame(payload, entry.lastClientTick));
2380
+ const payload = encodeCorrection(
2381
+ this.internals,
2382
+ membershipSafeDirty(this.ext, entry.correction, memberships)
2383
+ );
2384
+ if (payload) {
2385
+ this.send(
2386
+ entry.clientId,
2387
+ encodeCorrectFrame(payload, entry.lastClientTick, entry.lastAppliedTick)
2388
+ );
2389
+ }
1524
2390
  }
1525
2391
  let delta;
1526
- if (entry.pendingJoinAdds) {
2392
+ if (spatialDescs.length > 0) {
2393
+ const sourceDirty = entry.pendingJoinAdds ? stripAdds(this.plain, dirty, entry.pendingJoinAdds) : dirty;
2394
+ let shared;
2395
+ if (entry.pendingJoinAdds) {
2396
+ const sharedDirty = sharedViewDirty(this.ext, sourceDirty, entry.role);
2397
+ shared = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
2398
+ if (shared) this.stats.encodesLastFlush++;
2399
+ } else {
2400
+ const key = viewKeyFor(this.ext, entry.role);
2401
+ let cached = byView.get(key);
2402
+ if (cached === void 0) {
2403
+ const sharedDirty = sharedViewDirty(this.ext, dirty, entry.role);
2404
+ cached = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
2405
+ if (cached) this.stats.encodesLastFlush++;
2406
+ byView.set(key, cached);
2407
+ }
2408
+ shared = cached;
2409
+ }
2410
+ if (shared) this.send(entry.clientId, encodeFrame4(FrameType5.DELTA, shared));
2411
+ const aoiDirty = aoiViewDirty(
2412
+ this.ext,
2413
+ sourceDirty,
2414
+ entry.spatialMembership,
2415
+ memberships
2416
+ );
2417
+ const encodeStarted = performance.now();
2418
+ delta = isDirtyEmpty3(aoiDirty) ? null : encodeDelta4(this.ext, this.plain, aoiDirty, { tick: this.tick });
2419
+ this.stats.aoiEncodeMs += performance.now() - encodeStarted;
2420
+ if (delta) {
2421
+ this.stats.encodesLastFlush++;
2422
+ this.stats.aoiEncodesLastFlush++;
2423
+ }
2424
+ entry.pendingJoinAdds = void 0;
2425
+ entry.spatialMembership = memberships;
2426
+ } else if (entry.pendingJoinAdds) {
1527
2427
  delta = encodeViewDelta(
1528
2428
  this.ext,
1529
2429
  this.plain,
@@ -1552,21 +2452,29 @@ var RoomCore = class _RoomCore {
1552
2452
  };
1553
2453
 
1554
2454
  export {
2455
+ HOST_CALL_TIMEOUT_MS,
1555
2456
  RoomFullError,
1556
2457
  EVENT_RING_SIZE,
1557
2458
  inspectState,
1558
2459
  RPC_TIMEOUT_MS,
1559
2460
  MAX_CATCHUP,
1560
2461
  CRASH_AFTER_THROWS,
2462
+ initPhysics,
2463
+ loadedPhysics,
2464
+ resetPhysicsForTests,
2465
+ encodePhysicsSection,
2466
+ decodePhysicsSection,
1561
2467
  Mulberry32,
1562
2468
  isVisible,
1563
2469
  visibleTo,
1564
2470
  visibleNames,
1565
2471
  viewKeyFor,
1566
2472
  encodeViewSnapshot,
2473
+ createVisibilityPolicy,
1567
2474
  encodeViewDelta,
1568
2475
  catchUpDirty,
1569
2476
  SNAPSHOT_FORMAT_VERSION,
2477
+ READABLE_SNAPSHOT_VERSIONS,
1570
2478
  parseHibernationBlob,
1571
2479
  writeHibernationBlob,
1572
2480
  RoomCore