@irtio/runtime 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -143,10 +143,10 @@ function handleCall(core, clientId, payload) {
143
143
  return true;
144
144
  }
145
145
  }
146
- const ctx = core.ctxFor(clientId);
146
+ const ctx = { ...core.ctxFor(clientId), clientTick: call.clientTick || void 0 };
147
147
  let result;
148
148
  if (desc.name === REQUEST_OWNERSHIP) {
149
- result = runRequestOwnership(core, clientId, params);
149
+ result = runRequestOwnership(core, clientId, ctx, params);
150
150
  } else {
151
151
  const impl = core.definition.config.rpc?.[desc.name];
152
152
  if (!impl) {
@@ -175,7 +175,7 @@ function handleCall(core, clientId, payload) {
175
175
  }
176
176
  return true;
177
177
  }
178
- function runRequestOwnership(core, clientId, params) {
178
+ function runRequestOwnership(core, clientId, ctx, params) {
179
179
  const name = String(params.entity ?? "");
180
180
  const id = String(params.id ?? "");
181
181
  const c = collectionDescOf(core.ext, name);
@@ -185,10 +185,7 @@ function runRequestOwnership(core, clientId, params) {
185
185
  const handler = core.definition.config.onOwnershipRequest;
186
186
  const tracked = trackedEntity(core, name);
187
187
  if (handler) {
188
- const ran = core.tryRun(
189
- "onOwnershipRequest",
190
- () => handler(core.anyState, name, id, core.ctxFor(clientId))
191
- );
188
+ const ran = core.tryRun("onOwnershipRequest", () => handler(core.anyState, name, id, ctx));
192
189
  if (ran.ok && ran.value === true && plain.ownerOf(id) !== clientId) {
193
190
  tracked.setOwner(id, clientId);
194
191
  }
@@ -762,6 +759,7 @@ var Loop = class {
762
759
  physics.reconcile();
763
760
  physics.step();
764
761
  physics.sync();
762
+ this.core.captureHistory();
765
763
  });
766
764
  if (!ran.ok) {
767
765
  failed = failed === void 0 ? "the physics step" : `${failed} and the physics step`;
@@ -1054,6 +1052,20 @@ var MatterRuntime = class {
1054
1052
  }
1055
1053
  return spec.body;
1056
1054
  }
1055
+ // ---- M6 lane F: rewind ----
1056
+ /**
1057
+ * D72: every tracked body, in the order `sync()` walks them. The matter half of the same
1058
+ * read-only accessor `PhysicsRuntime` carries; see its comment for what calls it and when.
1059
+ */
1060
+ eachTrackedBody(fn) {
1061
+ for (const desc of this.collections) {
1062
+ const plainColl = plainEntity(this.core.plain, desc.name);
1063
+ for (const id of plainColl.ids()) {
1064
+ const attached = this.bodies.get(bodyKey(desc.name, id));
1065
+ if (attached) fn(desc.name, id, attached.body);
1066
+ }
1067
+ }
1068
+ }
1057
1069
  applyState(body, s) {
1058
1070
  const M = this.matter;
1059
1071
  M.Body.setPosition(body, { x: s.x, y: s.y });
@@ -1243,9 +1255,28 @@ function encodePhysicsSection(section) {
1243
1255
  return w.finish();
1244
1256
  }
1245
1257
  var MATTER_TAG = 1;
1258
+ var RAPIER2D_TAG = 2;
1246
1259
  function physicsSectionEngine(bytes) {
1247
1260
  const r = new ByteReader2(bytes);
1248
- return r.varint() === 0 && r.u8() === MATTER_TAG ? "matter2d" : "rapier3d";
1261
+ if (r.varint() !== 0) return "rapier3d";
1262
+ const tag = r.u8();
1263
+ if (tag === MATTER_TAG) return "matter2d";
1264
+ if (tag === RAPIER2D_TAG) return "rapier2d";
1265
+ return "rapier3d";
1266
+ }
1267
+ function encodeRapier2dSectionEnvelope(payload) {
1268
+ const w = new ByteWriter2(payload.length + 8);
1269
+ w.varint(0);
1270
+ w.u8(RAPIER2D_TAG);
1271
+ w.bytes(payload);
1272
+ return w.finish();
1273
+ }
1274
+ function decodeRapier2dSectionEnvelope(bytes) {
1275
+ const r = new ByteReader2(bytes);
1276
+ if (r.varint() !== 0 || r.u8() !== RAPIER2D_TAG) {
1277
+ throw new Error("irtio: this physics section was not written by rapier2d");
1278
+ }
1279
+ return r.rest();
1249
1280
  }
1250
1281
  function encodeMatterSectionEnvelope(payload) {
1251
1282
  const w = new ByteWriter2(payload.length + 8);
@@ -1396,6 +1427,22 @@ var PhysicsRuntime = class {
1396
1427
  this.bodies.set(bodyKey2(desc.name, id), body);
1397
1428
  return body;
1398
1429
  }
1430
+ // ---- M6 lane F: rewind ----
1431
+ /**
1432
+ * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
1433
+ * order). Read-only: it hands out the live bodies and nothing else, and the map stays private.
1434
+ * The pose history calls this once per tick and the rewind scratch calls it per rewind; a room
1435
+ * that declares no `physics.history` never calls it at all.
1436
+ */
1437
+ eachTrackedBody(fn) {
1438
+ for (const desc of this.collections) {
1439
+ const plainColl = plainEntity(this.core.plain, desc.name);
1440
+ for (const id of plainColl.ids()) {
1441
+ const body = this.bodies.get(bodyKey2(desc.name, id));
1442
+ if (body) fn(desc.name, id, body);
1443
+ }
1444
+ }
1445
+ }
1399
1446
  applyRecordToBody(desc, body, record) {
1400
1447
  const physics = desc.physics;
1401
1448
  if (!physics) return;
@@ -1599,6 +1646,262 @@ var Mulberry32 = class {
1599
1646
  }
1600
1647
  };
1601
1648
 
1649
+ // src/core/rapier2d.ts
1650
+ import { angleFrom2d, applyChannel2d as applyChannel2d2, channelOf2d as channelOf2d2 } from "@irtio/schema";
1651
+ var engine3;
1652
+ var loading3;
1653
+ async function initRapier2d() {
1654
+ if (engine3) return engine3;
1655
+ loading3 ??= (async () => {
1656
+ const mod = await import("@dimforge/rapier2d-compat");
1657
+ const ns = mod.default ?? mod;
1658
+ await ns.init();
1659
+ engine3 = ns;
1660
+ return ns;
1661
+ })();
1662
+ return loading3;
1663
+ }
1664
+ function loadedRapier2d() {
1665
+ return engine3;
1666
+ }
1667
+ function resetRapier2dForTests() {
1668
+ engine3 = void 0;
1669
+ loading3 = void 0;
1670
+ }
1671
+ function bodyKey3(collection, id) {
1672
+ return `${collection}\0${id}`;
1673
+ }
1674
+ var Rapier2dRuntime = class {
1675
+ engineKind = "rapier2d";
1676
+ /** matter2d only; present so every runtime satisfies one internal shape. */
1677
+ matter = void 0;
1678
+ matterEngine = void 0;
1679
+ rapier;
1680
+ world;
1681
+ /** `true` when the world was built from scratch and `setup` has to run. */
1682
+ rebuilt;
1683
+ /** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
1684
+ get needsSetup() {
1685
+ return this.rebuilt;
1686
+ }
1687
+ core;
1688
+ config;
1689
+ /** Physics-backed collections, in schema (name-sorted) order. */
1690
+ collections;
1691
+ bodies = /* @__PURE__ */ new Map();
1692
+ /** See `PhysicsRuntime.sleepSynced`: the one extra sync a body owes on the tick it sleeps. */
1693
+ sleepSynced = /* @__PURE__ */ new Set();
1694
+ constructor(core, rapier, options) {
1695
+ const config = core.definition.config.physics;
1696
+ if (!config) throw new Error("Rapier2dRuntime: the room config declares no physics");
1697
+ this.core = core;
1698
+ this.config = config;
1699
+ this.rapier = rapier;
1700
+ this.collections = core.ext.collections.filter(
1701
+ (c) => c.physics !== void 0
1702
+ );
1703
+ if (options.restore) {
1704
+ this.world = rapier.World.restoreSnapshot(options.restore.world);
1705
+ this.rebuilt = false;
1706
+ for (const [name, id, handle] of options.restore.bodies) {
1707
+ const body = this.world.getRigidBody(handle);
1708
+ if (body) this.bodies.set(bodyKey3(name, id), body);
1709
+ }
1710
+ } else {
1711
+ const g = config.gravity;
1712
+ this.world = new rapier.World({ x: g.x, y: g.y });
1713
+ this.rebuilt = true;
1714
+ }
1715
+ this.world.timestep = config.timestep ?? options.defaultTimestep;
1716
+ }
1717
+ get timestep() {
1718
+ return this.world.timestep;
1719
+ }
1720
+ /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
1721
+ runSetup(room) {
1722
+ const setup = this.config.setup;
1723
+ if (!setup) return;
1724
+ this.core.guard("physics.setup", () => setup(this.world, this.rapier, room));
1725
+ }
1726
+ free() {
1727
+ this.bodies.clear();
1728
+ this.world.free();
1729
+ }
1730
+ // -------------------------------------------------------------------------
1731
+ // Bodies
1732
+ // -------------------------------------------------------------------------
1733
+ /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
1734
+ bodyFor(collection, id) {
1735
+ const existing = this.bodies.get(bodyKey3(collection, id));
1736
+ if (existing) return existing;
1737
+ const desc = this.collections.find((c) => c.name === collection);
1738
+ if (!desc) return void 0;
1739
+ const coll = plainEntity(this.core.plain, collection);
1740
+ const record = coll.get(id);
1741
+ if (record === void 0) return void 0;
1742
+ return this.create(desc, id, record);
1743
+ }
1744
+ create(desc, id, record) {
1745
+ const factory = this.config.bodies?.[desc.name];
1746
+ if (!factory) {
1747
+ this.core.log("error", `irtio: physics.bodies.${desc.name} is missing; no body created`);
1748
+ return void 0;
1749
+ }
1750
+ const spec = this.core.guard(
1751
+ `physics.bodies.${desc.name}`,
1752
+ () => factory(this.rapier, record, id)
1753
+ );
1754
+ if (!spec || !spec.body) {
1755
+ this.core.log(
1756
+ "error",
1757
+ `irtio: physics.bodies.${desc.name} returned no { body } for ${JSON.stringify(id)}`
1758
+ );
1759
+ return void 0;
1760
+ }
1761
+ const body = this.world.createRigidBody(spec.body);
1762
+ for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
1763
+ this.applyRecordToBody(desc, body, record);
1764
+ this.bodies.set(bodyKey3(desc.name, id), body);
1765
+ return body;
1766
+ }
1767
+ // ---- M6 lane F: rewind ----
1768
+ /**
1769
+ * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
1770
+ * order). The rapier2d third of the same read-only accessor the other two runtimes carry.
1771
+ */
1772
+ eachTrackedBody(fn) {
1773
+ for (const desc of this.collections) {
1774
+ const plainColl = plainEntity(this.core.plain, desc.name);
1775
+ for (const id of plainColl.ids()) {
1776
+ const body = this.bodies.get(bodyKey3(desc.name, id));
1777
+ if (body) fn(desc.name, id, body);
1778
+ }
1779
+ }
1780
+ }
1781
+ applyRecordToBody(desc, body, record) {
1782
+ const physics = desc.physics;
1783
+ if (!physics) return;
1784
+ const t = body.translation();
1785
+ const v = body.linvel();
1786
+ const angle = body.rotation();
1787
+ const target = {
1788
+ x: t.x,
1789
+ y: t.y,
1790
+ qz: Math.sin(angle / 2),
1791
+ qw: Math.cos(angle / 2),
1792
+ vx: v.x,
1793
+ vy: v.y,
1794
+ wz: body.angvel()
1795
+ };
1796
+ for (const [channel, field] of physics.channels) {
1797
+ const raw = record[field];
1798
+ if (typeof raw !== "number") continue;
1799
+ applyChannel2d2(channel, raw, target);
1800
+ }
1801
+ body.setTranslation({ x: target.x, y: target.y }, true);
1802
+ body.setRotation(angleFrom2d(target.qz, target.qw), true);
1803
+ body.setLinvel({ x: target.vx, y: target.vy }, true);
1804
+ body.setAngvel(target.wz, true);
1805
+ }
1806
+ /**
1807
+ * Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
1808
+ * tick, right before the step, in collection order then instance order.
1809
+ */
1810
+ reconcile() {
1811
+ const live = /* @__PURE__ */ new Set();
1812
+ for (const desc of this.collections) {
1813
+ const coll = plainEntity(this.core.plain, desc.name);
1814
+ for (const id of coll.ids()) {
1815
+ const key = bodyKey3(desc.name, id);
1816
+ live.add(key);
1817
+ if (this.bodies.has(key)) continue;
1818
+ const record = coll.get(id);
1819
+ if (record !== void 0) this.create(desc, id, record);
1820
+ }
1821
+ }
1822
+ for (const [key, body] of [...this.bodies]) {
1823
+ if (live.has(key)) continue;
1824
+ this.bodies.delete(key);
1825
+ this.sleepSynced.delete(key);
1826
+ this.world.removeRigidBody(body);
1827
+ }
1828
+ }
1829
+ // -------------------------------------------------------------------------
1830
+ // Step and sync
1831
+ // -------------------------------------------------------------------------
1832
+ step() {
1833
+ this.world.step();
1834
+ if (!rapierHasStepped()) noteRapierStep();
1835
+ }
1836
+ /**
1837
+ * Body → schema, through the tracked proxies, so movement leaves the room as an ordinary delta.
1838
+ * Values are `Math.fround`ed for f32 fields, so room code reads exactly what the wire carries.
1839
+ */
1840
+ sync() {
1841
+ for (const desc of this.collections) {
1842
+ const physics = desc.physics;
1843
+ if (!physics) continue;
1844
+ const tracked = this.core.anyState[desc.name];
1845
+ const plainColl = plainEntity(this.core.plain, desc.name);
1846
+ const rounders = roundersFor3(desc);
1847
+ for (const id of plainColl.ids()) {
1848
+ const key = bodyKey3(desc.name, id);
1849
+ const body = this.bodies.get(key);
1850
+ if (!body) continue;
1851
+ if (body.isSleeping()) {
1852
+ if (this.sleepSynced.has(key)) continue;
1853
+ this.sleepSynced.add(key);
1854
+ } else {
1855
+ this.sleepSynced.delete(key);
1856
+ }
1857
+ const record = tracked.get(id);
1858
+ if (!record) continue;
1859
+ const t = body.translation();
1860
+ const v = body.linvel();
1861
+ const state = {
1862
+ x: t.x,
1863
+ y: t.y,
1864
+ angle: body.rotation(),
1865
+ vx: v.x,
1866
+ vy: v.y,
1867
+ angularVelocity: body.angvel()
1868
+ };
1869
+ for (const [channel, field] of physics.channels) {
1870
+ const next = (rounders[field] ?? identity3)(channelOf2d2(channel, state));
1871
+ if (record[field] !== next) record[field] = next;
1872
+ }
1873
+ }
1874
+ }
1875
+ }
1876
+ // -------------------------------------------------------------------------
1877
+ // Hibernation
1878
+ // -------------------------------------------------------------------------
1879
+ /**
1880
+ * The same `PhysicsSection` the 3D runtime writes — a real engine snapshot plus the
1881
+ * entity↔handle map, whose handles must ride as f64 for the reason spelled out on
1882
+ * {@link PhysicsSection}. What tells the two apart on the way back in is the envelope
1883
+ * `core/physics.ts` wraps this in, not anything in here.
1884
+ */
1885
+ serialize() {
1886
+ const bodies = [];
1887
+ for (const [key, body] of this.bodies) {
1888
+ const sep = key.indexOf("\0");
1889
+ bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
1890
+ }
1891
+ return { world: this.world.takeSnapshot(), bodies };
1892
+ }
1893
+ };
1894
+ function identity3(v) {
1895
+ return v;
1896
+ }
1897
+ function roundersFor3(desc) {
1898
+ const out = {};
1899
+ for (const f of desc.fields) {
1900
+ if (f.type.kind === "f32") out[f.name] = Math.fround;
1901
+ }
1902
+ return out;
1903
+ }
1904
+
1602
1905
  // src/core/views.ts
1603
1906
  import {
1604
1907
  collectionDirty,
@@ -1963,8 +2266,723 @@ import {
1963
2266
  validateForDeploy
1964
2267
  } from "@irtio/schema";
1965
2268
 
2269
+ // src/core/history.ts
2270
+ var HISTORY_MAX_TICKS = 240;
2271
+ var STRIDE = 13;
2272
+ function emptyPose() {
2273
+ return { x: 0, y: 0, z: 0, qx: 0, qy: 0, qz: 0, qw: 1, vx: 0, vy: 0, vz: 0, wx: 0, wy: 0, wz: 0 };
2274
+ }
2275
+ function newEntry() {
2276
+ return { tick: -1, count: 0, collections: [], ids: [], values: new Float64Array(0) };
2277
+ }
2278
+ var PoseHistory = class {
2279
+ depth;
2280
+ entries = [];
2281
+ /** Index of the newest entry in `entries`, or -1 when nothing has been captured. */
2282
+ head = -1;
2283
+ size = 0;
2284
+ /** Reused across every body of every capture: the capture path allocates nothing per body. */
2285
+ scratchPose = emptyPose();
2286
+ constructor(depth) {
2287
+ this.depth = Math.max(1, Math.min(HISTORY_MAX_TICKS, Math.floor(depth)));
2288
+ for (let i = 0; i < this.depth; i++) this.entries.push(newEntry());
2289
+ }
2290
+ get length() {
2291
+ return this.size;
2292
+ }
2293
+ /** The newest tick captured, or `undefined` when the buffer is empty. */
2294
+ get newestTick() {
2295
+ return this.size === 0 ? void 0 : this.entries[this.head].tick;
2296
+ }
2297
+ /** The oldest tick still held, or `undefined` when the buffer is empty. */
2298
+ get oldestTick() {
2299
+ if (this.size === 0) return void 0;
2300
+ const i = (this.head - (this.size - 1) + this.depth * 2) % this.depth;
2301
+ return this.entries[i].tick;
2302
+ }
2303
+ /** Drops everything. Used by the hibernation path, where a buffer cannot survive. */
2304
+ clear() {
2305
+ this.head = -1;
2306
+ this.size = 0;
2307
+ }
2308
+ /** Captures one tick's poses off the live runtime. Called right after `physics.sync()`. */
2309
+ capture(tick, physics) {
2310
+ this.head = this.size === 0 ? 0 : (this.head + 1) % this.depth;
2311
+ if (this.size < this.depth) this.size++;
2312
+ const entry = this.entries[this.head];
2313
+ entry.tick = tick;
2314
+ entry.count = 0;
2315
+ const pose = this.scratchPose;
2316
+ const kind = physics.engineKind;
2317
+ physics.eachTrackedBody((collection, id, body) => {
2318
+ if (kind === "rapier3d") readRapierPose(body, pose);
2319
+ else if (kind === "rapier2d") readRapier2dPose(body, pose);
2320
+ else readMatterPose(body, pose);
2321
+ this.push(entry, collection, id, pose);
2322
+ });
2323
+ }
2324
+ push(entry, collection, id, pose) {
2325
+ const i = entry.count;
2326
+ const need = (i + 1) * STRIDE;
2327
+ if (entry.values.length < need) {
2328
+ const grown = new Float64Array(Math.max(need, entry.values.length * 2, STRIDE * 8));
2329
+ grown.set(entry.values);
2330
+ entry.values = grown;
2331
+ }
2332
+ entry.collections[i] = collection;
2333
+ entry.ids[i] = id;
2334
+ const v = entry.values;
2335
+ const o = i * STRIDE;
2336
+ v[o] = pose.x;
2337
+ v[o + 1] = pose.y;
2338
+ v[o + 2] = pose.z;
2339
+ v[o + 3] = pose.qx;
2340
+ v[o + 4] = pose.qy;
2341
+ v[o + 5] = pose.qz;
2342
+ v[o + 6] = pose.qw;
2343
+ v[o + 7] = pose.vx;
2344
+ v[o + 8] = pose.vy;
2345
+ v[o + 9] = pose.vz;
2346
+ v[o + 10] = pose.wx;
2347
+ v[o + 11] = pose.wy;
2348
+ v[o + 12] = pose.wz;
2349
+ entry.count = i + 1;
2350
+ }
2351
+ /**
2352
+ * The entry a rewind to `requested` answers from, clamped into the window the buffer actually
2353
+ * holds. `undefined` only when nothing has been captured at all.
2354
+ */
2355
+ resolve(requested) {
2356
+ if (this.size === 0) return void 0;
2357
+ const newest = this.entries[this.head].tick;
2358
+ const oldest = this.oldestTick;
2359
+ const want = Number.isFinite(requested) ? Math.floor(requested) : newest;
2360
+ const tick = want < oldest ? oldest : want > newest ? newest : want;
2361
+ const entry = this.at(tick);
2362
+ if (!entry) return void 0;
2363
+ return { entry, tick: entry.tick, clamped: tick !== want };
2364
+ }
2365
+ /**
2366
+ * The entry for exactly `tick`. Index arithmetic first (captures are one tick apart, so the
2367
+ * offset from the head is the tick difference), with a scan as insurance: a room whose loop
2368
+ * ever skipped a capture would otherwise be answered with the wrong tick's poses, and answering
2369
+ * for a tick that was not recorded is the one thing this buffer must never do.
2370
+ */
2371
+ at(tick) {
2372
+ const newest = this.entries[this.head].tick;
2373
+ const offset = newest - tick;
2374
+ if (offset >= 0 && offset < this.size) {
2375
+ const e = this.entries[(this.head - offset + this.depth * 2) % this.depth];
2376
+ if (e.tick === tick) return e;
2377
+ }
2378
+ for (let k = 0; k < this.size; k++) {
2379
+ const e = this.entries[(this.head - k + this.depth * 2) % this.depth];
2380
+ if (e.tick === tick) return e;
2381
+ }
2382
+ return void 0;
2383
+ }
2384
+ };
2385
+ function poseAt(entry, index, into) {
2386
+ const v = entry.values;
2387
+ const o = index * STRIDE;
2388
+ into.x = v[o];
2389
+ into.y = v[o + 1];
2390
+ into.z = v[o + 2];
2391
+ into.qx = v[o + 3];
2392
+ into.qy = v[o + 4];
2393
+ into.qz = v[o + 5];
2394
+ into.qw = v[o + 6];
2395
+ into.vx = v[o + 7];
2396
+ into.vy = v[o + 8];
2397
+ into.vz = v[o + 9];
2398
+ into.wx = v[o + 10];
2399
+ into.wy = v[o + 11];
2400
+ into.wz = v[o + 12];
2401
+ }
2402
+ function readRapierPose(body, into) {
2403
+ const t = body.translation();
2404
+ const r = body.rotation();
2405
+ const v = body.linvel();
2406
+ const w = body.angvel();
2407
+ into.x = t.x;
2408
+ into.y = t.y;
2409
+ into.z = t.z;
2410
+ into.qx = r.x;
2411
+ into.qy = r.y;
2412
+ into.qz = r.z;
2413
+ into.qw = r.w;
2414
+ into.vx = v.x;
2415
+ into.vy = v.y;
2416
+ into.vz = v.z;
2417
+ into.wx = w.x;
2418
+ into.wy = w.y;
2419
+ into.wz = w.z;
2420
+ }
2421
+ function readRapier2dPose(body, into) {
2422
+ const t = body.translation();
2423
+ const angle = body.rotation();
2424
+ const v = body.linvel();
2425
+ into.x = t.x;
2426
+ into.y = t.y;
2427
+ into.z = 0;
2428
+ into.qx = 0;
2429
+ into.qy = 0;
2430
+ into.qz = Math.sin(angle / 2);
2431
+ into.qw = Math.cos(angle / 2);
2432
+ into.vx = v.x;
2433
+ into.vy = v.y;
2434
+ into.vz = 0;
2435
+ into.wx = 0;
2436
+ into.wy = 0;
2437
+ into.wz = body.angvel();
2438
+ }
2439
+ function readMatterPose(body, into) {
2440
+ into.x = body.position.x;
2441
+ into.y = body.position.y;
2442
+ into.z = 0;
2443
+ into.qx = 0;
2444
+ into.qy = 0;
2445
+ into.qz = Math.sin(body.angle / 2);
2446
+ into.qw = Math.cos(body.angle / 2);
2447
+ into.vx = body.velocity.x;
2448
+ into.vy = body.velocity.y;
2449
+ into.vz = 0;
2450
+ into.wx = 0;
2451
+ into.wy = 0;
2452
+ into.wz = body.angularVelocity;
2453
+ }
2454
+ function historyDepthOf(config) {
2455
+ const declared = config?.history;
2456
+ if (typeof declared !== "number" || !Number.isFinite(declared) || declared <= 0) return void 0;
2457
+ return Math.min(HISTORY_MAX_TICKS, Math.floor(declared));
2458
+ }
2459
+ function keyOf(collection, id) {
2460
+ return `${collection} ${id}`;
2461
+ }
2462
+ function angleOf(qz, qw) {
2463
+ return 2 * Math.atan2(qz, qw);
2464
+ }
2465
+ var BaseScratch = class {
2466
+ constructor(physics, depth, tickNow) {
2467
+ this.physics = physics;
2468
+ this.depth = depth;
2469
+ this.tickNow = tickNow;
2470
+ }
2471
+ physics;
2472
+ depth;
2473
+ tickNow;
2474
+ bodies = /* @__PURE__ */ new Map();
2475
+ present = /* @__PURE__ */ new Set();
2476
+ pose = emptyPose();
2477
+ view(entry, tick, requested, clamped) {
2478
+ this.sync();
2479
+ this.present.clear();
2480
+ for (let i = 0; i < entry.count; i++) {
2481
+ const key = keyOf(entry.collections[i], entry.ids[i]);
2482
+ const e = this.bodies.get(key);
2483
+ if (!e) continue;
2484
+ poseAt(entry, i, this.pose);
2485
+ this.place(e, this.pose);
2486
+ this.present.add(key);
2487
+ }
2488
+ for (const [key, e] of this.bodies) {
2489
+ if (!this.present.has(key)) this.hide(e);
2490
+ }
2491
+ return this.build(tick, requested, clamped);
2492
+ }
2493
+ /**
2494
+ * Brings the scratch's body set in step with the live world: a double for every live body that
2495
+ * has none, a rebuild for one whose collider (or part) count changed, and a prune for one that
2496
+ * has been gone longer than the history is deep and so can no longer appear in any entry.
2497
+ *
2498
+ * The prune is conservative rather than exact: `lastLive` only advances when a rewind happens,
2499
+ * so a room that rewinds rarely holds its dead doubles a little longer than it strictly must.
2500
+ * Over-retention is a few hundred bytes; under-retention would be a wrong answer.
2501
+ */
2502
+ sync() {
2503
+ const now = this.tickNow();
2504
+ const seen = /* @__PURE__ */ new Set();
2505
+ this.physics.eachTrackedBody((collection, id, body) => {
2506
+ const key = keyOf(collection, id);
2507
+ seen.add(key);
2508
+ const parts = this.partsOf(body);
2509
+ const existing = this.bodies.get(key);
2510
+ if (existing && existing.parts === parts) {
2511
+ existing.lastLive = now;
2512
+ return;
2513
+ }
2514
+ if (existing) {
2515
+ this.bodies.delete(key);
2516
+ this.destroy(existing);
2517
+ }
2518
+ const made = this.clone(collection, id, body, parts);
2519
+ if (!made) return;
2520
+ made.lastLive = now;
2521
+ this.bodies.set(key, made);
2522
+ });
2523
+ for (const [key, e] of [...this.bodies]) {
2524
+ if (seen.has(key)) continue;
2525
+ if (now - e.lastLive <= this.depth) continue;
2526
+ this.bodies.delete(key);
2527
+ this.destroy(e);
2528
+ }
2529
+ }
2530
+ };
2531
+ function qConj(q) {
2532
+ return { x: -q.x, y: -q.y, z: -q.z, w: q.w };
2533
+ }
2534
+ function qMul(a, b) {
2535
+ return {
2536
+ x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
2537
+ y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
2538
+ z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
2539
+ w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z
2540
+ };
2541
+ }
2542
+ function qRotate(q, v) {
2543
+ const p = qMul(qMul(q, { x: v.x, y: v.y, z: v.z, w: 0 }), qConj(q));
2544
+ return { x: p.x, y: p.y, z: p.z };
2545
+ }
2546
+ var RapierScratch = class extends BaseScratch {
2547
+ world;
2548
+ rapier;
2549
+ owners = /* @__PURE__ */ new Map();
2550
+ /** Live rigid-body handles the room tracks, so the statics pass knows what to skip. */
2551
+ trackedLive = /* @__PURE__ */ new Set();
2552
+ staticsCloned = false;
2553
+ constructor(physics, depth, tickNow) {
2554
+ super(physics, depth, tickNow);
2555
+ this.rapier = physics.rapier;
2556
+ this.world = new this.rapier.World({ x: 0, y: 0, z: 0 });
2557
+ this.world.timestep = 0;
2558
+ }
2559
+ partsOf(body) {
2560
+ return body.numColliders();
2561
+ }
2562
+ clone(collection, id, body, parts) {
2563
+ const live = body;
2564
+ this.trackedLive.add(live.handle);
2565
+ const made = this.world.createRigidBody(this.rapier.RigidBodyDesc.fixed());
2566
+ const origin = live.translation();
2567
+ const rot = live.rotation();
2568
+ const inv = qConj(rot);
2569
+ for (let i = 0; i < parts; i++) {
2570
+ const c = live.collider(i);
2571
+ const desc = new this.rapier.ColliderDesc(c.shape);
2572
+ desc.setSensor(c.isSensor());
2573
+ const w = c.translation();
2574
+ const local = qRotate(inv, { x: w.x - origin.x, y: w.y - origin.y, z: w.z - origin.z });
2575
+ desc.setTranslation(local.x, local.y, local.z);
2576
+ desc.setRotation(qMul(inv, c.rotation()));
2577
+ this.world.createCollider(desc, made);
2578
+ }
2579
+ this.owners.set(made.handle, { collection, id });
2580
+ return { collection, id, body: made, parts, lastLive: -1 };
2581
+ }
2582
+ place(entry, pose) {
2583
+ const b = entry.body;
2584
+ if (!b.isEnabled()) b.setEnabled(true);
2585
+ b.setTranslation({ x: pose.x, y: pose.y, z: pose.z }, false);
2586
+ b.setRotation({ x: pose.qx, y: pose.qy, z: pose.qz, w: pose.qw }, false);
2587
+ }
2588
+ /**
2589
+ * A body the requested tick did not have. `setEnabled(false)` takes the body and its colliders
2590
+ * out of the broad phase, so a query cannot hit it, which is the whole point: a body created
2591
+ * after the tick the shooter was looking at was not on their screen and must not be hittable.
2592
+ */
2593
+ hide(entry) {
2594
+ if (entry.body.isEnabled()) entry.body.setEnabled(false);
2595
+ }
2596
+ destroy(entry) {
2597
+ this.owners.delete(entry.body.handle);
2598
+ this.world.removeRigidBody(entry.body);
2599
+ }
2600
+ build(tick, requested, clamped) {
2601
+ this.cloneStatics();
2602
+ this.world.step();
2603
+ const owners = this.owners;
2604
+ return {
2605
+ tick,
2606
+ requested,
2607
+ clamped,
2608
+ rapier: {
2609
+ world: this.world,
2610
+ who: (collider) => {
2611
+ const parent = collider.parent();
2612
+ return parent ? owners.get(parent.handle) : void 0;
2613
+ }
2614
+ }
2615
+ };
2616
+ }
2617
+ /**
2618
+ * The live world's static geometry, everything `physics.setup` built, copied in once at the
2619
+ * first rewind. It is "as it stands now" rather than "as it stood then": static colliders do not
2620
+ * move, and a room that moves one has told the engine something that is not true.
2621
+ */
2622
+ cloneStatics() {
2623
+ if (this.staticsCloned) return;
2624
+ this.staticsCloned = true;
2625
+ const live = this.physics.world;
2626
+ if (!live) return;
2627
+ live.forEachCollider((c) => {
2628
+ const parent = c.parent();
2629
+ if (parent !== null && (!parent.isFixed() || this.trackedLive.has(parent.handle))) return;
2630
+ const desc = new this.rapier.ColliderDesc(c.shape);
2631
+ desc.setSensor(c.isSensor());
2632
+ const t = c.translation();
2633
+ desc.setTranslation(t.x, t.y, t.z);
2634
+ desc.setRotation(c.rotation());
2635
+ this.world.createCollider(desc);
2636
+ });
2637
+ }
2638
+ free() {
2639
+ this.owners.clear();
2640
+ this.world.free();
2641
+ }
2642
+ };
2643
+ var Rapier2dScratch = class extends BaseScratch {
2644
+ world;
2645
+ rapier;
2646
+ owners = /* @__PURE__ */ new Map();
2647
+ /** Live rigid-body handles the room tracks, so the statics pass knows what to skip. */
2648
+ trackedLive = /* @__PURE__ */ new Set();
2649
+ staticsCloned = false;
2650
+ constructor(physics, depth, tickNow) {
2651
+ super(physics, depth, tickNow);
2652
+ this.rapier = physics.rapier;
2653
+ this.world = new this.rapier.World({ x: 0, y: 0 });
2654
+ this.world.timestep = 0;
2655
+ }
2656
+ partsOf(body) {
2657
+ return body.numColliders();
2658
+ }
2659
+ clone(collection, id, body, parts) {
2660
+ const live = body;
2661
+ this.trackedLive.add(live.handle);
2662
+ const made = this.world.createRigidBody(this.rapier.RigidBodyDesc.fixed());
2663
+ const origin = live.translation();
2664
+ const angle = live.rotation();
2665
+ const cos = Math.cos(-angle);
2666
+ const sin = Math.sin(-angle);
2667
+ for (let i = 0; i < parts; i++) {
2668
+ const c = live.collider(i);
2669
+ const desc = new this.rapier.ColliderDesc(c.shape);
2670
+ desc.setSensor(c.isSensor());
2671
+ const w = c.translation();
2672
+ const dx = w.x - origin.x;
2673
+ const dy = w.y - origin.y;
2674
+ desc.setTranslation(dx * cos - dy * sin, dx * sin + dy * cos);
2675
+ desc.setRotation(c.rotation() - angle);
2676
+ this.world.createCollider(desc, made);
2677
+ }
2678
+ this.owners.set(made.handle, { collection, id });
2679
+ return { collection, id, body: made, parts, lastLive: -1 };
2680
+ }
2681
+ place(entry, pose) {
2682
+ const b = entry.body;
2683
+ if (!b.isEnabled()) b.setEnabled(true);
2684
+ b.setTranslation({ x: pose.x, y: pose.y }, false);
2685
+ b.setRotation(angleOf(pose.qz, pose.qw), false);
2686
+ }
2687
+ hide(entry) {
2688
+ if (entry.body.isEnabled()) entry.body.setEnabled(false);
2689
+ }
2690
+ destroy(entry) {
2691
+ this.owners.delete(entry.body.handle);
2692
+ this.world.removeRigidBody(entry.body);
2693
+ }
2694
+ build(tick, requested, clamped) {
2695
+ this.cloneStatics();
2696
+ this.world.step();
2697
+ const owners = this.owners;
2698
+ return {
2699
+ tick,
2700
+ requested,
2701
+ clamped,
2702
+ rapier2d: {
2703
+ world: this.world,
2704
+ who: (collider) => {
2705
+ const parent = collider.parent();
2706
+ return parent ? owners.get(parent.handle) : void 0;
2707
+ }
2708
+ }
2709
+ };
2710
+ }
2711
+ cloneStatics() {
2712
+ if (this.staticsCloned) return;
2713
+ this.staticsCloned = true;
2714
+ const live = this.physics.world;
2715
+ if (!live) return;
2716
+ live.forEachCollider((c) => {
2717
+ const parent = c.parent();
2718
+ if (parent !== null && (!parent.isFixed() || this.trackedLive.has(parent.handle))) return;
2719
+ const desc = new this.rapier.ColliderDesc(c.shape);
2720
+ desc.setSensor(c.isSensor());
2721
+ const t = c.translation();
2722
+ desc.setTranslation(t.x, t.y);
2723
+ desc.setRotation(c.rotation());
2724
+ this.world.createCollider(desc);
2725
+ });
2726
+ }
2727
+ free() {
2728
+ this.owners.clear();
2729
+ this.world.free();
2730
+ }
2731
+ };
2732
+ var MatterScratch = class extends BaseScratch {
2733
+ matter;
2734
+ owners = /* @__PURE__ */ new Map();
2735
+ /** Rebuilt per rewind: the doubles the requested tick actually had, plus the live statics. */
2736
+ visible = [];
2737
+ constructor(physics, depth, tickNow) {
2738
+ super(physics, depth, tickNow);
2739
+ this.matter = physics.matter;
2740
+ }
2741
+ partsOf(body) {
2742
+ return body.parts.length;
2743
+ }
2744
+ clone(collection, id, body, parts) {
2745
+ const M = this.matter;
2746
+ const live = body;
2747
+ const made = live.parts.length > 1 ? M.Body.create({ parts: live.parts.slice(1).map((p) => this.clonePart(p, live.angle)) }) : this.clonePart(live, live.angle);
2748
+ M.Body.setPosition(made, { x: 0, y: 0 });
2749
+ made.angle = 0;
2750
+ for (const p of made.parts) p.angle = 0;
2751
+ this.owners.set(made, { collection, id });
2752
+ return { collection, id, body: made, parts, lastLive: -1 };
2753
+ }
2754
+ /** One part, un-rotated by the parent's angle so the double starts at angle 0. */
2755
+ clonePart(part, parentAngle) {
2756
+ const M = this.matter;
2757
+ const verts = part.vertices.map((v) => ({ x: v.x, y: v.y }));
2758
+ if (parentAngle !== 0) M.Vertices.rotate(verts, -parentAngle, part.position);
2759
+ const made = M.Body.create({});
2760
+ M.Body.setVertices(made, verts);
2761
+ return made;
2762
+ }
2763
+ place(entry, pose) {
2764
+ const M = this.matter;
2765
+ M.Body.setAngle(entry.body, angleOf(pose.qz, pose.qw));
2766
+ M.Body.setPosition(entry.body, { x: pose.x, y: pose.y });
2767
+ this.visible.push(entry.body);
2768
+ }
2769
+ /** Nothing to undo: `visible` is rebuilt per rewind, so a body nobody placed is not in it. */
2770
+ hide() {
2771
+ }
2772
+ destroy(entry) {
2773
+ this.owners.delete(entry.body);
2774
+ }
2775
+ view(entry, tick, requested, clamped) {
2776
+ this.visible.length = 0;
2777
+ return super.view(entry, tick, requested, clamped);
2778
+ }
2779
+ build(tick, requested, clamped) {
2780
+ const M = this.matter;
2781
+ const engine4 = this.physics.matterEngine;
2782
+ if (engine4) {
2783
+ for (const b of M.Composite.allBodies(engine4.world)) {
2784
+ if (b.isStatic) this.visible.push(b);
2785
+ }
2786
+ }
2787
+ const owners = this.owners;
2788
+ return {
2789
+ tick,
2790
+ requested,
2791
+ clamped,
2792
+ matter: {
2793
+ bodies: this.visible,
2794
+ who: (body) => owners.get(body)
2795
+ }
2796
+ };
2797
+ }
2798
+ free() {
2799
+ this.owners.clear();
2800
+ this.visible.length = 0;
2801
+ }
2802
+ };
2803
+ var RewindState = class {
2804
+ history;
2805
+ scratch;
2806
+ inside = false;
2807
+ constructor(depth) {
2808
+ this.history = new PoseHistory(depth);
2809
+ }
2810
+ free() {
2811
+ this.scratch?.free();
2812
+ this.scratch = void 0;
2813
+ }
2814
+ run(physics, tickNow, requested, fn) {
2815
+ if (this.inside) {
2816
+ throw new Error(
2817
+ "room.rewind: already inside a rewind. The second call would repose the same scratch world under the query still reading it, so it is refused rather than answered wrongly."
2818
+ );
2819
+ }
2820
+ const resolved = this.history.resolve(requested);
2821
+ if (!resolved) {
2822
+ throw new Error(
2823
+ `room.rewind: this room has recorded no ticks yet, so there is no past to answer from. A woken room starts with an empty history and fills it over its next ${this.history.depth} tick(s).`
2824
+ );
2825
+ }
2826
+ this.scratch ??= physics.engineKind === "rapier3d" ? new RapierScratch(physics, this.history.depth, tickNow) : physics.engineKind === "rapier2d" ? new Rapier2dScratch(physics, this.history.depth, tickNow) : new MatterScratch(physics, this.history.depth, tickNow);
2827
+ const view = this.scratch.view(resolved.entry, resolved.tick, requested, resolved.clamped);
2828
+ this.inside = true;
2829
+ try {
2830
+ return fn(view);
2831
+ } finally {
2832
+ this.inside = false;
2833
+ }
2834
+ }
2835
+ };
2836
+
2837
+ // src/core/lobby.ts
2838
+ import {
2839
+ LOBBY_MEMBERS,
2840
+ LOBBY_MIN_PLAYERS,
2841
+ LOBBY_STATE,
2842
+ schemaHasLobby
2843
+ } from "@irtio/server";
2844
+ var runtimes = /* @__PURE__ */ new WeakMap();
2845
+ function runtimeOf(core) {
2846
+ let r = runtimes.get(core);
2847
+ if (!r) {
2848
+ r = { onStart: [], started: false };
2849
+ runtimes.set(core, r);
2850
+ }
2851
+ return r;
2852
+ }
2853
+ function lobbyConfigOf(core) {
2854
+ const declared = core.definition.config.lobby;
2855
+ if (declared === void 0) return void 0;
2856
+ if (!schemaHasLobby(core.definition.schema)) return void 0;
2857
+ return declared;
2858
+ }
2859
+ function stateOf3(core) {
2860
+ return core.anyState[LOBBY_STATE];
2861
+ }
2862
+ function evaluateLobby(core) {
2863
+ const config = lobbyConfigOf(core);
2864
+ if (config === void 0) return;
2865
+ const state = stateOf3(core);
2866
+ if (state === void 0) return;
2867
+ const members = trackedEntity(core, LOBBY_MEMBERS);
2868
+ const policy = config.start ?? "when-full";
2869
+ const present = [];
2870
+ for (const [clientId, entry] of core.clients) {
2871
+ if (!entry.connected) continue;
2872
+ present.push(clientId);
2873
+ if (!members.has(clientId)) members.add(clientId, { ready: false }, { owner: clientId });
2874
+ else if (members.ownerOf(clientId) !== clientId) members.setOwner(clientId, clientId);
2875
+ }
2876
+ const here = new Set(present);
2877
+ for (const id of [...members.ids()]) {
2878
+ if (!here.has(id)) members.remove(id);
2879
+ }
2880
+ const readyCount = policy === "when-ready" ? present.filter((id) => members.get(id)?.ready === true).length : 0;
2881
+ const capacity = Math.min(
2882
+ 255,
2883
+ config.size ?? core.definition.config.maxClients,
2884
+ core.definition.config.maxClients
2885
+ );
2886
+ const min = Math.max(LOBBY_MIN_PLAYERS, config.min ?? LOBBY_MIN_PLAYERS);
2887
+ if (state.readyUi !== (policy === "when-ready")) state.readyUi = policy === "when-ready";
2888
+ if (state.present !== present.length) state.present = Math.min(255, present.length);
2889
+ if (state.ready !== readyCount) state.ready = readyCount;
2890
+ if (state.capacity !== capacity) state.capacity = capacity;
2891
+ if (state.phase !== "lobby") return;
2892
+ if (policy === "when-full") {
2893
+ if (present.length >= capacity && capacity >= LOBBY_MIN_PLAYERS) startLobby(core, "when-full");
2894
+ return;
2895
+ }
2896
+ if (policy === "when-ready") {
2897
+ if (present.length >= min && readyCount === present.length) startLobby(core, "when-ready");
2898
+ return;
2899
+ }
2900
+ }
2901
+ function startLobby(core, why) {
2902
+ const runtime = runtimeOf(core);
2903
+ if (runtime.started) return;
2904
+ const state = stateOf3(core);
2905
+ if (state === void 0 || state.phase === "started") {
2906
+ runtime.started = true;
2907
+ return;
2908
+ }
2909
+ runtime.started = true;
2910
+ state.phase = "started";
2911
+ state.public = false;
2912
+ core.host.setLobby({ started: true });
2913
+ core.log("info", `lobby: the game started (${why})`);
2914
+ const declared = lobbyConfigOf(core)?.onStart;
2915
+ if (declared) {
2916
+ core.guard("lobby.onStart", () => declared(core.anyState, core.room));
2917
+ }
2918
+ for (const cb of [...runtime.onStart]) core.guard("lobby.onStart(cb)", cb);
2919
+ }
2920
+ function makeLobby(core) {
2921
+ const config = lobbyConfigOf(core);
2922
+ if (config === void 0) {
2923
+ throw new Error(
2924
+ "room.lobby: this room has no lobby. Spread lobbyCollections into the schema and declare lobby: {} in defineRoom \u2014 both are needed, and defineRoom refuses either one alone"
2925
+ );
2926
+ }
2927
+ const state = stateOf3(core);
2928
+ const members = trackedEntity(core, LOBBY_MEMBERS);
2929
+ return {
2930
+ get phase() {
2931
+ return state.phase ?? "lobby";
2932
+ },
2933
+ get ready() {
2934
+ const out = [];
2935
+ for (const [clientId, entry] of core.clients) {
2936
+ if (!entry.connected) continue;
2937
+ out.push({
2938
+ clientId,
2939
+ ready: members.get(clientId)?.ready === true
2940
+ });
2941
+ }
2942
+ return out;
2943
+ },
2944
+ get public() {
2945
+ return state.public === true;
2946
+ },
2947
+ start() {
2948
+ startLobby(core, "room.lobby.start()");
2949
+ },
2950
+ onStart(cb) {
2951
+ if (typeof cb !== "function") {
2952
+ core.log("warn", "room.lobby.onStart: expects a function; ignored");
2953
+ return;
2954
+ }
2955
+ runtimeOf(core).onStart.push(cb);
2956
+ },
2957
+ setPublic(value) {
2958
+ setLobbyPublic(core, value === true);
2959
+ }
2960
+ };
2961
+ }
2962
+ function setLobbyPublic(core, value) {
2963
+ const config = lobbyConfigOf(core);
2964
+ if (config === void 0) return;
2965
+ const state = stateOf3(core);
2966
+ if (state === void 0) return;
2967
+ if (state.phase === "started") {
2968
+ core.log(
2969
+ "warn",
2970
+ "room.lobby.setPublic: the game has started; the room stays out of the registry"
2971
+ );
2972
+ return;
2973
+ }
2974
+ const veto = config.onSetPublic;
2975
+ if (veto) {
2976
+ const outcome = core.tryRun("lobby.onSetPublic", () => veto(core.anyState, value));
2977
+ if (!outcome.ok || outcome.value === false) return;
2978
+ }
2979
+ if (state.public !== value) state.public = value;
2980
+ core.host.setLobby({ public: value, queue: config.queue ?? "default" });
2981
+ }
2982
+
1966
2983
  // src/core/messages.ts
1967
2984
  import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
2985
+ import { decodeFields as decodeFields2 } from "@irtio/schema";
1968
2986
  function toRoomTarget(target) {
1969
2987
  switch (target.kind) {
1970
2988
  case "all":
@@ -1985,7 +3003,28 @@ function deliver(core, target, frame, exclude) {
1985
3003
  if (entry.clientId === exclude) continue;
1986
3004
  if (target.kind === "client" && entry.clientId !== target.clientId) continue;
1987
3005
  if (target.kind === "role" && entry.role !== target.role) continue;
1988
- core.send(entry.clientId, frame);
3006
+ core.send(entry.clientId, frame.slice());
3007
+ }
3008
+ }
3009
+ function messagesOf(core) {
3010
+ return core.definition.schema.messages ?? [];
3011
+ }
3012
+ function decodeTyped(core, clientId, index, payload) {
3013
+ const desc = messagesOf(core)[index];
3014
+ if (!desc) {
3015
+ core.stats.messagesDropped++;
3016
+ core.log(
3017
+ "warn",
3018
+ `typed MSG from ${clientId}: no message with index ${index} in this schema; dropped`
3019
+ );
3020
+ return void 0;
3021
+ }
3022
+ try {
3023
+ return { name: desc.name, value: decodeFields2(desc.fields, payload) };
3024
+ } catch (err) {
3025
+ core.stats.messagesDropped++;
3026
+ core.log("warn", `typed MSG ${desc.name} from ${clientId} failed to decode:`, err);
3027
+ return void 0;
1989
3028
  }
1990
3029
  }
1991
3030
  function handleMsg(core, clientId, payload) {
@@ -2001,31 +3040,54 @@ function handleMsg(core, clientId, payload) {
2001
3040
  core.log("warn", `voice MSG from ${clientId} reached room code; dropped (supervisor bug)`);
2002
3041
  return true;
2003
3042
  }
3043
+ if (msg.typed && msg.target.kind === "server") {
3044
+ core.stats.messagesDropped++;
3045
+ core.log("warn", `typed MSG from ${clientId} addressed the server; dropped`);
3046
+ return true;
3047
+ }
2004
3048
  const roomTarget = toRoomTarget(msg.target);
2005
3049
  if (roomTarget === void 0) return true;
3050
+ let typed;
3051
+ if (msg.typed) {
3052
+ typed = decodeTyped(core, clientId, msg.typed.index, msg.payload);
3053
+ if (!typed) return true;
3054
+ }
2006
3055
  const onMessage = core.definition.config.onMessage;
2007
3056
  if (onMessage) {
2008
3057
  const ran = core.tryRun(
2009
3058
  "onMessage",
2010
- () => onMessage(core.anyState, clientId, roomTarget, msg.payload, core.ctxFor(clientId))
3059
+ () => onMessage(core.anyState, clientId, roomTarget, msg.payload, core.ctxFor(clientId), typed)
2011
3060
  );
2012
3061
  if (!ran.ok || ran.value === false) return true;
2013
3062
  }
2014
3063
  if (msg.target.kind === "server") return true;
2015
3064
  const frame = encodeFrame2(
2016
3065
  FrameType3.MSG,
2017
- encodeMsg({ target: { kind: "client", clientId }, payload: msg.payload })
3066
+ encodeMsg({
3067
+ target: { kind: "client", clientId },
3068
+ payload: msg.payload,
3069
+ ...msg.typed ? { typed: msg.typed } : {}
3070
+ })
2018
3071
  );
2019
3072
  deliver(core, msg.target, frame, clientId);
2020
3073
  return true;
2021
3074
  }
3075
+ function wireTargetOf(target) {
3076
+ return target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
3077
+ }
2022
3078
  function sendMessage(core, target, bytes) {
2023
- const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
2024
3079
  const frame = encodeFrame2(
2025
3080
  FrameType3.MSG,
2026
3081
  encodeMsg({ target: { kind: "server" }, payload: bytes })
2027
3082
  );
2028
- deliver(core, wire, frame);
3083
+ deliver(core, wireTargetOf(target), frame);
3084
+ }
3085
+ function sendTypedMessage(core, index, target, payload) {
3086
+ const frame = encodeFrame2(
3087
+ FrameType3.MSG,
3088
+ encodeMsg({ target: { kind: "server" }, payload, typed: { index } })
3089
+ );
3090
+ deliver(core, wireTargetOf(target), frame);
2029
3091
  }
2030
3092
 
2031
3093
  // src/core/room-api.ts
@@ -2035,7 +3097,7 @@ import {
2035
3097
  busPayloadProblem,
2036
3098
  encodeFrame as encodeFrame3
2037
3099
  } from "@irtio/protocol";
2038
- import { encodeDelta as encodeDelta3, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
3100
+ import { encodeDelta as encodeDelta3, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
2039
3101
  function makePhysicsApi(p) {
2040
3102
  return {
2041
3103
  get rapier() {
@@ -2068,6 +3130,22 @@ function makeMatterApi(p) {
2068
3130
  }
2069
3131
  };
2070
3132
  }
3133
+ function makeRapier2dApi(p) {
3134
+ return {
3135
+ get rapier() {
3136
+ return p.rapier;
3137
+ },
3138
+ get world() {
3139
+ return p.world;
3140
+ },
3141
+ get timestep() {
3142
+ return p.timestep;
3143
+ },
3144
+ body(collection, id) {
3145
+ return p.bodyFor(collection, id);
3146
+ }
3147
+ };
3148
+ }
2071
3149
  function makeKv(core) {
2072
3150
  return {
2073
3151
  get(playerId, key) {
@@ -2083,7 +3161,7 @@ function makeKv(core) {
2083
3161
  }
2084
3162
  function makeLeaderboard(core) {
2085
3163
  return {
2086
- submit(board, playerId, score) {
3164
+ submit(board, playerId, score, options) {
2087
3165
  if (!Number.isInteger(score)) {
2088
3166
  return rejectAsEvent(
2089
3167
  core,
@@ -2092,7 +3170,17 @@ function makeLeaderboard(core) {
2092
3170
  )
2093
3171
  );
2094
3172
  }
2095
- return startHostCall(core, { kind: "lbSubmit", board, playerId, score }, () => void 0);
3173
+ return startHostCall(
3174
+ core,
3175
+ {
3176
+ kind: "lbSubmit",
3177
+ board,
3178
+ playerId,
3179
+ score,
3180
+ ...options?.bucket === void 0 ? {} : { bucket: options.bucket }
3181
+ },
3182
+ () => void 0
3183
+ );
2096
3184
  }
2097
3185
  };
2098
3186
  }
@@ -2173,6 +3261,7 @@ function createRoomApi(core, publicUrl) {
2173
3261
  let lastNow = Number.NEGATIVE_INFINITY;
2174
3262
  let physicsApi;
2175
3263
  let matterApi;
3264
+ let rapier2dApi;
2176
3265
  let kvApi;
2177
3266
  let busApi;
2178
3267
  let leaderboardApi;
@@ -2183,6 +3272,14 @@ function createRoomApi(core, publicUrl) {
2183
3272
  const sep = publicUrl.includes("?") ? "&" : "?";
2184
3273
  return `${publicUrl}${sep}room=${encodeURIComponent(core.roomId)}`;
2185
3274
  };
3275
+ const messages = {};
3276
+ for (const desc of core.definition.schema.messages ?? []) {
3277
+ messages[desc.name] = {
3278
+ send(target, value) {
3279
+ sendTypedMessage(core, desc.index, target, encodeFields2(desc.fields, value));
3280
+ }
3281
+ };
3282
+ }
2186
3283
  const room = {
2187
3284
  get id() {
2188
3285
  return core.roomId;
@@ -2226,7 +3323,7 @@ function createRoomApi(core, publicUrl) {
2226
3323
  }
2227
3324
  if (p.engineKind !== "rapier3d") {
2228
3325
  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)"
3326
+ p.engineKind === "matter2d" ? "room.physics: this room runs matter2d. Read room.physics2d instead (it has .matter and .engine, where this one has .rapier and .world)" : "room.physics: this room runs rapier2d. Read room.physicsRapier2d instead (same .rapier and .world, but the 2D module: a Vector2 world, not a Vector3 one)"
2230
3327
  );
2231
3328
  }
2232
3329
  return physicsApi ?? (physicsApi = makePhysicsApi(p));
@@ -2240,14 +3337,36 @@ function createRoomApi(core, publicUrl) {
2240
3337
  }
2241
3338
  if (p.engineKind !== "matter2d") {
2242
3339
  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)"
3340
+ p.engineKind === "rapier2d" ? "room.physics2d: this room runs rapier2d, not matter2d. Read room.physicsRapier2d instead (it has .rapier and .world, where this one has .matter and .engine)" : "room.physics2d: this room runs rapier3d. Read room.physics instead (it has .rapier and .world, where this one has .matter and .engine)"
2244
3341
  );
2245
3342
  }
2246
3343
  return matterApi ?? (matterApi = makeMatterApi(p));
2247
3344
  },
3345
+ get physicsRapier2d() {
3346
+ const p = core.physics;
3347
+ if (!p) {
3348
+ throw new Error(
3349
+ "room.physicsRapier2d: this room has no physics \u2014 add physics: { engine: 'rapier2d', gravity, bodies } to defineRoom(...)"
3350
+ );
3351
+ }
3352
+ if (p.engineKind !== "rapier2d") {
3353
+ throw new Error(
3354
+ p.engineKind === "matter2d" ? "room.physicsRapier2d: this room runs matter2d. Read room.physics2d instead (it has .matter and .engine, where this one has .rapier and .world)" : "room.physicsRapier2d: this room runs rapier3d. Read room.physics instead (same .rapier and .world, but the 3D module: a Vector3 world, not a Vector2 one)"
3355
+ );
3356
+ }
3357
+ return rapier2dApi ?? (rapier2dApi = makeRapier2dApi(p));
3358
+ },
3359
+ // ---- M6 lane F: rewind ----
3360
+ // D72: one line, and deliberately only one. Everything the rewind does — the buffer, the
3361
+ // clamping, the scratch worlds, the reentrancy refusal — lives in `core/history.ts` behind
3362
+ // `RoomCore.rewind`, so this file stays what it says it is: a facade that reads through.
3363
+ rewind(tick, fn) {
3364
+ return core.rewind(tick, fn);
3365
+ },
2248
3366
  send(target, bytes) {
2249
3367
  sendMessage(core, target, bytes);
2250
3368
  },
3369
+ messages,
2251
3370
  setRole(clientId, role) {
2252
3371
  setRole(core, clientId, role);
2253
3372
  cached = void 0;
@@ -2313,6 +3432,10 @@ function createRoomApi(core, publicUrl) {
2313
3432
  get bus() {
2314
3433
  return busApi ?? (busApi = makeBus(core));
2315
3434
  },
3435
+ // ---- M6 lane H: lobby front door ----
3436
+ get lobby() {
3437
+ return makeLobby(core);
3438
+ },
2316
3439
  alarm(name, atMs) {
2317
3440
  if (!isAlarmName(core, name, "room.alarm")) return;
2318
3441
  if (!Number.isFinite(atMs)) {
@@ -2493,7 +3616,8 @@ var RoomCore = class _RoomCore {
2493
3616
  visibleIdsTotal: 0,
2494
3617
  membershipEnters: 0,
2495
3618
  membershipLeaves: 0,
2496
- corrections: 0
3619
+ corrections: 0,
3620
+ messagesDropped: 0
2497
3621
  };
2498
3622
  tick = 0;
2499
3623
  stopped = false;
@@ -2515,6 +3639,13 @@ var RoomCore = class _RoomCore {
2515
3639
  * cost the profiler has is behind this `undefined`.
2516
3640
  */
2517
3641
  ledger;
3642
+ /**
3643
+ * D72: the pose history and the rewind scratch, present only when the room's physics config
3644
+ * declares `history`. Every cost this lane has is behind this `undefined`, and it is
3645
+ * deliberately not in the hibernation blob: a woken room starts with an empty buffer and fills
3646
+ * it again over its next `history` ticks.
3647
+ */
3648
+ rewindState;
2518
3649
  seed;
2519
3650
  api;
2520
3651
  internals;
@@ -2558,6 +3689,8 @@ var RoomCore = class _RoomCore {
2558
3689
  this.loop = new Loop(self);
2559
3690
  this.api = createRoomApi(self, options.publicUrl ?? DEFAULT_PUBLIC_URL);
2560
3691
  this.physics = this.buildPhysics(restored);
3692
+ const historyDepth = this.physics ? historyDepthOf(definition.config.physics) : void 0;
3693
+ this.rewindState = historyDepth === void 0 ? void 0 : new RewindState(historyDepth);
2561
3694
  this.subscribeDeclaredChannels();
2562
3695
  if (restored) {
2563
3696
  const onWake = definition.config.onWake;
@@ -2640,14 +3773,24 @@ var RoomCore = class _RoomCore {
2640
3773
  const config = this.definition.config.physics;
2641
3774
  if (!config) return void 0;
2642
3775
  if (config.engine === "matter2d") return this.buildMatter(config, restored);
2643
- const engine3 = loadedPhysics();
2644
- if (!engine3) {
3776
+ if (config.engine === "rapier2d") return this.buildRapier2d(restored);
3777
+ const engine4 = loadedPhysics();
3778
+ if (!engine4) {
2645
3779
  throw new Error(
2646
3780
  "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"
2647
3781
  );
2648
3782
  }
2649
- const section = restored?.physics ? decodePhysicsSection(restored.physics) : void 0;
2650
- const physics = new PhysicsRuntime(this.internals, engine3, {
3783
+ let section;
3784
+ if (restored?.physics) {
3785
+ const wrote = physicsSectionEngine(restored.physics);
3786
+ if (wrote === "rapier3d") section = decodePhysicsSection(restored.physics);
3787
+ else {
3788
+ this.host.log("warn", [
3789
+ `irtio: this snapshot carries a ${wrote} world and the room now runs rapier3d. The world is rebuilt from schema state and physics.setup runs again.`
3790
+ ]);
3791
+ }
3792
+ }
3793
+ const physics = new PhysicsRuntime(this.internals, engine4, {
2651
3794
  ...section ? { restore: section } : {},
2652
3795
  defaultTimestep: 1 / this.definition.config.tickRate
2653
3796
  });
@@ -2678,11 +3821,12 @@ var RoomCore = class _RoomCore {
2678
3821
  }
2679
3822
  let section;
2680
3823
  if (restored?.physics) {
2681
- if (physicsSectionEngine(restored.physics) === "matter2d") {
3824
+ const wrote = physicsSectionEngine(restored.physics);
3825
+ if (wrote === "matter2d") {
2682
3826
  section = decodeMatterBodies(decodeMatterSectionEnvelope(restored.physics));
2683
3827
  } else {
2684
3828
  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."
3829
+ `irtio: this snapshot carries a ${wrote} world and the room now runs matter2d. The world is rebuilt from schema state and physics.setup runs again.`
2686
3830
  ]);
2687
3831
  }
2688
3832
  }
@@ -2699,6 +3843,44 @@ var RoomCore = class _RoomCore {
2699
3843
  physics.reconcile();
2700
3844
  return physics;
2701
3845
  }
3846
+ /**
3847
+ * The rapier2d half. It follows the *rapier3d* shape rather than matter2d's, because the 2D
3848
+ * build has a real `takeSnapshot()`: a restored world comes back whole and `setup` does not run
3849
+ * again. The only thing that differs from `buildPhysics` is which engine and which envelope.
3850
+ */
3851
+ buildRapier2d(restored) {
3852
+ const engine4 = loadedRapier2d();
3853
+ if (!engine4) {
3854
+ throw new Error(
3855
+ "RoomCore: this room declares rapier2d physics but the engine is not initialized \u2014 the host must `await initRapier2d()` (from '@irtio/runtime') before constructing the room; handlers are synchronous, so the WASM cannot be loaded later"
3856
+ );
3857
+ }
3858
+ let section;
3859
+ if (restored?.physics) {
3860
+ const wrote = physicsSectionEngine(restored.physics);
3861
+ if (wrote === "rapier2d") {
3862
+ section = decodePhysicsSection(decodeRapier2dSectionEnvelope(restored.physics));
3863
+ } else {
3864
+ this.host.log("warn", [
3865
+ `irtio: this snapshot carries a ${wrote} world and the room now runs rapier2d. The world is rebuilt from schema state and physics.setup runs again.`
3866
+ ]);
3867
+ }
3868
+ }
3869
+ const physics = new Rapier2dRuntime(this.internals, engine4, {
3870
+ ...section ? { restore: section } : {},
3871
+ defaultTimestep: 1 / this.definition.config.tickRate
3872
+ });
3873
+ if (physics.rebuilt) {
3874
+ if (restored) {
3875
+ this.host.log("info", [
3876
+ `irtio: no rapier2d world in this snapshot (format v${restored.version}) \u2014 rebuilding it from schema state and re-running physics.setup; contact state is not restored`
3877
+ ]);
3878
+ }
3879
+ physics.runSetup(this.room);
3880
+ physics.reconcile();
3881
+ }
3882
+ return physics;
3883
+ }
2702
3884
  /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
2703
3885
  static restore(definition, bytes, host, options) {
2704
3886
  return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
@@ -2763,6 +3945,35 @@ var RoomCore = class _RoomCore {
2763
3945
  captureTimeline() {
2764
3946
  this.recorder?.capture(this.tick, this.ext, this.plain);
2765
3947
  }
3948
+ // ---- M6 lane F: rewind ----
3949
+ /**
3950
+ * D72: record this tick's body poses, right after `physics.sync()` — the poses the clients are
3951
+ * about to be told about, under the tick number they will be told it under, which is the tick a
3952
+ * client later stamps its `CALL` with.
3953
+ */
3954
+ captureHistory() {
3955
+ const physics = this.physics;
3956
+ if (!physics || !this.rewindState) return;
3957
+ this.rewindState.history.capture(this.tick, physics);
3958
+ }
3959
+ /**
3960
+ * D72: `room.rewind(tick, fn)`. The live world is not touched and nothing is re-simulated; `fn`
3961
+ * queries a scratch world holding every tracked body at its pose at `tick`.
3962
+ */
3963
+ rewind(tick, fn) {
3964
+ const physics = this.physics;
3965
+ if (!physics) {
3966
+ throw new Error(
3967
+ "room.rewind: this room has no physics, so there are no poses to rewind. Add physics: { engine, gravity, bodies, history: <ticks> } to defineRoom(...)"
3968
+ );
3969
+ }
3970
+ if (!this.rewindState) {
3971
+ throw new Error(
3972
+ "room.rewind: this room declares no physics.history, so no poses are kept. Add history: <ticks> to the physics config; it is off by default because it costs heap per tick. See irt.io/docs/concepts/lag-compensation for how deep to make it."
3973
+ );
3974
+ }
3975
+ return this.rewindState.run(physics, () => this.tick, tick, fn);
3976
+ }
2766
3977
  /** Live JSON view of the room for the dev page / supervisor admin API. */
2767
3978
  inspect() {
2768
3979
  return {
@@ -2797,6 +4008,7 @@ var RoomCore = class _RoomCore {
2797
4008
  this.loop.stop();
2798
4009
  rejectAllPending(this.internals, "room stopped");
2799
4010
  rejectAllHostCalls(this.internals, "room stopped");
4011
+ this.rewindState?.free();
2800
4012
  this.physics?.free();
2801
4013
  }
2802
4014
  /**
@@ -2986,6 +4198,9 @@ var RoomCore = class _RoomCore {
2986
4198
  role: entry?.role ?? "",
2987
4199
  name: entry?.name ?? "",
2988
4200
  tick: this.tick,
4201
+ // D72: only an RPC has a stamp, and `rpc.ts` puts it on the ctx it hands the handler. Every
4202
+ // other entry point (join, leave, write, ownership) has no client tick by construction.
4203
+ clientTick: void 0,
2989
4204
  reconnecting,
2990
4205
  room: this.room
2991
4206
  };
@@ -3154,6 +4369,7 @@ var RoomCore = class _RoomCore {
3154
4369
  * its view's `DELTA` — encoded once per distinct view.
3155
4370
  */
3156
4371
  flush() {
4372
+ evaluateLobby(this.internals);
3157
4373
  const dirty = this.tracked.flush();
3158
4374
  serverWinsCorrections(this.internals, dirty);
3159
4375
  this.ledger?.newFlush();
@@ -3283,7 +4499,13 @@ var RoomCore = class _RoomCore {
3283
4499
  }
3284
4500
  };
3285
4501
  function encodeSection(physics) {
3286
- return physics.engineKind === "matter2d" ? encodeMatterSectionEnvelope(encodeMatterBodies(physics.serialize())) : encodePhysicsSection(physics.serialize());
4502
+ if (physics.engineKind === "matter2d") {
4503
+ return encodeMatterSectionEnvelope(encodeMatterBodies(physics.serialize()));
4504
+ }
4505
+ if (physics.engineKind === "rapier2d") {
4506
+ return encodeRapier2dSectionEnvelope(encodePhysicsSection(physics.serialize()));
4507
+ }
4508
+ return encodePhysicsSection(physics.serialize());
3287
4509
  }
3288
4510
 
3289
4511
  export {
@@ -3306,10 +4528,15 @@ export {
3306
4528
  onFirstRapierStep,
3307
4529
  encodePhysicsSection,
3308
4530
  physicsSectionEngine,
4531
+ encodeRapier2dSectionEnvelope,
4532
+ decodeRapier2dSectionEnvelope,
3309
4533
  encodeMatterSectionEnvelope,
3310
4534
  decodeMatterSectionEnvelope,
3311
4535
  decodePhysicsSection,
3312
4536
  Mulberry32,
4537
+ initRapier2d,
4538
+ loadedRapier2d,
4539
+ resetRapier2dForTests,
3313
4540
  isVisible,
3314
4541
  visibleTo,
3315
4542
  visibleNames,