@irtio/runtime 0.1.0 → 0.2.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})`);
@@ -463,6 +464,26 @@ function applyOp(core, clientId, c, op) {
463
464
  const prev = cloneValue(current);
464
465
  const next = cloneValue(current);
465
466
  applyMaskedPatch(fields, next, op.patch, op.mask);
467
+ const requested = cloneValue(next);
468
+ const simulated = c.physics;
469
+ if (simulated) {
470
+ const refused = [];
471
+ for (const leaf of leafPaths(fields, op.mask)) {
472
+ const field = leaf.names[0];
473
+ if (simulated.intents.includes(field)) continue;
474
+ next[field] = cloneValue(prev[field]);
475
+ refused.push(
476
+ simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`
477
+ );
478
+ }
479
+ if (refused.length > 0) {
480
+ core.recordEvent("write-rejected", clientId, `${c.name}: ${refused.join(", ")}`);
481
+ core.log(
482
+ "warn",
483
+ `WRITE to ${c.name}.${op.id} from ${clientId} touched fields the owner does not write: ${refused.join(", ")}`
484
+ );
485
+ }
486
+ }
466
487
  const validator = core.definition.config.validate?.[c.name];
467
488
  let result = next;
468
489
  if (validator) {
@@ -493,7 +514,7 @@ function applyOp(core, clientId, c, op) {
493
514
  for (const leaf of written) map.set(leafKey(leaf), cloneValue(valueAt(result, leaf.names)));
494
515
  }
495
516
  const corrected = written.filter(
496
- (leaf) => !deepEqual(valueAt(result, leaf.names), valueAt(next, leaf.names))
517
+ (leaf) => !deepEqual(valueAt(result, leaf.names), valueAt(requested, leaf.names))
497
518
  );
498
519
  correctLeaves(core, clientId, c.name, op.id, corrected);
499
520
  }
@@ -567,7 +588,7 @@ var Loop = class {
567
588
  this.lastActivity = this.lastWake;
568
589
  this.accumulator = 0;
569
590
  if (this.core.mode === "tick") this.scheduleTick();
570
- else this.armIdle(this.core.definition.config.idleMs);
591
+ this.armIdle(this.core.definition.config.idleMs);
571
592
  }
572
593
  stop() {
573
594
  this.running = false;
@@ -642,7 +663,7 @@ var Loop = class {
642
663
  }
643
664
  if (this.running && !this.core.stopped) this.scheduleTick();
644
665
  }
645
- /** One tick: inbound → room timers → `tick(state, dt, room)` → flush. */
666
+ /** One tick: inbound → room timers → `tick(state, dt, room)` → physics step → flush. */
646
667
  runTick() {
647
668
  const host = this.core.host;
648
669
  const started = host.now();
@@ -671,6 +692,14 @@ var Loop = class {
671
692
  }
672
693
  }
673
694
  }
695
+ const physics = this.core.physics;
696
+ if (physics) {
697
+ this.core.tryRun("physics", () => {
698
+ physics.reconcile();
699
+ physics.step();
700
+ physics.sync();
701
+ });
702
+ }
674
703
  this.core.flush();
675
704
  const elapsed = Math.max(0, host.now() - started);
676
705
  const stats = this.core.stats;
@@ -697,7 +726,6 @@ var Loop = class {
697
726
  return ok;
698
727
  }
