@irtio/runtime 0.8.0 → 0.9.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.
@@ -250,7 +250,7 @@ function createCallProxy(core, clientId) {
250
250
  }
251
251
  );
252
252
  }
253
- function createBroadcastProxy(core) {
253
+ function createBroadcastProxy(core, except) {
254
254
  return new Proxy(
255
255
  {},
256
256
  {
@@ -259,13 +259,19 @@ function createBroadcastProxy(core) {
259
259
  return (params) => {
260
260
  const desc = descFor(core, prop);
261
261
  for (const entry of core.clients.values()) {
262
- if (entry.connected) sendCall(core, entry.clientId, desc, params ?? {});
262
+ if (!entry.connected) continue;
263
+ if (except?.has(entry.clientId)) continue;
264
+ sendCall(core, entry.clientId, desc, params ?? {});
263
265
  }
264
266
  };
265
267
  }
266
268
  }
267
269
  );
268
270
  }
271
+ function createBroadcastExceptProxy(core, except) {
272
+ const ids = typeof except === "string" ? [except] : except;
273
+ return createBroadcastProxy(core, new Set(ids));
274
+ }
269
275
  function handleReply(core, clientId, payload) {
270
276
  let reply;
271
277
  try {
@@ -956,6 +962,7 @@ function decodeMatterBodies(bytes) {
956
962
  function bodyKey(collection, id) {
957
963
  return `${collection}\0${id}`;
958
964
  }
965
+ var PLANAR_CHANNELS = /* @__PURE__ */ new Set(["x", "y", "qz", "qw", "vx", "vy", "wz"]);
959
966
  var MatterRuntime = class {
960
967
  engineKind = "matter2d";
961
968
  /** rapier3d only; present so both runtimes satisfy one internal shape. */
@@ -981,6 +988,15 @@ var MatterRuntime = class {
981
988
  restore;
982
989
  stepMs;
983
990
  sleepSynced = /* @__PURE__ */ new Set();
991
+ /**
992
+ * The plain record object each body was built from. See `PhysicsRuntime.sourceRecords`: `add()`
993
+ * installs a new record object, so an id removed and re-added between two reconciles (a pooled
994
+ * projectile) is identifiable by identity, and is the one case where a live body has to be
995
+ * rebuilt so the new row's spawn pose and velocity are honoured.
996
+ */
997
+ sourceRecords = /* @__PURE__ */ new Map();
998
+ /** `collection.field` pairs already warned about a channel a plane cannot hold. */
999
+ warnedChannels = /* @__PURE__ */ new Set();
984
1000
  constructor(core, matter, options) {
985
1001
  const config = core.definition.config.physics;
986
1002
  if (!config) throw new Error("MatterRuntime: the room config declares no physics");
@@ -1050,6 +1066,7 @@ var MatterRuntime = class {
1050
1066
  } else {
1051
1067
  this.applyRecordToBody(desc, spec.body, record);
1052
1068
  }
1069
+ this.sourceRecords.set(key, record);
1053
1070
  return spec.body;
1054
1071
  }
1055
1072
  // ---- M6 lane F: rewind ----
@@ -1089,6 +1106,17 @@ var MatterRuntime = class {
1089
1106
  for (const [channel, field] of physics.channels) {
1090
1107
  const raw = record[field];
1091
1108
  if (typeof raw !== "number") continue;
1109
+ if (raw !== 0 && !PLANAR_CHANNELS.has(channel)) {
1110
+ const key = `${desc.name}.${field}`;
1111
+ if (!this.warnedChannels.has(key)) {
1112
+ this.warnedChannels.add(key);
1113
+ this.core.log(
1114
+ "warn",
1115
+ `irtio: ${desc.name}.${field} maps body channel ${channel}, which a matter2d room cannot hold (the world is a plane); the ${raw} it was added with was dropped`
1116
+ );
1117
+ }
1118
+ continue;
1119
+ }
1092
1120
  applyChannel2d(channel, raw, t);
1093
1121
  }
1094
1122
  this.applyState(body, {
@@ -1110,8 +1138,21 @@ var MatterRuntime = class {
1110
1138
  for (const id of coll.ids()) {
1111
1139
  const key = bodyKey(desc.name, id);
1112
1140
  live.add(key);
1113
- if (this.bodies.has(key)) continue;
1114
1141
  const record = coll.get(id);
1142
+ const existing = this.bodies.get(key);
1143
+ if (existing) {
1144
+ if (record === void 0) continue;
1145
+ const source = this.sourceRecords.get(key);
1146
+ if (source === void 0) {
1147
+ this.sourceRecords.set(key, record);
1148
+ continue;
1149
+ }
1150
+ if (source === record) continue;
1151
+ this.bodies.delete(key);
1152
+ this.sleepSynced.delete(key);
1153
+ this.sourceRecords.delete(key);
1154
+ this.matter.Composite.remove(this.engine.world, [existing.body, ...existing.constraints]);
1155
+ }
1115
1156
  if (record !== void 0) this.create(desc, id, record);
1116
1157
  }
1117
1158
  }
@@ -1119,6 +1160,7 @@ var MatterRuntime = class {
1119
1160
  if (live.has(key)) continue;
1120
1161
  this.bodies.delete(key);
1121
1162
  this.sleepSynced.delete(key);
1163
+ this.sourceRecords.delete(key);
1122
1164
  this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
1123
1165
  }
1124
1166
  }
@@ -1349,6 +1391,19 @@ var PhysicsRuntime = class {
1349
1391
  * world rebuilt from schema state would wake it with a kick). Found by the drift check.
1350
1392
  */
1351
1393
  sleepSynced = /* @__PURE__ */ new Set();
1394
+ /**
1395
+ * The plain record object each body was built from, by body key. `add()` always installs a
1396
+ * **new** record object (`normalizeRecord` builds one), so an id that is removed and re-added
1397
+ * between two reconciles — the pooled-projectile shape — is identifiable by identity alone,
1398
+ * which is the only way to see it from here: both edits happened before this module looked.
1399
+ * Without it the old body kept its pose and velocity and the new row's spawn values were lost.
1400
+ *
1401
+ * Unknown means adopt, never rebuild: a world restored from a hibernation blob has bodies with
1402
+ * no recorded source, and rebuilding those would throw away exactly what the blob preserved.
1403
+ */
1404
+ sourceRecords = /* @__PURE__ */ new Map();
1405
+ /** `collection.field` pairs already warned about a channel value the engine would not take. */
1406
+ warnedChannels = /* @__PURE__ */ new Set();
1352
1407
  constructor(core, rapier, options) {
1353
1408
  const config = core.definition.config.physics;
1354
1409
  if (!config) throw new Error("PhysicsRuntime: the room config declares no physics");
@@ -1423,10 +1478,50 @@ var PhysicsRuntime = class {
1423
1478
  }
1424
1479
  const body = this.world.createRigidBody(spec.body);
1425
1480
  for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
1481
+ this.warnLockedChannels(desc, spec, record);
1426
1482
  this.applyRecordToBody(desc, body, record);
1427
1483
  this.bodies.set(bodyKey2(desc.name, id), body);
1484
+ this.sourceRecords.set(bodyKey2(desc.name, id), record);
1428
1485
  return body;
1429
1486
  }
1487
+ /**
1488
+ * A spawn value the engine will not take, said once per field.
1489
+ *
1490
+ * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
1491
+ * accepts one on a fixed body and then ignores it — both read back as "the field was applied"
1492
+ * and behave as if it never was. The desc says which, before the body exists, so the check is
1493
+ * on the desc: the usual case is the 2D-in-3D recipe (`enabledTranslations(true, true, false)`)
1494
+ * meeting a row that spawns with a `vz`. Named by field, because the field is what the author
1495
+ * wrote and what they will grep for.
1496
+ */
1497
+ warnLockedChannels(desc, spec, record) {
1498
+ const physics = desc.physics;
1499
+ if (!physics) return;
1500
+ const b = spec.body;
1501
+ const frozen = b.status !== 0;
1502
+ const enabled = {
1503
+ vx: b.translationsEnabledX,
1504
+ vy: b.translationsEnabledY,
1505
+ vz: b.translationsEnabledZ,
1506
+ wx: b.rotationsEnabledX,
1507
+ wy: b.rotationsEnabledY,
1508
+ wz: b.rotationsEnabledZ
1509
+ };
1510
+ for (const [channel, field] of physics.channels) {
1511
+ const allowed = enabled[channel];
1512
+ if (allowed === void 0) continue;
1513
+ const raw = record[field];
1514
+ if (typeof raw !== "number" || raw === 0) continue;
1515
+ if (allowed && !frozen) continue;
1516
+ const key = `${desc.name}.${field}`;
1517
+ if (this.warnedChannels.has(key)) continue;
1518
+ this.warnedChannels.add(key);
1519
+ this.core.log(
1520
+ "warn",
1521
+ `irtio: ${desc.name}.${field} (body channel ${channel}) was added as ${raw}, but ` + (frozen ? `physics.bodies.${desc.name} builds a non-dynamic body, which never moves under it` : `that degree of freedom is disabled by physics.bodies.${desc.name}`) + " \u2014 the value was dropped"
1522
+ );
1523
+ }
1524
+ }
1430
1525
  // ---- M6 lane F: rewind ----
1431
1526
  /**
1432
1527
  * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
@@ -1522,8 +1617,21 @@ var PhysicsRuntime = class {
1522
1617
  for (const id of coll.ids()) {
1523
1618
  const key = bodyKey2(desc.name, id);
1524
1619
  live.add(key);
1525
- if (this.bodies.has(key)) continue;
1526
1620
  const record = coll.get(id);
1621
+ const existing = this.bodies.get(key);
1622
+ if (existing) {
1623
+ if (record === void 0) continue;
1624
+ const source = this.sourceRecords.get(key);
1625
+ if (source === void 0) {
1626
+ this.sourceRecords.set(key, record);
1627
+ continue;
1628
+ }
1629
+ if (source === record) continue;
1630
+ this.bodies.delete(key);
1631
+ this.sleepSynced.delete(key);
1632
+ this.sourceRecords.delete(key);
1633
+ this.world.removeRigidBody(existing);
1634
+ }
1527
1635
  if (record !== void 0) this.create(desc, id, record);
1528
1636
  }
1529
1637
  }
@@ -1531,6 +1639,7 @@ var PhysicsRuntime = class {
1531
1639
  if (live.has(key)) continue;
1532
1640
  this.bodies.delete(key);
1533
1641
  this.sleepSynced.delete(key);
1642
+ this.sourceRecords.delete(key);
1534
1643
  this.world.removeRigidBody(body);
1535
1644
  }
1536
1645
  }
@@ -1630,6 +1739,14 @@ function channelValue(channel, t, r, v, w) {
1630
1739
  }
1631
1740
 
1632
1741
  // src/core/random.ts
1742
+ function seedFromRoomId(roomId) {
1743
+ let h = 2166136261;
1744
+ for (let i = 0; i < roomId.length; i++) {
1745
+ h ^= roomId.charCodeAt(i);
1746
+ h = Math.imul(h, 16777619) >>> 0;
1747
+ }
1748
+ return h === 0 ? 2654435769 : h >>> 0;
1749
+ }
1633
1750
  var Mulberry32 = class {
1634
1751
  /** Current internal state (u32). Survives hibernation. */
1635
1752
  state;
@@ -1691,6 +1808,16 @@ var Rapier2dRuntime = class {
1691
1808
  bodies = /* @__PURE__ */ new Map();
1692
1809
  /** See `PhysicsRuntime.sleepSynced`: the one extra sync a body owes on the tick it sleeps. */
1693
1810
  sleepSynced = /* @__PURE__ */ new Set();
1811
+ /**
1812
+ * See `PhysicsRuntime.sourceRecords`: the plain record object each body was built from. `add()`
1813
+ * always installs a new record object, so an id removed and re-added between two reconciles —
1814
+ * the pooled-projectile shape — is identifiable by identity alone, and its body is rebuilt so
1815
+ * the new row's spawn pose and velocity are what the world gets. Unknown means adopt, never
1816
+ * rebuild: a world restored from a hibernation blob has bodies with no recorded source.
1817
+ */
1818
+ sourceRecords = /* @__PURE__ */ new Map();
1819
+ /** `collection.field` pairs already warned about a channel value the engine would not take. */
1820
+ warnedChannels = /* @__PURE__ */ new Set();
1694
1821
  constructor(core, rapier, options) {
1695
1822
  const config = core.definition.config.physics;
1696
1823
  if (!config) throw new Error("Rapier2dRuntime: the room config declares no physics");
@@ -1760,10 +1887,48 @@ var Rapier2dRuntime = class {
1760
1887
  }
1761
1888
  const body = this.world.createRigidBody(spec.body);
1762
1889
  for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
1890
+ this.warnLockedChannels(desc, spec, record);
1763
1891
  this.applyRecordToBody(desc, body, record);
1764
1892
  this.bodies.set(bodyKey3(desc.name, id), body);
1893
+ this.sourceRecords.set(bodyKey3(desc.name, id), record);
1765
1894
  return body;
1766
1895
  }
1896
+ /**
1897
+ * A spawn value the engine will not take, said once per field — the planar half of
1898
+ * `PhysicsRuntime.warnLockedChannels`.
1899
+ *
1900
+ * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
1901
+ * accepts one on a fixed body and then ignores it: both read back as "the field was applied"
1902
+ * and behave as if it never was. The desc says which, before the body exists, so the check is on
1903
+ * the desc. In the plane there are three movable channels — `vx`, `vy` and the one rotation
1904
+ * `wz` — and the desc carries exactly those three flags. Named by field, because the field is
1905
+ * what the author wrote and what they will grep for.
1906
+ */
1907
+ warnLockedChannels(desc, spec, record) {
1908
+ const physics = desc.physics;
1909
+ if (!physics) return;
1910
+ const b = spec.body;
1911
+ const frozen = b.status !== 0;
1912
+ const enabled = {
1913
+ vx: b.translationsEnabledX,
1914
+ vy: b.translationsEnabledY,
1915
+ wz: b.rotationsEnabled
1916
+ };
1917
+ for (const [channel, field] of physics.channels) {
1918
+ const allowed = enabled[channel];
1919
+ if (allowed === void 0) continue;
1920
+ const raw = record[field];
1921
+ if (typeof raw !== "number" || raw === 0) continue;
1922
+ if (allowed && !frozen) continue;
1923
+ const key = `${desc.name}.${field}`;
1924
+ if (this.warnedChannels.has(key)) continue;
1925
+ this.warnedChannels.add(key);
1926
+ this.core.log(
1927
+ "warn",
1928
+ `irtio: ${desc.name}.${field} (body channel ${channel}) was added as ${raw}, but ` + (frozen ? `physics.bodies.${desc.name} builds a non-dynamic body, which never moves under it` : `that degree of freedom is disabled by physics.bodies.${desc.name}`) + " \u2014 the value was dropped"
1929
+ );
1930
+ }
1931
+ }
1767
1932
  // ---- M6 lane F: rewind ----
1768
1933
  /**
1769
1934
  * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
@@ -1814,8 +1979,21 @@ var Rapier2dRuntime = class {
1814
1979
  for (const id of coll.ids()) {
1815
1980
  const key = bodyKey3(desc.name, id);
1816
1981
  live.add(key);
1817
- if (this.bodies.has(key)) continue;
1818
1982
  const record = coll.get(id);
1983
+ const existing = this.bodies.get(key);
1984
+ if (existing) {
1985
+ if (record === void 0) continue;
1986
+ const source = this.sourceRecords.get(key);
1987
+ if (source === void 0) {
1988
+ this.sourceRecords.set(key, record);
1989
+ continue;
1990
+ }
1991
+ if (source === record) continue;
1992
+ this.bodies.delete(key);
1993
+ this.sleepSynced.delete(key);
1994
+ this.sourceRecords.delete(key);
1995
+ this.world.removeRigidBody(existing);
1996
+ }
1819
1997
  if (record !== void 0) this.create(desc, id, record);
1820
1998
  }
1821
1999
  }
@@ -1823,6 +2001,7 @@ var Rapier2dRuntime = class {
1823
2001
  if (live.has(key)) continue;
1824
2002
  this.bodies.delete(key);
1825
2003
  this.sleepSynced.delete(key);
2004
+ this.sourceRecords.delete(key);
1826
2005
  this.world.removeRigidBody(body);
1827
2006
  }
1828
2007
  }
@@ -2144,8 +2323,8 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
2144
2323
  // src/core/snapshot.ts
2145
2324
  import { withBuiltins } from "@irtio/protocol";
2146
2325
  import { ByteReader as ByteReader3, ByteWriter as ByteWriter3, decodeSnapshot } from "@irtio/schema";
2147
- var SNAPSHOT_FORMAT_VERSION = 2;
2148
- var READABLE_SNAPSHOT_VERSIONS = [1, 2];
2326
+ var SNAPSHOT_FORMAT_VERSION = 3;
2327
+ var READABLE_SNAPSHOT_VERSIONS = [1, 2, 3];
2149
2328
  function parseHibernationBlob(bytes) {
2150
2329
  const r = new ByteReader3(bytes);
2151
2330
  const version = r.u8();
@@ -2156,22 +2335,31 @@ function parseHibernationBlob(bytes) {
2156
2335
  const rngState = r.u32();
2157
2336
  const tick = r.u32();
2158
2337
  const mode = r.u8() === 1 ? "event" : "tick";
2338
+ const schemaHash8 = version >= 3 ? r.bytes(8).slice() : void 0;
2159
2339
  let physics;
2160
2340
  if (version >= 2) {
2161
2341
  const section = r.blob();
2162
2342
  if (section.length > 0) physics = section;
2163
2343
  }
2164
- return { version, seed, rngState, tick, mode, physics, snapshot: r.rest() };
2344
+ return { version, seed, rngState, tick, mode, schemaHash8, physics, snapshot: r.rest() };
2345
+ }
2346
+ function sameSchemaHash(a, b) {
2347
+ if (!a || !b) return true;
2348
+ if (a.length !== b.length) return false;
2349
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2350
+ return true;
2165
2351
  }
2166
2352
  function writeHibernationBlob(header, snapshot, physics) {
2167
- const version = physics ? 2 : 1;
2168
- const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 24);
2353
+ const hash = header.schemaHash8;
2354
+ const version = hash ? 3 : physics ? 2 : 1;
2355
+ const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 32);
2169
2356
  w.u8(version);
2170
2357
  w.u32(header.seed >>> 0);
2171
2358
  w.u32(header.rngState >>> 0);
2172
2359
  w.u32(header.tick >>> 0);
2173
2360
  w.u8(header.mode === "event" ? 1 : 0);
2174
- if (physics) w.blob(physics);
2361
+ if (hash) w.bytes(hash);
2362
+ if (version >= 2) w.blob(physics ?? new Uint8Array(0));
2175
2363
  w.bytes(snapshot);
2176
2364
  return w.finish();
2177
2365
  }
@@ -3284,9 +3472,20 @@ function createRoomApi(core, publicUrl) {
3284
3472
  get id() {
3285
3473
  return core.roomId;
3286
3474
  },
3475
+ /**
3476
+ * The same tracked state object handlers get. The one caller that has no other way to it is
3477
+ * `physics.setup`, which runs before `onCreate` on a fresh room and after the snapshot is
3478
+ * decoded on a rebuild — see the doc on `Room.state`.
3479
+ */
3480
+ get state() {
3481
+ return core.anyState;
3482
+ },
3287
3483
  get link() {
3288
3484
  return link();
3289
3485
  },
3486
+ get seed() {
3487
+ return core.seed;
3488
+ },
3290
3489
  get tick() {
3291
3490
  return core.tick;
3292
3491
  },
@@ -3477,6 +3676,9 @@ function createRoomApi(core, publicUrl) {
3477
3676
  },
3478
3677
  get broadcast() {
3479
3678
  return broadcast;
3679
+ },
3680
+ broadcastExcept(clientId) {
3681
+ return createBroadcastExceptProxy(core, clientId);
3480
3682
  }
3481
3683
  };
3482
3684
  const broadcast = createBroadcastProxy(core);
@@ -3665,8 +3867,13 @@ var RoomCore = class _RoomCore {
3665
3867
  }
3666
3868
  let restored;
3667
3869
  let plain;
3668
- if (options.restoreFrom) {
3669
- restored = parseHibernationBlob(options.restoreFrom);
3870
+ const candidate = options.restoreFrom ? parseHibernationBlob(options.restoreFrom) : void 0;
3871
+ if (candidate && !sameSchemaHash(candidate.schemaHash8, this.ext.hash8)) {
3872
+ host.log("warn", [`irtio: room ${options.roomId}: schema changed, snapshot discarded`]);
3873
+ } else if (candidate) {
3874
+ restored = candidate;
3875
+ }
3876
+ if (restored) {
3670
3877
  plain = decodeSnapshot2(this.ext, restored.snapshot).state;
3671
3878
  const presence = plainEntity(plain, PRESENCE_COLLECTION);
3672
3879
  for (const id of [...presence.ids()]) presence.remove(id);
@@ -3681,7 +3888,7 @@ var RoomCore = class _RoomCore {
3681
3888
  this.plain = plain;
3682
3889
  this.tracked = track(this.ext, plain);
3683
3890
  this.anyState = this.tracked.state;
3684
- this.seed = restored?.seed ?? options.seed ?? 1;
3891
+ this.seed = restored?.seed ?? options.seed ?? seedFromRoomId(this.roomId);
3685
3892
  this.rng = new Mulberry32(restored?.rngState ?? this.seed);
3686
3893
  this.tick = restored?.tick ?? 0;
3687
3894
  const self = this;
@@ -4025,7 +4232,13 @@ var RoomCore = class _RoomCore {
4025
4232
  snapshot() {
4026
4233
  const physics = this.physics ? encodeSection(this.physics) : void 0;
4027
4234
  return writeHibernationBlob(
4028
- { seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
4235
+ {
4236
+ seed: this.seed,
4237
+ rngState: this.rng.state,
4238
+ tick: this.tick,
4239
+ mode: this.mode,
4240
+ schemaHash8: this.ext.hash8
4241
+ },
4029
4242
  encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
4030
4243
  physics
4031
4244
  );
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  parseHibernationBlob,
3
3
  writeHibernationBlob
4
- } from "./chunk-ZCD2TBXB.js";
4
+ } from "./chunk-ADZZEVJU.js";
5
5
 
6
6
  // src/migrate.ts
7
7
  import { PRESENCE_COLLECTION, withBuiltins } from "@irtio/protocol";
@@ -53,7 +53,15 @@ function migrateSnapshot(blob, chain, options) {
53
53
  }
54
54
  plain = fromMigrationState(withBuiltins(schema), state, last.version);
55
55
  const bytes = writeHibernationBlob(
56
- { seed: parsed.seed, rngState: parsed.rngState, tick: parsed.tick, mode: parsed.mode },
56
+ {
57
+ seed: parsed.seed,
58
+ rngState: parsed.rngState,
59
+ tick: parsed.tick,
60
+ mode: parsed.mode,
61
+ // Stamped with the *target* schema: these bytes are now that schema's, and a room waking on
62
+ // it must not see them as a mismatch.
63
+ schemaHash8: withBuiltins(schema).hash8
64
+ },
57
65
  encodeSnapshot(withBuiltins(schema), plain, { tick: parsed.tick })
58
66
  );
59
67
  return {
@@ -302,7 +302,11 @@ interface RoomHost {
302
302
  }
303
303
  interface RoomCoreOptions {
304
304
  readonly roomId: string;
305
- /** Seed for `room.random()`; default 1. */
305
+ /**
306
+ * Seed for `room.random()`. Defaults to a stable 32-bit hash of `roomId`, so two rooms of the
307
+ * same game generate different worlds; pass a number to pin the sequence instead. A room
308
+ * restored from a snapshot ignores both and keeps the seed it was created with.
309
+ */
306
310
  readonly seed?: number;
307
311
  /** Base URL for `room.link` (`<publicUrl>?room=<id>`); default `http://localhost/`. */
308
312
  readonly publicUrl?: string;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { D as DEFAULT_TIMELINE_MAX_RECORDS, a as DEFAULT_TIMELINE_MAX_TICKS, E as EVENT_RING_SIZE, H as HOST_CALL_TIMEOUT_MS, b as HostCall, c as HostCallResult, J as JoinOptions, d as JoinResult, L as LogLevel, R as RoomCoreApi, e as RoomCoreOptions, f as RoomEvent, g as RoomEventKind, h as RoomFullError, i as RoomHost, j as RoomInspection, k as RoomStats, T as TimelineDump, l as TimelineFrame, m as TimelineRecorder, n as TimelineRecorderOptions, o as inspectState } from './contract-CRilLVdx.js';
2
- export { C as CRASH_AFTER_THROWS, M as MAX_CATCHUP, a as MatterBodyRecord, b as MatterSection, c as Mulberry32, P as PhysicsEngineTag, d as PhysicsSection, R as RoomCore, e as decodeMatterBodies, f as decodeMatterSectionEnvelope, g as decodePhysicsSection, h as decodeRapier2dSectionEnvelope, i as encodeMatterBodies, j as encodeMatterSectionEnvelope, k as encodePhysicsSection, l as encodeRapier2dSectionEnvelope, m as initMatter, n as initPhysics, o as initRapier2d, p as loadedMatter, q as loadedPhysics, r as loadedRapier2d, s as physicsSectionEngine, t as resetMatterForTests, u as resetPhysicsForTests, v as resetRapier2dForTests } from './room-YJGz6Caw.js';
1
+ export { D as DEFAULT_TIMELINE_MAX_RECORDS, a as DEFAULT_TIMELINE_MAX_TICKS, E as EVENT_RING_SIZE, H as HOST_CALL_TIMEOUT_MS, b as HostCall, c as HostCallResult, J as JoinOptions, d as JoinResult, L as LogLevel, R as RoomCoreApi, e as RoomCoreOptions, f as RoomEvent, g as RoomEventKind, h as RoomFullError, i as RoomHost, j as RoomInspection, k as RoomStats, T as TimelineDump, l as TimelineFrame, m as TimelineRecorder, n as TimelineRecorderOptions, o as inspectState } from './contract-C5aqs49-.js';
2
+ export { C as CRASH_AFTER_THROWS, M as MAX_CATCHUP, a as MatterBodyRecord, b as MatterSection, c as Mulberry32, P as PhysicsEngineTag, d as PhysicsSection, R as RoomCore, e as decodeMatterBodies, f as decodeMatterSectionEnvelope, g as decodePhysicsSection, h as decodeRapier2dSectionEnvelope, i as encodeMatterBodies, j as encodeMatterSectionEnvelope, k as encodePhysicsSection, l as encodeRapier2dSectionEnvelope, m as initMatter, n as initPhysics, o as initRapier2d, p as loadedMatter, q as loadedPhysics, r as loadedRapier2d, s as physicsSectionEngine, t as resetMatterForTests, u as resetPhysicsForTests, v as resetRapier2dForTests } from './room-C4RZJ2KO.js';
3
3
  import { CollectionDesc, AnySchema, PlainState, DirtySet } from '@irtio/schema';
4
4
  import '@irtio/protocol';
5
5
  import '@irtio/server';
@@ -70,9 +70,16 @@ declare function catchUpDirty(ext: AnySchema, plain: PlainState, fromRole: strin
70
70
  * u32 rng state
71
71
  * u32 tick
72
72
  * u8 mode (1 = event, 0 = tick)
73
+ * [v3] 8 bytes schema hash (`withBuiltins(schema).hash8`)
73
74
  * [v2] blob physics section (varint length + bytes; length 0 = no world)
74
75
  * ... encodeSnapshot(withBuiltins(schema), state)
75
76
  *
77
+ * **v3** adds the schema hash. Before it, restoring a snapshot written under a different schema
78
+ * fed the old bytes to the new decoder and surfaced as a `TypeError` from deep inside the string
79
+ * decoder; the room could only be started again by hand-deleting `.irtio/snapshots`. The hash is
80
+ * in the envelope (not only in the codec payload) so the check costs no decode: a mismatch is
81
+ * seen, logged and the snapshot discarded before anything is parsed against the wrong schema.
82
+ *
76
83
  * **v2 (week 9, D22)** adds the physics section: the Rapier world snapshot plus the entity↔handle
77
84
  * map (`core/physics.ts` encodes it). It rides *inside* this blob rather than beside it so that
78
85
  * wake is atomic — state and world always come back from the same bytes, and every store, cache
@@ -89,8 +96,8 @@ declare function catchUpDirty(ext: AnySchema, plain: PlainState, fromRole: strin
89
96
  * instead of parsing bytes it may not yet know how to read.
90
97
  */
91
98
 
92
- /** Written when the blob carries a physics world; v1 otherwise (and v1 is still read). */
93
- declare const SNAPSHOT_FORMAT_VERSION = 2;
99
+ /** Written when the header carries a schema hash (every room does); older versions still read. */
100
+ declare const SNAPSHOT_FORMAT_VERSION = 3;
94
101
  /** Versions this build can parse. */
95
102
  declare const READABLE_SNAPSHOT_VERSIONS: readonly number[];
96
103
  type SnapshotMode = 'tick' | 'event';
@@ -99,6 +106,12 @@ interface SnapshotHeader {
99
106
  readonly rngState: number;
100
107
  readonly tick: number;
101
108
  readonly mode: SnapshotMode;
109
+ /**
110
+ * `withBuiltins(schema).hash8` of the schema these bytes were written under. Omitted only by a
111
+ * writer that has no schema in hand; a restore that finds it absent cannot check and proceeds
112
+ * as pre-v3 builds did.
113
+ */
114
+ readonly schemaHash8?: Uint8Array | undefined;
102
115
  }
103
116
  interface ParsedSnapshot extends SnapshotHeader {
104
117
  /** The blob's own format version (1 or 2). */
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  fromMigrationState,
3
3
  migrateSnapshot,
4
4
  toMigrationState
5
- } from "./chunk-DBGMG2S3.js";
5
+ } from "./chunk-SBDZ45JG.js";
6
6
  import {
7
7
  CRASH_AFTER_THROWS,
8
8
  DEFAULT_TIMELINE_MAX_RECORDS,
@@ -47,7 +47,7 @@ import {
47
47
  visibleNames,
48
48
  visibleTo,
49
49
  writeHibernationBlob
50
- } from "./chunk-ZCD2TBXB.js";
50
+ } from "./chunk-ADZZEVJU.js";
51
51
  export {
52
52
  CRASH_AFTER_THROWS,
53
53
  DEFAULT_TIMELINE_MAX_RECORDS,
@@ -1,12 +1,8 @@
1
1
  import { AttributeOptions, ProfileSnapshot } from '@irtio/protocol';
2
2
  import { DirtySet, AnySchema, PlainState, Tracked, State } from '@irtio/schema';
3
3
  import { RoomDefinition, RoomMode, Room, RewindView, Ctx, MatterModule, MatterEngine, MatterBody, RapierModule, RapierWorld, RapierRigidBody, Rapier2dModule, Rapier2dWorld, Rapier2dRigidBody, ResolvedRoomConfig, LeaveReason } from '@irtio/server';
4
- import { i as RoomHost, k as RoomStats, L as LogLevel, R as RoomCoreApi, e as RoomCoreOptions, g as RoomEventKind, n as TimelineRecorderOptions, T as TimelineDump, j as RoomInspection, J as JoinOptions, d as JoinResult, c as HostCallResult } from './contract-CRilLVdx.js';
4
+ import { i as RoomHost, k as RoomStats, L as LogLevel, R as RoomCoreApi, e as RoomCoreOptions, g as RoomEventKind, n as TimelineRecorderOptions, T as TimelineDump, j as RoomInspection, J as JoinOptions, d as JoinResult, c as HostCallResult } from './contract-C5aqs49-.js';
5
5
 
6
- /**
7
- * `room.random()` — mulberry32 over a u32 seed. Deterministic, tiny, and serializable: the
8
- * generator's whole state is one u32, so `serialize()`/`restore()` round-trip it exactly.
9
- */
10
6
  declare class Mulberry32 {
11
7
  /** Current internal state (u32). Survives hibernation. */
12
8
  state: number;
@@ -139,6 +135,8 @@ interface RoomInternals {
139
135
  readonly ext: AnySchema;
140
136
  readonly host: RoomHost;
141
137
  readonly roomId: string;
138
+ /** The resolved RNG seed: explicit option, else derived from the room id; restored on wake. */
139
+ readonly seed: number;
142
140
  readonly mode: RoomMode;
143
141
  /** The plain state the tracked proxies wrap (what the codec reads). */
144
142
  readonly plain: PlainState;
@@ -346,6 +344,15 @@ declare class MatterRuntime {
346
344
  private readonly restore;
347
345
  private readonly stepMs;
348
346
  private readonly sleepSynced;
347
+ /**
348
+ * The plain record object each body was built from. See `PhysicsRuntime.sourceRecords`: `add()`
349
+ * installs a new record object, so an id removed and re-added between two reconciles (a pooled
350
+ * projectile) is identifiable by identity, and is the one case where a live body has to be
351
+ * rebuilt so the new row's spawn pose and velocity are honoured.
352
+ */
353
+ private readonly sourceRecords;
354
+ /** `collection.field` pairs already warned about a channel a plane cannot hold. */
355
+ private readonly warnedChannels;
349
356
  constructor(core: RoomInternals, matter: MatterModule, options: MatterRuntimeOptions);
350
357
  get timestep(): number;
351
358
  runSetup(room: Room): void;
@@ -488,6 +495,19 @@ declare class PhysicsRuntime {
488
495
  * world rebuilt from schema state would wake it with a kick). Found by the drift check.
489
496
  */
490
497
  private readonly sleepSynced;
498
+ /**
499
+ * The plain record object each body was built from, by body key. `add()` always installs a
500
+ * **new** record object (`normalizeRecord` builds one), so an id that is removed and re-added
501
+ * between two reconciles — the pooled-projectile shape — is identifiable by identity alone,
502
+ * which is the only way to see it from here: both edits happened before this module looked.
503
+ * Without it the old body kept its pose and velocity and the new row's spawn values were lost.
504
+ *
505
+ * Unknown means adopt, never rebuild: a world restored from a hibernation blob has bodies with
506
+ * no recorded source, and rebuilding those would throw away exactly what the blob preserved.
507
+ */
508
+ private readonly sourceRecords;
509
+ /** `collection.field` pairs already warned about a channel value the engine would not take. */
510
+ private readonly warnedChannels;
491
511
  constructor(core: RoomInternals, rapier: RapierModule, options: PhysicsRuntimeOptions);
492
512
  get timestep(): number;
493
513
  /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
@@ -496,6 +516,17 @@ declare class PhysicsRuntime {
496
516
  /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
497
517
  bodyFor(collection: string, id: string): RapierRigidBody | undefined;
498
518
  private create;
519
+ /**
520
+ * A spawn value the engine will not take, said once per field.
521
+ *
522
+ * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
523
+ * accepts one on a fixed body and then ignores it — both read back as "the field was applied"
524
+ * and behave as if it never was. The desc says which, before the body exists, so the check is
525
+ * on the desc: the usual case is the 2D-in-3D recipe (`enabledTranslations(true, true, false)`)
526
+ * meeting a row that spawns with a `vz`. Named by field, because the field is what the author
527
+ * wrote and what they will grep for.
528
+ */
529
+ private warnLockedChannels;
499
530
  /**
500
531
  * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
501
532
  * order). Read-only: it hands out the live bodies and nothing else, and the map stays private.
@@ -585,6 +616,16 @@ declare class Rapier2dRuntime {
585
616
  private readonly bodies;
586
617
  /** See `PhysicsRuntime.sleepSynced`: the one extra sync a body owes on the tick it sleeps. */
587
618
  private readonly sleepSynced;
619
+ /**
620
+ * See `PhysicsRuntime.sourceRecords`: the plain record object each body was built from. `add()`
621
+ * always installs a new record object, so an id removed and re-added between two reconciles —
622
+ * the pooled-projectile shape — is identifiable by identity alone, and its body is rebuilt so
623
+ * the new row's spawn pose and velocity are what the world gets. Unknown means adopt, never
624
+ * rebuild: a world restored from a hibernation blob has bodies with no recorded source.
625
+ */
626
+ private readonly sourceRecords;
627
+ /** `collection.field` pairs already warned about a channel value the engine would not take. */
628
+ private readonly warnedChannels;
588
629
  constructor(core: RoomInternals, rapier: Rapier2dModule, options: Rapier2dRuntimeOptions);
589
630
  get timestep(): number;
590
631
  /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
@@ -593,6 +634,18 @@ declare class Rapier2dRuntime {
593
634
  /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
594
635
  bodyFor(collection: string, id: string): Rapier2dRigidBody | undefined;
595
636
  private create;
637
+ /**
638
+ * A spawn value the engine will not take, said once per field — the planar half of
639
+ * `PhysicsRuntime.warnLockedChannels`.
640
+ *
641
+ * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
642
+ * accepts one on a fixed body and then ignores it: both read back as "the field was applied"
643
+ * and behave as if it never was. The desc says which, before the body exists, so the check is on
644
+ * the desc. In the plane there are three movable channels — `vx`, `vy` and the one rotation
645
+ * `wz` — and the desc carries exactly those three flags. Named by field, because the field is
646
+ * what the author wrote and what they will grep for.
647
+ */
648
+ private warnLockedChannels;
596
649
  /**
597
650
  * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
598
651
  * order). The rapier2d third of the same read-only accessor the other two runtimes carry.
@@ -668,7 +721,7 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
668
721
  * it again over its next `history` ticks.
669
722
  */
670
723
  private readonly rewindState;
671
- private readonly seed;
724
+ readonly seed: number;
672
725
  private readonly api;
673
726
  private readonly internals;
674
727
  private started;
@@ -1,8 +1,8 @@
1
- import { R as RoomCore } from '../room-YJGz6Caw.js';
2
- export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-YJGz6Caw.js';
1
+ import { R as RoomCore } from '../room-C4RZJ2KO.js';
2
+ export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-C4RZJ2KO.js';
3
3
  import { ErrorCodeName, FrameType } from '@irtio/protocol';
4
4
  import { NpcConfig, LeaveReason, Room, RoomDefinition } from '@irtio/server';
5
- import { i as RoomHost, L as LogLevel, R as RoomCoreApi, b as HostCall, c as HostCallResult, k as RoomStats } from '../contract-CRilLVdx.js';
5
+ import { i as RoomHost, L as LogLevel, R as RoomCoreApi, b as HostCall, c as HostCallResult, k as RoomStats } from '../contract-C5aqs49-.js';
6
6
  import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
7
7
 
8
8
  /**
@@ -331,7 +331,7 @@ interface FakeClient<S extends AnySchema = AnySchema> {
331
331
  */
332
332
 
333
333
  interface HarnessOptions {
334
- /** `room.random()` seed; default 1 (same as `RoomCore`). */
334
+ /** `room.random()` seed; defaults to a hash of `roomId`, exactly as `RoomCore` does. */
335
335
  readonly seed?: number;
336
336
  /** Base URL for `room.link`. */
337
337
  readonly publicUrl?: string;
@@ -5,7 +5,7 @@ import {
5
5
  initPhysics,
6
6
  initRapier2d,
7
7
  visibleNames
8
- } from "../chunk-ZCD2TBXB.js";
8
+ } from "../chunk-ADZZEVJU.js";
9
9
 
10
10
  // src/test/clock.ts
11
11
  var FakeClock = class {
@@ -677,7 +677,7 @@ var Harness = class {
677
677
  this.host.onSend = (clientId, frame) => this.deliver(clientId, frame);
678
678
  this.coreRef = new RoomCore(definition, this.host, {
679
679
  roomId: options.roomId ?? "test-room",
680
- seed: options.seed ?? 1,
680
+ ...options.seed !== void 0 ? { seed: options.seed } : {},
681
681
  ...options.publicUrl !== void 0 ? { publicUrl: options.publicUrl } : {},
682
682
  ...options.restoreFrom !== void 0 ? { restoreFrom: options.restoreFrom } : {}
683
683
  });
@@ -963,7 +963,7 @@ var Harness = class {
963
963
  this.nextClientId = 1;
964
964
  this.coreRef = RoomCore.restore(this.definition, bytes, this.host, {
965
965
  roomId: this.options.roomId ?? "test-room",
966
- seed: this.options.seed ?? 1,
966
+ ...this.options.seed !== void 0 ? { seed: this.options.seed } : {},
967
967
  ...this.options.publicUrl !== void 0 ? { publicUrl: this.options.publicUrl } : {}
968
968
  });
969
969
  this.coreRef.start();
@@ -1,4 +1,4 @@
1
- import { L as LogLevel, T as TimelineDump, k as RoomStats, b as HostCall, c as HostCallResult, R as RoomCoreApi, i as RoomHost } from '../contract-CRilLVdx.js';
1
+ import { L as LogLevel, T as TimelineDump, k as RoomStats, b as HostCall, c as HostCallResult, R as RoomCoreApi, i as RoomHost } from '../contract-C5aqs49-.js';
2
2
  import { ErrorCodeName, ProfileSnapshot } from '@irtio/protocol';
3
3
  import { NpcConfig, LeaveReason } from '@irtio/server';
4
4
  import '@irtio/schema';
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  migrateSnapshot
3
- } from "../chunk-DBGMG2S3.js";
3
+ } from "../chunk-SBDZ45JG.js";
4
4
  import {
5
5
  RoomCore,
6
6
  RoomFullError,
@@ -9,7 +9,7 @@ import {
9
9
  initRapier2d,
10
10
  onFirstRapierStep,
11
11
  rapierHasStepped
12
- } from "../chunk-ZCD2TBXB.js";
12
+ } from "../chunk-ADZZEVJU.js";
13
13
 
14
14
  // src/worker/index.ts
15
15
  import { getHeapStatistics } from "v8";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/runtime",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "irtio room runtime: RoomCore, worker_threads host, in-process test harness",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -31,9 +31,9 @@
31
31
  "@dimforge/rapier2d-compat": "0.20.0",
32
32
  "@dimforge/rapier3d-compat": "0.20.0",
33
33
  "matter-js": "0.20.0",
34
- "@irtio/protocol": "0.8.0",
35
- "@irtio/schema": "0.8.0",
36
- "@irtio/server": "0.8.0"
34
+ "@irtio/schema": "0.9.0",
35
+ "@irtio/server": "0.9.0",
36
+ "@irtio/protocol": "0.9.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/matter-js": "0.20.2"