@irtio/runtime 0.5.1 → 0.6.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.
@@ -230,6 +230,7 @@ function createCallProxy(core, clientId) {
230
230
  return new Promise((resolve, reject2) => {
231
231
  const entry = core.clients.get(clientId);
232
232
  if (!entry || !entry.connected) {
233
+ core.scheduleContinuationFlush();
233
234
  reject2(new Error(`room.call: ${clientId} is not connected`));
234
235
  return;
235
236
  }
@@ -328,6 +329,75 @@ function rejectAllPending(core, reason) {
328
329
  // src/core/loop.ts
329
330
  import { FrameType as FrameType2 } from "@irtio/protocol";
330
331
 
332
+ // src/core/host-calls.ts
333
+ var states2 = /* @__PURE__ */ new WeakMap();
334
+ function stateOf2(core) {
335
+ let s = states2.get(core);
336
+ if (!s) {
337
+ s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
338
+ states2.set(core, s);
339
+ }
340
+ return s;
341
+ }
342
+ function rejectAsEvent(core, err) {
343
+ core.scheduleContinuationFlush();
344
+ return Promise.reject(err);
345
+ }
346
+ function startHostCall(core, call, map) {
347
+ if (core.stopped) {
348
+ return rejectAsEvent(core, new Error(`room.${call.kind}: the room is stopped`));
349
+ }
350
+ return new Promise((resolve, reject2) => {
351
+ const s = stateOf2(core);
352
+ const reqId = s.nextReqId;
353
+ s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
354
+ s.pending.set(reqId, {
355
+ kind: call.kind,
356
+ deadline: core.host.now() + HOST_CALL_TIMEOUT_MS,
357
+ map,
358
+ resolve,
359
+ reject: reject2
360
+ });
361
+ try {
362
+ core.host.hostCall(reqId, call);
363
+ } catch (err) {
364
+ s.pending.delete(reqId);
365
+ core.scheduleContinuationFlush();
366
+ reject2(err instanceof Error ? err : new Error(String(err)));
367
+ return;
368
+ }
369
+ if (core.mode === "event") {
370
+ core.loop.after(HOST_CALL_TIMEOUT_MS, () => checkHostCallTimeouts(core));
371
+ }
372
+ });
373
+ }
374
+ function completeHostCall(core, reqId, result) {
375
+ const s = stateOf2(core);
376
+ const pending = s.pending.get(reqId);
377
+ if (!pending) return false;
378
+ s.pending.delete(reqId);
379
+ if (result.ok) pending.resolve(pending.map(result.value));
380
+ else pending.reject(new Error(`${result.code}: ${result.message}`));
381
+ return true;
382
+ }
383
+ function checkHostCallTimeouts(core) {
384
+ const s = stateOf2(core);
385
+ if (s.pending.size === 0) return;
386
+ const now = core.host.now();
387
+ for (const [reqId, p] of [...s.pending]) {
388
+ if (p.deadline > now) continue;
389
+ s.pending.delete(reqId);
390
+ p.reject(new Error(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
391
+ }
392
+ }
393
+ function rejectAllHostCalls(core, reason) {
394
+ const s = stateOf2(core);
395
+ for (const [reqId, p] of [...s.pending]) {
396
+ s.pending.delete(reqId);
397
+ p.reject(new Error(reason));
398
+ }
399
+ }
400
+
331
401
  // src/core/writes.ts
332
402
  import {
333
403
  SERVER_OWNER as SERVER_OWNER2,
@@ -674,6 +744,7 @@ var Loop = class {
674
744
  this.drainInbound();
675
745
  this.fireDueTimers();
676
746
  checkTimeouts(this.core);
747
+ checkHostCallTimeouts(this.core);
677
748
  const config = this.core.definition.config;
678
749
  let failed;
679
750
  if (config.tick) {
@@ -709,6 +780,7 @@ var Loop = class {
709
780
  }
710
781
  }
711
782
  this.core.flush();
783
+ this.core.captureTimeline();
712
784
  const elapsed = Math.max(0, host.now() - started);
713
785
  const stats = this.core.stats;
714
786
  stats.ticks++;
@@ -748,7 +820,7 @@ var Loop = class {
748
820
  if (!this.running || this.core.stopped) return;
749
821
  const idleMs = this.core.definition.config.idleMs;
750
822
  if (idleMs <= 0) return;
751
- if (this.core.mode === "tick" && this.core.clients.size > 0) {
823
+ if (this.core.mode === "tick" && hasHumanClients(this.core)) {
752
824
  this.lastActivity = this.core.host.now();
753
825
  this.armIdle(idleMs);
754
826
  return;
@@ -815,31 +887,352 @@ var Loop = class {
815
887
  this.internal.add(handle);
816
888
  }
817
889
  };
890
+ function hasHumanClients(core) {
891
+ for (const c of core.clients.values()) {
892
+ if (!c.npc) return true;
893
+ }
894
+ return false;
895
+ }
818
896
 
819
- // src/core/physics.ts
820
- import { ByteReader, ByteWriter } from "@irtio/schema";
897
+ // src/core/matter.ts
898
+ import {
899
+ ByteReader,
900
+ ByteWriter,
901
+ applyChannel2d,
902
+ channelOf2d
903
+ } from "@irtio/schema";
821
904
  var engine;
822
905
  var loading;
823
- async function initPhysics() {
906
+ async function initMatter() {
824
907
  if (engine) return engine;
825
908
  loading ??= (async () => {
826
- const mod = await import("@dimforge/rapier3d-compat");
909
+ const mod = await import("matter-js");
827
910
  const ns = mod.default ?? mod;
828
- await ns.init();
829
911
  engine = ns;
830
912
  return ns;
831
913
  })();
832
914
  return loading;
833
915
  }
834
- function loadedPhysics() {
916
+ function loadedMatter() {
835
917
  return engine;
836
918
  }
837
- function resetPhysicsForTests() {
919
+ function resetMatterForTests() {
838
920
  engine = void 0;
839
921
  loading = void 0;
840
922
  }
923
+ function encodeMatterBodies(section) {
924
+ const w = new ByteWriter(section.bodies.length * 72 + 8);
925
+ w.varint(section.bodies.length);
926
+ for (const b of section.bodies) {
927
+ w.str(b.collection);
928
+ w.str(b.id);
929
+ w.f64(b.x);
930
+ w.f64(b.y);
931
+ w.f64(b.angle);
932
+ w.f64(b.vx);
933
+ w.f64(b.vy);
934
+ w.f64(b.angularVelocity);
935
+ w.u8(b.sleeping ? 1 : 0);
936
+ }
937
+ return w.finish();
938
+ }
939
+ function decodeMatterBodies(bytes) {
940
+ const r = new ByteReader(bytes);
941
+ const count = r.varint();
942
+ const bodies = [];
943
+ for (let i = 0; i < count; i++) {
944
+ bodies.push({
945
+ collection: r.str(),
946
+ id: r.str(),
947
+ x: r.f64(),
948
+ y: r.f64(),
949
+ angle: r.f64(),
950
+ vx: r.f64(),
951
+ vy: r.f64(),
952
+ angularVelocity: r.f64(),
953
+ sleeping: r.u8() === 1
954
+ });
955
+ }
956
+ return { bodies };
957
+ }
958
+ function bodyKey(collection, id) {
959
+ return `${collection}\0${id}`;
960
+ }
961
+ var MatterRuntime = class {
962
+ engineKind = "matter2d";
963
+ /** rapier3d only; present so both runtimes satisfy one internal shape. */
964
+ rapier = void 0;
965
+ world = void 0;
966
+ matter;
967
+ engine;
968
+ /** Alias under the name `PhysicsApi` uses, so `room.physics2d` reads through one field. */
969
+ get matterEngine() {
970
+ return this.engine;
971
+ }
972
+ /** `true` when the blob carried no world at all: the caller logs it. */
973
+ rebuilt;
974
+ /**
975
+ * Always `true`. Unlike Rapier's, a matter2d world is never restored as a world — only as
976
+ * per-body state on a world the builder made — so `setup` has to run every time.
977
+ */
978
+ needsSetup = true;
979
+ core;
980
+ config;
981
+ collections;
982
+ bodies = /* @__PURE__ */ new Map();
983
+ restore;
984
+ stepMs;
985
+ sleepSynced = /* @__PURE__ */ new Set();
986
+ constructor(core, matter, options) {
987
+ const config = core.definition.config.physics;
988
+ if (!config) throw new Error("MatterRuntime: the room config declares no physics");
989
+ this.core = core;
990
+ this.config = config;
991
+ this.matter = matter;
992
+ this.collections = core.ext.collections.filter(
993
+ (c) => c.physics !== void 0
994
+ );
995
+ this.engine = matter.Engine.create();
996
+ this.engine.gravity.x = config.gravity.x;
997
+ this.engine.gravity.y = config.gravity.y;
998
+ this.stepMs = (config.timestep ?? options.defaultTimestep) * 1e3;
999
+ this.rebuilt = options.restore === void 0;
1000
+ this.restore = options.restore ? new Map(options.restore.bodies.map((b) => [bodyKey(b.collection, b.id), b])) : void 0;
1001
+ }
1002
+ get timestep() {
1003
+ return this.stepMs / 1e3;
1004
+ }
1005
+ runSetup(room) {
1006
+ const setup = this.config.setup;
1007
+ if (!setup) return;
1008
+ this.core.guard("physics.setup", () => setup(this.engine, this.matter, room));
1009
+ }
1010
+ free() {
1011
+ this.bodies.clear();
1012
+ this.matter.Engine.clear(this.engine);
1013
+ }
1014
+ // -------------------------------------------------------------------------
1015
+ // Bodies
1016
+ // -------------------------------------------------------------------------
1017
+ bodyFor(collection, id) {
1018
+ const existing = this.bodies.get(bodyKey(collection, id));
1019
+ if (existing) return existing.body;
1020
+ const desc = this.collections.find((c) => c.name === collection);
1021
+ if (!desc) return void 0;
1022
+ const coll = plainEntity(this.core.plain, collection);
1023
+ const record = coll.get(id);
1024
+ if (record === void 0) return void 0;
1025
+ return this.create(desc, id, record);
1026
+ }
1027
+ create(desc, id, record) {
1028
+ const factory = this.config.bodies?.[desc.name];
1029
+ if (!factory) {
1030
+ this.core.log("error", `irtio: physics.bodies.${desc.name} is missing; no body created`);
1031
+ return void 0;
1032
+ }
1033
+ const spec = this.core.guard(
1034
+ `physics.bodies.${desc.name}`,
1035
+ () => factory(this.matter, record, id)
1036
+ );
1037
+ if (!spec || !spec.body) {
1038
+ this.core.log(
1039
+ "error",
1040
+ `irtio: physics.bodies.${desc.name} returned no { body } for ${JSON.stringify(id)}`
1041
+ );
1042
+ return void 0;
1043
+ }
1044
+ const key = bodyKey(desc.name, id);
1045
+ const constraints = spec.constraints ?? [];
1046
+ this.matter.Composite.add(this.engine.world, [spec.body, ...constraints]);
1047
+ this.bodies.set(key, { body: spec.body, constraints });
1048
+ const saved = this.restore?.get(key);
1049
+ if (saved) {
1050
+ this.restore?.delete(key);
1051
+ this.applyState(spec.body, saved);
1052
+ } else {
1053
+ this.applyRecordToBody(desc, spec.body, record);
1054
+ }
1055
+ return spec.body;
1056
+ }
1057
+ applyState(body, s) {
1058
+ const M = this.matter;
1059
+ M.Body.setPosition(body, { x: s.x, y: s.y });
1060
+ M.Body.setAngle(body, s.angle);
1061
+ M.Body.setVelocity(body, { x: s.vx, y: s.vy });
1062
+ M.Body.setAngularVelocity(body, s.angularVelocity);
1063
+ if (s.sleeping) M.Sleeping.set(body, true);
1064
+ }
1065
+ applyRecordToBody(desc, body, record) {
1066
+ const physics = desc.physics;
1067
+ if (!physics) return;
1068
+ const t = {
1069
+ x: body.position.x,
1070
+ y: body.position.y,
1071
+ qz: Math.sin(body.angle / 2),
1072
+ qw: Math.cos(body.angle / 2),
1073
+ vx: body.velocity.x,
1074
+ vy: body.velocity.y,
1075
+ wz: body.angularVelocity
1076
+ };
1077
+ for (const [channel, field] of physics.channels) {
1078
+ const raw = record[field];
1079
+ if (typeof raw !== "number") continue;
1080
+ applyChannel2d(channel, raw, t);
1081
+ }
1082
+ this.applyState(body, {
1083
+ collection: desc.name,
1084
+ id: "",
1085
+ x: t.x,
1086
+ y: t.y,
1087
+ angle: 2 * Math.atan2(t.qz, t.qw),
1088
+ vx: t.vx,
1089
+ vy: t.vy,
1090
+ angularVelocity: t.wz,
1091
+ sleeping: false
1092
+ });
1093
+ }
1094
+ reconcile() {
1095
+ const live = /* @__PURE__ */ new Set();
1096
+ for (const desc of this.collections) {
1097
+ const coll = plainEntity(this.core.plain, desc.name);
1098
+ for (const id of coll.ids()) {
1099
+ const key = bodyKey(desc.name, id);
1100
+ live.add(key);
1101
+ if (this.bodies.has(key)) continue;
1102
+ const record = coll.get(id);
1103
+ if (record !== void 0) this.create(desc, id, record);
1104
+ }
1105
+ }
1106
+ for (const [key, attached] of [...this.bodies]) {
1107
+ if (live.has(key)) continue;
1108
+ this.bodies.delete(key);
1109
+ this.sleepSynced.delete(key);
1110
+ this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
1111
+ }
1112
+ }
1113
+ // -------------------------------------------------------------------------
1114
+ // Step and sync
1115
+ // -------------------------------------------------------------------------
1116
+ step() {
1117
+ this.matter.Engine.update(this.engine, this.stepMs);
1118
+ }
1119
+ sync() {
1120
+ for (const desc of this.collections) {
1121
+ const physics = desc.physics;
1122
+ if (!physics) continue;
1123
+ const tracked = this.core.anyState[desc.name];
1124
+ const plainColl = plainEntity(this.core.plain, desc.name);
1125
+ const rounders = roundersFor(desc);
1126
+ for (const id of plainColl.ids()) {
1127
+ const key = bodyKey(desc.name, id);
1128
+ const attached = this.bodies.get(key);
1129
+ if (!attached) continue;
1130
+ const body = attached.body;
1131
+ if (body.isSleeping) {
1132
+ if (this.sleepSynced.has(key)) continue;
1133
+ this.sleepSynced.add(key);
1134
+ } else {
1135
+ this.sleepSynced.delete(key);
1136
+ }
1137
+ const record = tracked.get(id);
1138
+ if (!record) continue;
1139
+ const state = {
1140
+ x: body.position.x,
1141
+ y: body.position.y,
1142
+ angle: body.angle,
1143
+ vx: body.velocity.x,
1144
+ vy: body.velocity.y,
1145
+ angularVelocity: body.angularVelocity
1146
+ };
1147
+ for (const [channel, field] of physics.channels) {
1148
+ const next = (rounders[field] ?? identity)(channelOf2d(channel, state));
1149
+ if (record[field] !== next) record[field] = next;
1150
+ }
1151
+ }
1152
+ }
1153
+ }
1154
+ // -------------------------------------------------------------------------
1155
+ // Hibernation
1156
+ // -------------------------------------------------------------------------
1157
+ serialize() {
1158
+ const bodies = [];
1159
+ for (const [key, attached] of this.bodies) {
1160
+ const sep = key.indexOf("\0");
1161
+ const b = attached.body;
1162
+ bodies.push({
1163
+ collection: key.slice(0, sep),
1164
+ id: key.slice(sep + 1),
1165
+ x: b.position.x,
1166
+ y: b.position.y,
1167
+ angle: b.angle,
1168
+ vx: b.velocity.x,
1169
+ vy: b.velocity.y,
1170
+ angularVelocity: b.angularVelocity,
1171
+ sleeping: b.isSleeping === true
1172
+ });
1173
+ }
1174
+ return { bodies };
1175
+ }
1176
+ };
1177
+ function identity(v) {
1178
+ return v;
1179
+ }
1180
+ function roundersFor(desc) {
1181
+ const out = {};
1182
+ for (const f of desc.fields) {
1183
+ if (f.type.kind === "f32") out[f.name] = Math.fround;
1184
+ }
1185
+ return out;
1186
+ }
1187
+
1188
+ // src/core/physics.ts
1189
+ import { ByteReader as ByteReader2, ByteWriter as ByteWriter2 } from "@irtio/schema";
1190
+ var engine2;
1191
+ var loading2;
1192
+ async function initPhysics() {
1193
+ if (engine2) return engine2;
1194
+ loading2 ??= (async () => {
1195
+ const mod = await import("@dimforge/rapier3d-compat");
1196
+ const ns = mod.default ?? mod;
1197
+ await ns.init();
1198
+ engine2 = ns;
1199
+ return ns;
1200
+ })();
1201
+ return loading2;
1202
+ }
1203
+ function loadedPhysics() {
1204
+ return engine2;
1205
+ }
1206
+ function resetPhysicsForTests() {
1207
+ engine2 = void 0;
1208
+ loading2 = void 0;
1209
+ rapierStepped = false;
1210
+ firstStepWaiters.length = 0;
1211
+ }
1212
+ var rapierStepped = false;
1213
+ var firstStepWaiters = [];
1214
+ function rapierHasStepped() {
1215
+ return rapierStepped;
1216
+ }
1217
+ function onFirstRapierStep(cb) {
1218
+ if (rapierStepped) {
1219
+ cb();
1220
+ return;
1221
+ }
1222
+ firstStepWaiters.push(cb);
1223
+ }
1224
+ function noteRapierStep() {
1225
+ rapierStepped = true;
1226
+ const waiters = firstStepWaiters.splice(0, firstStepWaiters.length);
1227
+ for (const cb of waiters) {
1228
+ try {
1229
+ cb();
1230
+ } catch {
1231
+ }
1232
+ }
1233
+ }
841
1234
  function encodePhysicsSection(section) {
842
- const w = new ByteWriter(section.world.length + section.bodies.length * 24 + 8);
1235
+ const w = new ByteWriter2(section.world.length + section.bodies.length * 24 + 8);
843
1236
  w.blob(section.world);
844
1237
  w.varint(section.bodies.length);
845
1238
  for (const [name, id, handle] of section.bodies) {
@@ -849,15 +1242,34 @@ function encodePhysicsSection(section) {
849
1242
  }
850
1243
  return w.finish();
851
1244
  }
1245
+ var MATTER_TAG = 1;
1246
+ function physicsSectionEngine(bytes) {
1247
+ const r = new ByteReader2(bytes);
1248
+ return r.varint() === 0 && r.u8() === MATTER_TAG ? "matter2d" : "rapier3d";
1249
+ }
1250
+ function encodeMatterSectionEnvelope(payload) {
1251
+ const w = new ByteWriter2(payload.length + 8);
1252
+ w.varint(0);
1253
+ w.u8(MATTER_TAG);
1254
+ w.bytes(payload);
1255
+ return w.finish();
1256
+ }
1257
+ function decodeMatterSectionEnvelope(bytes) {
1258
+ const r = new ByteReader2(bytes);
1259
+ if (r.varint() !== 0 || r.u8() !== MATTER_TAG) {
1260
+ throw new Error("irtio: this physics section was not written by matter2d");
1261
+ }
1262
+ return r.rest();
1263
+ }
852
1264
  function decodePhysicsSection(bytes) {
853
- const r = new ByteReader(bytes);
1265
+ const r = new ByteReader2(bytes);
854
1266
  const world = r.blob().slice();
855
1267
  const count = r.varint();
856
1268
  const bodies = [];
857
1269
  for (let i = 0; i < count; i++) bodies.push([r.str(), r.str(), r.f64()]);
858
1270
  return { world, bodies };
859
1271
  }
860
- function bodyKey(collection, id) {
1272
+ function bodyKey2(collection, id) {
861
1273
  return `${collection}\0${id}`;
862
1274
  }
863
1275
  function planarLockWarning(spec) {
@@ -880,10 +1292,18 @@ function planarLockWarning(spec) {
880
1292
  var CUBOID_SHAPE = 1;
881
1293
  var ROUND_CUBOID_SHAPE = 12;
882
1294
  var PhysicsRuntime = class {
1295
+ engineKind = "rapier3d";
1296
+ /** matter2d only; present so both runtimes satisfy one internal shape. */
1297
+ matter = void 0;
1298
+ matterEngine = void 0;
883
1299
  rapier;
884
1300
  world;
885
1301
  /** `true` when the world was built from scratch and `setup` has to run. */
886
1302
  rebuilt;
1303
+ /** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
1304
+ get needsSetup() {
1305
+ return this.rebuilt;
1306
+ }
887
1307
  core;
888
1308
  config;
889
1309
  /** Physics-backed collections, in schema (name-sorted) order. */
@@ -912,7 +1332,7 @@ var PhysicsRuntime = class {
912
1332
  this.rebuilt = false;
913
1333
  for (const [name, id, handle] of options.restore.bodies) {
914
1334
  const body = this.world.getRigidBody(handle);
915
- if (body) this.bodies.set(bodyKey(name, id), body);
1335
+ if (body) this.bodies.set(bodyKey2(name, id), body);
916
1336
  }
917
1337
  } else {
918
1338
  const g = config.gravity;
@@ -939,7 +1359,7 @@ var PhysicsRuntime = class {
939
1359
  // -------------------------------------------------------------------------
940
1360
  /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
941
1361
  bodyFor(collection, id) {
942
- const existing = this.bodies.get(bodyKey(collection, id));
1362
+ const existing = this.bodies.get(bodyKey2(collection, id));
943
1363
  if (existing) return existing;
944
1364
  const desc = this.collections.find((c) => c.name === collection);
945
1365
  if (!desc) return void 0;
@@ -973,7 +1393,7 @@ var PhysicsRuntime = class {
973
1393
  const body = this.world.createRigidBody(spec.body);
974
1394
  for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
975
1395
  this.applyRecordToBody(desc, body, record);
976
- this.bodies.set(bodyKey(desc.name, id), body);
1396
+ this.bodies.set(bodyKey2(desc.name, id), body);
977
1397
  return body;
978
1398
  }
979
1399
  applyRecordToBody(desc, body, record) {
@@ -1053,7 +1473,7 @@ var PhysicsRuntime = class {
1053
1473
  for (const desc of this.collections) {
1054
1474
  const coll = plainEntity(this.core.plain, desc.name);
1055
1475
  for (const id of coll.ids()) {
1056
- const key = bodyKey(desc.name, id);
1476
+ const key = bodyKey2(desc.name, id);
1057
1477
  live.add(key);
1058
1478
  if (this.bodies.has(key)) continue;
1059
1479
  const record = coll.get(id);
@@ -1072,6 +1492,7 @@ var PhysicsRuntime = class {
1072
1492
  // -------------------------------------------------------------------------
1073
1493
  step() {
1074
1494
  this.world.step();
1495
+ if (!rapierStepped) noteRapierStep();
1075
1496
  }
1076
1497
  /**
1077
1498
  * Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
@@ -1084,9 +1505,9 @@ var PhysicsRuntime = class {
1084
1505
  if (!physics) continue;
1085
1506
  const tracked = this.core.anyState[desc.name];
1086
1507
  const plainColl = plainEntity(this.core.plain, desc.name);
1087
- const rounders = roundersFor(desc);
1508
+ const rounders = roundersFor2(desc);
1088
1509
  for (const id of plainColl.ids()) {
1089
- const key = bodyKey(desc.name, id);
1510
+ const key = bodyKey2(desc.name, id);
1090
1511
  const body = this.bodies.get(key);
1091
1512
  if (!body) continue;
1092
1513
  if (body.isSleeping()) {
@@ -1102,7 +1523,7 @@ var PhysicsRuntime = class {
1102
1523
  const v = body.linvel();
1103
1524
  const w = body.angvel();
1104
1525
  for (const [channel, field] of physics.channels) {
1105
- const next = (rounders[field] ?? identity)(channelValue(channel, t, r, v, w));
1526
+ const next = (rounders[field] ?? identity2)(channelValue(channel, t, r, v, w));
1106
1527
  if (record[field] !== next) record[field] = next;
1107
1528
  }
1108
1529
  }
@@ -1120,10 +1541,10 @@ var PhysicsRuntime = class {
1120
1541
  return { world: this.world.takeSnapshot(), bodies };
1121
1542
  }
1122
1543
  };
1123
- function identity(v) {
1544
+ function identity2(v) {
1124
1545
  return v;
1125
1546
  }
1126
- function roundersFor(desc) {
1547
+ function roundersFor2(desc) {
1127
1548
  const out = {};
1128
1549
  for (const f of desc.fields) {
1129
1550
  if (f.type.kind === "f32") out[f.name] = Math.fround;
@@ -1418,11 +1839,12 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
1418
1839
  }
1419
1840
 
1420
1841
  // src/core/snapshot.ts
1421
- import { ByteReader as ByteReader2, ByteWriter as ByteWriter2 } from "@irtio/schema";
1842
+ import { withBuiltins } from "@irtio/protocol";
1843
+ import { ByteReader as ByteReader3, ByteWriter as ByteWriter3, decodeSnapshot } from "@irtio/schema";
1422
1844
  var SNAPSHOT_FORMAT_VERSION = 2;
1423
1845
  var READABLE_SNAPSHOT_VERSIONS = [1, 2];
1424
1846
  function parseHibernationBlob(bytes) {
1425
- const r = new ByteReader2(bytes);
1847
+ const r = new ByteReader3(bytes);
1426
1848
  const version = r.u8();
1427
1849
  if (!READABLE_SNAPSHOT_VERSIONS.includes(version)) {
1428
1850
  throw new Error(`RoomCore.restore: unsupported snapshot format version ${version}`);
@@ -1440,7 +1862,7 @@ function parseHibernationBlob(bytes) {
1440
1862
  }
1441
1863
  function writeHibernationBlob(header, snapshot, physics) {
1442
1864
  const version = physics ? 2 : 1;
1443
- const w = new ByteWriter2(snapshot.length + (physics?.length ?? 0) + 24);
1865
+ const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 24);
1444
1866
  w.u8(version);
1445
1867
  w.u32(header.seed >>> 0);
1446
1868
  w.u32(header.rngState >>> 0);
@@ -1450,21 +1872,90 @@ function writeHibernationBlob(header, snapshot, physics) {
1450
1872
  w.bytes(snapshot);
1451
1873
  return w.finish();
1452
1874
  }
1875
+ function decodeSave(bytes, schema) {
1876
+ const parsed = parseHibernationBlob(bytes);
1877
+ const ext = withBuiltins(schema);
1878
+ const plain = decodeSnapshot(ext, parsed.snapshot).state;
1879
+ return {
1880
+ version: parsed.version,
1881
+ seed: parsed.seed,
1882
+ rngState: parsed.rngState,
1883
+ tick: parsed.tick,
1884
+ mode: parsed.mode,
1885
+ hasPhysics: parsed.physics !== void 0,
1886
+ physicsEngine: parsed.physics ? physicsSectionEngine(parsed.physics) : void 0,
1887
+ state: inspectState(ext, plain)
1888
+ };
1889
+ }
1890
+
1891
+ // src/core/timeline.ts
1892
+ import { EntityCollection as EntityCollection2 } from "@irtio/schema";
1893
+ var DEFAULT_TIMELINE_MAX_TICKS = 3600;
1894
+ var DEFAULT_TIMELINE_MAX_RECORDS = 2e5;
1895
+ function recordsIn(state) {
1896
+ let n = 0;
1897
+ for (const value of Object.values(state)) {
1898
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1899
+ n += Object.keys(value).length;
1900
+ }
1901
+ }
1902
+ return Math.max(1, n);
1903
+ }
1904
+ var TimelineRecorder = class {
1905
+ frames = [];
1906
+ costs = [];
1907
+ records = 0;
1908
+ dropped = 0;
1909
+ maxTicks;
1910
+ maxRecords;
1911
+ constructor(options = {}) {
1912
+ this.maxTicks = Math.max(1, options.maxTicks ?? DEFAULT_TIMELINE_MAX_TICKS);
1913
+ this.maxRecords = Math.max(1, options.maxRecords ?? DEFAULT_TIMELINE_MAX_RECORDS);
1914
+ }
1915
+ collections = {};
1916
+ capture(tick, schema, plain) {
1917
+ const state = structuredClone(inspectState(schema, plain));
1918
+ for (const c of schema.collections) {
1919
+ this.collections[c.name] = plain[c.name] instanceof EntityCollection2 ? "entity" : "single";
1920
+ }
1921
+ const cost = recordsIn(state);
1922
+ this.frames.push({ tick, at: Date.now(), state });
1923
+ this.costs.push(cost);
1924
+ this.records += cost;
1925
+ while (this.frames.length > this.maxTicks || this.records > this.maxRecords && this.frames.length > 1) {
1926
+ this.frames.shift();
1927
+ this.records -= this.costs.shift() ?? 0;
1928
+ this.dropped++;
1929
+ }
1930
+ }
1931
+ dump(roomId) {
1932
+ return {
1933
+ roomId,
1934
+ collections: { ...this.collections },
1935
+ frames: this.frames.map((f) => ({ tick: f.tick, at: f.at, state: f.state })),
1936
+ dropped: this.dropped,
1937
+ maxTicks: this.maxTicks,
1938
+ maxRecords: this.maxRecords
1939
+ };
1940
+ }
1941
+ };
1453
1942
 
1454
1943
  // src/core/room.ts
1455
1944
  import {
1456
1945
  FrameType as FrameType5,
1457
1946
  PRESENCE_COLLECTION,
1947
+ ProfileLedger,
1948
+ busChannelProblem as busChannelProblem2,
1458
1949
  decodeFrame,
1459
1950
  encodeCorrectFrame,
1460
1951
  encodeFrame as encodeFrame4,
1461
- withBuiltins
1952
+ withBuiltins as withBuiltins2
1462
1953
  } from "@irtio/protocol";
1463
1954
  import {
1464
1955
  cloneValue as cloneValue2,
1465
1956
  createDirtySet as createDirtySet2,
1466
1957
  createState,
1467
- decodeSnapshot,
1958
+ decodeSnapshot as decodeSnapshot2,
1468
1959
  encodeDelta as encodeDelta4,
1469
1960
  encodeSnapshot as encodeSnapshot2,
1470
1961
  isDirtyEmpty as isDirtyEmpty3,
@@ -1472,71 +1963,6 @@ import {
1472
1963
  validateForDeploy
1473
1964
  } from "@irtio/schema";
1474
1965
 
1475
- // src/core/host-calls.ts
1476
- var states2 = /* @__PURE__ */ new WeakMap();
1477
- function stateOf2(core) {
1478
- let s = states2.get(core);
1479
- if (!s) {
1480
- s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
1481
- states2.set(core, s);
1482
- }
1483
- return s;
1484
- }
1485
- function startHostCall(core, call, map) {
1486
- return new Promise((resolve, reject2) => {
1487
- if (core.stopped) {
1488
- reject2(new Error(`room.${call.kind}: the room is stopped`));
1489
- return;
1490
- }
1491
- const s = stateOf2(core);
1492
- const reqId = s.nextReqId;
1493
- s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
1494
- s.pending.set(reqId, {
1495
- kind: call.kind,
1496
- deadline: core.host.now() + HOST_CALL_TIMEOUT_MS,
1497
- map,
1498
- resolve,
1499
- reject: reject2
1500
- });
1501
- try {
1502
- core.host.hostCall(reqId, call);
1503
- } catch (err) {
1504
- s.pending.delete(reqId);
1505
- reject2(err instanceof Error ? err : new Error(String(err)));
1506
- return;
1507
- }
1508
- if (core.mode === "event") {
1509
- core.loop.after(HOST_CALL_TIMEOUT_MS, () => checkHostCallTimeouts(core));
1510
- }
1511
- });
1512
- }
1513
- function completeHostCall(core, reqId, result) {
1514
- const s = stateOf2(core);
1515
- const pending = s.pending.get(reqId);
1516
- if (!pending) return false;
1517
- s.pending.delete(reqId);
1518
- if (result.ok) pending.resolve(pending.map(result.value));
1519
- else pending.reject(new Error(`${result.code}: ${result.message}`));
1520
- return true;
1521
- }
1522
- function checkHostCallTimeouts(core) {
1523
- const s = stateOf2(core);
1524
- if (s.pending.size === 0) return;
1525
- const now = core.host.now();
1526
- for (const [reqId, p] of [...s.pending]) {
1527
- if (p.deadline > now) continue;
1528
- s.pending.delete(reqId);
1529
- p.reject(new Error(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
1530
- }
1531
- }
1532
- function rejectAllHostCalls(core, reason) {
1533
- const s = stateOf2(core);
1534
- for (const [reqId, p] of [...s.pending]) {
1535
- s.pending.delete(reqId);
1536
- p.reject(new Error(reason));
1537
- }
1538
- }
1539
-
1540
1966
  // src/core/messages.ts
1541
1967
  import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
1542
1968
  function toRoomTarget(target) {
@@ -1549,6 +1975,8 @@ function toRoomTarget(target) {
1549
1975
  return { role: target.role };
1550
1976
  case "server":
1551
1977
  return "server";
1978
+ case "voice":
1979
+ return void 0;
1552
1980
  }
1553
1981
  }
1554
1982
  function deliver(core, target, frame, exclude) {
@@ -1569,17 +1997,17 @@ function handleMsg(core, clientId, payload) {
1569
1997
  core.log("warn", `MSG from ${clientId} failed to decode:`, err);
1570
1998
  return false;
1571
1999
  }
2000
+ if (msg.target.kind === "voice") {
2001
+ core.log("warn", `voice MSG from ${clientId} reached room code; dropped (supervisor bug)`);
2002
+ return true;
2003
+ }
2004
+ const roomTarget = toRoomTarget(msg.target);
2005
+ if (roomTarget === void 0) return true;
1572
2006
  const onMessage = core.definition.config.onMessage;
1573
2007
  if (onMessage) {
1574
2008
  const ran = core.tryRun(
1575
2009
  "onMessage",
1576
- () => onMessage(
1577
- core.anyState,
1578
- clientId,
1579
- toRoomTarget(msg.target),
1580
- msg.payload,
1581
- core.ctxFor(clientId)
1582
- )
2010
+ () => onMessage(core.anyState, clientId, roomTarget, msg.payload, core.ctxFor(clientId))
1583
2011
  );
1584
2012
  if (!ran.ok || ran.value === false) return true;
1585
2013
  }
@@ -1601,7 +2029,12 @@ function sendMessage(core, target, bytes) {
1601
2029
  }
1602
2030
 
1603
2031
  // src/core/room-api.ts
1604
- import { FrameType as FrameType4, encodeFrame as encodeFrame3 } from "@irtio/protocol";
2032
+ import {
2033
+ FrameType as FrameType4,
2034
+ busChannelProblem,
2035
+ busPayloadProblem,
2036
+ encodeFrame as encodeFrame3
2037
+ } from "@irtio/protocol";
1605
2038
  import { encodeDelta as encodeDelta3, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
1606
2039
  function makePhysicsApi(p) {
1607
2040
  return {
@@ -1619,6 +2052,22 @@ function makePhysicsApi(p) {
1619
2052
  }
1620
2053
  };
1621
2054
  }
2055
+ function makeMatterApi(p) {
2056
+ return {
2057
+ get matter() {
2058
+ return p.matter;
2059
+ },
2060
+ get engine() {
2061
+ return p.matterEngine;
2062
+ },
2063
+ get timestep() {
2064
+ return p.timestep;
2065
+ },
2066
+ body(collection, id) {
2067
+ return p.bodyFor(collection, id);
2068
+ }
2069
+ };
2070
+ }
1622
2071
  function makeKv(core) {
1623
2072
  return {
1624
2073
  get(playerId, key) {
@@ -1632,11 +2081,104 @@ function makeKv(core) {
1632
2081
  }
1633
2082
  };
1634
2083
  }
2084
+ function makeLeaderboard(core) {
2085
+ return {
2086
+ submit(board, playerId, score) {
2087
+ if (!Number.isInteger(score)) {
2088
+ return rejectAsEvent(
2089
+ core,
2090
+ new Error(
2091
+ `room.leaderboard.submit: score must be a whole number, got ${String(score)} (leaderboards are integer-only, by decision)`
2092
+ )
2093
+ );
2094
+ }
2095
+ return startHostCall(core, { kind: "lbSubmit", board, playerId, score }, () => void 0);
2096
+ }
2097
+ };
2098
+ }
2099
+ function makeRatings(core) {
2100
+ return {
2101
+ report(queue, results) {
2102
+ return startHostCall(
2103
+ core,
2104
+ {
2105
+ kind: "ratingReport",
2106
+ queue,
2107
+ // Copied rather than passed through: `results` is the game's own array and the host call
2108
+ // crosses a worker boundary, so a room that mutated it after calling would otherwise be
2109
+ // reporting something it did not say.
2110
+ results: (Array.isArray(results) ? results : []).map((r) => ({
2111
+ playerId: String(r?.playerId),
2112
+ place: Number(r?.place)
2113
+ }))
2114
+ },
2115
+ () => void 0
2116
+ );
2117
+ },
2118
+ set(queue, playerId, value) {
2119
+ return startHostCall(
2120
+ core,
2121
+ {
2122
+ kind: "ratingSet",
2123
+ queue,
2124
+ playerId,
2125
+ rating: Number(value?.rating),
2126
+ ...value?.deviation !== void 0 ? { deviation: Number(value.deviation) } : {}
2127
+ },
2128
+ () => void 0
2129
+ );
2130
+ }
2131
+ };
2132
+ }
2133
+ function makeBus(core) {
2134
+ const refuse = (verb, problem) => {
2135
+ core.log("warn", `room.bus.${verb}: ${problem.code}: ${problem.message}`);
2136
+ };
2137
+ const setSubscribed = (verb, channel) => {
2138
+ const problem = busChannelProblem(channel);
2139
+ if (problem) {
2140
+ refuse(verb, problem);
2141
+ return;
2142
+ }
2143
+ core.host.busSubscribe(channel, verb === "subscribe");
2144
+ };
2145
+ return {
2146
+ publish(channel, payload) {
2147
+ const problem = busChannelProblem(channel) ?? busPayloadProblem(payload);
2148
+ if (problem) {
2149
+ refuse("publish", problem);
2150
+ return;
2151
+ }
2152
+ core.host.busPublish(channel, payload);
2153
+ },
2154
+ subscribe(channel) {
2155
+ setSubscribed("subscribe", channel);
2156
+ },
2157
+ unsubscribe(channel) {
2158
+ setSubscribed("unsubscribe", channel);
2159
+ },
2160
+ send(roomId, payload) {
2161
+ if (typeof roomId !== "string" || roomId === "") {
2162
+ return rejectAsEvent(core, new Error("room.bus.send: a target roomId is required"));
2163
+ }
2164
+ const problem = busPayloadProblem(payload);
2165
+ if (problem) return rejectAsEvent(core, new Error(`${problem.code}: ${problem.message}`));
2166
+ return startHostCall(core, { kind: "busSend", roomId, payload }, () => void 0);
2167
+ }
2168
+ };
2169
+ }
1635
2170
  function createRoomApi(core, publicUrl) {
1636
2171
  let cached;
2172
+ let npcCounter = 0;
1637
2173
  let lastNow = Number.NEGATIVE_INFINITY;
1638
2174
  let physicsApi;
2175
+ let matterApi;
1639
2176
  let kvApi;
2177
+ let busApi;
2178
+ let leaderboardApi;
2179
+ let ratingsApi;
2180
+ const declaredBackfill = core.definition.config.backfill === true;
2181
+ let backfillOpen = declaredBackfill;
1640
2182
  const link = () => {
1641
2183
  const sep = publicUrl.includes("?") ? "&" : "?";
1642
2184
  return `${publicUrl}${sep}room=${encodeURIComponent(core.roomId)}`;
@@ -1662,7 +2204,8 @@ function createRoomApi(core, publicUrl) {
1662
2204
  clientId: c.clientId,
1663
2205
  role: c.role,
1664
2206
  name: c.name,
1665
- connected: c.connected
2207
+ connected: c.connected,
2208
+ npc: c.npc
1666
2209
  }));
1667
2210
  }
1668
2211
  return cached;
@@ -1681,8 +2224,27 @@ function createRoomApi(core, publicUrl) {
1681
2224
  "room.physics: this room has no physics \u2014 add physics: { engine: 'rapier3d', gravity, bodies } to defineRoom(...)"
1682
2225
  );
1683
2226
  }
2227
+ if (p.engineKind !== "rapier3d") {
2228
+ throw new Error(
2229
+ "room.physics: this room runs matter2d. Read room.physics2d instead (it has .matter and .engine, where this one has .rapier and .world)"
2230
+ );
2231
+ }
1684
2232
  return physicsApi ?? (physicsApi = makePhysicsApi(p));
1685
2233
  },
2234
+ get physics2d() {
2235
+ const p = core.physics;
2236
+ if (!p) {
2237
+ throw new Error(
2238
+ "room.physics2d: this room has no physics \u2014 add physics: { engine: 'matter2d', gravity, bodies } to defineRoom(...)"
2239
+ );
2240
+ }
2241
+ if (p.engineKind !== "matter2d") {
2242
+ throw new Error(
2243
+ "room.physics2d: this room runs rapier3d. Read room.physics instead (it has .rapier and .world, where this one has .matter and .engine)"
2244
+ );
2245
+ }
2246
+ return matterApi ?? (matterApi = makeMatterApi(p));
2247
+ },
1686
2248
  send(target, bytes) {
1687
2249
  sendMessage(core, target, bytes);
1688
2250
  },
@@ -1724,6 +2286,33 @@ function createRoomApi(core, publicUrl) {
1724
2286
  get kv() {
1725
2287
  return kvApi ?? (kvApi = makeKv(core));
1726
2288
  },
2289
+ get leaderboard() {
2290
+ return leaderboardApi ?? (leaderboardApi = makeLeaderboard(core));
2291
+ },
2292
+ get ratings() {
2293
+ return ratingsApi ?? (ratingsApi = makeRatings(core));
2294
+ },
2295
+ get backfill() {
2296
+ return {
2297
+ set(open) {
2298
+ if (!declaredBackfill) {
2299
+ core.log(
2300
+ "warn",
2301
+ "room.backfill.set: this room type did not declare `backfill: true`, so the matchmaker will never offer it. The call is ignored."
2302
+ );
2303
+ return;
2304
+ }
2305
+ backfillOpen = open;
2306
+ core.host.setBackfill(open);
2307
+ },
2308
+ get open() {
2309
+ return backfillOpen;
2310
+ }
2311
+ };
2312
+ },
2313
+ get bus() {
2314
+ return busApi ?? (busApi = makeBus(core));
2315
+ },
1727
2316
  alarm(name, atMs) {
1728
2317
  if (!isAlarmName(core, name, "room.alarm")) return;
1729
2318
  if (!Number.isFinite(atMs)) {
@@ -1739,6 +2328,27 @@ function createRoomApi(core, publicUrl) {
1739
2328
  }
1740
2329
  core.host.setAlarm(name, void 0);
1741
2330
  },
2331
+ spawnNPC(config) {
2332
+ const clientId = `npc-${core.roomId}-${++npcCounter}`;
2333
+ const checked = checkNpcConfig(core, config);
2334
+ if (checked) core.host.spawnNpc(clientId, checked);
2335
+ let despawned = !checked;
2336
+ return {
2337
+ clientId,
2338
+ despawn() {
2339
+ if (despawned) return;
2340
+ despawned = true;
2341
+ core.host.despawnNpc(clientId);
2342
+ }
2343
+ };
2344
+ },
2345
+ despawnNPC(clientId) {
2346
+ if (typeof clientId !== "string" || clientId === "") {
2347
+ core.log("warn", "room.despawnNPC: a client id is required; ignored");
2348
+ return;
2349
+ }
2350
+ core.host.despawnNpc(clientId);
2351
+ },
1742
2352
  call(clientId) {
1743
2353
  return createCallProxy(core, clientId);
1744
2354
  },
@@ -1793,6 +2403,50 @@ function setRole(core, clientId, role) {
1793
2403
  if (presence) presence.role = role;
1794
2404
  core.invalidateClients();
1795
2405
  }
2406
+ function checkNpcConfig(core, config) {
2407
+ if (typeof config !== "object" || config === null) {
2408
+ throw new Error("room.spawnNPC: a config is required, like " + NPC_EXAMPLE);
2409
+ }
2410
+ const brain = config.brain;
2411
+ if (typeof brain !== "object" || brain === null) {
2412
+ throw new Error("room.spawnNPC: config.brain is required, like " + NPC_EXAMPLE);
2413
+ }
2414
+ if (brain.kind !== "script") {
2415
+ throw new Error(
2416
+ `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>' }.`
2417
+ );
2418
+ }
2419
+ if (typeof brain.script !== "string" || brain.script === "") {
2420
+ throw new Error(
2421
+ "room.spawnNPC: brain.script must name an entry in the room definition's npcs map, like " + NPC_EXAMPLE
2422
+ );
2423
+ }
2424
+ const npcs = core.definition.config.npcs;
2425
+ if (!npcs || typeof npcs[brain.script] !== "function") {
2426
+ const known = npcs ? Object.keys(npcs) : [];
2427
+ throw new Error(
2428
+ `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.")
2429
+ );
2430
+ }
2431
+ if (config.role !== void 0 && typeof config.role !== "string") {
2432
+ throw new Error("room.spawnNPC: role must be a string when given");
2433
+ }
2434
+ if (config.name !== void 0 && typeof config.name !== "string") {
2435
+ throw new Error("room.spawnNPC: name must be a string when given");
2436
+ }
2437
+ if (config.seed !== void 0 && !Number.isFinite(config.seed)) {
2438
+ throw new Error("room.spawnNPC: seed must be a finite number when given");
2439
+ }
2440
+ return {
2441
+ brain: { kind: "script", script: brain.script },
2442
+ ...config.role !== void 0 ? { role: config.role } : {},
2443
+ ...config.name !== void 0 ? { name: config.name } : {},
2444
+ // Seeded from the room's own recorded rng when the caller did not pick one, so a room that
2445
+ // spawns its NPCs the same way twice gets the same NPCs twice.
2446
+ seed: config.seed ?? Math.floor(core.rng.next() * 2147483647)
2447
+ };
2448
+ }
2449
+ var NPC_EXAMPLE = "{ brain: { kind: 'script', script: 'chaser' } }";
1796
2450
 
1797
2451
  // src/core/room.ts
1798
2452
  var DEFAULT_PUBLIC_URL = "http://localhost/";
@@ -1850,6 +2504,17 @@ var RoomCore = class _RoomCore {
1850
2504
  * into a timeout in an unrelated assertion ten seconds later. Unset in production.
1851
2505
  */
1852
2506
  onHandlerError;
2507
+ /**
2508
+ * D41: the recorded authoritative timeline. Armed by `startRecording()` and off otherwise, so a
2509
+ * room nobody asked to record pays nothing. A room that was asked captures its own state at the
2510
+ * end of every tick, which is the only moment in a tick where that state is settled.
2511
+ */
2512
+ recorder;
2513
+ /**
2514
+ * D65: the bandwidth ledger, present only when `RoomCoreOptions.profile` asked for one. Every
2515
+ * cost the profiler has is behind this `undefined`.
2516
+ */
2517
+ ledger;
1853
2518
  seed;
1854
2519
  api;
1855
2520
  internals;
@@ -1861,7 +2526,8 @@ var RoomCore = class _RoomCore {
1861
2526
  this.host = host;
1862
2527
  this.roomId = options.roomId;
1863
2528
  this.mode = definition.config.mode;
1864
- this.ext = withBuiltins(definition.schema);
2529
+ this.ext = withBuiltins2(definition.schema);
2530
+ this.ledger = options.profile === true ? new ProfileLedger(this.ext, definition.schema) : void 0;
1865
2531
  for (const issue of validateForDeploy(this.ext)) {
1866
2532
  if (issue.level === "error") throw new Error(`RoomCore: ${issue.message}`);
1867
2533
  host.log("warn", [`irtio: ${issue.message}`]);
@@ -1870,7 +2536,7 @@ var RoomCore = class _RoomCore {
1870
2536
  let plain;
1871
2537
  if (options.restoreFrom) {
1872
2538
  restored = parseHibernationBlob(options.restoreFrom);
1873
- plain = decodeSnapshot(this.ext, restored.snapshot).state;
2539
+ plain = decodeSnapshot2(this.ext, restored.snapshot).state;
1874
2540
  const presence = plainEntity(plain, PRESENCE_COLLECTION);
1875
2541
  for (const id of [...presence.ids()]) presence.remove(id);
1876
2542
  if (restored.mode !== this.mode) {
@@ -1892,6 +2558,7 @@ var RoomCore = class _RoomCore {
1892
2558
  this.loop = new Loop(self);
1893
2559
  this.api = createRoomApi(self, options.publicUrl ?? DEFAULT_PUBLIC_URL);
1894
2560
  this.physics = this.buildPhysics(restored);
2561
+ this.subscribeDeclaredChannels();
1895
2562
  if (restored) {
1896
2563
  const onWake = definition.config.onWake;
1897
2564
  if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
@@ -1900,6 +2567,64 @@ var RoomCore = class _RoomCore {
1900
2567
  if (onCreate) this.guard("onCreate", () => onCreate(this.state, this.room));
1901
2568
  }
1902
2569
  }
2570
+ subscribeDeclaredChannels() {
2571
+ const channels = this.busConfig?.channels;
2572
+ if (!channels) return;
2573
+ for (const channel of Object.keys(channels)) {
2574
+ const problem = busChannelProblem2(channel);
2575
+ if (problem) {
2576
+ this.log("warn", `bus.channels: ${problem.message}; that channel is not subscribed`);
2577
+ continue;
2578
+ }
2579
+ this.host.busSubscribe(channel, true);
2580
+ }
2581
+ }
2582
+ get busConfig() {
2583
+ return this.definition.config.bus;
2584
+ }
2585
+ /**
2586
+ * D59: one published message arriving on a channel this room is subscribed to.
2587
+ *
2588
+ * Same scheduling class as an alarm or an RPC — a discrete event between ticks — so a tick-mode
2589
+ * room never sees a `tick` run half-delivered. `from` is supervisor-stamped, so the handler may
2590
+ * trust it as far as it trusts its own project.
2591
+ */
2592
+ deliverBusEvent(channel, from, payload) {
2593
+ if (this.stopped) return;
2594
+ const handler = this.busConfig?.channels?.[channel];
2595
+ if (!handler) {
2596
+ return;
2597
+ }
2598
+ this.recordEvent("bus", void 0, channel);
2599
+ this.guard(
2600
+ `bus.channels.${channel}`,
2601
+ () => handler(this.state, { channel, from, payload }, this.room)
2602
+ );
2603
+ this.eventFlush();
2604
+ }
2605
+ /**
2606
+ * D59: one directed `room.bus.send` arriving. At-least-once, so this can run twice for one send;
2607
+ * that is the receiver's problem to be idempotent about and the docs say so.
2608
+ *
2609
+ * A throw propagates through `guard` and counts toward the crash threshold exactly as any other
2610
+ * handler throw does. That is deliberate, and it is why the supervisor bounds redelivery: the
2611
+ * two behaviours together would otherwise let one poisonous message close a room on every wake,
2612
+ * forever.
2613
+ */
2614
+ deliverBusMessage(from, payload) {
2615
+ if (this.stopped) return;
2616
+ const handler = this.busConfig?.onMessage;
2617
+ if (!handler) {
2618
+ this.log(
2619
+ "warn",
2620
+ `a bus message from ${JSON.stringify(from)} was delivered but the room declares no bus.onMessage handler; it is dropped`
2621
+ );
2622
+ return;
2623
+ }
2624
+ this.recordEvent("bus", void 0, "onMessage");
2625
+ this.guard("bus.onMessage", () => handler(this.state, { from, payload }, this.room));
2626
+ this.eventFlush();
2627
+ }
1903
2628
  /**
1904
2629
  * D22: builds the world, or returns `undefined` for a room with no `physics:` config.
1905
2630
  *
@@ -1914,14 +2639,15 @@ var RoomCore = class _RoomCore {
1914
2639
  buildPhysics(restored) {
1915
2640
  const config = this.definition.config.physics;
1916
2641
  if (!config) return void 0;
1917
- const engine2 = loadedPhysics();
1918
- if (!engine2) {
2642
+ if (config.engine === "matter2d") return this.buildMatter(config, restored);
2643
+ const engine3 = loadedPhysics();
2644
+ if (!engine3) {
1919
2645
  throw new Error(
1920
2646
  "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
2647
  );
1922
2648
  }
1923
2649
  const section = restored?.physics ? decodePhysicsSection(restored.physics) : void 0;
1924
- const physics = new PhysicsRuntime(this.internals, engine2, {
2650
+ const physics = new PhysicsRuntime(this.internals, engine3, {
1925
2651
  ...section ? { restore: section } : {},
1926
2652
  defaultTimestep: 1 / this.definition.config.tickRate
1927
2653
  });
@@ -1936,6 +2662,43 @@ var RoomCore = class _RoomCore {
1936
2662
  }
1937
2663
  return physics;
1938
2664
  }
2665
+ /**
2666
+ * D45: the matter2d half. It differs from Rapier's in one structural way — there is no engine
2667
+ * snapshot to restore, so the world is always **built** and per-body state is reapplied on top
2668
+ * of it. `setup` therefore runs on every wake, and only the "there was no world at all" case is
2669
+ * worth logging.
2670
+ */
2671
+ buildMatter(config, restored) {
2672
+ void config;
2673
+ const matter = loadedMatter();
2674
+ if (!matter) {
2675
+ throw new Error(
2676
+ "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."
2677
+ );
2678
+ }
2679
+ let section;
2680
+ if (restored?.physics) {
2681
+ if (physicsSectionEngine(restored.physics) === "matter2d") {
2682
+ section = decodeMatterBodies(decodeMatterSectionEnvelope(restored.physics));
2683
+ } else {
2684
+ this.host.log("warn", [
2685
+ "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."
2686
+ ]);
2687
+ }
2688
+ }
2689
+ const physics = new MatterRuntime(this.internals, matter, {
2690
+ ...section ? { restore: section } : {},
2691
+ defaultTimestep: 1 / this.definition.config.tickRate
2692
+ });
2693
+ if (physics.rebuilt && restored) {
2694
+ this.host.log("info", [
2695
+ `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.`
2696
+ ]);
2697
+ }
2698
+ physics.runSetup(this.room);
2699
+ physics.reconcile();
2700
+ return physics;
2701
+ }
1939
2702
  /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
1940
2703
  static restore(definition, bytes, host, options) {
1941
2704
  return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
@@ -1983,6 +2746,23 @@ var RoomCore = class _RoomCore {
1983
2746
  if (detail !== void 0) e.detail = detail;
1984
2747
  this.events.push(e);
1985
2748
  }
2749
+ /**
2750
+ * D41: begin (or restart) recording the authoritative timeline. Calling it again clears what
2751
+ * was recorded, so two scenario runs against one long-lived dev server do not read each
2752
+ * other's ticks.
2753
+ */
2754
+ startRecording(options = {}) {
2755
+ this.recorder = new TimelineRecorder(options);
2756
+ this.recorder.capture(this.tick, this.ext, this.plain);
2757
+ }
2758
+ /** D41: what has been recorded so far, or `undefined` when nobody armed the recorder. */
2759
+ recording() {
2760
+ return this.recorder?.dump(this.roomId);
2761
+ }
2762
+ /** D41: called at the end of every tick (and every event-mode flush). No-op when unarmed. */
2763
+ captureTimeline() {
2764
+ this.recorder?.capture(this.tick, this.ext, this.plain);
2765
+ }
1986
2766
  /** Live JSON view of the room for the dev page / supervisor admin API. */
1987
2767
  inspect() {
1988
2768
  return {
@@ -2031,7 +2811,7 @@ var RoomCore = class _RoomCore {
2031
2811
  * written.
2032
2812
  */
2033
2813
  snapshot() {
2034
- const physics = this.physics ? encodePhysicsSection(this.physics.serialize()) : void 0;
2814
+ const physics = this.physics ? encodeSection(this.physics) : void 0;
2035
2815
  return writeHibernationBlob(
2036
2816
  { seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
2037
2817
  encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
@@ -2111,6 +2891,7 @@ var RoomCore = class _RoomCore {
2111
2891
  // join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
2112
2892
  // and nothing else here changes.
2113
2893
  playerId: options.playerId ?? clientId,
2894
+ npc: options.npc === true,
2114
2895
  role,
2115
2896
  name,
2116
2897
  connected: true,
@@ -2131,11 +2912,12 @@ var RoomCore = class _RoomCore {
2131
2912
  const ctx = this.ctxFor(clientId, reconnecting);
2132
2913
  this.guard("onJoin", () => onJoin(this.state, ctx));
2133
2914
  }
2134
- this.loop.noteActivity();
2915
+ if (!entry.npc) this.loop.noteActivity();
2135
2916
  this.recordEvent("join", clientId, reconnecting ? "reconnect" : void 0);
2136
2917
  const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
2137
2918
  entry.spatialMembership = memberships;
2138
2919
  const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
2920
+ this.ledger?.attributeSnapshot("out", snapshot);
2139
2921
  const pending = /* @__PURE__ */ new Map();
2140
2922
  for (const [name2, cd] of this.tracked.dirty) {
2141
2923
  if (cd.added.size === 0) continue;
@@ -2161,6 +2943,7 @@ var RoomCore = class _RoomCore {
2161
2943
  if (isDirtyEmpty3(this.tracked.dirty)) return;
2162
2944
  this.tick++;
2163
2945
  this.flush();
2946
+ this.captureTimeline();
2164
2947
  }
2165
2948
  leave(clientId, reason) {
2166
2949
  const entry = this.clients.get(clientId);
@@ -2232,6 +3015,10 @@ var RoomCore = class _RoomCore {
2232
3015
  * Draining a bounded number of turns first covers the chains rooms actually write, coalesces
2233
3016
  * concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
2234
3017
  * the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
3018
+ *
3019
+ * Public on `RoomInternals` (bug #48) so the room API's local rejections — a float score, an
3020
+ * empty bus target, a call to a client that is not connected — flush the state their `.catch()`
3021
+ * writes, instead of leaving it for whatever frame happens to arrive next.
2235
3022
  */
2236
3023
  scheduleContinuationFlush() {
2237
3024
  if (this.mode !== "event" || this.continuationFlushPending) return;
@@ -2276,15 +3063,31 @@ var RoomCore = class _RoomCore {
2276
3063
  // -------------------------------------------------------------------------
2277
3064
  // Frames
2278
3065
  // -------------------------------------------------------------------------
2279
- send(clientId, frame) {
3066
+ /**
3067
+ * D65: `hint` is profiling context and nothing else — the payload object a per-view frame was
3068
+ * built from (so one encode is walked once and replayed per recipient) and the AOI ids whose
3069
+ * ops are visibility churn. Ignored entirely when this room is not profiling, which is why it
3070
+ * is an optional argument rather than a second method.
3071
+ */
3072
+ send(clientId, frame, hint) {
2280
3073
  if (this.stopped) return;
2281
3074
  const entry = this.clients.get(clientId);
2282
3075
  if (!entry || !entry.connected) return;
3076
+ const size = frame.length;
3077
+ this.ledger?.attribute("out", frame, { ...hint, peer: clientId });
2283
3078
  this.host.send(clientId, frame);
2284
3079
  this.stats.framesOut++;
2285
- this.stats.bytesOut += frame.length;
3080
+ this.stats.bytesOut += size;
2286
3081
  const by = this.stats.bytesOutByClient;
2287
- by.set(clientId, (by.get(clientId) ?? 0) + frame.length);
3082
+ by.set(clientId, (by.get(clientId) ?? 0) + size);
3083
+ }
3084
+ /**
3085
+ * D65: the ledger so far. The room's own view, so it counts what `send()` sent and what
3086
+ * `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
3087
+ * `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
3088
+ */
3089
+ profile() {
3090
+ return this.ledger?.snapshot();
2288
3091
  }
2289
3092
  badFrame(clientId, reason) {
2290
3093
  this.log("warn", `bad frame from ${clientId}: ${reason}`);
@@ -2300,7 +3103,8 @@ var RoomCore = class _RoomCore {
2300
3103
  return;
2301
3104
  }
2302
3105
  this.stats.framesIn++;
2303
- this.loop.noteActivity();
3106
+ this.ledger?.attribute("in", frame, { peer: clientId });
3107
+ if (this.clients.get(clientId)?.npc !== true) this.loop.noteActivity();
2304
3108
  let type;
2305
3109
  let payload;
2306
3110
  try {
@@ -2352,6 +3156,7 @@ var RoomCore = class _RoomCore {
2352
3156
  flush() {
2353
3157
  const dirty = this.tracked.flush();
2354
3158
  serverWinsCorrections(this.internals, dirty);
3159
+ this.ledger?.newFlush();
2355
3160
  this.stats.encodesLastFlush = 0;
2356
3161
  this.stats.aoiEncodesLastFlush = 0;
2357
3162
  const spatialDescs = this.ext.collections.filter((desc) => desc.visibility === "spatial-grid");
@@ -2370,11 +3175,23 @@ var RoomCore = class _RoomCore {
2370
3175
  indexes
2371
3176
  );
2372
3177
  this.stats.gridQueryMs += performance.now() - queryStarted;
3178
+ let churn;
2373
3179
  for (const [name, ids] of memberships) {
2374
3180
  const before = entry.spatialMembership.get(name) ?? /* @__PURE__ */ new Set();
2375
3181
  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++;
3182
+ const changed = this.ledger ? dirty.get(name) : void 0;
3183
+ let crossed;
3184
+ for (const id of ids) {
3185
+ if (before.has(id)) continue;
3186
+ this.stats.membershipEnters++;
3187
+ if (this.ledger && changed?.added.has(id) !== true) (crossed ??= /* @__PURE__ */ new Set()).add(id);
3188
+ }
3189
+ for (const id of before) {
3190
+ if (ids.has(id)) continue;
3191
+ this.stats.membershipLeaves++;
3192
+ if (this.ledger && changed?.removed.has(id) !== true) (crossed ??= /* @__PURE__ */ new Set()).add(id);
3193
+ }
3194
+ if (crossed) (churn ??= /* @__PURE__ */ new Map()).set(name, crossed);
2378
3195
  }
2379
3196
  if (entry.correction) {
2380
3197
  const payload = encodeCorrection(
@@ -2407,7 +3224,13 @@ var RoomCore = class _RoomCore {
2407
3224
  }
2408
3225
  shared = cached;
2409
3226
  }
2410
- if (shared) this.send(entry.clientId, encodeFrame4(FrameType5.DELTA, shared));
3227
+ if (shared) {
3228
+ this.send(
3229
+ entry.clientId,
3230
+ encodeFrame4(FrameType5.DELTA, shared),
3231
+ this.ledger ? { shared } : void 0
3232
+ );
3233
+ }
2411
3234
  const aoiDirty = aoiViewDirty(
2412
3235
  this.ext,
2413
3236
  sourceDirty,
@@ -2443,13 +3266,25 @@ var RoomCore = class _RoomCore {
2443
3266
  }
2444
3267
  delta = cached;
2445
3268
  }
2446
- if (delta) this.send(entry.clientId, encodeFrame4(FrameType5.DELTA, delta));
3269
+ if (delta) {
3270
+ this.send(
3271
+ entry.clientId,
3272
+ encodeFrame4(FrameType5.DELTA, delta),
3273
+ this.ledger ? {
3274
+ ...churn ? { churn } : {},
3275
+ ...spatialDescs.length > 0 ? {} : { shared: delta }
3276
+ } : void 0
3277
+ );
3278
+ }
2447
3279
  }
2448
3280
  entry.correction = void 0;
2449
3281
  entry.accepted.clear();
2450
3282
  }
2451
3283
  }
2452
3284
  };
3285
+ function encodeSection(physics) {
3286
+ return physics.engineKind === "matter2d" ? encodeMatterSectionEnvelope(encodeMatterBodies(physics.serialize())) : encodePhysicsSection(physics.serialize());
3287
+ }
2453
3288
 
2454
3289
  export {
2455
3290
  HOST_CALL_TIMEOUT_MS,
@@ -2459,10 +3294,20 @@ export {
2459
3294
  RPC_TIMEOUT_MS,
2460
3295
  MAX_CATCHUP,
2461
3296
  CRASH_AFTER_THROWS,
3297
+ initMatter,
3298
+ loadedMatter,
3299
+ resetMatterForTests,
3300
+ encodeMatterBodies,
3301
+ decodeMatterBodies,
2462
3302
  initPhysics,
2463
3303
  loadedPhysics,
2464
3304
  resetPhysicsForTests,
3305
+ rapierHasStepped,
3306
+ onFirstRapierStep,
2465
3307
  encodePhysicsSection,
3308
+ physicsSectionEngine,
3309
+ encodeMatterSectionEnvelope,
3310
+ decodeMatterSectionEnvelope,
2466
3311
  decodePhysicsSection,
2467
3312
  Mulberry32,
2468
3313
  isVisible,
@@ -2477,5 +3322,9 @@ export {
2477
3322
  READABLE_SNAPSHOT_VERSIONS,
2478
3323
  parseHibernationBlob,
2479
3324
  writeHibernationBlob,
3325
+ decodeSave,
3326
+ DEFAULT_TIMELINE_MAX_TICKS,
3327
+ DEFAULT_TIMELINE_MAX_RECORDS,
3328
+ TimelineRecorder,
2480
3329
  RoomCore
2481
3330
  };