699
728
  noteActivity() {
700
- if (this.core.mode !== "event") return;
701
729
  this.lastActivity = this.core.host.now();
702
730
  this.slept = false;
703
731
  if (this.running && this.idleHandle === void 0) {
@@ -711,6 +739,12 @@ var Loop = class {
711
739
  this.idleHandle = void 0;
712
740
  if (!this.running || this.core.stopped) return;
713
741
  const idleMs = this.core.definition.config.idleMs;
742
+ if (idleMs <= 0) return;
743
+ if (this.core.mode === "tick" && this.core.clients.size > 0) {
744
+ this.lastActivity = this.core.host.now();
745
+ this.armIdle(idleMs);
746
+ return;
747
+ }
714
748
  const waited = this.core.host.now() - this.lastActivity;
715
749
  if (waited >= idleMs) {
716
750
  if (!this.slept) {
@@ -774,6 +808,325 @@ var Loop = class {
774
808
  }
775
809
  };
776
810
 
811
+ // src/core/physics.ts
812
+ import { ByteReader, ByteWriter } from "@irtio/schema";
813
+ var engine;
814
+ var loading;
815
+ async function initPhysics() {
816
+ if (engine) return engine;
817
+ loading ??= (async () => {
818
+ const mod = await import("@dimforge/rapier3d-compat");
819
+ const ns = mod.default ?? mod;
820
+ await ns.init();
821
+ engine = ns;
822
+ return ns;
823
+ })();
824
+ return loading;
825
+ }
826
+ function loadedPhysics() {
827
+ return engine;
828
+ }
829
+ function resetPhysicsForTests() {
830
+ engine = void 0;
831
+ loading = void 0;
832
+ }
833
+ function encodePhysicsSection(section) {
834
+ const w = new ByteWriter(section.world.length + section.bodies.length * 24 + 8);
835
+ w.blob(section.world);
836
+ w.varint(section.bodies.length);
837
+ for (const [name, id, handle] of section.bodies) {
838
+ w.str(name);
839
+ w.str(id);
840
+ w.f64(handle);
841
+ }
842
+ return w.finish();
843
+ }
844
+ function decodePhysicsSection(bytes) {
845
+ const r = new ByteReader(bytes);
846
+ const world = r.blob().slice();
847
+ const count = r.varint();
848
+ const bodies = [];
849
+ for (let i = 0; i < count; i++) bodies.push([r.str(), r.str(), r.f64()]);
850
+ return { world, bodies };
851
+ }
852
+ function bodyKey(collection, id) {
853
+ return `${collection}\0${id}`;
854
+ }
855
+ var PhysicsRuntime = class {
856
+ rapier;
857
+ world;
858
+ /** `true` when the world was built from scratch and `setup` has to run. */
859
+ rebuilt;
860
+ core;
861
+ config;
862
+ /** Physics-backed collections, in schema (name-sorted) order. */
863
+ collections;
864
+ bodies = /* @__PURE__ */ new Map();
865
+ /**
866
+ * Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
867
+ * to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
868
+ * tick it falls asleep, or the schema keeps a phantom residual velocity forever (and a
869
+ * world rebuilt from schema state would wake it with a kick). Found by the drift check.
870
+ */
871
+ sleepSynced = /* @__PURE__ */ new Set();
872
+ constructor(core, rapier, options) {
873
+ const config = core.definition.config.physics;
874
+ if (!config) throw new Error("PhysicsRuntime: the room config declares no physics");
875
+ this.core = core;
876
+ this.config = config;
877
+ this.rapier = rapier;
878
+ this.collections = core.ext.collections.filter(
879
+ (c) => c.physics !== void 0
880
+ );
881
+ if (options.restore) {
882
+ this.world = rapier.World.restoreSnapshot(options.restore.world);
883
+ this.rebuilt = false;
884
+ for (const [name, id, handle] of options.restore.bodies) {
885
+ const body = this.world.getRigidBody(handle);
886
+ if (body) this.bodies.set(bodyKey(name, id), body);
887
+ }
888
+ } else {
889
+ const g = config.gravity;
890
+ this.world = new rapier.World({ x: g.x, y: g.y, z: g.z });
891
+ this.rebuilt = true;
892
+ }
893
+ this.world.timestep = config.timestep ?? options.defaultTimestep;
894
+ }
895
+ get timestep() {
896
+ return this.world.timestep;
897
+ }
898
+ /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
899
+ runSetup(room) {
900
+ const setup = this.config.setup;
901
+ if (!setup) return;
902
+ this.core.guard("physics.setup", () => setup(this.world, this.rapier, room));
903
+ }
904
+ free() {
905
+ this.bodies.clear();
906
+ this.world.free();
907
+ }
908
+ // -------------------------------------------------------------------------
909
+ // Bodies
910
+ // -------------------------------------------------------------------------
911
+ /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
912
+ bodyFor(collection, id) {
913
+ const existing = this.bodies.get(bodyKey(collection, id));
914
+ if (existing) return existing;
915
+ const desc = this.collections.find((c) => c.name === collection);
916
+ if (!desc) return void 0;
917
+ const coll = plainEntity(this.core.plain, collection);
918
+ const record = coll.get(id);
919
+ if (record === void 0) return void 0;
920
+ return this.create(desc, id, record);
921
+ }
922
+ create(desc, id, record) {
923
+ const factory = this.config.bodies?.[desc.name];
924
+ if (!factory) {
925
+ this.core.log("error", `irtio: physics.bodies.${desc.name} is missing; no body created`);
926
+ return void 0;
927
+ }
928
+ const spec = this.core.guard(
929
+ `physics.bodies.${desc.name}`,
930
+ () => factory(this.rapier, record, id)
931
+ );
932
+ if (!spec || !spec.body) {
933
+ this.core.log(
934
+ "error",
935
+ `irtio: physics.bodies.${desc.name} returned no { body } for ${JSON.stringify(id)}`
936
+ );
937
+ return void 0;
938
+ }
939
+ const body = this.world.createRigidBody(spec.body);
940
+ for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
941
+ this.applyRecordToBody(desc, body, record);
942
+ this.bodies.set(bodyKey(desc.name, id), body);
943
+ return body;
944
+ }
945
+ applyRecordToBody(desc, body, record) {
946
+ const physics = desc.physics;
947
+ if (!physics) return;
948
+ const t = { ...body.translation() };
949
+ const r = { ...body.rotation() };
950
+ const v = { ...body.linvel() };
951
+ const w = { ...body.angvel() };
952
+ let setT = false;
953
+ let setR = false;
954
+ let setV = false;
955
+ let setW = false;
956
+ for (const [channel, field] of physics.channels) {
957
+ const raw = record[field];
958
+ if (typeof raw !== "number") continue;
959
+ switch (channel) {
960
+ case "x":
961
+ case "y":
962
+ case "z":
963
+ t[channel] = raw;
964
+ setT = true;
965
+ break;
966
+ case "qx":
967
+ r.x = raw;
968
+ setR = true;
969
+ break;
970
+ case "qy":
971
+ r.y = raw;
972
+ setR = true;
973
+ break;
974
+ case "qz":
975
+ r.z = raw;
976
+ setR = true;
977
+ break;
978
+ case "qw":
979
+ r.w = raw;
980
+ setR = true;
981
+ break;
982
+ case "vx":
983
+ v.x = raw;
984
+ setV = true;
985
+ break;
986
+ case "vy":
987
+ v.y = raw;
988
+ setV = true;
989
+ break;
990
+ case "vz":
991
+ v.z = raw;
992
+ setV = true;
993
+ break;
994
+ case "wx":
995
+ w.x = raw;
996
+ setW = true;
997
+ break;
998
+ case "wy":
999
+ w.y = raw;
1000
+ setW = true;
1001
+ break;
1002
+ case "wz":
1003
+ w.z = raw;
1004
+ setW = true;
1005
+ break;
1006
+ }
1007
+ }
1008
+ if (setT) body.setTranslation(t, true);
1009
+ if (setR) body.setRotation(r, true);
1010
+ if (setV) body.setLinvel(v, true);
1011
+ if (setW) body.setAngvel(w, true);
1012
+ }
1013
+ /**
1014
+ * Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
1015
+ * tick, right before the step, in collection order then instance order.
1016
+ */
1017
+ reconcile() {
1018
+ const live = /* @__PURE__ */ new Set();
1019
+ for (const desc of this.collections) {
1020
+ const coll = plainEntity(this.core.plain, desc.name);
1021
+ for (const id of coll.ids()) {
1022
+ const key = bodyKey(desc.name, id);
1023
+ live.add(key);
1024
+ if (this.bodies.has(key)) continue;
1025
+ const record = coll.get(id);
1026
+ if (record !== void 0) this.create(desc, id, record);
1027
+ }
1028
+ }
1029
+ for (const [key, body] of [...this.bodies]) {
1030
+ if (live.has(key)) continue;
1031
+ this.bodies.delete(key);
1032
+ this.sleepSynced.delete(key);
1033
+ this.world.removeRigidBody(body);
1034
+ }
1035
+ }
1036
+ // -------------------------------------------------------------------------
1037
+ // Step and sync
1038
+ // -------------------------------------------------------------------------
1039
+ step() {
1040
+ this.world.step();
1041
+ }
1042
+ /**
1043
+ * Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
1044
+ * Values are `Math.fround`ed for f32 fields, so what room code reads is exactly what the wire
1045
+ * carries — and so a field that has not really moved does not re-dirty every tick.
1046
+ */
1047
+ sync() {
1048
+ for (const desc of this.collections) {
1049
+ const physics = desc.physics;
1050
+ if (!physics) continue;
1051
+ const tracked = this.core.anyState[desc.name];
1052
+ const plainColl = plainEntity(this.core.plain, desc.name);
1053
+ const rounders = roundersFor(desc);
1054
+ for (const id of plainColl.ids()) {
1055
+ const key = bodyKey(desc.name, id);
1056
+ const body = this.bodies.get(key);
1057
+ if (!body) continue;
1058
+ if (body.isSleeping()) {
1059
+ if (this.sleepSynced.has(key)) continue;
1060
+ this.sleepSynced.add(key);
1061
+ } else {
1062
+ this.sleepSynced.delete(key);
1063
+ }
1064
+ const record = tracked.get(id);
1065
+ if (!record) continue;
1066
+ const t = body.translation();
1067
+ const r = body.rotation();
1068
+ const v = body.linvel();
1069
+ const w = body.angvel();
1070
+ for (const [channel, field] of physics.channels) {
1071
+ const next = (rounders[field] ?? identity)(channelValue(channel, t, r, v, w));
1072
+ if (record[field] !== next) record[field] = next;
1073
+ }
1074
+ }
1075
+ }
1076
+ }
1077
+ // -------------------------------------------------------------------------
1078
+ // Hibernation
1079
+ // -------------------------------------------------------------------------
1080
+ serialize() {
1081
+ const bodies = [];
1082
+ for (const [key, body] of this.bodies) {
1083
+ const sep = key.indexOf("\0");
1084
+ bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
1085
+ }
1086
+ return { world: this.world.takeSnapshot(), bodies };
1087
+ }
1088
+ };
1089
+ function identity(v) {
1090
+ return v;
1091
+ }
1092
+ function roundersFor(desc) {
1093
+ const out = {};
1094
+ for (const f of desc.fields) {
1095
+ if (f.type.kind === "f32") out[f.name] = Math.fround;
1096
+ }
1097
+ return out;
1098
+ }
1099
+ function channelValue(channel, t, r, v, w) {
1100
+ switch (channel) {
1101
+ case "x":
1102
+ return t.x;
1103
+ case "y":
1104
+ return t.y;
1105
+ case "z":
1106
+ return t.z;
1107
+ case "qx":
1108
+ return r.x;
1109
+ case "qy":
1110
+ return r.y;
1111
+ case "qz":
1112
+ return r.z;
1113
+ case "qw":
1114
+ return r.w;
1115
+ case "vx":
1116
+ return v.x;
1117
+ case "vy":
1118
+ return v.y;
1119
+ case "vz":
1120
+ return v.z;
1121
+ case "wx":
1122
+ return w.x;
1123
+ case "wy":
1124
+ return w.y;
1125
+ default:
1126
+ return w.z;
1127
+ }
1128
+ }
1129
+
777
1130
  // src/core/random.ts
778
1131
  var Mulberry32 = class {
779
1132
  /** Current internal state (u32). Survives hibernation. */
@@ -802,8 +1155,74 @@ import {
802
1155
  isDirtyEmpty,
803
1156
  markAdd,
804
1157
  markPath,
805
- markRemove
1158
+ markRemove,
1159
+ mergeDirty
806
1160
  } from "@irtio/schema";
1161
+
1162
+ // src/core/spatial.ts
1163
+ function cellFor(grid, value) {
1164
+ if (typeof value !== "object" || value === null) return void 0;
1165
+ const record = value;
1166
+ const x = record[grid.x];
1167
+ const y = record[grid.y];
1168
+ if (typeof x !== "number" || typeof y !== "number") return void 0;
1169
+ return { x: Math.floor(x / grid.cell), y: Math.floor(y / grid.cell) };
1170
+ }
1171
+ function cellKey(cell) {
1172
+ return `${cell.x},${cell.y}`;
1173
+ }
1174
+ function buildSpatialIndex(plain, desc) {
1175
+ if (!desc.grid || desc.kind !== "entity") {
1176
+ throw new Error(`${desc.name}: cannot build an index without an entity grid`);
1177
+ }
1178
+ const collection = plainEntity(plain, desc.name);
1179
+ const buckets = /* @__PURE__ */ new Map();
1180
+ const order = /* @__PURE__ */ new Map();
1181
+ let n = 0;
1182
+ for (const id of collection.ids()) {
1183
+ order.set(id, n++);
1184
+ const cell = cellFor(desc.grid, collection.get(id));
1185
+ if (!cell) continue;
1186
+ const key = cellKey(cell);
1187
+ let ids = buckets.get(key);
1188
+ if (!ids) {
1189
+ ids = /* @__PURE__ */ new Set();
1190
+ buckets.set(key, ids);
1191
+ }
1192
+ ids.add(id);
1193
+ }
1194
+ return { desc, buckets, collection, order };
1195
+ }
1196
+ function querySpatialIndex(index, clientId, role) {
1197
+ const grid = index.desc.grid;
1198
+ if ((grid.wideRoles ?? []).includes(role)) return new Set(index.collection.ids());
1199
+ const anchor = index.collection.get(clientId);
1200
+ const anchorCell = cellFor(grid, anchor);
1201
+ if (!anchor || !anchorCell) return /* @__PURE__ */ new Set();
1202
+ const candidates = /* @__PURE__ */ new Set();
1203
+ for (let x = anchorCell.x - grid.radius; x <= anchorCell.x + grid.radius; x++) {
1204
+ for (let y = anchorCell.y - grid.radius; y <= anchorCell.y + grid.radius; y++) {
1205
+ const ids = index.buckets.get(cellKey({ x, y }));
1206
+ if (ids) for (const id of ids) candidates.add(id);
1207
+ }
1208
+ }
1209
+ candidates.add(clientId);
1210
+ const ranked = [];
1211
+ for (const id of candidates) if (index.order.has(id)) ranked.push(id);
1212
+ ranked.sort((a, b) => index.order.get(a) - index.order.get(b));
1213
+ return new Set(ranked);
1214
+ }
1215
+ function buildSpatialIndexes(collections, plain) {
1216
+ const indexes = /* @__PURE__ */ new Map();
1217
+ for (const desc of collections) {
1218
+ if (desc.visibility === "spatial-grid" && desc.grid) {
1219
+ indexes.set(desc.name, buildSpatialIndex(plain, desc));
1220
+ }
1221
+ }
1222
+ return indexes;
1223
+ }
1224
+
1225
+ // src/core/views.ts
807
1226
  var cache = /* @__PURE__ */ new WeakMap();
808
1227
  function infoOf(ext) {
809
1228
  let i = cache.get(ext);
@@ -837,8 +1256,82 @@ function visibleNames(ext, role) {
837
1256
  function viewKeyFor(ext, role) {
838
1257
  return infoOf(ext).scoped.has(role) ? role : "all";
839
1258
  }
840
- function encodeViewSnapshot(ext, plain, role, tick) {
841
- return encodeSnapshot(ext, plain, { tick }, { collections: visibleTo(role) });
1259
+ function encodeViewSnapshot(ext, plain, role, tick, memberships) {
1260
+ return encodeSnapshot(
1261
+ ext,
1262
+ plain,
1263
+ { tick },
1264
+ {
1265
+ collections: visibleTo(role),
1266
+ entities: (c, id) => c.visibility !== "spatial-grid" || (memberships?.get(c.name)?.has(id) ?? false)
1267
+ }
1268
+ );
1269
+ }
1270
+ function spatialMemberships(ext, plain, clientId, role, indexes = buildSpatialIndexes(ext.collections, plain)) {
1271
+ const out = /* @__PURE__ */ new Map();
1272
+ for (const [name, index] of indexes) out.set(name, querySpatialIndex(index, clientId, role));
1273
+ return out;
1274
+ }
1275
+ function createVisibilityPolicy(ext, plain, clientId, role) {
1276
+ const memberships = spatialMemberships(ext, plain, clientId, role);
1277
+ return {
1278
+ memberships,
1279
+ maySeeCollection: (desc) => isVisible(desc, role),
1280
+ maySeeEntity: (desc, id) => isVisible(desc, role) && (desc.visibility !== "spatial-grid" || (memberships.get(desc.name)?.has(id) ?? false)),
1281
+ describeSpatial: (desc, ids) => {
1282
+ const grid = desc.grid;
1283
+ if (desc.visibility !== "spatial-grid" || !grid) return void 0;
1284
+ const coll = plainEntity(plain, desc.name);
1285
+ const at = (id) => {
1286
+ const cell = cellFor(grid, coll.get(id));
1287
+ return cell ? `${id}@(${cell.x},${cell.y})` : `${id}@(no position)`;
1288
+ };
1289
+ return `viewer ${at(clientId)}, ${desc.name} ${ids.map(at).join(" ")}, radius ${grid.radius}`;
1290
+ }
1291
+ };
1292
+ }
1293
+ function sharedViewDirty(ext, dirty, role) {
1294
+ return filterDirty(dirty, (name) => {
1295
+ const desc = ext.collection(name);
1296
+ return desc.visibility !== "spatial-grid" && isVisible(desc, role);
1297
+ });
1298
+ }
1299
+ function aoiViewDirty(ext, dirty, previous, current) {
1300
+ const out = createDirtySet();
1301
+ for (const desc of ext.collections) {
1302
+ if (desc.visibility !== "spatial-grid") continue;
1303
+ const before = previous.get(desc.name) ?? /* @__PURE__ */ new Set();
1304
+ const now = current.get(desc.name) ?? /* @__PURE__ */ new Set();
1305
+ for (const id of before) if (!now.has(id)) markRemove(out, desc.name, id);
1306
+ for (const id of now) if (!before.has(id)) markAdd(out, desc.name, id);
1307
+ const changed = dirty.get(desc.name);
1308
+ if (!changed) continue;
1309
+ const visibleChanges = createDirtySet();
1310
+ const collection = collectionDirty(visibleChanges, desc.name);
1311
+ for (const id of changed.added) if (now.has(id)) collection.added.add(id);
1312
+ for (const id of changed.removed) if (before.has(id)) collection.removed.add(id);
1313
+ for (const [id, record] of changed.updated) {
1314
+ if (before.has(id) && now.has(id)) collection.updated.set(id, record);
1315
+ }
1316
+ mergeDirty(out, visibleChanges);
1317
+ }
1318
+ return out;
1319
+ }
1320
+ function membershipSafeDirty(ext, dirty, memberships) {
1321
+ const out = createDirtySet();
1322
+ for (const [name, changed] of dirty) {
1323
+ const desc = ext.collection(name);
1324
+ if (desc.visibility !== "spatial-grid") {
1325
+ out.set(name, changed);
1326
+ continue;
1327
+ }
1328
+ const visible = memberships.get(name) ?? /* @__PURE__ */ new Set();
1329
+ const kept = collectionDirty(out, name);
1330
+ for (const id of changed.added) if (visible.has(id)) kept.added.add(id);
1331
+ for (const id of changed.removed) if (visible.has(id)) kept.removed.add(id);
1332
+ for (const [id, record] of changed.updated) if (visible.has(id)) kept.updated.set(id, record);
1333
+ }
1334
+ return out;
842
1335
  }
843
1336
  function encodeViewDelta(ext, plain, dirty, role, tick) {
844
1337
  const keep = visibleNames(ext, role);
@@ -891,27 +1384,35 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
891
1384
  }
892
1385
 
893
1386
  // src/core/snapshot.ts
894
- import { ByteReader, ByteWriter } from "@irtio/schema";
895
- var SNAPSHOT_FORMAT_VERSION = 1;
1387
+ import { ByteReader as ByteReader2, ByteWriter as ByteWriter2 } from "@irtio/schema";
1388
+ var SNAPSHOT_FORMAT_VERSION = 2;
1389
+ var READABLE_SNAPSHOT_VERSIONS = [1, 2];
896
1390
  function parseHibernationBlob(bytes) {
897
- const r = new ByteReader(bytes);
1391
+ const r = new ByteReader2(bytes);
898
1392
  const version = r.u8();
899
- if (version !== SNAPSHOT_FORMAT_VERSION) {
1393
+ if (!READABLE_SNAPSHOT_VERSIONS.includes(version)) {
900
1394
  throw new Error(`RoomCore.restore: unsupported snapshot format version ${version}`);
901
1395
  }
902
1396
  const seed = r.u32();
903
1397
  const rngState = r.u32();
904
1398
  const tick = r.u32();
905
1399
  const mode = r.u8() === 1 ? "event" : "tick";
906
- return { seed, rngState, tick, mode, snapshot: r.rest() };
1400
+ let physics;
1401
+ if (version >= 2) {
1402
+ const section = r.blob();
1403
+ if (section.length > 0) physics = section;
1404
+ }
1405
+ return { version, seed, rngState, tick, mode, physics, snapshot: r.rest() };
907
1406
  }
908
- function writeHibernationBlob(header, snapshot) {
909
- const w = new ByteWriter(snapshot.length + 16);
910
- w.u8(SNAPSHOT_FORMAT_VERSION);
1407
+ function writeHibernationBlob(header, snapshot, physics) {
1408
+ const version = physics ? 2 : 1;
1409
+ const w = new ByteWriter2(snapshot.length + (physics?.length ?? 0) + 24);
1410
+ w.u8(version);
911
1411
  w.u32(header.seed >>> 0);
912
1412
  w.u32(header.rngState >>> 0);
913
1413
  w.u32(header.tick >>> 0);
914
1414
  w.u8(header.mode === "event" ? 1 : 0);
1415
+ if (physics) w.blob(physics);
915
1416
  w.bytes(snapshot);
916
1417
  return w.finish();
917
1418
  }
@@ -930,12 +1431,78 @@ import {
930
1431
  createDirtySet as createDirtySet2,
931
1432
  createState,
932
1433
  decodeSnapshot,
1434
+ encodeDelta as encodeDelta4,
933
1435
  encodeSnapshot as encodeSnapshot2,
934
1436
  isDirtyEmpty as isDirtyEmpty3,
935
1437
  track,
936
1438
  validateForDeploy
937
1439
  } from "@irtio/schema";
938
1440
 
1441
+ // src/core/host-calls.ts
1442
+ var states2 = /* @__PURE__ */ new WeakMap();
1443
+ function stateOf2(core) {
1444
+ let s = states2.get(core);
1445
+ if (!s) {
1446
+ s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
1447
+ states2.set(core, s);
1448
+ }
1449
+ return s;
1450
+ }
1451
+ function startHostCall(core, call, map) {
1452
+ return new Promise((resolve, reject2) => {
1453
+ if (core.stopped) {
1454
+ reject2(new Error(`room.${call.kind}: the room is stopped`));
1455
+ return;
1456
+ }
1457
+ const s = stateOf2(core);
1458
+ const reqId = s.nextReqId;
1459
+ s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
1460
+ s.pending.set(reqId, {
1461
+ kind: call.kind,
1462
+ deadline: core.host.now() + HOST_CALL_TIMEOUT_MS,
1463
+ map,
1464
+ resolve,
1465
+ reject: reject2
1466
+ });
1467
+ try {
1468
+ core.host.hostCall(reqId, call);
1469
+ } catch (err) {
1470
+ s.pending.delete(reqId);
1471
+ reject2(err instanceof Error ? err : new Error(String(err)));
1472
+ return;
1473
+ }
1474
+ if (core.mode === "event") {
1475
+ core.loop.after(HOST_CALL_TIMEOUT_MS, () => checkHostCallTimeouts(core));
1476
+ }
1477
+ });
1478
+ }
1479
+ function completeHostCall(core, reqId, result) {
1480
+ const s = stateOf2(core);
1481
+ const pending = s.pending.get(reqId);
1482
+ if (!pending) return false;
1483
+ s.pending.delete(reqId);
1484
+ if (result.ok) pending.resolve(pending.map(result.value));
1485
+ else pending.reject(new Error(`${result.code}: ${result.message}`));
1486
+ return true;
1487
+ }
1488
+ function checkHostCallTimeouts(core) {
1489
+ const s = stateOf2(core);
1490
+ if (s.pending.size === 0) return;
1491
+ const now = core.host.now();
1492
+ for (const [reqId, p] of [...s.pending]) {
1493
+ if (p.deadline > now) continue;
1494
+ s.pending.delete(reqId);
1495
+ p.reject(new Error(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
1496
+ }
1497
+ }
1498
+ function rejectAllHostCalls(core, reason) {
1499
+ const s = stateOf2(core);
1500
+ for (const [reqId, p] of [...s.pending]) {
1501
+ s.pending.delete(reqId);
1502
+ p.reject(new Error(reason));
1503
+ }
1504
+ }
1505
+
939
1506
  // src/core/messages.ts
940
1507
  import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
941
1508
  function toRoomTarget(target) {
@@ -1002,9 +1569,40 @@ function sendMessage(core, target, bytes) {
1002
1569
  // src/core/room-api.ts
1003
1570
  import { FrameType as FrameType4, encodeFrame as encodeFrame3 } from "@irtio/protocol";
1004
1571
  import { encodeDelta as encodeDelta3, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
1572
+ function makePhysicsApi(p) {
1573
+ return {
1574
+ get rapier() {
1575
+ return p.rapier;
1576
+ },
1577
+ get world() {
1578
+ return p.world;
1579
+ },
1580
+ get timestep() {
1581
+ return p.timestep;
1582
+ },
1583
+ body(collection, id) {
1584
+ return p.bodyFor(collection, id);
1585
+ }
1586
+ };
1587
+ }
1588
+ function makeKv(core) {
1589
+ return {
1590
+ get(playerId, key) {
1591
+ return startHostCall(core, { kind: "kvGet", playerId, key }, (v) => v);
1592
+ },
1593
+ set(playerId, key, value) {
1594
+ return startHostCall(core, { kind: "kvSet", playerId, key, value }, () => void 0);
1595
+ },
1596
+ delete(playerId, key) {
1597
+ return startHostCall(core, { kind: "kvDelete", playerId, key }, () => void 0);
1598
+ }
1599
+ };
1600
+ }
1005
1601
  function createRoomApi(core, publicUrl) {
1006
1602
  let cached;
1007
1603
  let lastNow = Number.NEGATIVE_INFINITY;
1604
+ let physicsApi;
1605
+ let kvApi;
1008
1606
  const link = () => {
1009
1607
  const sep = publicUrl.includes("?") ? "&" : "?";
1010
1608
  return `${publicUrl}${sep}room=${encodeURIComponent(core.roomId)}`;
@@ -1038,6 +1636,19 @@ function createRoomApi(core, publicUrl) {
1038
1636
  random() {
1039
1637
  return core.rng.next();
1040
1638
  },
1639
+ // D22. A single object, built once: `room.physics` is read on every tick of a physics room.
1640
+ // In a room with no `physics:` config, touching it is a mistake worth naming loudly rather
1641
+ // than a silent `undefined` that shows up three frames later as "cannot read world of
1642
+ // undefined". The property itself is safe to *hold*; only reading through it throws.
1643
+ get physics() {
1644
+ const p = core.physics;
1645
+ if (!p) {
1646
+ throw new Error(
1647
+ "room.physics: this room has no physics \u2014 add physics: { engine: 'rapier3d', gravity, bodies } to defineRoom(...)"
1648
+ );
1649
+ }
1650
+ return physicsApi ?? (physicsApi = makePhysicsApi(p));
1651
+ },
1041
1652
  send(target, bytes) {
1042
1653
  sendMessage(core, target, bytes);
1043
1654
  },
@@ -1073,6 +1684,27 @@ function createRoomApi(core, publicUrl) {
1073
1684
  clearInterval(handle) {
1074
1685
  core.loop.clearTimer(handle);
1075
1686
  },
1687
+ save() {
1688
+ return startHostCall(core, { kind: "save" }, (id) => id ?? "");
1689
+ },
1690
+ get kv() {
1691
+ return kvApi ?? (kvApi = makeKv(core));
1692
+ },
1693
+ alarm(name, atMs) {
1694
+ if (!isAlarmName(core, name, "room.alarm")) return;
1695
+ if (!Number.isFinite(atMs)) {
1696
+ core.log("warn", `room.alarm: ${JSON.stringify(name)} needs a finite time; ignored`);
1697
+ return;
1698
+ }
1699
+ core.host.setAlarm(name, atMs);
1700
+ },
1701
+ cancelAlarm(name) {
1702
+ if (typeof name !== "string" || name === "") {
1703
+ core.log("warn", "room.cancelAlarm: a name is required; ignored");
1704
+ return;
1705
+ }
1706
+ core.host.setAlarm(name, void 0);
1707
+ },
1076
1708
  call(clientId) {
1077
1709
  return createCallProxy(core, clientId);
1078
1710
  },
@@ -1088,6 +1720,21 @@ function createRoomApi(core, publicUrl) {
1088
1720
  }
1089
1721
  };
1090
1722
  }
1723
+ function isAlarmName(core, name, where) {
1724
+ if (typeof name !== "string" || name === "") {
1725
+ core.log("warn", `${where}: a name is required; ignored`);
1726
+ return false;
1727
+ }
1728
+ const alarms = core.definition.config.alarms;
1729
+ if (!alarms || typeof alarms[name] !== "function") {
1730
+ core.log(
1731
+ "warn",
1732
+ `${where}: no handler named ${JSON.stringify(name)} \u2014 add it to the room's \`alarms: { \u2026 }\` config; the alarm was not armed`
1733
+ );
1734
+ return false;
1735
+ }
1736
+ return true;
1737
+ }
1091
1738
  function setRole(core, clientId, role) {
1092
1739
  const entry = core.clients.get(clientId);
1093
1740
  if (!entry) {
@@ -1115,6 +1762,7 @@ function setRole(core, clientId, role) {
1115
1762
 
1116
1763
  // src/core/room.ts
1117
1764
  var DEFAULT_PUBLIC_URL = "http://localhost/";
1765
+ var CONTINUATION_FLUSH_TURNS = 8;
1118
1766
  function ownJoinCapture(plain, name, added, clientId) {
1119
1767
  const coll = plainEntity(plain, name);
1120
1768
  let mine;
@@ -1137,6 +1785,8 @@ var RoomCore = class _RoomCore {
1137
1785
  rng;
1138
1786
  clients = /* @__PURE__ */ new Map();
1139
1787
  loop;
1788
+ /** D22: the Rapier world, or `undefined` in a room whose config declares no physics. */
1789
+ physics;
1140
1790
  stats = {
1141
1791
  ticks: 0,
1142
1792
  lastTickMs: 0,
@@ -1148,6 +1798,13 @@ var RoomCore = class _RoomCore {
1148
1798
  bytesOutByClient: /* @__PURE__ */ new Map(),
1149
1799
  handlerErrors: 0,
1150
1800
  encodesLastFlush: 0,
1801
+ aoiEncodesLastFlush: 0,
1802
+ gridBuildMs: 0,
1803
+ gridQueryMs: 0,
1804
+ aoiEncodeMs: 0,
1805
+ visibleIdsTotal: 0,
1806
+ membershipEnters: 0,
1807
+ membershipLeaves: 0,
1151
1808
  corrections: 0
1152
1809
  };
1153
1810
  tick = 0;
@@ -1156,6 +1813,8 @@ var RoomCore = class _RoomCore {
1156
1813
  api;
1157
1814
  internals;
1158
1815
  started = false;
1816
+ /** One pending continuation flush at a time; concurrent completions coalesce into it. */
1817
+ continuationFlushPending = false;
1159
1818
  constructor(definition, host, options) {
1160
1819
  this.definition = definition;
1161
1820
  this.host = host;
@@ -1191,6 +1850,7 @@ var RoomCore = class _RoomCore {
1191
1850
  this.internals = self;
1192
1851
  this.loop = new Loop(self);
1193
1852
  this.api = createRoomApi(self, options.publicUrl ?? DEFAULT_PUBLIC_URL);
1853
+ this.physics = this.buildPhysics(restored);
1194
1854
  if (restored) {
1195
1855
  const onWake = definition.config.onWake;
1196
1856
  if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
@@ -1199,6 +1859,42 @@ var RoomCore = class _RoomCore {
1199
1859
  if (onCreate) this.guard("onCreate", () => onCreate(this.state, this.room));
1200
1860
  }
1201
1861
  }
1862
+ /**
1863
+ * D22: builds the world, or returns `undefined` for a room with no `physics:` config.
1864
+ *
1865
+ * A v2 blob restores the world from its own bytes and `setup` does **not** run — the static
1866
+ * geometry is already in there. Anything else (a fresh room, a v1 blob written before this
1867
+ * room had physics, a migrated snapshot whose world was deliberately dropped) builds a world,
1868
+ * runs `setup`, and lets the first tick's `reconcile` rebuild the bodies from schema state:
1869
+ * positions and velocities live in schema fields, so the rebuild is faithful to what the state
1870
+ * says. Transient contact state — resting contacts, accumulated impulses — is not in the schema
1871
+ * and is lost; a stack of boxes may settle again with a small visible jolt.
1872
+ */
1873
+ buildPhysics(restored) {
1874
+ const config = this.definition.config.physics;
1875
+ if (!config) return void 0;
1876
+ const engine2 = loadedPhysics();
1877
+ if (!engine2) {
1878
+ throw new Error(
1879
+ "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"
1880
+ );
1881
+ }
1882
+ const section = restored?.physics ? decodePhysicsSection(restored.physics) : void 0;
1883
+ const physics = new PhysicsRuntime(this.internals, engine2, {
1884
+ ...section ? { restore: section } : {},
1885
+ defaultTimestep: 1 / this.definition.config.tickRate
1886
+ });
1887
+ if (physics.rebuilt) {
1888
+ if (restored) {
1889
+ this.host.log("info", [
1890
+ `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`
1891
+ ]);
1892
+ }
1893
+ physics.runSetup(this.room);
1894
+ physics.reconcile();
1895
+ }
1896
+ return physics;
1897
+ }
1202
1898
  /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
1203
1899
  static restore(definition, bytes, host, options) {
1204
1900
  return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
@@ -1275,16 +1971,35 @@ var RoomCore = class _RoomCore {
1275
1971
  this.started = false;
1276
1972
  this.loop.stop();
1277
1973
  rejectAllPending(this.internals, "room stopped");
1974
+ rejectAllHostCalls(this.internals, "room stopped");
1975
+ this.physics?.free();
1976
+ }
1977
+ /**
1978
+ * The hibernation blob's bytes, and **nothing else** — no `onSleep`, no timers cleared, no
1979
+ * pending work rejected. A save (D24) is a *copy* of the room; hibernation is the room
1980
+ * *leaving*. They want identical bytes and opposite side effects, so the bytes live here and
1981
+ * the departure lives in `serialize()`.
1982
+ *
1983
+ * Conflating the two is not hypothetical: the first cut of `room.save()` routed through
1984
+ * `serialize()`, which rejected every pending host call — including the `save()` that had just
1985
+ * asked for it. The room waited out its own 10 s deadline for a save that had already been
1986
+ * written.
1987
+ */
1988
+ snapshot() {
1989
+ const physics = this.physics ? encodePhysicsSection(this.physics.serialize()) : void 0;
1990
+ return writeHibernationBlob(
1991
+ { seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
1992
+ encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
1993
+ physics
1994
+ );
1278
1995
  }
1279
1996
  serialize() {
1280
1997
  const onSleep = this.definition.config.onSleep;
1281
1998
  if (onSleep) this.guard("onSleep", () => onSleep(this.state, this.room));
1282
1999
  this.loop.clearAllTimers();
1283
2000
  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
- );
2001
+ rejectAllHostCalls(this.internals, "room is hibernating");
2002
+ return this.snapshot();
1288
2003
  }
1289
2004
  // -------------------------------------------------------------------------
1290
2005
  // Clients
@@ -1319,7 +2034,13 @@ var RoomCore = class _RoomCore {
1319
2034
  return {
1320
2035
  tick: this.tick,
1321
2036
  role: role2,
1322
- snapshot: encodeViewSnapshot(this.ext, this.plain, role2, this.tick)
2037
+ snapshot: encodeViewSnapshot(
2038
+ this.ext,
2039
+ this.plain,
2040
+ role2,
2041
+ this.tick,
2042
+ spatialMemberships(this.ext, this.plain, clientId, role2)
2043
+ )
1323
2044
  };
1324
2045
  }
1325
2046
  const presence = this.presence;
@@ -1340,13 +2061,19 @@ var RoomCore = class _RoomCore {
1340
2061
  }
1341
2062
  const entry = existing ?? {
1342
2063
  clientId,
2064
+ // D25: defaults to the client id, which a resume token carries across a reconnect — so for
2065
+ // a key join `ctx.playerId` is exactly as durable as the resume token and no more. A JWT
2066
+ // join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
2067
+ // and nothing else here changes.
2068
+ playerId: options.playerId ?? clientId,
1343
2069
  role,
1344
2070
  name,
1345
2071
  connected: true,
1346
2072
  correction: void 0,
1347
2073
  accepted: /* @__PURE__ */ new Map(),
1348
2074
  lastClientTick: 0,
1349
- pendingJoinAdds: void 0
2075
+ pendingJoinAdds: void 0,
2076
+ spatialMembership: /* @__PURE__ */ new Map()
1350
2077
  };
1351
2078
  entry.role = role;
1352
2079
  entry.name = name;
@@ -1360,12 +2087,23 @@ var RoomCore = class _RoomCore {
1360
2087
  }
1361
2088
  this.loop.noteActivity();
1362
2089
  this.recordEvent("join", clientId, reconnecting ? "reconnect" : void 0);
1363
- const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick);
2090
+ const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
2091
+ entry.spatialMembership = memberships;
2092
+ const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
1364
2093
  const pending = /* @__PURE__ */ new Map();
1365
2094
  for (const [name2, cd] of this.tracked.dirty) {
1366
2095
  if (cd.added.size === 0) continue;
1367
2096
  const mine = ownJoinCapture(this.plain, name2, cd.added, clientId);
1368
- if (mine) pending.set(name2, mine);
2097
+ const visible = memberships.get(name2);
2098
+ if (visible) {
2099
+ const coll = plainEntity(this.plain, name2);
2100
+ const captured = mine ?? /* @__PURE__ */ new Map();
2101
+ for (const id of cd.added) {
2102
+ if (!visible.has(id) || captured.has(id)) continue;
2103
+ captured.set(id, { value: cloneValue2(coll.get(id)), owner: coll.ownerOf(id) });
2104
+ }
2105
+ if (captured.size > 0) pending.set(name2, captured);
2106
+ } else if (mine) pending.set(name2, mine);
1369
2107
  }
1370
2108
  entry.pendingJoinAdds = pending.size > 0 ? pending : void 0;
1371
2109
  this.eventFlush();
@@ -1415,6 +2153,7 @@ var RoomCore = class _RoomCore {
1415
2153
  const entry = this.clients.get(clientId);
1416
2154
  return {
1417
2155
  clientId,
2156
+ playerId: entry?.playerId ?? clientId,
1418
2157
  role: entry?.role ?? "",
1419
2158
  name: entry?.name ?? "",
1420
2159
  tick: this.tick,
@@ -1422,6 +2161,63 @@ var RoomCore = class _RoomCore {
1422
2161
  room: this.room
1423
2162
  };
1424
2163
  }
2164
+ /**
2165
+ * Week 12: the host answering a `room.save()` / `room.kv.*`. The continuation runs here — its
2166
+ * own event, between ticks, off the back of a host turn — and the flush afterwards is what
2167
+ * makes "state mutated in a continuation is tracked normally" true rather than aspirational.
2168
+ */
2169
+ completeHostCall(reqId, result) {
2170
+ if (this.stopped) return;
2171
+ if (!completeHostCall(this.internals, reqId, result)) {
2172
+ this.log("warn", `completeHostCall: nothing is waiting on reqId ${reqId}`);
2173
+ return;
2174
+ }
2175
+ checkHostCallTimeouts(this.internals);
2176
+ this.scheduleContinuationFlush();
2177
+ }
2178
+ /**
2179
+ * Flushes whatever a promise continuation wrote, as its own event, once the microtask queue
2180
+ * that continuation lives on has drained.
2181
+ *
2182
+ * The subtlety this exists for: `resolve()` does not run the room's `.then` — it *queues* it,
2183
+ * and every promise link between the resolve and the room's callback costs another microtask
2184
+ * turn. A single `queueMicrotask(flush)` therefore only ever catches a continuation exactly one
2185
+ * link deep, and silently drops the state written by anything the room chained further out.
2186
+ * Draining a bounded number of turns first covers the chains rooms actually write, coalesces
2187
+ * concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
2188
+ * the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
2189
+ */
2190
+ scheduleContinuationFlush() {
2191
+ if (this.mode !== "event" || this.continuationFlushPending) return;
2192
+ this.continuationFlushPending = true;
2193
+ let turns = 0;
2194
+ const drain = () => {
2195
+ if (++turns < CONTINUATION_FLUSH_TURNS) {
2196
+ queueMicrotask(drain);
2197
+ return;
2198
+ }
2199
+ this.continuationFlushPending = false;
2200
+ if (!this.stopped) this.eventFlush();
2201
+ };
2202
+ queueMicrotask(drain);
2203
+ }
2204
+ /**
2205
+ * D26: one durable alarm firing. Same scheduling class as an RPC — a discrete event between
2206
+ * ticks — so a tick-mode room never sees a `tick` run half-alarmed, and a handler that re-arms
2207
+ * its own name is the supported way to build a repeating timer.
2208
+ */
2209
+ fireAlarm(name) {
2210
+ if (this.stopped) return;
2211
+ const alarms = this.definition.config.alarms;
2212
+ const handler = alarms?.[name];
2213
+ if (!handler) {
2214
+ this.log("warn", `alarm ${JSON.stringify(name)} fired but the room has no handler for it`);
2215
+ return;
2216
+ }
2217
+ this.recordEvent("alarm", void 0, name);
2218
+ this.guard(`alarms.${name}`, () => handler(this.state, this.room));
2219
+ this.eventFlush();
2220
+ }
1425
2221
  correctionFor(clientId) {
1426
2222
  const entry = this.clients.get(clientId);
1427
2223
  if (!entry) return void 0;
@@ -1486,12 +2282,8 @@ var RoomCore = class _RoomCore {
1486
2282
  case FrameType5.REPLY: {
1487
2283
  if (!handleReply(this.internals, clientId, payload)) {
1488
2284
  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
- });
2285
+ } else {
2286
+ this.scheduleContinuationFlush();
1495
2287
  }
1496
2288
  return;
1497
2289
  }
@@ -1515,15 +2307,72 @@ var RoomCore = class _RoomCore {
1515
2307
  const dirty = this.tracked.flush();
1516
2308
  serverWinsCorrections(this.internals, dirty);
1517
2309
  this.stats.encodesLastFlush = 0;
2310
+ this.stats.aoiEncodesLastFlush = 0;
2311
+ const spatialDescs = this.ext.collections.filter((desc) => desc.visibility === "spatial-grid");
2312
+ const buildStarted = performance.now();
2313
+ const indexes = buildSpatialIndexes(spatialDescs, this.plain);
2314
+ this.stats.gridBuildMs += performance.now() - buildStarted;
1518
2315
  const byView = /* @__PURE__ */ new Map();
1519
2316
  for (const entry of this.clients.values()) {
1520
2317
  if (entry.connected) {
2318
+ const queryStarted = performance.now();
2319
+ const memberships = spatialMemberships(
2320
+ this.ext,
2321
+ this.plain,
2322
+ entry.clientId,
2323
+ entry.role,
2324
+ indexes
2325
+ );
2326
+ this.stats.gridQueryMs += performance.now() - queryStarted;
2327
+ for (const [name, ids] of memberships) {
2328
+ const before = entry.spatialMembership.get(name) ?? /* @__PURE__ */ new Set();
2329
+ this.stats.visibleIdsTotal += ids.size;
2330
+ for (const id of ids) if (!before.has(id)) this.stats.membershipEnters++;
2331
+ for (const id of before) if (!ids.has(id)) this.stats.membershipLeaves++;
2332
+ }
1521
2333
  if (entry.correction) {
1522
- const payload = encodeCorrection(this.internals, entry.correction);
2334
+ const payload = encodeCorrection(
2335
+ this.internals,
2336
+ membershipSafeDirty(this.ext, entry.correction, memberships)
2337
+ );
1523
2338
  if (payload) this.send(entry.clientId, encodeCorrectFrame(payload, entry.lastClientTick));
1524
2339
  }
1525
2340
  let delta;
1526
- if (entry.pendingJoinAdds) {
2341
+ if (spatialDescs.length > 0) {
2342
+ const sourceDirty = entry.pendingJoinAdds ? stripAdds(this.plain, dirty, entry.pendingJoinAdds) : dirty;
2343
+ let shared;
2344
+ if (entry.pendingJoinAdds) {
2345
+ const sharedDirty = sharedViewDirty(this.ext, sourceDirty, entry.role);
2346
+ shared = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
2347
+ if (shared) this.stats.encodesLastFlush++;
2348
+ } else {
2349
+ const key = viewKeyFor(this.ext, entry.role);
2350
+ let cached = byView.get(key);
2351
+ if (cached === void 0) {
2352
+ const sharedDirty = sharedViewDirty(this.ext, dirty, entry.role);
2353
+ cached = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
2354
+ if (cached) this.stats.encodesLastFlush++;
2355
+ byView.set(key, cached);
2356
+ }
2357
+ shared = cached;
2358
+ }
2359
+ if (shared) this.send(entry.clientId, encodeFrame4(FrameType5.DELTA, shared));
2360
+ const aoiDirty = aoiViewDirty(
2361
+ this.ext,
2362
+ sourceDirty,
2363
+ entry.spatialMembership,
2364
+ memberships
2365
+ );
2366
+ const encodeStarted = performance.now();
2367
+ delta = isDirtyEmpty3(aoiDirty) ? null : encodeDelta4(this.ext, this.plain, aoiDirty, { tick: this.tick });
2368
+ this.stats.aoiEncodeMs += performance.now() - encodeStarted;
2369
+ if (delta) {
2370
+ this.stats.encodesLastFlush++;
2371
+ this.stats.aoiEncodesLastFlush++;
2372
+ }
2373
+ entry.pendingJoinAdds = void 0;
2374
+ entry.spatialMembership = memberships;
2375
+ } else if (entry.pendingJoinAdds) {
1527
2376
  delta = encodeViewDelta(
1528
2377
  this.ext,
1529
2378
  this.plain,
@@ -1552,21 +2401,29 @@ var RoomCore = class _RoomCore {
1552
2401
  };
1553
2402
 
1554
2403
  export {
2404
+ HOST_CALL_TIMEOUT_MS,
1555
2405
  RoomFullError,
1556
2406
  EVENT_RING_SIZE,
1557
2407
  inspectState,
1558
2408
  RPC_TIMEOUT_MS,
1559
2409
  MAX_CATCHUP,
1560
2410
  CRASH_AFTER_THROWS,
2411
+ initPhysics,
2412
+ loadedPhysics,
2413
+ resetPhysicsForTests,
2414
+ encodePhysicsSection,
2415
+ decodePhysicsSection,
1561
2416
  Mulberry32,
1562
2417
  isVisible,
1563
2418
  visibleTo,
1564
2419
  visibleNames,
1565
2420
  viewKeyFor,
1566
2421
  encodeViewSnapshot,
2422
+ createVisibilityPolicy,
1567
2423
  encodeViewDelta,
1568
2424
  catchUpDirty,
1569
2425
  SNAPSHOT_FORMAT_VERSION,
2426
+ READABLE_SNAPSHOT_VERSIONS,
1570
2427
  parseHibernationBlob,
1571
2428
  writeHibernationBlob,
1572
2429
  RoomCore