@irtio/runtime 0.5.2 → 0.7.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.
@@ -143,10 +143,10 @@ function handleCall(core, clientId, payload) {
143
143
  return true;
144
144
  }
145
145
  }
146
- const ctx = core.ctxFor(clientId);
146
+ const ctx = { ...core.ctxFor(clientId), clientTick: call.clientTick || void 0 };
147
147
  let result;
148
148
  if (desc.name === REQUEST_OWNERSHIP) {
149
- result = runRequestOwnership(core, clientId, params);
149
+ result = runRequestOwnership(core, clientId, ctx, params);
150
150
  } else {
151
151
  const impl = core.definition.config.rpc?.[desc.name];
152
152
  if (!impl) {
@@ -175,7 +175,7 @@ function handleCall(core, clientId, payload) {
175
175
  }
176
176
  return true;
177
177
  }
178
- function runRequestOwnership(core, clientId, params) {
178
+ function runRequestOwnership(core, clientId, ctx, params) {
179
179
  const name = String(params.entity ?? "");
180
180
  const id = String(params.id ?? "");
181
181
  const c = collectionDescOf(core.ext, name);
@@ -185,10 +185,7 @@ function runRequestOwnership(core, clientId, params) {
185
185
  const handler = core.definition.config.onOwnershipRequest;
186
186
  const tracked = trackedEntity(core, name);
187
187
  if (handler) {
188
- const ran = core.tryRun(
189
- "onOwnershipRequest",
190
- () => handler(core.anyState, name, id, core.ctxFor(clientId))
191
- );
188
+ const ran = core.tryRun("onOwnershipRequest", () => handler(core.anyState, name, id, ctx));
192
189
  if (ran.ok && ran.value === true && plain.ownerOf(id) !== clientId) {
193
190
  tracked.setOwner(id, clientId);
194
191
  }
@@ -230,6 +227,7 @@ function createCallProxy(core, clientId) {
230
227
  return new Promise((resolve, reject2) => {
231
228
  const entry = core.clients.get(clientId);
232
229
  if (!entry || !entry.connected) {
230
+ core.scheduleContinuationFlush();
233
231
  reject2(new Error(`room.call: ${clientId} is not connected`));
234
232
  return;
235
233
  }
@@ -328,6 +326,75 @@ function rejectAllPending(core, reason) {
328
326
  // src/core/loop.ts
329
327
  import { FrameType as FrameType2 } from "@irtio/protocol";
330
328
 
329
+ // src/core/host-calls.ts
330
+ var states2 = /* @__PURE__ */ new WeakMap();
331
+ function stateOf2(core) {
332
+ let s = states2.get(core);
333
+ if (!s) {
334
+ s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
335
+ states2.set(core, s);
336
+ }
337
+ return s;
338
+ }
339
+ function rejectAsEvent(core, err) {
340
+ core.scheduleContinuationFlush();
341
+ return Promise.reject(err);
342
+ }
343
+ function startHostCall(core, call, map) {
344
+ if (core.stopped) {
345
+ return rejectAsEvent(core, new Error(`room.${call.kind}: the room is stopped`));
346
+ }
347
+ return new Promise((resolve, reject2) => {
348
+ const s = stateOf2(core);
349
+ const reqId = s.nextReqId;
350
+ s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
351
+ s.pending.set(reqId, {
352
+ kind: call.kind,
353
+ deadline: core.host.now() + HOST_CALL_TIMEOUT_MS,
354
+ map,
355
+ resolve,
356
+ reject: reject2
357
+ });
358
+ try {
359
+ core.host.hostCall(reqId, call);
360
+ } catch (err) {
361
+ s.pending.delete(reqId);
362
+ core.scheduleContinuationFlush();
363
+ reject2(err instanceof Error ? err : new Error(String(err)));
364
+ return;
365
+ }
366
+ if (core.mode === "event") {
367
+ core.loop.after(HOST_CALL_TIMEOUT_MS, () => checkHostCallTimeouts(core));
368
+ }
369
+ });
370
+ }
371
+ function completeHostCall(core, reqId, result) {
372
+ const s = stateOf2(core);
373
+ const pending = s.pending.get(reqId);
374
+ if (!pending) return false;
375
+ s.pending.delete(reqId);
376
+ if (result.ok) pending.resolve(pending.map(result.value));
377
+ else pending.reject(new Error(`${result.code}: ${result.message}`));
378
+ return true;
379
+ }
380
+ function checkHostCallTimeouts(core) {
381
+ const s = stateOf2(core);
382
+ if (s.pending.size === 0) return;
383
+ const now = core.host.now();
384
+ for (const [reqId, p] of [...s.pending]) {
385
+ if (p.deadline > now) continue;
386
+ s.pending.delete(reqId);
387
+ p.reject(new Error(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
388
+ }
389
+ }
390
+ function rejectAllHostCalls(core, reason) {
391
+ const s = stateOf2(core);
392
+ for (const [reqId, p] of [...s.pending]) {
393
+ s.pending.delete(reqId);
394
+ p.reject(new Error(reason));
395
+ }
396
+ }
397
+
331
398
  // src/core/writes.ts
332
399
  import {
333
400
  SERVER_OWNER as SERVER_OWNER2,
@@ -674,6 +741,7 @@ var Loop = class {
674
741
  this.drainInbound();
675
742
  this.fireDueTimers();
676
743
  checkTimeouts(this.core);
744
+ checkHostCallTimeouts(this.core);
677
745
  const config = this.core.definition.config;
678
746
  let failed;
679
747
  if (config.tick) {
@@ -691,6 +759,7 @@ var Loop = class {
691
759
  physics.reconcile();
692
760
  physics.step();
693
761
  physics.sync();
762
+ this.core.captureHistory();
694
763
  });
695
764
  if (!ran.ok) {
696
765
  failed = failed === void 0 ? "the physics step" : `${failed} and the physics step`;
@@ -709,6 +778,7 @@ var Loop = class {
709
778
  }
710
779
  }
711
780
  this.core.flush();
781
+ this.core.captureTimeline();
712
782
  const elapsed = Math.max(0, host.now() - started);
713
783
  const stats = this.core.stats;
714
784
  stats.ticks++;
@@ -748,7 +818,7 @@ var Loop = class {
748
818
  if (!this.running || this.core.stopped) return;
749
819
  const idleMs = this.core.definition.config.idleMs;
750
820
  if (idleMs <= 0) return;
751
- if (this.core.mode === "tick" && this.core.clients.size > 0) {
821
+ if (this.core.mode === "tick" && hasHumanClients(this.core)) {
752
822
  this.lastActivity = this.core.host.now();
753
823
  this.armIdle(idleMs);
754
824
  return;
@@ -815,31 +885,366 @@ var Loop = class {
815
885
  this.internal.add(handle);
816
886
  }
817
887
  };
888
+ function hasHumanClients(core) {
889
+ for (const c of core.clients.values()) {
890
+ if (!c.npc) return true;
891
+ }
892
+ return false;
893
+ }
818
894
 
819
- // src/core/physics.ts
820
- import { ByteReader, ByteWriter } from "@irtio/schema";
895
+ // src/core/matter.ts
896
+ import {
897
+ ByteReader,
898
+ ByteWriter,
899
+ applyChannel2d,
900
+ channelOf2d
901
+ } from "@irtio/schema";
821
902
  var engine;
822
903
  var loading;
823
- async function initPhysics() {
904
+ async function initMatter() {
824
905
  if (engine) return engine;
825
906
  loading ??= (async () => {
826
- const mod = await import("@dimforge/rapier3d-compat");
907
+ const mod = await import("matter-js");
827
908
  const ns = mod.default ?? mod;
828
- await ns.init();
829
909
  engine = ns;
830
910
  return ns;
831
911
  })();
832
912
  return loading;
833
913
  }
834
- function loadedPhysics() {
914
+ function loadedMatter() {
835
915
  return engine;
836
916
  }
837
- function resetPhysicsForTests() {
917
+ function resetMatterForTests() {
838
918
  engine = void 0;
839
919
  loading = void 0;
840
920
  }
921
+ function encodeMatterBodies(section) {
922
+ const w = new ByteWriter(section.bodies.length * 72 + 8);
923
+ w.varint(section.bodies.length);
924
+ for (const b of section.bodies) {
925
+ w.str(b.collection);
926
+ w.str(b.id);
927
+ w.f64(b.x);
928
+ w.f64(b.y);
929
+ w.f64(b.angle);
930
+ w.f64(b.vx);
931
+ w.f64(b.vy);
932
+ w.f64(b.angularVelocity);
933
+ w.u8(b.sleeping ? 1 : 0);
934
+ }
935
+ return w.finish();
936
+ }
937
+ function decodeMatterBodies(bytes) {
938
+ const r = new ByteReader(bytes);
939
+ const count = r.varint();
940
+ const bodies = [];
941
+ for (let i = 0; i < count; i++) {
942
+ bodies.push({
943
+ collection: r.str(),
944
+ id: r.str(),
945
+ x: r.f64(),
946
+ y: r.f64(),
947
+ angle: r.f64(),
948
+ vx: r.f64(),
949
+ vy: r.f64(),
950
+ angularVelocity: r.f64(),
951
+ sleeping: r.u8() === 1
952
+ });
953
+ }
954
+ return { bodies };
955
+ }
956
+ function bodyKey(collection, id) {
957
+ return `${collection}\0${id}`;
958
+ }
959
+ var MatterRuntime = class {
960
+ engineKind = "matter2d";
961
+ /** rapier3d only; present so both runtimes satisfy one internal shape. */
962
+ rapier = void 0;
963
+ world = void 0;
964
+ matter;
965
+ engine;
966
+ /** Alias under the name `PhysicsApi` uses, so `room.physics2d` reads through one field. */
967
+ get matterEngine() {
968
+ return this.engine;
969
+ }
970
+ /** `true` when the blob carried no world at all: the caller logs it. */
971
+ rebuilt;
972
+ /**
973
+ * Always `true`. Unlike Rapier's, a matter2d world is never restored as a world — only as
974
+ * per-body state on a world the builder made — so `setup` has to run every time.
975
+ */
976
+ needsSetup = true;
977
+ core;
978
+ config;
979
+ collections;
980
+ bodies = /* @__PURE__ */ new Map();
981
+ restore;
982
+ stepMs;
983
+ sleepSynced = /* @__PURE__ */ new Set();
984
+ constructor(core, matter, options) {
985
+ const config = core.definition.config.physics;
986
+ if (!config) throw new Error("MatterRuntime: the room config declares no physics");
987
+ this.core = core;
988
+ this.config = config;
989
+ this.matter = matter;
990
+ this.collections = core.ext.collections.filter(
991
+ (c) => c.physics !== void 0
992
+ );
993
+ this.engine = matter.Engine.create();
994
+ this.engine.gravity.x = config.gravity.x;
995
+ this.engine.gravity.y = config.gravity.y;
996
+ this.stepMs = (config.timestep ?? options.defaultTimestep) * 1e3;
997
+ this.rebuilt = options.restore === void 0;
998
+ this.restore = options.restore ? new Map(options.restore.bodies.map((b) => [bodyKey(b.collection, b.id), b])) : void 0;
999
+ }
1000
+ get timestep() {
1001
+ return this.stepMs / 1e3;
1002
+ }
1003
+ runSetup(room) {
1004
+ const setup = this.config.setup;
1005
+ if (!setup) return;
1006
+ this.core.guard("physics.setup", () => setup(this.engine, this.matter, room));
1007
+ }
1008
+ free() {
1009
+ this.bodies.clear();
1010
+ this.matter.Engine.clear(this.engine);
1011
+ }
1012
+ // -------------------------------------------------------------------------
1013
+ // Bodies
1014
+ // -------------------------------------------------------------------------
1015
+ bodyFor(collection, id) {
1016
+ const existing = this.bodies.get(bodyKey(collection, id));
1017
+ if (existing) return existing.body;
1018
+ const desc = this.collections.find((c) => c.name === collection);
1019
+ if (!desc) return void 0;
1020
+ const coll = plainEntity(this.core.plain, collection);
1021
+ const record = coll.get(id);
1022
+ if (record === void 0) return void 0;
1023
+ return this.create(desc, id, record);
1024
+ }
1025
+ create(desc, id, record) {
1026
+ const factory = this.config.bodies?.[desc.name];
1027
+ if (!factory) {
1028
+ this.core.log("error", `irtio: physics.bodies.${desc.name} is missing; no body created`);
1029
+ return void 0;
1030
+ }
1031
+ const spec = this.core.guard(
1032
+ `physics.bodies.${desc.name}`,
1033
+ () => factory(this.matter, record, id)
1034
+ );
1035
+ if (!spec || !spec.body) {
1036
+ this.core.log(
1037
+ "error",
1038
+ `irtio: physics.bodies.${desc.name} returned no { body } for ${JSON.stringify(id)}`
1039
+ );
1040
+ return void 0;
1041
+ }
1042
+ const key = bodyKey(desc.name, id);
1043
+ const constraints = spec.constraints ?? [];
1044
+ this.matter.Composite.add(this.engine.world, [spec.body, ...constraints]);
1045
+ this.bodies.set(key, { body: spec.body, constraints });
1046
+ const saved = this.restore?.get(key);
1047
+ if (saved) {
1048
+ this.restore?.delete(key);
1049
+ this.applyState(spec.body, saved);
1050
+ } else {
1051
+ this.applyRecordToBody(desc, spec.body, record);
1052
+ }
1053
+ return spec.body;
1054
+ }
1055
+ // ---- M6 lane F: rewind ----
1056
+ /**
1057
+ * D72: every tracked body, in the order `sync()` walks them. The matter half of the same
1058
+ * read-only accessor `PhysicsRuntime` carries; see its comment for what calls it and when.
1059
+ */
1060
+ eachTrackedBody(fn) {
1061
+ for (const desc of this.collections) {
1062
+ const plainColl = plainEntity(this.core.plain, desc.name);
1063
+ for (const id of plainColl.ids()) {
1064
+ const attached = this.bodies.get(bodyKey(desc.name, id));
1065
+ if (attached) fn(desc.name, id, attached.body);
1066
+ }
1067
+ }
1068
+ }
1069
+ applyState(body, s) {
1070
+ const M = this.matter;
1071
+ M.Body.setPosition(body, { x: s.x, y: s.y });
1072
+ M.Body.setAngle(body, s.angle);
1073
+ M.Body.setVelocity(body, { x: s.vx, y: s.vy });
1074
+ M.Body.setAngularVelocity(body, s.angularVelocity);
1075
+ if (s.sleeping) M.Sleeping.set(body, true);
1076
+ }
1077
+ applyRecordToBody(desc, body, record) {
1078
+ const physics = desc.physics;
1079
+ if (!physics) return;
1080
+ const t = {
1081
+ x: body.position.x,
1082
+ y: body.position.y,
1083
+ qz: Math.sin(body.angle / 2),
1084
+ qw: Math.cos(body.angle / 2),
1085
+ vx: body.velocity.x,
1086
+ vy: body.velocity.y,
1087
+ wz: body.angularVelocity
1088
+ };
1089
+ for (const [channel, field] of physics.channels) {
1090
+ const raw = record[field];
1091
+ if (typeof raw !== "number") continue;
1092
+ applyChannel2d(channel, raw, t);
1093
+ }
1094
+ this.applyState(body, {
1095
+ collection: desc.name,
1096
+ id: "",
1097
+ x: t.x,
1098
+ y: t.y,
1099
+ angle: 2 * Math.atan2(t.qz, t.qw),
1100
+ vx: t.vx,
1101
+ vy: t.vy,
1102
+ angularVelocity: t.wz,
1103
+ sleeping: false
1104
+ });
1105
+ }
1106
+ reconcile() {
1107
+ const live = /* @__PURE__ */ new Set();
1108
+ for (const desc of this.collections) {
1109
+ const coll = plainEntity(this.core.plain, desc.name);
1110
+ for (const id of coll.ids()) {
1111
+ const key = bodyKey(desc.name, id);
1112
+ live.add(key);
1113
+ if (this.bodies.has(key)) continue;
1114
+ const record = coll.get(id);
1115
+ if (record !== void 0) this.create(desc, id, record);
1116
+ }
1117
+ }
1118
+ for (const [key, attached] of [...this.bodies]) {
1119
+ if (live.has(key)) continue;
1120
+ this.bodies.delete(key);
1121
+ this.sleepSynced.delete(key);
1122
+ this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
1123
+ }
1124
+ }
1125
+ // -------------------------------------------------------------------------
1126
+ // Step and sync
1127
+ // -------------------------------------------------------------------------
1128
+ step() {
1129
+ this.matter.Engine.update(this.engine, this.stepMs);
1130
+ }
1131
+ sync() {
1132
+ for (const desc of this.collections) {
1133
+ const physics = desc.physics;
1134
+ if (!physics) continue;
1135
+ const tracked = this.core.anyState[desc.name];
1136
+ const plainColl = plainEntity(this.core.plain, desc.name);
1137
+ const rounders = roundersFor(desc);
1138
+ for (const id of plainColl.ids()) {
1139
+ const key = bodyKey(desc.name, id);
1140
+ const attached = this.bodies.get(key);
1141
+ if (!attached) continue;
1142
+ const body = attached.body;
1143
+ if (body.isSleeping) {
1144
+ if (this.sleepSynced.has(key)) continue;
1145
+ this.sleepSynced.add(key);
1146
+ } else {
1147
+ this.sleepSynced.delete(key);
1148
+ }
1149
+ const record = tracked.get(id);
1150
+ if (!record) continue;
1151
+ const state = {
1152
+ x: body.position.x,
1153
+ y: body.position.y,
1154
+ angle: body.angle,
1155
+ vx: body.velocity.x,
1156
+ vy: body.velocity.y,
1157
+ angularVelocity: body.angularVelocity
1158
+ };
1159
+ for (const [channel, field] of physics.channels) {
1160
+ const next = (rounders[field] ?? identity)(channelOf2d(channel, state));
1161
+ if (record[field] !== next) record[field] = next;
1162
+ }
1163
+ }
1164
+ }
1165
+ }
1166
+ // -------------------------------------------------------------------------
1167
+ // Hibernation
1168
+ // -------------------------------------------------------------------------
1169
+ serialize() {
1170
+ const bodies = [];
1171
+ for (const [key, attached] of this.bodies) {
1172
+ const sep = key.indexOf("\0");
1173
+ const b = attached.body;
1174
+ bodies.push({
1175
+ collection: key.slice(0, sep),
1176
+ id: key.slice(sep + 1),
1177
+ x: b.position.x,
1178
+ y: b.position.y,
1179
+ angle: b.angle,
1180
+ vx: b.velocity.x,
1181
+ vy: b.velocity.y,
1182
+ angularVelocity: b.angularVelocity,
1183
+ sleeping: b.isSleeping === true
1184
+ });
1185
+ }
1186
+ return { bodies };
1187
+ }
1188
+ };
1189
+ function identity(v) {
1190
+ return v;
1191
+ }
1192
+ function roundersFor(desc) {
1193
+ const out = {};
1194
+ for (const f of desc.fields) {
1195
+ if (f.type.kind === "f32") out[f.name] = Math.fround;
1196
+ }
1197
+ return out;
1198
+ }
1199
+
1200
+ // src/core/physics.ts
1201
+ import { ByteReader as ByteReader2, ByteWriter as ByteWriter2 } from "@irtio/schema";
1202
+ var engine2;
1203
+ var loading2;
1204
+ async function initPhysics() {
1205
+ if (engine2) return engine2;
1206
+ loading2 ??= (async () => {
1207
+ const mod = await import("@dimforge/rapier3d-compat");
1208
+ const ns = mod.default ?? mod;
1209
+ await ns.init();
1210
+ engine2 = ns;
1211
+ return ns;
1212
+ })();
1213
+ return loading2;
1214
+ }
1215
+ function loadedPhysics() {
1216
+ return engine2;
1217
+ }
1218
+ function resetPhysicsForTests() {
1219
+ engine2 = void 0;
1220
+ loading2 = void 0;
1221
+ rapierStepped = false;
1222
+ firstStepWaiters.length = 0;
1223
+ }
1224
+ var rapierStepped = false;
1225
+ var firstStepWaiters = [];
1226
+ function rapierHasStepped() {
1227
+ return rapierStepped;
1228
+ }
1229
+ function onFirstRapierStep(cb) {
1230
+ if (rapierStepped) {
1231
+ cb();
1232
+ return;
1233
+ }
1234
+ firstStepWaiters.push(cb);
1235
+ }
1236
+ function noteRapierStep() {
1237
+ rapierStepped = true;
1238
+ const waiters = firstStepWaiters.splice(0, firstStepWaiters.length);
1239
+ for (const cb of waiters) {
1240
+ try {
1241
+ cb();
1242
+ } catch {
1243
+ }
1244
+ }
1245
+ }
841
1246
  function encodePhysicsSection(section) {
842
- const w = new ByteWriter(section.world.length + section.bodies.length * 24 + 8);
1247
+ const w = new ByteWriter2(section.world.length + section.bodies.length * 24 + 8);
843
1248
  w.blob(section.world);
844
1249
  w.varint(section.bodies.length);
845
1250
  for (const [name, id, handle] of section.bodies) {
@@ -849,15 +1254,34 @@ function encodePhysicsSection(section) {
849
1254
  }
850
1255
  return w.finish();
851
1256
  }
1257
+ var MATTER_TAG = 1;
1258
+ function physicsSectionEngine(bytes) {
1259
+ const r = new ByteReader2(bytes);
1260
+ return r.varint() === 0 && r.u8() === MATTER_TAG ? "matter2d" : "rapier3d";
1261
+ }
1262
+ function encodeMatterSectionEnvelope(payload) {
1263
+ const w = new ByteWriter2(payload.length + 8);
1264
+ w.varint(0);
1265
+ w.u8(MATTER_TAG);
1266
+ w.bytes(payload);
1267
+ return w.finish();
1268
+ }
1269
+ function decodeMatterSectionEnvelope(bytes) {
1270
+ const r = new ByteReader2(bytes);
1271
+ if (r.varint() !== 0 || r.u8() !== MATTER_TAG) {
1272
+ throw new Error("irtio: this physics section was not written by matter2d");
1273
+ }
1274
+ return r.rest();
1275
+ }
852
1276
  function decodePhysicsSection(bytes) {
853
- const r = new ByteReader(bytes);
1277
+ const r = new ByteReader2(bytes);
854
1278
  const world = r.blob().slice();
855
1279
  const count = r.varint();
856
1280
  const bodies = [];
857
1281
  for (let i = 0; i < count; i++) bodies.push([r.str(), r.str(), r.f64()]);
858
1282
  return { world, bodies };
859
1283
  }
860
- function bodyKey(collection, id) {
1284
+ function bodyKey2(collection, id) {
861
1285
  return `${collection}\0${id}`;
862
1286
  }
863
1287
  function planarLockWarning(spec) {
@@ -880,10 +1304,18 @@ function planarLockWarning(spec) {
880
1304
  var CUBOID_SHAPE = 1;
881
1305
  var ROUND_CUBOID_SHAPE = 12;
882
1306
  var PhysicsRuntime = class {
1307
+ engineKind = "rapier3d";
1308
+ /** matter2d only; present so both runtimes satisfy one internal shape. */
1309
+ matter = void 0;
1310
+ matterEngine = void 0;
883
1311
  rapier;
884
1312
  world;
885
1313
  /** `true` when the world was built from scratch and `setup` has to run. */
886
1314
  rebuilt;
1315
+ /** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
1316
+ get needsSetup() {
1317
+ return this.rebuilt;
1318
+ }
887
1319
  core;
888
1320
  config;
889
1321
  /** Physics-backed collections, in schema (name-sorted) order. */
@@ -912,7 +1344,7 @@ var PhysicsRuntime = class {
912
1344
  this.rebuilt = false;
913
1345
  for (const [name, id, handle] of options.restore.bodies) {
914
1346
  const body = this.world.getRigidBody(handle);
915
- if (body) this.bodies.set(bodyKey(name, id), body);
1347
+ if (body) this.bodies.set(bodyKey2(name, id), body);
916
1348
  }
917
1349
  } else {
918
1350
  const g = config.gravity;
@@ -939,7 +1371,7 @@ var PhysicsRuntime = class {
939
1371
  // -------------------------------------------------------------------------
940
1372
  /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
941
1373
  bodyFor(collection, id) {
942
- const existing = this.bodies.get(bodyKey(collection, id));
1374
+ const existing = this.bodies.get(bodyKey2(collection, id));
943
1375
  if (existing) return existing;
944
1376
  const desc = this.collections.find((c) => c.name === collection);
945
1377
  if (!desc) return void 0;
@@ -973,9 +1405,25 @@ var PhysicsRuntime = class {
973
1405
  const body = this.world.createRigidBody(spec.body);
974
1406
  for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
975
1407
  this.applyRecordToBody(desc, body, record);
976
- this.bodies.set(bodyKey(desc.name, id), body);
1408
+ this.bodies.set(bodyKey2(desc.name, id), body);
977
1409
  return body;
978
1410
  }
1411
+ // ---- M6 lane F: rewind ----
1412
+ /**
1413
+ * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
1414
+ * order). Read-only: it hands out the live bodies and nothing else, and the map stays private.
1415
+ * The pose history calls this once per tick and the rewind scratch calls it per rewind; a room
1416
+ * that declares no `physics.history` never calls it at all.
1417
+ */
1418
+ eachTrackedBody(fn) {
1419
+ for (const desc of this.collections) {
1420
+ const plainColl = plainEntity(this.core.plain, desc.name);
1421
+ for (const id of plainColl.ids()) {
1422
+ const body = this.bodies.get(bodyKey2(desc.name, id));
1423
+ if (body) fn(desc.name, id, body);
1424
+ }
1425
+ }
1426
+ }
979
1427
  applyRecordToBody(desc, body, record) {
980
1428
  const physics = desc.physics;
981
1429
  if (!physics) return;
@@ -1053,7 +1501,7 @@ var PhysicsRuntime = class {
1053
1501
  for (const desc of this.collections) {
1054
1502
  const coll = plainEntity(this.core.plain, desc.name);
1055
1503
  for (const id of coll.ids()) {
1056
- const key = bodyKey(desc.name, id);
1504
+ const key = bodyKey2(desc.name, id);
1057
1505
  live.add(key);
1058
1506
  if (this.bodies.has(key)) continue;
1059
1507
  const record = coll.get(id);
@@ -1072,6 +1520,7 @@ var PhysicsRuntime = class {
1072
1520
  // -------------------------------------------------------------------------
1073
1521
  step() {
1074
1522
  this.world.step();
1523
+ if (!rapierStepped) noteRapierStep();
1075
1524
  }
1076
1525
  /**
1077
1526
  * Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
@@ -1084,9 +1533,9 @@ var PhysicsRuntime = class {
1084
1533
  if (!physics) continue;
1085
1534
  const tracked = this.core.anyState[desc.name];
1086
1535
  const plainColl = plainEntity(this.core.plain, desc.name);
1087
- const rounders = roundersFor(desc);
1536
+ const rounders = roundersFor2(desc);
1088
1537
  for (const id of plainColl.ids()) {
1089
- const key = bodyKey(desc.name, id);
1538
+ const key = bodyKey2(desc.name, id);
1090
1539
  const body = this.bodies.get(key);
1091
1540
  if (!body) continue;
1092
1541
  if (body.isSleeping()) {
@@ -1102,7 +1551,7 @@ var PhysicsRuntime = class {
1102
1551
  const v = body.linvel();
1103
1552
  const w = body.angvel();
1104
1553
  for (const [channel, field] of physics.channels) {
1105
- const next = (rounders[field] ?? identity)(channelValue(channel, t, r, v, w));
1554
+ const next = (rounders[field] ?? identity2)(channelValue(channel, t, r, v, w));
1106
1555
  if (record[field] !== next) record[field] = next;
1107
1556
  }
1108
1557
  }
@@ -1120,10 +1569,10 @@ var PhysicsRuntime = class {
1120
1569
  return { world: this.world.takeSnapshot(), bodies };
1121
1570
  }
1122
1571
  };
1123
- function identity(v) {
1572
+ function identity2(v) {
1124
1573
  return v;
1125
1574
  }
1126
- function roundersFor(desc) {
1575
+ function roundersFor2(desc) {
1127
1576
  const out = {};
1128
1577
  for (const f of desc.fields) {
1129
1578
  if (f.type.kind === "f32") out[f.name] = Math.fround;
@@ -1418,11 +1867,12 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
1418
1867
  }
1419
1868
 
1420
1869
  // src/core/snapshot.ts
1421
- import { ByteReader as ByteReader2, ByteWriter as ByteWriter2 } from "@irtio/schema";
1870
+ import { withBuiltins } from "@irtio/protocol";
1871
+ import { ByteReader as ByteReader3, ByteWriter as ByteWriter3, decodeSnapshot } from "@irtio/schema";
1422
1872
  var SNAPSHOT_FORMAT_VERSION = 2;
1423
1873
  var READABLE_SNAPSHOT_VERSIONS = [1, 2];
1424
1874
  function parseHibernationBlob(bytes) {
1425
- const r = new ByteReader2(bytes);
1875
+ const r = new ByteReader3(bytes);
1426
1876
  const version = r.u8();
1427
1877
  if (!READABLE_SNAPSHOT_VERSIONS.includes(version)) {
1428
1878
  throw new Error(`RoomCore.restore: unsupported snapshot format version ${version}`);
@@ -1440,7 +1890,7 @@ function parseHibernationBlob(bytes) {
1440
1890
  }
1441
1891
  function writeHibernationBlob(header, snapshot, physics) {
1442
1892
  const version = physics ? 2 : 1;
1443
- const w = new ByteWriter2(snapshot.length + (physics?.length ?? 0) + 24);
1893
+ const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 24);
1444
1894
  w.u8(version);
1445
1895
  w.u32(header.seed >>> 0);
1446
1896
  w.u32(header.rngState >>> 0);
@@ -1450,21 +1900,90 @@ function writeHibernationBlob(header, snapshot, physics) {
1450
1900
  w.bytes(snapshot);
1451
1901
  return w.finish();
1452
1902
  }
1903
+ function decodeSave(bytes, schema) {
1904
+ const parsed = parseHibernationBlob(bytes);
1905
+ const ext = withBuiltins(schema);
1906
+ const plain = decodeSnapshot(ext, parsed.snapshot).state;
1907
+ return {
1908
+ version: parsed.version,
1909
+ seed: parsed.seed,
1910
+ rngState: parsed.rngState,
1911
+ tick: parsed.tick,
1912
+ mode: parsed.mode,
1913
+ hasPhysics: parsed.physics !== void 0,
1914
+ physicsEngine: parsed.physics ? physicsSectionEngine(parsed.physics) : void 0,
1915
+ state: inspectState(ext, plain)
1916
+ };
1917
+ }
1918
+
1919
+ // src/core/timeline.ts
1920
+ import { EntityCollection as EntityCollection2 } from "@irtio/schema";
1921
+ var DEFAULT_TIMELINE_MAX_TICKS = 3600;
1922
+ var DEFAULT_TIMELINE_MAX_RECORDS = 2e5;
1923
+ function recordsIn(state) {
1924
+ let n = 0;
1925
+ for (const value of Object.values(state)) {
1926
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1927
+ n += Object.keys(value).length;
1928
+ }
1929
+ }
1930
+ return Math.max(1, n);
1931
+ }
1932
+ var TimelineRecorder = class {
1933
+ frames = [];
1934
+ costs = [];
1935
+ records = 0;
1936
+ dropped = 0;
1937
+ maxTicks;
1938
+ maxRecords;
1939
+ constructor(options = {}) {
1940
+ this.maxTicks = Math.max(1, options.maxTicks ?? DEFAULT_TIMELINE_MAX_TICKS);
1941
+ this.maxRecords = Math.max(1, options.maxRecords ?? DEFAULT_TIMELINE_MAX_RECORDS);
1942
+ }
1943
+ collections = {};
1944
+ capture(tick, schema, plain) {
1945
+ const state = structuredClone(inspectState(schema, plain));
1946
+ for (const c of schema.collections) {
1947
+ this.collections[c.name] = plain[c.name] instanceof EntityCollection2 ? "entity" : "single";
1948
+ }
1949
+ const cost = recordsIn(state);
1950
+ this.frames.push({ tick, at: Date.now(), state });
1951
+ this.costs.push(cost);
1952
+ this.records += cost;
1953
+ while (this.frames.length > this.maxTicks || this.records > this.maxRecords && this.frames.length > 1) {
1954
+ this.frames.shift();
1955
+ this.records -= this.costs.shift() ?? 0;
1956
+ this.dropped++;
1957
+ }
1958
+ }
1959
+ dump(roomId) {
1960
+ return {
1961
+ roomId,
1962
+ collections: { ...this.collections },
1963
+ frames: this.frames.map((f) => ({ tick: f.tick, at: f.at, state: f.state })),
1964
+ dropped: this.dropped,
1965
+ maxTicks: this.maxTicks,
1966
+ maxRecords: this.maxRecords
1967
+ };
1968
+ }
1969
+ };
1453
1970
 
1454
1971
  // src/core/room.ts
1455
1972
  import {
1456
1973
  FrameType as FrameType5,
1457
1974
  PRESENCE_COLLECTION,
1975
+ ProfileLedger,
1976
+ busChannelProblem as busChannelProblem2,
1458
1977
  decodeFrame,
1459
1978
  encodeCorrectFrame,
1460
1979
  encodeFrame as encodeFrame4,
1461
- withBuiltins
1980
+ withBuiltins as withBuiltins2
1462
1981
  } from "@irtio/protocol";
1463
1982
  import {
1464
1983
  cloneValue as cloneValue2,
1465
1984
  createDirtySet as createDirtySet2,
1466
1985
  createState,
1467
- decodeSnapshot,
1986
+ decodeSnapshot as decodeSnapshot2,
1468
1987
  encodeDelta as encodeDelta4,
1469
1988
  encodeSnapshot as encodeSnapshot2,
1470
1989
  isDirtyEmpty as isDirtyEmpty3,
@@ -1472,73 +1991,469 @@ import {
1472
1991
  validateForDeploy
1473
1992
  } from "@irtio/schema";
1474
1993
 
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);
1994
+ // src/core/history.ts
1995
+ var HISTORY_MAX_TICKS = 240;
1996
+ var STRIDE = 13;
1997
+ function emptyPose() {
1998
+ return { x: 0, y: 0, z: 0, qx: 0, qy: 0, qz: 0, qw: 1, vx: 0, vy: 0, vz: 0, wx: 0, wy: 0, wz: 0 };
1999
+ }
2000
+ function newEntry() {
2001
+ return { tick: -1, count: 0, collections: [], ids: [], values: new Float64Array(0) };
2002
+ }
2003
+ var PoseHistory = class {
2004
+ depth;
2005
+ entries = [];
2006
+ /** Index of the newest entry in `entries`, or -1 when nothing has been captured. */
2007
+ head = -1;
2008
+ size = 0;
2009
+ /** Reused across every body of every capture: the capture path allocates nothing per body. */
2010
+ scratchPose = emptyPose();
2011
+ constructor(depth) {
2012
+ this.depth = Math.max(1, Math.min(HISTORY_MAX_TICKS, Math.floor(depth)));
2013
+ for (let i = 0; i < this.depth; i++) this.entries.push(newEntry());
2014
+ }
2015
+ get length() {
2016
+ return this.size;
2017
+ }
2018
+ /** The newest tick captured, or `undefined` when the buffer is empty. */
2019
+ get newestTick() {
2020
+ return this.size === 0 ? void 0 : this.entries[this.head].tick;
2021
+ }
2022
+ /** The oldest tick still held, or `undefined` when the buffer is empty. */
2023
+ get oldestTick() {
2024
+ if (this.size === 0) return void 0;
2025
+ const i = (this.head - (this.size - 1) + this.depth * 2) % this.depth;
2026
+ return this.entries[i].tick;
2027
+ }
2028
+ /** Drops everything. Used by the hibernation path, where a buffer cannot survive. */
2029
+ clear() {
2030
+ this.head = -1;
2031
+ this.size = 0;
2032
+ }
2033
+ /** Captures one tick's poses off the live runtime. Called right after `physics.sync()`. */
2034
+ capture(tick, physics) {
2035
+ this.head = this.size === 0 ? 0 : (this.head + 1) % this.depth;
2036
+ if (this.size < this.depth) this.size++;
2037
+ const entry = this.entries[this.head];
2038
+ entry.tick = tick;
2039
+ entry.count = 0;
2040
+ const pose = this.scratchPose;
2041
+ const rapier = physics.engineKind === "rapier3d";
2042
+ physics.eachTrackedBody((collection, id, body) => {
2043
+ if (rapier) readRapierPose(body, pose);
2044
+ else readMatterPose(body, pose);
2045
+ this.push(entry, collection, id, pose);
2046
+ });
2047
+ }
2048
+ push(entry, collection, id, pose) {
2049
+ const i = entry.count;
2050
+ const need = (i + 1) * STRIDE;
2051
+ if (entry.values.length < need) {
2052
+ const grown = new Float64Array(Math.max(need, entry.values.length * 2, STRIDE * 8));
2053
+ grown.set(entry.values);
2054
+ entry.values = grown;
2055
+ }
2056
+ entry.collections[i] = collection;
2057
+ entry.ids[i] = id;
2058
+ const v = entry.values;
2059
+ const o = i * STRIDE;
2060
+ v[o] = pose.x;
2061
+ v[o + 1] = pose.y;
2062
+ v[o + 2] = pose.z;
2063
+ v[o + 3] = pose.qx;
2064
+ v[o + 4] = pose.qy;
2065
+ v[o + 5] = pose.qz;
2066
+ v[o + 6] = pose.qw;
2067
+ v[o + 7] = pose.vx;
2068
+ v[o + 8] = pose.vy;
2069
+ v[o + 9] = pose.vz;
2070
+ v[o + 10] = pose.wx;
2071
+ v[o + 11] = pose.wy;
2072
+ v[o + 12] = pose.wz;
2073
+ entry.count = i + 1;
2074
+ }
2075
+ /**
2076
+ * The entry a rewind to `requested` answers from, clamped into the window the buffer actually
2077
+ * holds. `undefined` only when nothing has been captured at all.
2078
+ */
2079
+ resolve(requested) {
2080
+ if (this.size === 0) return void 0;
2081
+ const newest = this.entries[this.head].tick;
2082
+ const oldest = this.oldestTick;
2083
+ const want = Number.isFinite(requested) ? Math.floor(requested) : newest;
2084
+ const tick = want < oldest ? oldest : want > newest ? newest : want;
2085
+ const entry = this.at(tick);
2086
+ if (!entry) return void 0;
2087
+ return { entry, tick: entry.tick, clamped: tick !== want };
2088
+ }
2089
+ /**
2090
+ * The entry for exactly `tick`. Index arithmetic first (captures are one tick apart, so the
2091
+ * offset from the head is the tick difference), with a scan as insurance: a room whose loop
2092
+ * ever skipped a capture would otherwise be answered with the wrong tick's poses, and answering
2093
+ * for a tick that was not recorded is the one thing this buffer must never do.
2094
+ */
2095
+ at(tick) {
2096
+ const newest = this.entries[this.head].tick;
2097
+ const offset = newest - tick;
2098
+ if (offset >= 0 && offset < this.size) {
2099
+ const e = this.entries[(this.head - offset + this.depth * 2) % this.depth];
2100
+ if (e.tick === tick) return e;
2101
+ }
2102
+ for (let k = 0; k < this.size; k++) {
2103
+ const e = this.entries[(this.head - k + this.depth * 2) % this.depth];
2104
+ if (e.tick === tick) return e;
2105
+ }
2106
+ return void 0;
2107
+ }
2108
+ };
2109
+ function poseAt(entry, index, into) {
2110
+ const v = entry.values;
2111
+ const o = index * STRIDE;
2112
+ into.x = v[o];
2113
+ into.y = v[o + 1];
2114
+ into.z = v[o + 2];
2115
+ into.qx = v[o + 3];
2116
+ into.qy = v[o + 4];
2117
+ into.qz = v[o + 5];
2118
+ into.qw = v[o + 6];
2119
+ into.vx = v[o + 7];
2120
+ into.vy = v[o + 8];
2121
+ into.vz = v[o + 9];
2122
+ into.wx = v[o + 10];
2123
+ into.wy = v[o + 11];
2124
+ into.wz = v[o + 12];
2125
+ }
2126
+ function readRapierPose(body, into) {
2127
+ const t = body.translation();
2128
+ const r = body.rotation();
2129
+ const v = body.linvel();
2130
+ const w = body.angvel();
2131
+ into.x = t.x;
2132
+ into.y = t.y;
2133
+ into.z = t.z;
2134
+ into.qx = r.x;
2135
+ into.qy = r.y;
2136
+ into.qz = r.z;
2137
+ into.qw = r.w;
2138
+ into.vx = v.x;
2139
+ into.vy = v.y;
2140
+ into.vz = v.z;
2141
+ into.wx = w.x;
2142
+ into.wy = w.y;
2143
+ into.wz = w.z;
2144
+ }
2145
+ function readMatterPose(body, into) {
2146
+ into.x = body.position.x;
2147
+ into.y = body.position.y;
2148
+ into.z = 0;
2149
+ into.qx = 0;
2150
+ into.qy = 0;
2151
+ into.qz = Math.sin(body.angle / 2);
2152
+ into.qw = Math.cos(body.angle / 2);
2153
+ into.vx = body.velocity.x;
2154
+ into.vy = body.velocity.y;
2155
+ into.vz = 0;
2156
+ into.wx = 0;
2157
+ into.wy = 0;
2158
+ into.wz = body.angularVelocity;
2159
+ }
2160
+ function historyDepthOf(config) {
2161
+ const declared = config?.history;
2162
+ if (typeof declared !== "number" || !Number.isFinite(declared) || declared <= 0) return void 0;
2163
+ return Math.min(HISTORY_MAX_TICKS, Math.floor(declared));
2164
+ }
2165
+ function keyOf(collection, id) {
2166
+ return `${collection} ${id}`;
2167
+ }
2168
+ function angleOf(qz, qw) {
2169
+ return 2 * Math.atan2(qz, qw);
2170
+ }
2171
+ var BaseScratch = class {
2172
+ constructor(physics, depth, tickNow) {
2173
+ this.physics = physics;
2174
+ this.depth = depth;
2175
+ this.tickNow = tickNow;
2176
+ }
2177
+ physics;
2178
+ depth;
2179
+ tickNow;
2180
+ bodies = /* @__PURE__ */ new Map();
2181
+ present = /* @__PURE__ */ new Set();
2182
+ pose = emptyPose();
2183
+ view(entry, tick, requested, clamped) {
2184
+ this.sync();
2185
+ this.present.clear();
2186
+ for (let i = 0; i < entry.count; i++) {
2187
+ const key = keyOf(entry.collections[i], entry.ids[i]);
2188
+ const e = this.bodies.get(key);
2189
+ if (!e) continue;
2190
+ poseAt(entry, i, this.pose);
2191
+ this.place(e, this.pose);
2192
+ this.present.add(key);
2193
+ }
2194
+ for (const [key, e] of this.bodies) {
2195
+ if (!this.present.has(key)) this.hide(e);
2196
+ }
2197
+ return this.build(tick, requested, clamped);
2198
+ }
2199
+ /**
2200
+ * Brings the scratch's body set in step with the live world: a double for every live body that
2201
+ * has none, a rebuild for one whose collider (or part) count changed, and a prune for one that
2202
+ * has been gone longer than the history is deep and so can no longer appear in any entry.
2203
+ *
2204
+ * The prune is conservative rather than exact: `lastLive` only advances when a rewind happens,
2205
+ * so a room that rewinds rarely holds its dead doubles a little longer than it strictly must.
2206
+ * Over-retention is a few hundred bytes; under-retention would be a wrong answer.
2207
+ */
2208
+ sync() {
2209
+ const now = this.tickNow();
2210
+ const seen = /* @__PURE__ */ new Set();
2211
+ this.physics.eachTrackedBody((collection, id, body) => {
2212
+ const key = keyOf(collection, id);
2213
+ seen.add(key);
2214
+ const parts = this.partsOf(body);
2215
+ const existing = this.bodies.get(key);
2216
+ if (existing && existing.parts === parts) {
2217
+ existing.lastLive = now;
2218
+ return;
2219
+ }
2220
+ if (existing) {
2221
+ this.bodies.delete(key);
2222
+ this.destroy(existing);
2223
+ }
2224
+ const made = this.clone(collection, id, body, parts);
2225
+ if (!made) return;
2226
+ made.lastLive = now;
2227
+ this.bodies.set(key, made);
2228
+ });
2229
+ for (const [key, e] of [...this.bodies]) {
2230
+ if (seen.has(key)) continue;
2231
+ if (now - e.lastLive <= this.depth) continue;
2232
+ this.bodies.delete(key);
2233
+ this.destroy(e);
2234
+ }
2235
+ }
2236
+ };
2237
+ function qConj(q) {
2238
+ return { x: -q.x, y: -q.y, z: -q.z, w: q.w };
2239
+ }
2240
+ function qMul(a, b) {
2241
+ return {
2242
+ x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
2243
+ y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
2244
+ z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
2245
+ w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z
2246
+ };
2247
+ }
2248
+ function qRotate(q, v) {
2249
+ const p = qMul(qMul(q, { x: v.x, y: v.y, z: v.z, w: 0 }), qConj(q));
2250
+ return { x: p.x, y: p.y, z: p.z };
2251
+ }
2252
+ var RapierScratch = class extends BaseScratch {
2253
+ world;
2254
+ rapier;
2255
+ owners = /* @__PURE__ */ new Map();
2256
+ /** Live rigid-body handles the room tracks, so the statics pass knows what to skip. */
2257
+ trackedLive = /* @__PURE__ */ new Set();
2258
+ staticsCloned = false;
2259
+ constructor(physics, depth, tickNow) {
2260
+ super(physics, depth, tickNow);
2261
+ this.rapier = physics.rapier;
2262
+ this.world = new this.rapier.World({ x: 0, y: 0, z: 0 });
2263
+ this.world.timestep = 0;
2264
+ }
2265
+ partsOf(body) {
2266
+ return body.numColliders();
2267
+ }
2268
+ clone(collection, id, body, parts) {
2269
+ const live = body;
2270
+ this.trackedLive.add(live.handle);
2271
+ const made = this.world.createRigidBody(this.rapier.RigidBodyDesc.fixed());
2272
+ const origin = live.translation();
2273
+ const rot = live.rotation();
2274
+ const inv = qConj(rot);
2275
+ for (let i = 0; i < parts; i++) {
2276
+ const c = live.collider(i);
2277
+ const desc = new this.rapier.ColliderDesc(c.shape);
2278
+ desc.setSensor(c.isSensor());
2279
+ const w = c.translation();
2280
+ const local = qRotate(inv, { x: w.x - origin.x, y: w.y - origin.y, z: w.z - origin.z });
2281
+ desc.setTranslation(local.x, local.y, local.z);
2282
+ desc.setRotation(qMul(inv, c.rotation()));
2283
+ this.world.createCollider(desc, made);
2284
+ }
2285
+ this.owners.set(made.handle, { collection, id });
2286
+ return { collection, id, body: made, parts, lastLive: -1 };
2287
+ }
2288
+ place(entry, pose) {
2289
+ const b = entry.body;
2290
+ if (!b.isEnabled()) b.setEnabled(true);
2291
+ b.setTranslation({ x: pose.x, y: pose.y, z: pose.z }, false);
2292
+ b.setRotation({ x: pose.qx, y: pose.qy, z: pose.qz, w: pose.qw }, false);
2293
+ }
2294
+ /**
2295
+ * A body the requested tick did not have. `setEnabled(false)` takes the body and its colliders
2296
+ * out of the broad phase, so a query cannot hit it, which is the whole point: a body created
2297
+ * after the tick the shooter was looking at was not on their screen and must not be hittable.
2298
+ */
2299
+ hide(entry) {
2300
+ if (entry.body.isEnabled()) entry.body.setEnabled(false);
2301
+ }
2302
+ destroy(entry) {
2303
+ this.owners.delete(entry.body.handle);
2304
+ this.world.removeRigidBody(entry.body);
2305
+ }
2306
+ build(tick, requested, clamped) {
2307
+ this.cloneStatics();
2308
+ this.world.step();
2309
+ const owners = this.owners;
2310
+ return {
2311
+ tick,
2312
+ requested,
2313
+ clamped,
2314
+ rapier: {
2315
+ world: this.world,
2316
+ who: (collider) => {
2317
+ const parent = collider.parent();
2318
+ return parent ? owners.get(parent.handle) : void 0;
2319
+ }
2320
+ }
2321
+ };
2322
+ }
2323
+ /**
2324
+ * The live world's static geometry, everything `physics.setup` built, copied in once at the
2325
+ * first rewind. It is "as it stands now" rather than "as it stood then": static colliders do not
2326
+ * move, and a room that moves one has told the engine something that is not true.
2327
+ */
2328
+ cloneStatics() {
2329
+ if (this.staticsCloned) return;
2330
+ this.staticsCloned = true;
2331
+ const live = this.physics.world;
2332
+ if (!live) return;
2333
+ live.forEachCollider((c) => {
2334
+ const parent = c.parent();
2335
+ if (parent !== null && (!parent.isFixed() || this.trackedLive.has(parent.handle))) return;
2336
+ const desc = new this.rapier.ColliderDesc(c.shape);
2337
+ desc.setSensor(c.isSensor());
2338
+ const t = c.translation();
2339
+ desc.setTranslation(t.x, t.y, t.z);
2340
+ desc.setRotation(c.rotation());
2341
+ this.world.createCollider(desc);
2342
+ });
2343
+ }
2344
+ free() {
2345
+ this.owners.clear();
2346
+ this.world.free();
2347
+ }
2348
+ };
2349
+ var MatterScratch = class extends BaseScratch {
2350
+ matter;
2351
+ owners = /* @__PURE__ */ new Map();
2352
+ /** Rebuilt per rewind: the doubles the requested tick actually had, plus the live statics. */
2353
+ visible = [];
2354
+ constructor(physics, depth, tickNow) {
2355
+ super(physics, depth, tickNow);
2356
+ this.matter = physics.matter;
2357
+ }
2358
+ partsOf(body) {
2359
+ return body.parts.length;
2360
+ }
2361
+ clone(collection, id, body, parts) {
2362
+ const M = this.matter;
2363
+ const live = body;
2364
+ const made = live.parts.length > 1 ? M.Body.create({ parts: live.parts.slice(1).map((p) => this.clonePart(p, live.angle)) }) : this.clonePart(live, live.angle);
2365
+ M.Body.setPosition(made, { x: 0, y: 0 });
2366
+ made.angle = 0;
2367
+ for (const p of made.parts) p.angle = 0;
2368
+ this.owners.set(made, { collection, id });
2369
+ return { collection, id, body: made, parts, lastLive: -1 };
2370
+ }
2371
+ /** One part, un-rotated by the parent's angle so the double starts at angle 0. */
2372
+ clonePart(part, parentAngle) {
2373
+ const M = this.matter;
2374
+ const verts = part.vertices.map((v) => ({ x: v.x, y: v.y }));
2375
+ if (parentAngle !== 0) M.Vertices.rotate(verts, -parentAngle, part.position);
2376
+ const made = M.Body.create({});
2377
+ M.Body.setVertices(made, verts);
2378
+ return made;
2379
+ }
2380
+ place(entry, pose) {
2381
+ const M = this.matter;
2382
+ M.Body.setAngle(entry.body, angleOf(pose.qz, pose.qw));
2383
+ M.Body.setPosition(entry.body, { x: pose.x, y: pose.y });
2384
+ this.visible.push(entry.body);
2385
+ }
2386
+ /** Nothing to undo: `visible` is rebuilt per rewind, so a body nobody placed is not in it. */
2387
+ hide() {
2388
+ }
2389
+ destroy(entry) {
2390
+ this.owners.delete(entry.body);
2391
+ }
2392
+ view(entry, tick, requested, clamped) {
2393
+ this.visible.length = 0;
2394
+ return super.view(entry, tick, requested, clamped);
2395
+ }
2396
+ build(tick, requested, clamped) {
2397
+ const M = this.matter;
2398
+ const engine3 = this.physics.matterEngine;
2399
+ if (engine3) {
2400
+ for (const b of M.Composite.allBodies(engine3.world)) {
2401
+ if (b.isStatic) this.visible.push(b);
2402
+ }
2403
+ }
2404
+ const owners = this.owners;
2405
+ return {
2406
+ tick,
2407
+ requested,
2408
+ clamped,
2409
+ matter: {
2410
+ bodies: this.visible,
2411
+ who: (body) => owners.get(body)
2412
+ }
2413
+ };
1482
2414
  }
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;
2415
+ free() {
2416
+ this.owners.clear();
2417
+ this.visible.length = 0;
2418
+ }
2419
+ };
2420
+ var RewindState = class {
2421
+ history;
2422
+ scratch;
2423
+ inside = false;
2424
+ constructor(depth) {
2425
+ this.history = new PoseHistory(depth);
2426
+ }
2427
+ free() {
2428
+ this.scratch?.free();
2429
+ this.scratch = void 0;
2430
+ }
2431
+ run(physics, tickNow, requested, fn) {
2432
+ if (this.inside) {
2433
+ throw new Error(
2434
+ "room.rewind: already inside a rewind. The second call would repose the same scratch world under the query still reading it, so it is refused rather than answered wrongly."
2435
+ );
1490
2436
  }
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;
2437
+ const resolved = this.history.resolve(requested);
2438
+ if (!resolved) {
2439
+ throw new Error(
2440
+ `room.rewind: this room has recorded no ticks yet, so there is no past to answer from. A woken room starts with an empty history and fills it over its next ${this.history.depth} tick(s).`
2441
+ );
1507
2442
  }
1508
- if (core.mode === "event") {
1509
- core.loop.after(HOST_CALL_TIMEOUT_MS, () => checkHostCallTimeouts(core));
2443
+ this.scratch ??= physics.engineKind === "rapier3d" ? new RapierScratch(physics, this.history.depth, tickNow) : new MatterScratch(physics, this.history.depth, tickNow);
2444
+ const view = this.scratch.view(resolved.entry, resolved.tick, requested, resolved.clamped);
2445
+ this.inside = true;
2446
+ try {
2447
+ return fn(view);
2448
+ } finally {
2449
+ this.inside = false;
1510
2450
  }
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
2451
  }
1538
- }
2452
+ };
1539
2453
 
1540
2454
  // src/core/messages.ts
1541
2455
  import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
2456
+ import { decodeFields as decodeFields2 } from "@irtio/schema";
1542
2457
  function toRoomTarget(target) {
1543
2458
  switch (target.kind) {
1544
2459
  case "all":
@@ -1549,6 +2464,8 @@ function toRoomTarget(target) {
1549
2464
  return { role: target.role };
1550
2465
  case "server":
1551
2466
  return "server";
2467
+ case "voice":
2468
+ return void 0;
1552
2469
  }
1553
2470
  }
1554
2471
  function deliver(core, target, frame, exclude) {
@@ -1557,7 +2474,28 @@ function deliver(core, target, frame, exclude) {
1557
2474
  if (entry.clientId === exclude) continue;
1558
2475
  if (target.kind === "client" && entry.clientId !== target.clientId) continue;
1559
2476
  if (target.kind === "role" && entry.role !== target.role) continue;
1560
- core.send(entry.clientId, frame);
2477
+ core.send(entry.clientId, frame.slice());
2478
+ }
2479
+ }
2480
+ function messagesOf(core) {
2481
+ return core.definition.schema.messages ?? [];
2482
+ }
2483
+ function decodeTyped(core, clientId, index, payload) {
2484
+ const desc = messagesOf(core)[index];
2485
+ if (!desc) {
2486
+ core.stats.messagesDropped++;
2487
+ core.log(
2488
+ "warn",
2489
+ `typed MSG from ${clientId}: no message with index ${index} in this schema; dropped`
2490
+ );
2491
+ return void 0;
2492
+ }
2493
+ try {
2494
+ return { name: desc.name, value: decodeFields2(desc.fields, payload) };
2495
+ } catch (err) {
2496
+ core.stats.messagesDropped++;
2497
+ core.log("warn", `typed MSG ${desc.name} from ${clientId} failed to decode:`, err);
2498
+ return void 0;
1561
2499
  }
1562
2500
  }
1563
2501
  function handleMsg(core, clientId, payload) {
@@ -1569,40 +2507,68 @@ function handleMsg(core, clientId, payload) {
1569
2507
  core.log("warn", `MSG from ${clientId} failed to decode:`, err);
1570
2508
  return false;
1571
2509
  }
2510
+ if (msg.target.kind === "voice") {
2511
+ core.log("warn", `voice MSG from ${clientId} reached room code; dropped (supervisor bug)`);
2512
+ return true;
2513
+ }
2514
+ if (msg.typed && msg.target.kind === "server") {
2515
+ core.stats.messagesDropped++;
2516
+ core.log("warn", `typed MSG from ${clientId} addressed the server; dropped`);
2517
+ return true;
2518
+ }
2519
+ const roomTarget = toRoomTarget(msg.target);
2520
+ if (roomTarget === void 0) return true;
2521
+ let typed;
2522
+ if (msg.typed) {
2523
+ typed = decodeTyped(core, clientId, msg.typed.index, msg.payload);
2524
+ if (!typed) return true;
2525
+ }
1572
2526
  const onMessage = core.definition.config.onMessage;
1573
2527
  if (onMessage) {
1574
2528
  const ran = core.tryRun(
1575
2529
  "onMessage",
1576
- () => onMessage(
1577
- core.anyState,
1578
- clientId,
1579
- toRoomTarget(msg.target),
1580
- msg.payload,
1581
- core.ctxFor(clientId)
1582
- )
2530
+ () => onMessage(core.anyState, clientId, roomTarget, msg.payload, core.ctxFor(clientId), typed)
1583
2531
  );
1584
2532
  if (!ran.ok || ran.value === false) return true;
1585
2533
  }
1586
2534
  if (msg.target.kind === "server") return true;
1587
2535
  const frame = encodeFrame2(
1588
2536
  FrameType3.MSG,
1589
- encodeMsg({ target: { kind: "client", clientId }, payload: msg.payload })
2537
+ encodeMsg({
2538
+ target: { kind: "client", clientId },
2539
+ payload: msg.payload,
2540
+ ...msg.typed ? { typed: msg.typed } : {}
2541
+ })
1590
2542
  );
1591
2543
  deliver(core, msg.target, frame, clientId);
1592
2544
  return true;
1593
2545
  }
2546
+ function wireTargetOf(target) {
2547
+ return target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
2548
+ }
1594
2549
  function sendMessage(core, target, bytes) {
1595
- const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
1596
2550
  const frame = encodeFrame2(
1597
2551
  FrameType3.MSG,
1598
2552
  encodeMsg({ target: { kind: "server" }, payload: bytes })
1599
2553
  );
1600
- deliver(core, wire, frame);
2554
+ deliver(core, wireTargetOf(target), frame);
2555
+ }
2556
+ function sendTypedMessage(core, index, target, payload) {
2557
+ const frame = encodeFrame2(
2558
+ FrameType3.MSG,
2559
+ encodeMsg({ target: { kind: "server" }, payload, typed: { index } })
2560
+ );
2561
+ deliver(core, wireTargetOf(target), frame);
1601
2562
  }
1602
2563
 
1603
2564
  // src/core/room-api.ts
1604
- import { FrameType as FrameType4, encodeFrame as encodeFrame3 } from "@irtio/protocol";
1605
- import { encodeDelta as encodeDelta3, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
2565
+ import {
2566
+ FrameType as FrameType4,
2567
+ busChannelProblem,
2568
+ busPayloadProblem,
2569
+ encodeFrame as encodeFrame3
2570
+ } from "@irtio/protocol";
2571
+ import { encodeDelta as encodeDelta3, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
1606
2572
  function makePhysicsApi(p) {
1607
2573
  return {
1608
2574
  get rapier() {
@@ -1619,6 +2585,22 @@ function makePhysicsApi(p) {
1619
2585
  }
1620
2586
  };
1621
2587
  }
2588
+ function makeMatterApi(p) {
2589
+ return {
2590
+ get matter() {
2591
+ return p.matter;
2592
+ },
2593
+ get engine() {
2594
+ return p.matterEngine;
2595
+ },
2596
+ get timestep() {
2597
+ return p.timestep;
2598
+ },
2599
+ body(collection, id) {
2600
+ return p.bodyFor(collection, id);
2601
+ }
2602
+ };
2603
+ }
1622
2604
  function makeKv(core) {
1623
2605
  return {
1624
2606
  get(playerId, key) {
@@ -1632,15 +2614,126 @@ function makeKv(core) {
1632
2614
  }
1633
2615
  };
1634
2616
  }
2617
+ function makeLeaderboard(core) {
2618
+ return {
2619
+ submit(board, playerId, score, options) {
2620
+ if (!Number.isInteger(score)) {
2621
+ return rejectAsEvent(
2622
+ core,
2623
+ new Error(
2624
+ `room.leaderboard.submit: score must be a whole number, got ${String(score)} (leaderboards are integer-only, by decision)`
2625
+ )
2626
+ );
2627
+ }
2628
+ return startHostCall(
2629
+ core,
2630
+ {
2631
+ kind: "lbSubmit",
2632
+ board,
2633
+ playerId,
2634
+ score,
2635
+ ...options?.bucket === void 0 ? {} : { bucket: options.bucket }
2636
+ },
2637
+ () => void 0
2638
+ );
2639
+ }
2640
+ };
2641
+ }
2642
+ function makeRatings(core) {
2643
+ return {
2644
+ report(queue, results) {
2645
+ return startHostCall(
2646
+ core,
2647
+ {
2648
+ kind: "ratingReport",
2649
+ queue,
2650
+ // Copied rather than passed through: `results` is the game's own array and the host call
2651
+ // crosses a worker boundary, so a room that mutated it after calling would otherwise be
2652
+ // reporting something it did not say.
2653
+ results: (Array.isArray(results) ? results : []).map((r) => ({
2654
+ playerId: String(r?.playerId),
2655
+ place: Number(r?.place)
2656
+ }))
2657
+ },
2658
+ () => void 0
2659
+ );
2660
+ },
2661
+ set(queue, playerId, value) {
2662
+ return startHostCall(
2663
+ core,
2664
+ {
2665
+ kind: "ratingSet",
2666
+ queue,
2667
+ playerId,
2668
+ rating: Number(value?.rating),
2669
+ ...value?.deviation !== void 0 ? { deviation: Number(value.deviation) } : {}
2670
+ },
2671
+ () => void 0
2672
+ );
2673
+ }
2674
+ };
2675
+ }
2676
+ function makeBus(core) {
2677
+ const refuse = (verb, problem) => {
2678
+ core.log("warn", `room.bus.${verb}: ${problem.code}: ${problem.message}`);
2679
+ };
2680
+ const setSubscribed = (verb, channel) => {
2681
+ const problem = busChannelProblem(channel);
2682
+ if (problem) {
2683
+ refuse(verb, problem);
2684
+ return;
2685
+ }
2686
+ core.host.busSubscribe(channel, verb === "subscribe");
2687
+ };
2688
+ return {
2689
+ publish(channel, payload) {
2690
+ const problem = busChannelProblem(channel) ?? busPayloadProblem(payload);
2691
+ if (problem) {
2692
+ refuse("publish", problem);
2693
+ return;
2694
+ }
2695
+ core.host.busPublish(channel, payload);
2696
+ },
2697
+ subscribe(channel) {
2698
+ setSubscribed("subscribe", channel);
2699
+ },
2700
+ unsubscribe(channel) {
2701
+ setSubscribed("unsubscribe", channel);
2702
+ },
2703
+ send(roomId, payload) {
2704
+ if (typeof roomId !== "string" || roomId === "") {
2705
+ return rejectAsEvent(core, new Error("room.bus.send: a target roomId is required"));
2706
+ }
2707
+ const problem = busPayloadProblem(payload);
2708
+ if (problem) return rejectAsEvent(core, new Error(`${problem.code}: ${problem.message}`));
2709
+ return startHostCall(core, { kind: "busSend", roomId, payload }, () => void 0);
2710
+ }
2711
+ };
2712
+ }
1635
2713
  function createRoomApi(core, publicUrl) {
1636
2714
  let cached;
2715
+ let npcCounter = 0;
1637
2716
  let lastNow = Number.NEGATIVE_INFINITY;
1638
2717
  let physicsApi;
2718
+ let matterApi;
1639
2719
  let kvApi;
2720
+ let busApi;
2721
+ let leaderboardApi;
2722
+ let ratingsApi;
2723
+ const declaredBackfill = core.definition.config.backfill === true;
2724
+ let backfillOpen = declaredBackfill;
1640
2725
  const link = () => {
1641
2726
  const sep = publicUrl.includes("?") ? "&" : "?";
1642
2727
  return `${publicUrl}${sep}room=${encodeURIComponent(core.roomId)}`;
1643
2728
  };
2729
+ const messages = {};
2730
+ for (const desc of core.definition.schema.messages ?? []) {
2731
+ messages[desc.name] = {
2732
+ send(target, value) {
2733
+ sendTypedMessage(core, desc.index, target, encodeFields2(desc.fields, value));
2734
+ }
2735
+ };
2736
+ }
1644
2737
  const room = {
1645
2738
  get id() {
1646
2739
  return core.roomId;
@@ -1662,7 +2755,8 @@ function createRoomApi(core, publicUrl) {
1662
2755
  clientId: c.clientId,
1663
2756
  role: c.role,
1664
2757
  name: c.name,
1665
- connected: c.connected
2758
+ connected: c.connected,
2759
+ npc: c.npc
1666
2760
  }));
1667
2761
  }
1668
2762
  return cached;
@@ -1681,11 +2775,38 @@ function createRoomApi(core, publicUrl) {
1681
2775
  "room.physics: this room has no physics \u2014 add physics: { engine: 'rapier3d', gravity, bodies } to defineRoom(...)"
1682
2776
  );
1683
2777
  }
2778
+ if (p.engineKind !== "rapier3d") {
2779
+ throw new Error(
2780
+ "room.physics: this room runs matter2d. Read room.physics2d instead (it has .matter and .engine, where this one has .rapier and .world)"
2781
+ );
2782
+ }
1684
2783
  return physicsApi ?? (physicsApi = makePhysicsApi(p));
1685
2784
  },
2785
+ get physics2d() {
2786
+ const p = core.physics;
2787
+ if (!p) {
2788
+ throw new Error(
2789
+ "room.physics2d: this room has no physics \u2014 add physics: { engine: 'matter2d', gravity, bodies } to defineRoom(...)"
2790
+ );
2791
+ }
2792
+ if (p.engineKind !== "matter2d") {
2793
+ throw new Error(
2794
+ "room.physics2d: this room runs rapier3d. Read room.physics instead (it has .rapier and .world, where this one has .matter and .engine)"
2795
+ );
2796
+ }
2797
+ return matterApi ?? (matterApi = makeMatterApi(p));
2798
+ },
2799
+ // ---- M6 lane F: rewind ----
2800
+ // D72: one line, and deliberately only one. Everything the rewind does — the buffer, the
2801
+ // clamping, the scratch worlds, the reentrancy refusal — lives in `core/history.ts` behind
2802
+ // `RoomCore.rewind`, so this file stays what it says it is: a facade that reads through.
2803
+ rewind(tick, fn) {
2804
+ return core.rewind(tick, fn);
2805
+ },
1686
2806
  send(target, bytes) {
1687
2807
  sendMessage(core, target, bytes);
1688
2808
  },
2809
+ messages,
1689
2810
  setRole(clientId, role) {
1690
2811
  setRole(core, clientId, role);
1691
2812
  cached = void 0;
@@ -1724,6 +2845,33 @@ function createRoomApi(core, publicUrl) {
1724
2845
  get kv() {
1725
2846
  return kvApi ?? (kvApi = makeKv(core));
1726
2847
  },
2848
+ get leaderboard() {
2849
+ return leaderboardApi ?? (leaderboardApi = makeLeaderboard(core));
2850
+ },
2851
+ get ratings() {
2852
+ return ratingsApi ?? (ratingsApi = makeRatings(core));
2853
+ },
2854
+ get backfill() {
2855
+ return {
2856
+ set(open) {
2857
+ if (!declaredBackfill) {
2858
+ core.log(
2859
+ "warn",
2860
+ "room.backfill.set: this room type did not declare `backfill: true`, so the matchmaker will never offer it. The call is ignored."
2861
+ );
2862
+ return;
2863
+ }
2864
+ backfillOpen = open;
2865
+ core.host.setBackfill(open);
2866
+ },
2867
+ get open() {
2868
+ return backfillOpen;
2869
+ }
2870
+ };
2871
+ },
2872
+ get bus() {
2873
+ return busApi ?? (busApi = makeBus(core));
2874
+ },
1727
2875
  alarm(name, atMs) {
1728
2876
  if (!isAlarmName(core, name, "room.alarm")) return;
1729
2877
  if (!Number.isFinite(atMs)) {
@@ -1739,6 +2887,27 @@ function createRoomApi(core, publicUrl) {
1739
2887
  }
1740
2888
  core.host.setAlarm(name, void 0);
1741
2889
  },
2890
+ spawnNPC(config) {
2891
+ const clientId = `npc-${core.roomId}-${++npcCounter}`;
2892
+ const checked = checkNpcConfig(core, config);
2893
+ if (checked) core.host.spawnNpc(clientId, checked);
2894
+ let despawned = !checked;
2895
+ return {
2896
+ clientId,
2897
+ despawn() {
2898
+ if (despawned) return;
2899
+ despawned = true;
2900
+ core.host.despawnNpc(clientId);
2901
+ }
2902
+ };
2903
+ },
2904
+ despawnNPC(clientId) {
2905
+ if (typeof clientId !== "string" || clientId === "") {
2906
+ core.log("warn", "room.despawnNPC: a client id is required; ignored");
2907
+ return;
2908
+ }
2909
+ core.host.despawnNpc(clientId);
2910
+ },
1742
2911
  call(clientId) {
1743
2912
  return createCallProxy(core, clientId);
1744
2913
  },
@@ -1793,6 +2962,50 @@ function setRole(core, clientId, role) {
1793
2962
  if (presence) presence.role = role;
1794
2963
  core.invalidateClients();
1795
2964
  }
2965
+ function checkNpcConfig(core, config) {
2966
+ if (typeof config !== "object" || config === null) {
2967
+ throw new Error("room.spawnNPC: a config is required, like " + NPC_EXAMPLE);
2968
+ }
2969
+ const brain = config.brain;
2970
+ if (typeof brain !== "object" || brain === null) {
2971
+ throw new Error("room.spawnNPC: config.brain is required, like " + NPC_EXAMPLE);
2972
+ }
2973
+ if (brain.kind !== "script") {
2974
+ throw new Error(
2975
+ `room.spawnNPC: brain.kind ${JSON.stringify(brain.kind)} is not supported. The only brain this version runs is { kind: 'script', script: '<a key of the room's npcs map>' }.`
2976
+ );
2977
+ }
2978
+ if (typeof brain.script !== "string" || brain.script === "") {
2979
+ throw new Error(
2980
+ "room.spawnNPC: brain.script must name an entry in the room definition's npcs map, like " + NPC_EXAMPLE
2981
+ );
2982
+ }
2983
+ const npcs = core.definition.config.npcs;
2984
+ if (!npcs || typeof npcs[brain.script] !== "function") {
2985
+ const known = npcs ? Object.keys(npcs) : [];
2986
+ throw new Error(
2987
+ `room.spawnNPC: no npc script named ${JSON.stringify(brain.script)}. ` + (known.length > 0 ? `This room defines ${known.join(", ")}.` : "This room's defineRoom(...) has no npcs: { \u2026 } map.")
2988
+ );
2989
+ }
2990
+ if (config.role !== void 0 && typeof config.role !== "string") {
2991
+ throw new Error("room.spawnNPC: role must be a string when given");
2992
+ }
2993
+ if (config.name !== void 0 && typeof config.name !== "string") {
2994
+ throw new Error("room.spawnNPC: name must be a string when given");
2995
+ }
2996
+ if (config.seed !== void 0 && !Number.isFinite(config.seed)) {
2997
+ throw new Error("room.spawnNPC: seed must be a finite number when given");
2998
+ }
2999
+ return {
3000
+ brain: { kind: "script", script: brain.script },
3001
+ ...config.role !== void 0 ? { role: config.role } : {},
3002
+ ...config.name !== void 0 ? { name: config.name } : {},
3003
+ // Seeded from the room's own recorded rng when the caller did not pick one, so a room that
3004
+ // spawns its NPCs the same way twice gets the same NPCs twice.
3005
+ seed: config.seed ?? Math.floor(core.rng.next() * 2147483647)
3006
+ };
3007
+ }
3008
+ var NPC_EXAMPLE = "{ brain: { kind: 'script', script: 'chaser' } }";
1796
3009
 
1797
3010
  // src/core/room.ts
1798
3011
  var DEFAULT_PUBLIC_URL = "http://localhost/";
@@ -1839,7 +3052,8 @@ var RoomCore = class _RoomCore {
1839
3052
  visibleIdsTotal: 0,
1840
3053
  membershipEnters: 0,
1841
3054
  membershipLeaves: 0,
1842
- corrections: 0
3055
+ corrections: 0,
3056
+ messagesDropped: 0
1843
3057
  };
1844
3058
  tick = 0;
1845
3059
  stopped = false;
@@ -1850,6 +3064,24 @@ var RoomCore = class _RoomCore {
1850
3064
  * into a timeout in an unrelated assertion ten seconds later. Unset in production.
1851
3065
  */
1852
3066
  onHandlerError;
3067
+ /**
3068
+ * D41: the recorded authoritative timeline. Armed by `startRecording()` and off otherwise, so a
3069
+ * room nobody asked to record pays nothing. A room that was asked captures its own state at the
3070
+ * end of every tick, which is the only moment in a tick where that state is settled.
3071
+ */
3072
+ recorder;
3073
+ /**
3074
+ * D65: the bandwidth ledger, present only when `RoomCoreOptions.profile` asked for one. Every
3075
+ * cost the profiler has is behind this `undefined`.
3076
+ */
3077
+ ledger;
3078
+ /**
3079
+ * D72: the pose history and the rewind scratch, present only when the room's physics config
3080
+ * declares `history`. Every cost this lane has is behind this `undefined`, and it is
3081
+ * deliberately not in the hibernation blob: a woken room starts with an empty buffer and fills
3082
+ * it again over its next `history` ticks.
3083
+ */
3084
+ rewindState;
1853
3085
  seed;
1854
3086
  api;
1855
3087
  internals;
@@ -1861,7 +3093,8 @@ var RoomCore = class _RoomCore {
1861
3093
  this.host = host;
1862
3094
  this.roomId = options.roomId;
1863
3095
  this.mode = definition.config.mode;
1864
- this.ext = withBuiltins(definition.schema);
3096
+ this.ext = withBuiltins2(definition.schema);
3097
+ this.ledger = options.profile === true ? new ProfileLedger(this.ext, definition.schema) : void 0;
1865
3098
  for (const issue of validateForDeploy(this.ext)) {
1866
3099
  if (issue.level === "error") throw new Error(`RoomCore: ${issue.message}`);
1867
3100
  host.log("warn", [`irtio: ${issue.message}`]);
@@ -1870,7 +3103,7 @@ var RoomCore = class _RoomCore {
1870
3103
  let plain;
1871
3104
  if (options.restoreFrom) {
1872
3105
  restored = parseHibernationBlob(options.restoreFrom);
1873
- plain = decodeSnapshot(this.ext, restored.snapshot).state;
3106
+ plain = decodeSnapshot2(this.ext, restored.snapshot).state;
1874
3107
  const presence = plainEntity(plain, PRESENCE_COLLECTION);
1875
3108
  for (const id of [...presence.ids()]) presence.remove(id);
1876
3109
  if (restored.mode !== this.mode) {
@@ -1892,6 +3125,9 @@ var RoomCore = class _RoomCore {
1892
3125
  this.loop = new Loop(self);
1893
3126
  this.api = createRoomApi(self, options.publicUrl ?? DEFAULT_PUBLIC_URL);
1894
3127
  this.physics = this.buildPhysics(restored);
3128
+ const historyDepth = this.physics ? historyDepthOf(definition.config.physics) : void 0;
3129
+ this.rewindState = historyDepth === void 0 ? void 0 : new RewindState(historyDepth);
3130
+ this.subscribeDeclaredChannels();
1895
3131
  if (restored) {
1896
3132
  const onWake = definition.config.onWake;
1897
3133
  if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
@@ -1900,6 +3136,64 @@ var RoomCore = class _RoomCore {
1900
3136
  if (onCreate) this.guard("onCreate", () => onCreate(this.state, this.room));
1901
3137
  }
1902
3138
  }
3139
+ subscribeDeclaredChannels() {
3140
+ const channels = this.busConfig?.channels;
3141
+ if (!channels) return;
3142
+ for (const channel of Object.keys(channels)) {
3143
+ const problem = busChannelProblem2(channel);
3144
+ if (problem) {
3145
+ this.log("warn", `bus.channels: ${problem.message}; that channel is not subscribed`);
3146
+ continue;
3147
+ }
3148
+ this.host.busSubscribe(channel, true);
3149
+ }
3150
+ }
3151
+ get busConfig() {
3152
+ return this.definition.config.bus;
3153
+ }
3154
+ /**
3155
+ * D59: one published message arriving on a channel this room is subscribed to.
3156
+ *
3157
+ * Same scheduling class as an alarm or an RPC — a discrete event between ticks — so a tick-mode
3158
+ * room never sees a `tick` run half-delivered. `from` is supervisor-stamped, so the handler may
3159
+ * trust it as far as it trusts its own project.
3160
+ */
3161
+ deliverBusEvent(channel, from, payload) {
3162
+ if (this.stopped) return;
3163
+ const handler = this.busConfig?.channels?.[channel];
3164
+ if (!handler) {
3165
+ return;
3166
+ }
3167
+ this.recordEvent("bus", void 0, channel);
3168
+ this.guard(
3169
+ `bus.channels.${channel}`,
3170
+ () => handler(this.state, { channel, from, payload }, this.room)
3171
+ );
3172
+ this.eventFlush();
3173
+ }
3174
+ /**
3175
+ * D59: one directed `room.bus.send` arriving. At-least-once, so this can run twice for one send;
3176
+ * that is the receiver's problem to be idempotent about and the docs say so.
3177
+ *
3178
+ * A throw propagates through `guard` and counts toward the crash threshold exactly as any other
3179
+ * handler throw does. That is deliberate, and it is why the supervisor bounds redelivery: the
3180
+ * two behaviours together would otherwise let one poisonous message close a room on every wake,
3181
+ * forever.
3182
+ */
3183
+ deliverBusMessage(from, payload) {
3184
+ if (this.stopped) return;
3185
+ const handler = this.busConfig?.onMessage;
3186
+ if (!handler) {
3187
+ this.log(
3188
+ "warn",
3189
+ `a bus message from ${JSON.stringify(from)} was delivered but the room declares no bus.onMessage handler; it is dropped`
3190
+ );
3191
+ return;
3192
+ }
3193
+ this.recordEvent("bus", void 0, "onMessage");
3194
+ this.guard("bus.onMessage", () => handler(this.state, { from, payload }, this.room));
3195
+ this.eventFlush();
3196
+ }
1903
3197
  /**
1904
3198
  * D22: builds the world, or returns `undefined` for a room with no `physics:` config.
1905
3199
  *
@@ -1914,14 +3208,15 @@ var RoomCore = class _RoomCore {
1914
3208
  buildPhysics(restored) {
1915
3209
  const config = this.definition.config.physics;
1916
3210
  if (!config) return void 0;
1917
- const engine2 = loadedPhysics();
1918
- if (!engine2) {
3211
+ if (config.engine === "matter2d") return this.buildMatter(config, restored);
3212
+ const engine3 = loadedPhysics();
3213
+ if (!engine3) {
1919
3214
  throw new Error(
1920
3215
  "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
3216
  );
1922
3217
  }
1923
3218
  const section = restored?.physics ? decodePhysicsSection(restored.physics) : void 0;
1924
- const physics = new PhysicsRuntime(this.internals, engine2, {
3219
+ const physics = new PhysicsRuntime(this.internals, engine3, {
1925
3220
  ...section ? { restore: section } : {},
1926
3221
  defaultTimestep: 1 / this.definition.config.tickRate
1927
3222
  });
@@ -1936,6 +3231,43 @@ var RoomCore = class _RoomCore {
1936
3231
  }
1937
3232
  return physics;
1938
3233
  }
3234
+ /**
3235
+ * D45: the matter2d half. It differs from Rapier's in one structural way — there is no engine
3236
+ * snapshot to restore, so the world is always **built** and per-body state is reapplied on top
3237
+ * of it. `setup` therefore runs on every wake, and only the "there was no world at all" case is
3238
+ * worth logging.
3239
+ */
3240
+ buildMatter(config, restored) {
3241
+ void config;
3242
+ const matter = loadedMatter();
3243
+ if (!matter) {
3244
+ throw new Error(
3245
+ "RoomCore: this room declares matter2d physics but matter-js is not loaded. The host must `await initMatter()` (from '@irtio/runtime') before constructing the room; handlers are synchronous, so it cannot be imported later."
3246
+ );
3247
+ }
3248
+ let section;
3249
+ if (restored?.physics) {
3250
+ if (physicsSectionEngine(restored.physics) === "matter2d") {
3251
+ section = decodeMatterBodies(decodeMatterSectionEnvelope(restored.physics));
3252
+ } else {
3253
+ this.host.log("warn", [
3254
+ "irtio: this snapshot carries a rapier3d world and the room now runs matter2d. The world is rebuilt from schema state and physics.setup runs again."
3255
+ ]);
3256
+ }
3257
+ }
3258
+ const physics = new MatterRuntime(this.internals, matter, {
3259
+ ...section ? { restore: section } : {},
3260
+ defaultTimestep: 1 / this.definition.config.tickRate
3261
+ });
3262
+ if (physics.rebuilt && restored) {
3263
+ this.host.log("info", [
3264
+ `irtio: no matter2d world in this snapshot (format v${restored.version}). Rebuilding it from schema state and re-running physics.setup; contact state is not restored.`
3265
+ ]);
3266
+ }
3267
+ physics.runSetup(this.room);
3268
+ physics.reconcile();
3269
+ return physics;
3270
+ }
1939
3271
  /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
1940
3272
  static restore(definition, bytes, host, options) {
1941
3273
  return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
@@ -1983,6 +3315,52 @@ var RoomCore = class _RoomCore {
1983
3315
  if (detail !== void 0) e.detail = detail;
1984
3316
  this.events.push(e);
1985
3317
  }
3318
+ /**
3319
+ * D41: begin (or restart) recording the authoritative timeline. Calling it again clears what
3320
+ * was recorded, so two scenario runs against one long-lived dev server do not read each
3321
+ * other's ticks.
3322
+ */
3323
+ startRecording(options = {}) {
3324
+ this.recorder = new TimelineRecorder(options);
3325
+ this.recorder.capture(this.tick, this.ext, this.plain);
3326
+ }
3327
+ /** D41: what has been recorded so far, or `undefined` when nobody armed the recorder. */
3328
+ recording() {
3329
+ return this.recorder?.dump(this.roomId);
3330
+ }
3331
+ /** D41: called at the end of every tick (and every event-mode flush). No-op when unarmed. */
3332
+ captureTimeline() {
3333
+ this.recorder?.capture(this.tick, this.ext, this.plain);
3334
+ }
3335
+ // ---- M6 lane F: rewind ----
3336
+ /**
3337
+ * D72: record this tick's body poses, right after `physics.sync()` — the poses the clients are
3338
+ * about to be told about, under the tick number they will be told it under, which is the tick a
3339
+ * client later stamps its `CALL` with.
3340
+ */
3341
+ captureHistory() {
3342
+ const physics = this.physics;
3343
+ if (!physics || !this.rewindState) return;
3344
+ this.rewindState.history.capture(this.tick, physics);
3345
+ }
3346
+ /**
3347
+ * D72: `room.rewind(tick, fn)`. The live world is not touched and nothing is re-simulated; `fn`
3348
+ * queries a scratch world holding every tracked body at its pose at `tick`.
3349
+ */
3350
+ rewind(tick, fn) {
3351
+ const physics = this.physics;
3352
+ if (!physics) {
3353
+ throw new Error(
3354
+ "room.rewind: this room has no physics, so there are no poses to rewind. Add physics: { engine, gravity, bodies, history: <ticks> } to defineRoom(...)"
3355
+ );
3356
+ }
3357
+ if (!this.rewindState) {
3358
+ throw new Error(
3359
+ "room.rewind: this room declares no physics.history, so no poses are kept. Add history: <ticks> to the physics config; it is off by default because it costs heap per tick. See irt.io/docs/concepts/lag-compensation for how deep to make it."
3360
+ );
3361
+ }
3362
+ return this.rewindState.run(physics, () => this.tick, tick, fn);
3363
+ }
1986
3364
  /** Live JSON view of the room for the dev page / supervisor admin API. */
1987
3365
  inspect() {
1988
3366
  return {
@@ -2017,6 +3395,7 @@ var RoomCore = class _RoomCore {
2017
3395
  this.loop.stop();
2018
3396
  rejectAllPending(this.internals, "room stopped");
2019
3397
  rejectAllHostCalls(this.internals, "room stopped");
3398
+ this.rewindState?.free();
2020
3399
  this.physics?.free();
2021
3400
  }
2022
3401
  /**
@@ -2031,7 +3410,7 @@ var RoomCore = class _RoomCore {
2031
3410
  * written.
2032
3411
  */
2033
3412
  snapshot() {
2034
- const physics = this.physics ? encodePhysicsSection(this.physics.serialize()) : void 0;
3413
+ const physics = this.physics ? encodeSection(this.physics) : void 0;
2035
3414
  return writeHibernationBlob(
2036
3415
  { seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
2037
3416
  encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
@@ -2111,6 +3490,7 @@ var RoomCore = class _RoomCore {
2111
3490
  // join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
2112
3491
  // and nothing else here changes.
2113
3492
  playerId: options.playerId ?? clientId,
3493
+ npc: options.npc === true,
2114
3494
  role,
2115
3495
  name,
2116
3496
  connected: true,
@@ -2131,11 +3511,12 @@ var RoomCore = class _RoomCore {
2131
3511
  const ctx = this.ctxFor(clientId, reconnecting);
2132
3512
  this.guard("onJoin", () => onJoin(this.state, ctx));
2133
3513
  }
2134
- this.loop.noteActivity();
3514
+ if (!entry.npc) this.loop.noteActivity();
2135
3515
  this.recordEvent("join", clientId, reconnecting ? "reconnect" : void 0);
2136
3516
  const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
2137
3517
  entry.spatialMembership = memberships;
2138
3518
  const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
3519
+ this.ledger?.attributeSnapshot("out", snapshot);
2139
3520
  const pending = /* @__PURE__ */ new Map();
2140
3521
  for (const [name2, cd] of this.tracked.dirty) {
2141
3522
  if (cd.added.size === 0) continue;
@@ -2161,6 +3542,7 @@ var RoomCore = class _RoomCore {
2161
3542
  if (isDirtyEmpty3(this.tracked.dirty)) return;
2162
3543
  this.tick++;
2163
3544
  this.flush();
3545
+ this.captureTimeline();
2164
3546
  }
2165
3547
  leave(clientId, reason) {
2166
3548
  const entry = this.clients.get(clientId);
@@ -2203,6 +3585,9 @@ var RoomCore = class _RoomCore {
2203
3585
  role: entry?.role ?? "",
2204
3586
  name: entry?.name ?? "",
2205
3587
  tick: this.tick,
3588
+ // D72: only an RPC has a stamp, and `rpc.ts` puts it on the ctx it hands the handler. Every
3589
+ // other entry point (join, leave, write, ownership) has no client tick by construction.
3590
+ clientTick: void 0,
2206
3591
  reconnecting,
2207
3592
  room: this.room
2208
3593
  };
@@ -2232,6 +3617,10 @@ var RoomCore = class _RoomCore {
2232
3617
  * Draining a bounded number of turns first covers the chains rooms actually write, coalesces
2233
3618
  * concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
2234
3619
  * the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
3620
+ *
3621
+ * Public on `RoomInternals` (bug #48) so the room API's local rejections — a float score, an
3622
+ * empty bus target, a call to a client that is not connected — flush the state their `.catch()`
3623
+ * writes, instead of leaving it for whatever frame happens to arrive next.
2235
3624
  */
2236
3625
  scheduleContinuationFlush() {
2237
3626
  if (this.mode !== "event" || this.continuationFlushPending) return;
@@ -2276,15 +3665,31 @@ var RoomCore = class _RoomCore {
2276
3665
  // -------------------------------------------------------------------------
2277
3666
  // Frames
2278
3667
  // -------------------------------------------------------------------------
2279
- send(clientId, frame) {
3668
+ /**
3669
+ * D65: `hint` is profiling context and nothing else — the payload object a per-view frame was
3670
+ * built from (so one encode is walked once and replayed per recipient) and the AOI ids whose
3671
+ * ops are visibility churn. Ignored entirely when this room is not profiling, which is why it
3672
+ * is an optional argument rather than a second method.
3673
+ */
3674
+ send(clientId, frame, hint) {
2280
3675
  if (this.stopped) return;
2281
3676
  const entry = this.clients.get(clientId);
2282
3677
  if (!entry || !entry.connected) return;
3678
+ const size = frame.length;
3679
+ this.ledger?.attribute("out", frame, { ...hint, peer: clientId });
2283
3680
  this.host.send(clientId, frame);
2284
3681
  this.stats.framesOut++;
2285
- this.stats.bytesOut += frame.length;
3682
+ this.stats.bytesOut += size;
2286
3683
  const by = this.stats.bytesOutByClient;
2287
- by.set(clientId, (by.get(clientId) ?? 0) + frame.length);
3684
+ by.set(clientId, (by.get(clientId) ?? 0) + size);
3685
+ }
3686
+ /**
3687
+ * D65: the ledger so far. The room's own view, so it counts what `send()` sent and what
3688
+ * `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
3689
+ * `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
3690
+ */
3691
+ profile() {
3692
+ return this.ledger?.snapshot();
2288
3693
  }
2289
3694
  badFrame(clientId, reason) {
2290
3695
  this.log("warn", `bad frame from ${clientId}: ${reason}`);
@@ -2300,7 +3705,8 @@ var RoomCore = class _RoomCore {
2300
3705
  return;
2301
3706
  }
2302
3707
  this.stats.framesIn++;
2303
- this.loop.noteActivity();
3708
+ this.ledger?.attribute("in", frame, { peer: clientId });
3709
+ if (this.clients.get(clientId)?.npc !== true) this.loop.noteActivity();
2304
3710
  let type;
2305
3711
  let payload;
2306
3712
  try {
@@ -2352,6 +3758,7 @@ var RoomCore = class _RoomCore {
2352
3758
  flush() {
2353
3759
  const dirty = this.tracked.flush();
2354
3760
  serverWinsCorrections(this.internals, dirty);
3761
+ this.ledger?.newFlush();
2355
3762
  this.stats.encodesLastFlush = 0;
2356
3763
  this.stats.aoiEncodesLastFlush = 0;
2357
3764
  const spatialDescs = this.ext.collections.filter((desc) => desc.visibility === "spatial-grid");
@@ -2370,11 +3777,23 @@ var RoomCore = class _RoomCore {
2370
3777
  indexes
2371
3778
  );
2372
3779
  this.stats.gridQueryMs += performance.now() - queryStarted;
3780
+ let churn;
2373
3781
  for (const [name, ids] of memberships) {
2374
3782
  const before = entry.spatialMembership.get(name) ?? /* @__PURE__ */ new Set();
2375
3783
  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++;
3784
+ const changed = this.ledger ? dirty.get(name) : void 0;
3785
+ let crossed;
3786
+ for (const id of ids) {
3787
+ if (before.has(id)) continue;
3788
+ this.stats.membershipEnters++;
3789
+ if (this.ledger && changed?.added.has(id) !== true) (crossed ??= /* @__PURE__ */ new Set()).add(id);
3790
+ }
3791
+ for (const id of before) {
3792
+ if (ids.has(id)) continue;
3793
+ this.stats.membershipLeaves++;
3794
+ if (this.ledger && changed?.removed.has(id) !== true) (crossed ??= /* @__PURE__ */ new Set()).add(id);
3795
+ }
3796
+ if (crossed) (churn ??= /* @__PURE__ */ new Map()).set(name, crossed);
2378
3797
  }
2379
3798
  if (entry.correction) {
2380
3799
  const payload = encodeCorrection(
@@ -2407,7 +3826,13 @@ var RoomCore = class _RoomCore {
2407
3826
  }
2408
3827
  shared = cached;
2409
3828
  }
2410
- if (shared) this.send(entry.clientId, encodeFrame4(FrameType5.DELTA, shared));
3829
+ if (shared) {
3830
+ this.send(
3831
+ entry.clientId,
3832
+ encodeFrame4(FrameType5.DELTA, shared),
3833
+ this.ledger ? { shared } : void 0
3834
+ );
3835
+ }
2411
3836
  const aoiDirty = aoiViewDirty(
2412
3837
  this.ext,
2413
3838
  sourceDirty,
@@ -2443,13 +3868,25 @@ var RoomCore = class _RoomCore {
2443
3868
  }
2444
3869
  delta = cached;
2445
3870
  }
2446
- if (delta) this.send(entry.clientId, encodeFrame4(FrameType5.DELTA, delta));
3871
+ if (delta) {
3872
+ this.send(
3873
+ entry.clientId,
3874
+ encodeFrame4(FrameType5.DELTA, delta),
3875
+ this.ledger ? {
3876
+ ...churn ? { churn } : {},
3877
+ ...spatialDescs.length > 0 ? {} : { shared: delta }
3878
+ } : void 0
3879
+ );
3880
+ }
2447
3881
  }
2448
3882
  entry.correction = void 0;
2449
3883
  entry.accepted.clear();
2450
3884
  }
2451
3885
  }
2452
3886
  };
3887
+ function encodeSection(physics) {
3888
+ return physics.engineKind === "matter2d" ? encodeMatterSectionEnvelope(encodeMatterBodies(physics.serialize())) : encodePhysicsSection(physics.serialize());
3889
+ }
2453
3890
 
2454
3891
  export {
2455
3892
  HOST_CALL_TIMEOUT_MS,
@@ -2459,10 +3896,20 @@ export {
2459
3896
  RPC_TIMEOUT_MS,
2460
3897
  MAX_CATCHUP,
2461
3898
  CRASH_AFTER_THROWS,
3899
+ initMatter,
3900
+ loadedMatter,
3901
+ resetMatterForTests,
3902
+ encodeMatterBodies,
3903
+ decodeMatterBodies,
2462
3904
  initPhysics,
2463
3905
  loadedPhysics,
2464
3906
  resetPhysicsForTests,
3907
+ rapierHasStepped,
3908
+ onFirstRapierStep,
2465
3909
  encodePhysicsSection,
3910
+ physicsSectionEngine,
3911
+ encodeMatterSectionEnvelope,
3912
+ decodeMatterSectionEnvelope,
2466
3913
  decodePhysicsSection,
2467
3914
  Mulberry32,
2468
3915
  isVisible,
@@ -2477,5 +3924,9 @@ export {
2477
3924
  READABLE_SNAPSHOT_VERSIONS,
2478
3925
  parseHibernationBlob,
2479
3926
  writeHibernationBlob,
3927
+ decodeSave,
3928
+ DEFAULT_TIMELINE_MAX_TICKS,
3929
+ DEFAULT_TIMELINE_MAX_RECORDS,
3930
+ TimelineRecorder,
2480
3931
  RoomCore
2481
3932
  };