@irtio/runtime 0.7.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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  parseHibernationBlob,
3
3
  writeHibernationBlob
4
- } from "./chunk-EXPFVRD4.js";
4
+ } from "./chunk-ZCD2TBXB.js";
5
5
 
6
6
  // src/migrate.ts
7
7
  import { PRESENCE_COLLECTION, withBuiltins } from "@irtio/protocol";
@@ -1255,9 +1255,28 @@ function encodePhysicsSection(section) {
1255
1255
  return w.finish();
1256
1256
  }
1257
1257
  var MATTER_TAG = 1;
1258
+ var RAPIER2D_TAG = 2;
1258
1259
  function physicsSectionEngine(bytes) {
1259
1260
  const r = new ByteReader2(bytes);
1260
- 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();
1261
1280
  }
1262
1281
  function encodeMatterSectionEnvelope(payload) {
1263
1282
  const w = new ByteWriter2(payload.length + 8);
@@ -1627,6 +1646,262 @@ var Mulberry32 = class {
1627
1646
  }
1628
1647
  };
1629
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
+
1630
1905
  // src/core/views.ts
1631
1906
  import {
1632
1907
  collectionDirty,
@@ -2038,9 +2313,10 @@ var PoseHistory = class {
2038
2313
  entry.tick = tick;
2039
2314
  entry.count = 0;
2040
2315
  const pose = this.scratchPose;
2041
- const rapier = physics.engineKind === "rapier3d";
2316
+ const kind = physics.engineKind;
2042
2317
  physics.eachTrackedBody((collection, id, body) => {
2043
- if (rapier) readRapierPose(body, pose);
2318
+ if (kind === "rapier3d") readRapierPose(body, pose);
2319
+ else if (kind === "rapier2d") readRapier2dPose(body, pose);
2044
2320
  else readMatterPose(body, pose);
2045
2321
  this.push(entry, collection, id, pose);
2046
2322
  });
@@ -2142,6 +2418,24 @@ function readRapierPose(body, into) {
2142
2418
  into.wy = w.y;
2143
2419
  into.wz = w.z;
2144
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
+ }
2145
2439
  function readMatterPose(body, into) {
2146
2440
  into.x = body.position.x;
2147
2441
  into.y = body.position.y;
@@ -2346,6 +2640,95 @@ var RapierScratch = class extends BaseScratch {
2346
2640
  this.world.free();
2347
2641
  }
2348
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
+ };
2349
2732
  var MatterScratch = class extends BaseScratch {
2350
2733
  matter;
2351
2734
  owners = /* @__PURE__ */ new Map();
@@ -2395,9 +2778,9 @@ var MatterScratch = class extends BaseScratch {
2395
2778
  }
2396
2779
  build(tick, requested, clamped) {
2397
2780
  const M = this.matter;
2398
- const engine3 = this.physics.matterEngine;
2399
- if (engine3) {
2400
- for (const b of M.Composite.allBodies(engine3.world)) {
2781
+ const engine4 = this.physics.matterEngine;
2782
+ if (engine4) {
2783
+ for (const b of M.Composite.allBodies(engine4.world)) {
2401
2784
  if (b.isStatic) this.visible.push(b);
2402
2785
  }
2403
2786
  }
@@ -2440,7 +2823,7 @@ var RewindState = class {
2440
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).`
2441
2824
  );
2442
2825
  }
2443
- this.scratch ??= physics.engineKind === "rapier3d" ? new RapierScratch(physics, this.history.depth, tickNow) : new MatterScratch(physics, this.history.depth, tickNow);
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);
2444
2827
  const view = this.scratch.view(resolved.entry, resolved.tick, requested, resolved.clamped);
2445
2828
  this.inside = true;
2446
2829
  try {
@@ -2451,6 +2834,152 @@ var RewindState = class {
2451
2834
  }
2452
2835
  };
2453
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
+
2454
2983
  // src/core/messages.ts
2455
2984
  import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
2456
2985
  import { decodeFields as decodeFields2 } from "@irtio/schema";
@@ -2601,6 +3130,22 @@ function makeMatterApi(p) {
2601
3130
  }
2602
3131
  };
2603
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
+ }
2604
3149
  function makeKv(core) {
2605
3150
  return {
2606
3151
  get(playerId, key) {
@@ -2716,6 +3261,7 @@ function createRoomApi(core, publicUrl) {
2716
3261
  let lastNow = Number.NEGATIVE_INFINITY;
2717
3262
  let physicsApi;
2718
3263
  let matterApi;
3264
+ let rapier2dApi;
2719
3265
  let kvApi;
2720
3266
  let busApi;
2721
3267
  let leaderboardApi;
@@ -2777,7 +3323,7 @@ function createRoomApi(core, publicUrl) {
2777
3323
  }
2778
3324
  if (p.engineKind !== "rapier3d") {
2779
3325
  throw new Error(
2780
- "room.physics: this room runs matter2d. Read room.physics2d instead (it has .matter and .engine, where this one has .rapier and .world)"
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)"
2781
3327
  );
2782
3328
  }
2783
3329
  return physicsApi ?? (physicsApi = makePhysicsApi(p));
@@ -2791,11 +3337,25 @@ function createRoomApi(core, publicUrl) {
2791
3337
  }
2792
3338
  if (p.engineKind !== "matter2d") {
2793
3339
  throw new Error(
2794
- "room.physics2d: this room runs rapier3d. Read room.physics instead (it has .rapier and .world, where this one has .matter and .engine)"
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)"
2795
3341
  );
2796
3342
  }
2797
3343
  return matterApi ?? (matterApi = makeMatterApi(p));
2798
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
+ },
2799
3359
  // ---- M6 lane F: rewind ----
2800
3360
  // D72: one line, and deliberately only one. Everything the rewind does — the buffer, the
2801
3361
  // clamping, the scratch worlds, the reentrancy refusal — lives in `core/history.ts` behind
@@ -2872,6 +3432,10 @@ function createRoomApi(core, publicUrl) {
2872
3432
  get bus() {
2873
3433
  return busApi ?? (busApi = makeBus(core));
2874
3434
  },
3435
+ // ---- M6 lane H: lobby front door ----
3436
+ get lobby() {
3437
+ return makeLobby(core);
3438
+ },
2875
3439
  alarm(name, atMs) {
2876
3440
  if (!isAlarmName(core, name, "room.alarm")) return;
2877
3441
  if (!Number.isFinite(atMs)) {
@@ -3209,14 +3773,24 @@ var RoomCore = class _RoomCore {
3209
3773
  const config = this.definition.config.physics;
3210
3774
  if (!config) return void 0;
3211
3775
  if (config.engine === "matter2d") return this.buildMatter(config, restored);
3212
- const engine3 = loadedPhysics();
3213
- if (!engine3) {
3776
+ if (config.engine === "rapier2d") return this.buildRapier2d(restored);
3777
+ const engine4 = loadedPhysics();
3778
+ if (!engine4) {
3214
3779
  throw new Error(
3215
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"
3216
3781
  );
3217
3782
  }
3218
- const section = restored?.physics ? decodePhysicsSection(restored.physics) : void 0;
3219
- 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, {
3220
3794
  ...section ? { restore: section } : {},
3221
3795
  defaultTimestep: 1 / this.definition.config.tickRate
3222
3796
  });
@@ -3247,11 +3821,12 @@ var RoomCore = class _RoomCore {
3247
3821
  }
3248
3822
  let section;
3249
3823
  if (restored?.physics) {
3250
- if (physicsSectionEngine(restored.physics) === "matter2d") {
3824
+ const wrote = physicsSectionEngine(restored.physics);
3825
+ if (wrote === "matter2d") {
3251
3826
  section = decodeMatterBodies(decodeMatterSectionEnvelope(restored.physics));
3252
3827
  } else {
3253
3828
  this.host.log("warn", [
3254
- "irtio: this snapshot carries a rapier3d world and the room now runs matter2d. The world is rebuilt from schema state and physics.setup runs again."
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.`
3255
3830
  ]);
3256
3831
  }
3257
3832
  }
@@ -3268,6 +3843,44 @@ var RoomCore = class _RoomCore {
3268
3843
  physics.reconcile();
3269
3844
  return physics;
3270
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
+ }
3271
3884
  /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
3272
3885
  static restore(definition, bytes, host, options) {
3273
3886
  return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
@@ -3756,6 +4369,7 @@ var RoomCore = class _RoomCore {
3756
4369
  * its view's `DELTA` — encoded once per distinct view.
3757
4370
  */
3758
4371
  flush() {
4372
+ evaluateLobby(this.internals);
3759
4373
  const dirty = this.tracked.flush();
3760
4374
  serverWinsCorrections(this.internals, dirty);
3761
4375
  this.ledger?.newFlush();
@@ -3885,7 +4499,13 @@ var RoomCore = class _RoomCore {
3885
4499
  }
3886
4500
  };
3887
4501
  function encodeSection(physics) {
3888
- 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());
3889
4509
  }
3890
4510
 
3891
4511
  export {
@@ -3908,10 +4528,15 @@ export {
3908
4528
  onFirstRapierStep,
3909
4529
  encodePhysicsSection,
3910
4530
  physicsSectionEngine,
4531
+ encodeRapier2dSectionEnvelope,
4532
+ decodeRapier2dSectionEnvelope,
3911
4533
  encodeMatterSectionEnvelope,
3912
4534
  decodeMatterSectionEnvelope,
3913
4535
  decodePhysicsSection,
3914
4536
  Mulberry32,
4537
+ initRapier2d,
4538
+ loadedRapier2d,
4539
+ resetRapier2dForTests,
3915
4540
  isVisible,
3916
4541
  visibleTo,
3917
4542
  visibleNames,
@@ -250,6 +250,24 @@ interface RoomHost {
250
250
  * A host with no room list (the in-process test harness) records it and does nothing else.
251
251
  */
252
252
  setBackfill(open: boolean): void;
253
+ /**
254
+ * D75: `room.lobby.setPublic(v)`, and the terminal deregistration a game start performs.
255
+ *
256
+ * Fire-and-forget, and host state rather than room state, for exactly `setBackfill`'s reason:
257
+ * the supervisor holds it, reports it on the room list the agent already polls, and control
258
+ * turns that report into a registry row. None of which the room could arrange for itself and
259
+ * none of which belongs in the hibernation blob.
260
+ *
261
+ * `started` is a one-way door. A room that reports it is deregistered terminally, and no later
262
+ * report — raced, stale or forged — can put it back in front of a stranger.
263
+ *
264
+ * A host with no room list (the in-process test harness) records it and does nothing else.
265
+ */
266
+ setLobby(update: {
267
+ readonly public?: boolean;
268
+ readonly queue?: string;
269
+ readonly started?: boolean;
270
+ }): void;
253
271
  /**
254
272
  * D44: `room.spawnNPC(config)`. The host opens a loopback client session under `clientId` and
255
273
  * runs the named entry of the room definition's `npcs` map against it. The room minted the id,
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-DwxqjeWP.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 encodeMatterBodies, i as encodeMatterSectionEnvelope, j as encodePhysicsSection, k as initMatter, l as initPhysics, m as loadedMatter, n as loadedPhysics, p as physicsSectionEngine, r as resetMatterForTests, o as resetPhysicsForTests } from './room-CoQDczh2.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-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';
3
3
  import { CollectionDesc, AnySchema, PlainState, DirtySet } from '@irtio/schema';
4
4
  import '@irtio/protocol';
5
5
  import '@irtio/server';
@@ -121,7 +121,7 @@ interface DecodedSave extends SnapshotHeader {
121
121
  * there is no section at all. A pre-D45 blob has no discriminant and reads as `'rapier3d'`,
122
122
  * which is what it is.
123
123
  */
124
- readonly physicsEngine: 'rapier3d' | 'matter2d' | undefined;
124
+ readonly physicsEngine: 'rapier3d' | 'matter2d' | 'rapier2d' | undefined;
125
125
  /**
126
126
  * The room's authoritative state, in exactly the shape `inspectState` and the recorded
127
127
  * timeline use: entity collections as `{ id: { owner, value } }`, singletons as their value.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  fromMigrationState,
3
3
  migrateSnapshot,
4
4
  toMigrationState
5
- } from "./chunk-ZNNTF7Y3.js";
5
+ } from "./chunk-DBGMG2S3.js";
6
6
  import {
7
7
  CRASH_AFTER_THROWS,
8
8
  DEFAULT_TIMELINE_MAX_RECORDS,
@@ -22,27 +22,32 @@ import {
22
22
  decodeMatterBodies,
23
23
  decodeMatterSectionEnvelope,
24
24
  decodePhysicsSection,
25
+ decodeRapier2dSectionEnvelope,
25
26
  decodeSave,
26
27
  encodeMatterBodies,
27
28
  encodeMatterSectionEnvelope,
28
29
  encodePhysicsSection,
30
+ encodeRapier2dSectionEnvelope,
29
31
  encodeViewDelta,
30
32
  encodeViewSnapshot,
31
33
  initMatter,
32
34
  initPhysics,
35
+ initRapier2d,
33
36
  inspectState,
34
37
  isVisible,
35
38
  loadedMatter,
36
39
  loadedPhysics,
40
+ loadedRapier2d,
37
41
  parseHibernationBlob,
38
42
  physicsSectionEngine,
39
43
  resetMatterForTests,
40
44
  resetPhysicsForTests,
45
+ resetRapier2dForTests,
41
46
  viewKeyFor,
42
47
  visibleNames,
43
48
  visibleTo,
44
49
  writeHibernationBlob
45
- } from "./chunk-EXPFVRD4.js";
50
+ } from "./chunk-ZCD2TBXB.js";
46
51
  export {
47
52
  CRASH_AFTER_THROWS,
48
53
  DEFAULT_TIMELINE_MAX_RECORDS,
@@ -62,24 +67,29 @@ export {
62
67
  decodeMatterBodies,
63
68
  decodeMatterSectionEnvelope,
64
69
  decodePhysicsSection,
70
+ decodeRapier2dSectionEnvelope,
65
71
  decodeSave,
66
72
  encodeMatterBodies,
67
73
  encodeMatterSectionEnvelope,
68
74
  encodePhysicsSection,
75
+ encodeRapier2dSectionEnvelope,
69
76
  encodeViewDelta,
70
77
  encodeViewSnapshot,
71
78
  fromMigrationState,
72
79
  initMatter,
73
80
  initPhysics,
81
+ initRapier2d,
74
82
  inspectState,
75
83
  isVisible,
76
84
  loadedMatter,
77
85
  loadedPhysics,
86
+ loadedRapier2d,
78
87
  migrateSnapshot,
79
88
  parseHibernationBlob,
80
89
  physicsSectionEngine,
81
90
  resetMatterForTests,
82
91
  resetPhysicsForTests,
92
+ resetRapier2dForTests,
83
93
  toMigrationState,
84
94
  viewKeyFor,
85
95
  visibleNames,
@@ -1,7 +1,7 @@
1
1
  import { AttributeOptions, ProfileSnapshot } from '@irtio/protocol';
2
2
  import { DirtySet, AnySchema, PlainState, Tracked, State } from '@irtio/schema';
3
- import { RoomDefinition, RoomMode, Room, RewindView, Ctx, MatterModule, MatterEngine, MatterBody, RapierModule, RapierWorld, RapierRigidBody, 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-DwxqjeWP.js';
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';
5
5
 
6
6
  /**
7
7
  * `room.random()` — mulberry32 over a u32 seed. Deterministic, tiny, and serializable: the
@@ -93,8 +93,8 @@ interface QueuedFrame {
93
93
  }
94
94
  /** What the core modules may do with the physics world (`core/physics.ts` implements it). */
95
95
  interface PhysicsApi {
96
- /** D45: which of the two blessed engines this room is running. */
97
- readonly engineKind: 'rapier3d' | 'matter2d';
96
+ /** D45: which of the blessed engines this room is running. */
97
+ readonly engineKind: 'rapier3d' | 'matter2d' | 'rapier2d';
98
98
  readonly timestep: number;
99
99
  /** Creates bodies for new instances, destroys bodies whose instance is gone. */
100
100
  reconcile(): void;
@@ -110,9 +110,9 @@ interface PhysicsApi {
110
110
  * `bodyFor`, and it never hands out the map itself.
111
111
  */
112
112
  eachTrackedBody(fn: (collection: string, id: string, body: unknown) => void): void;
113
- /** rapier3d only. */
113
+ /** rapier3d and rapier2d: the engine namespace. `engineKind` says which module it is. */
114
114
  readonly rapier: unknown;
115
- /** rapier3d only. */
115
+ /** rapier3d and rapier2d: the live `World`. */
116
116
  readonly world: unknown;
117
117
  /** matter2d only: the `matter-js` namespace. */
118
118
  readonly matter: unknown;
@@ -431,9 +431,27 @@ declare function encodePhysicsSection(section: PhysicsSection): Uint8Array;
431
431
  * pre-D45 blob is therefore byte-identical and reads as Rapier without a version bump, which is
432
432
  * what the recommendation in the plan asked for and why format 3 was not needed.
433
433
  */
434
- type PhysicsEngineTag = 'rapier3d' | 'matter2d';
435
- /** Reads the engine out of an encoded section without decoding the rest of it. */
434
+ type PhysicsEngineTag = 'rapier3d' | 'matter2d' | 'rapier2d';
435
+ /**
436
+ * Reads the engine out of an encoded section without decoding the rest of it.
437
+ *
438
+ * The `else` is deliberately bare rather than a third tag comparison: a pre-D45 blob has no
439
+ * discriminant at all, and every byte sequence that is not the zero-length-world envelope is a
440
+ * rapier3d world snapshot. Adding rapier2d must not change that, or every blob written before
441
+ * this engine existed stops decoding.
442
+ */
436
443
  declare function physicsSectionEngine(bytes: Uint8Array): PhysicsEngineTag;
444
+ /**
445
+ * Wraps a rapier2d section in the same discriminated envelope, under its own tag.
446
+ *
447
+ * The payload is an ordinary {@link encodePhysicsSection} — rapier2d has a real
448
+ * `world.takeSnapshot()`, so unlike matter2d it has a world to write, and the only thing the
449
+ * envelope buys is telling the two Rapiers apart. Restoring a 2D snapshot into a 3D world (or the
450
+ * reverse) does not fail cleanly, which is exactly why the discriminant is not optional here.
451
+ */
452
+ declare function encodeRapier2dSectionEnvelope(payload: Uint8Array): Uint8Array;
453
+ /** The payload inside a rapier2d envelope. Throws if the section was written by another engine. */
454
+ declare function decodeRapier2dSectionEnvelope(bytes: Uint8Array): Uint8Array;
437
455
  /** Wraps a matter2d body-state payload in the discriminated envelope. */
438
456
  declare function encodeMatterSectionEnvelope(payload: Uint8Array): Uint8Array;
439
457
  /** The payload inside a matter2d envelope. Throws if the section is a Rapier one. */
@@ -501,6 +519,106 @@ declare class PhysicsRuntime {
501
519
  serialize(): PhysicsSection;
502
520
  }
503
521
 
522
+ /**
523
+ * The rapier2d world inside the room: Rapier, held in a plane.
524
+ *
525
+ * It is modelled on `core/physics.ts` rather than on `core/matter.ts`, and the choice is the whole
526
+ * point of the engine existing. Everything that made the Rapier path good is kept:
527
+ *
528
+ * - **A real engine snapshot.** `world.takeSnapshot()` / `World.restoreSnapshot()` exist in the 2D
529
+ * build too, so a woken room is byte-for-byte the world that went to sleep, contacts and sleep
530
+ * timers included, and `setup` runs only on a genuine rebuild. matter2d has no equivalent and
531
+ * rebuilds every wake; that difference is why a settled pile can jolt there and does not here.
532
+ * - **The world applies gravity.** It goes into the `World` constructor and dynamic bodies fall
533
+ * without the room lifting a finger. A matter2d room's hooks apply gravity themselves; a
534
+ * rapier2d room that copied that would fall twice as fast.
535
+ * - **Velocities are per second.** `linvel()` is metres per second, not matter's per-step
536
+ * displacement, so nothing here rescales anything.
537
+ *
538
+ * What it takes from the 2D side is only the plane: `{ x, y }` gravity, and the channel mapping in
539
+ * `@irtio/schema` (`channelOf2d` / `applyChannel2d` / `angleFrom2d`), which is the single place the
540
+ * six planar quantities are written onto the thirteen 3D wire channels. A second copy of that
541
+ * mapping here would be a way for the two sides to disagree by a sign, so there is not one.
542
+ *
543
+ * There is no planar-lock warning in this file. Bug 6's friction trap needs a third axis to lock,
544
+ * and this engine does not have one.
545
+ *
546
+ * ## Async init
547
+ *
548
+ * Rapier's WASM needs `await RAPIER.init()`, and room handlers are synchronous, so the engine is
549
+ * initialized before the room is constructed — by the worker host at bundle-load time, and by
550
+ * `initRapier2d()` in the test harness. This is `initPhysics()`'s twin, against the 2D module.
551
+ */
552
+
553
+ /**
554
+ * Loads and initializes `@dimforge/rapier2d-compat` once per process. Idempotent and safe to call
555
+ * concurrently. It is a *separate* WASM module from the 3D build — the two do not share an
556
+ * instance and loading one does not load the other — so a rapier2d room pays the 2D module's
557
+ * footprint and nothing else.
558
+ */
559
+ declare function initRapier2d(): Promise<Rapier2dModule>;
560
+ /** The initialized engine, or `undefined` when `initRapier2d()` has not resolved yet. */
561
+ declare function loadedRapier2d(): Rapier2dModule | undefined;
562
+ /** Test seam: forget the loaded engine (never used in production paths). */
563
+ declare function resetRapier2dForTests(): void;
564
+ interface Rapier2dRuntimeOptions {
565
+ /** From a v2 hibernation blob's rapier2d section. Absent → a fresh world, and `setup` runs. */
566
+ readonly restore?: PhysicsSection;
567
+ /** Seconds per step when the config does not name one (the tick interval). */
568
+ readonly defaultTimestep: number;
569
+ }
570
+ declare class Rapier2dRuntime {
571
+ readonly engineKind: "rapier2d";
572
+ /** matter2d only; present so every runtime satisfies one internal shape. */
573
+ readonly matter: undefined;
574
+ readonly matterEngine: undefined;
575
+ readonly rapier: Rapier2dModule;
576
+ readonly world: Rapier2dWorld;
577
+ /** `true` when the world was built from scratch and `setup` has to run. */
578
+ readonly rebuilt: boolean;
579
+ /** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
580
+ get needsSetup(): boolean;
581
+ private readonly core;
582
+ private readonly config;
583
+ /** Physics-backed collections, in schema (name-sorted) order. */
584
+ private readonly collections;
585
+ private readonly bodies;
586
+ /** See `PhysicsRuntime.sleepSynced`: the one extra sync a body owes on the tick it sleeps. */
587
+ private readonly sleepSynced;
588
+ constructor(core: RoomInternals, rapier: Rapier2dModule, options: Rapier2dRuntimeOptions);
589
+ get timestep(): number;
590
+ /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
591
+ runSetup(room: Room): void;
592
+ free(): void;
593
+ /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
594
+ bodyFor(collection: string, id: string): Rapier2dRigidBody | undefined;
595
+ private create;
596
+ /**
597
+ * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
598
+ * order). The rapier2d third of the same read-only accessor the other two runtimes carry.
599
+ */
600
+ eachTrackedBody(fn: (collection: string, id: string, body: Rapier2dRigidBody) => void): void;
601
+ private applyRecordToBody;
602
+ /**
603
+ * Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
604
+ * tick, right before the step, in collection order then instance order.
605
+ */
606
+ reconcile(): void;
607
+ step(): void;
608
+ /**
609
+ * Body → schema, through the tracked proxies, so movement leaves the room as an ordinary delta.
610
+ * Values are `Math.fround`ed for f32 fields, so room code reads exactly what the wire carries.
611
+ */
612
+ sync(): void;
613
+ /**
614
+ * The same `PhysicsSection` the 3D runtime writes — a real engine snapshot plus the
615
+ * entity↔handle map, whose handles must ride as f64 for the reason spelled out on
616
+ * {@link PhysicsSection}. What tells the two apart on the way back in is the envelope
617
+ * `core/physics.ts` wraps this in, not anything in here.
618
+ */
619
+ serialize(): PhysicsSection;
620
+ }
621
+
504
622
  /**
505
623
  * `RoomCore` — the host-agnostic room runtime. It owns the extended schema, the
506
624
  * tracked authority state, presence, the loop, and the frame dispatch; everything outside comes
@@ -521,7 +639,7 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
521
639
  readonly clients: Map<string, ClientEntry>;
522
640
  readonly loop: Loop;
523
641
  /** D22: the Rapier world, or `undefined` in a room whose config declares no physics. */
524
- readonly physics: PhysicsRuntime | MatterRuntime | undefined;
642
+ readonly physics: PhysicsRuntime | MatterRuntime | Rapier2dRuntime | undefined;
525
643
  readonly stats: RoomStats;
526
644
  tick: number;
527
645
  stopped: boolean;
@@ -596,6 +714,12 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
596
714
  * worth logging.
597
715
  */
598
716
  private buildMatter;
717
+ /**
718
+ * The rapier2d half. It follows the *rapier3d* shape rather than matter2d's, because the 2D
719
+ * build has a real `takeSnapshot()`: a restored world comes back whole and `setup` does not run
720
+ * again. The only thing that differs from `buildPhysics` is which engine and which envelope.
721
+ */
722
+ private buildRapier2d;
599
723
  /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
600
724
  static restore<S2 extends AnySchema>(definition: RoomDefinition<S2>, bytes: Uint8Array, host: RoomHost, options: Omit<RoomCoreOptions, 'restoreFrom'>): RoomCore<S2>;
601
725
  get schema(): S;
@@ -707,4 +831,4 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
707
831
  flush(): void;
708
832
  }
709
833
 
710
- export { CRASH_AFTER_THROWS as C, MAX_CATCHUP as M, type PhysicsEngineTag as P, RoomCore as R, type MatterBodyRecord as a, type MatterSection as b, Mulberry32 as c, type PhysicsSection as d, decodeMatterBodies as e, decodeMatterSectionEnvelope as f, decodePhysicsSection as g, encodeMatterBodies as h, encodeMatterSectionEnvelope as i, encodePhysicsSection as j, initMatter as k, initPhysics as l, loadedMatter as m, loadedPhysics as n, resetPhysicsForTests as o, physicsSectionEngine as p, resetMatterForTests as r };
834
+ export { CRASH_AFTER_THROWS as C, MAX_CATCHUP as M, type PhysicsEngineTag as P, RoomCore as R, type MatterBodyRecord as a, type MatterSection as b, Mulberry32 as c, type PhysicsSection as d, decodeMatterBodies as e, decodeMatterSectionEnvelope as f, decodePhysicsSection as g, decodeRapier2dSectionEnvelope as h, encodeMatterBodies as i, encodeMatterSectionEnvelope as j, encodePhysicsSection as k, encodeRapier2dSectionEnvelope as l, initMatter as m, initPhysics as n, initRapier2d as o, loadedMatter as p, loadedPhysics as q, loadedRapier2d as r, physicsSectionEngine as s, resetMatterForTests as t, resetPhysicsForTests as u, resetRapier2dForTests as v };
@@ -1,8 +1,8 @@
1
- import { R as RoomCore } from '../room-CoQDczh2.js';
2
- export { k as initMatter, l as initPhysics } from '../room-CoQDczh2.js';
1
+ import { R as RoomCore } from '../room-YJGz6Caw.js';
2
+ export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-YJGz6Caw.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-DwxqjeWP.js';
5
+ import { i as RoomHost, L as LogLevel, R as RoomCoreApi, b as HostCall, c as HostCallResult, k as RoomStats } from '../contract-CRilLVdx.js';
6
6
  import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
7
7
 
8
8
  /**
@@ -166,6 +166,17 @@ declare class HarnessHost implements RoomHost {
166
166
  /** D63-e: the harness has no room list to report on, so it records the value and stops. */
167
167
  backfillOpen: boolean | undefined;
168
168
  setBackfill(open: boolean): void;
169
+ /** D75: recorded, like the backfill flag, and for the same reason — no room list to report on. */
170
+ lobby: {
171
+ public?: boolean;
172
+ queue?: string;
173
+ started?: boolean;
174
+ };
175
+ setLobby(update: {
176
+ readonly public?: boolean;
177
+ readonly queue?: string;
178
+ readonly started?: boolean;
179
+ }): void;
169
180
  busPublish(channel: string, payload: string): void;
170
181
  busSubscribe(channel: string, subscribed: boolean): void;
171
182
  setAlarm(name: string, atMs: number | undefined): void;
@@ -3,8 +3,9 @@ import {
3
3
  createVisibilityPolicy,
4
4
  initMatter,
5
5
  initPhysics,
6
+ initRapier2d,
6
7
  visibleNames
7
- } from "../chunk-EXPFVRD4.js";
8
+ } from "../chunk-ZCD2TBXB.js";
8
9
 
9
10
  // src/test/clock.ts
10
11
  var FakeClock = class {
@@ -235,6 +236,12 @@ var HarnessHost = class {
235
236
  setBackfill(open) {
236
237
  this.backfillOpen = open;
237
238
  }
239
+ // ---- M6 lane H: lobby front door ----
240
+ /** D75: recorded, like the backfill flag, and for the same reason — no room list to report on. */
241
+ lobby = {};
242
+ setLobby(update) {
243
+ this.lobby = { ...this.lobby, ...update };
244
+ }
238
245
  busPublish(channel, payload) {
239
246
  this.busPublishes.push({ channel, payload });
240
247
  }
@@ -974,5 +981,6 @@ export {
974
981
  createRoomHarness,
975
982
  frameTypeName,
976
983
  initMatter,
977
- initPhysics
984
+ initPhysics,
985
+ initRapier2d
978
986
  };
@@ -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-DwxqjeWP.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-CRilLVdx.js';
2
2
  import { ErrorCodeName, ProfileSnapshot } from '@irtio/protocol';
3
3
  import { NpcConfig, LeaveReason } from '@irtio/server';
4
4
  import '@irtio/schema';
@@ -151,6 +151,9 @@ type FromWorker = {
151
151
  /** D63-e: the room type's declared backfill eligibility. The ceiling on
152
152
  * `room.backfill.set`, and `false` for every room that declares nothing. */
153
153
  backfill?: boolean;
154
+ /** D75: the room type declared a lobby. The ceiling on `setLobby`, and the supervisor
155
+ * re-checks it for exactly the reason it re-checks `backfill`. */
156
+ lobby?: boolean;
154
157
  /** D47: the physics engine this room runs, when it declares one. Reported so room-hours
155
158
  * rows carry the engine as a dimension; the supervisor never acts on it. */
156
159
  engine?: string;
@@ -289,6 +292,11 @@ type FromWorker = {
289
292
  | {
290
293
  t: 'setBackfill';
291
294
  open: boolean;
295
+ } | {
296
+ t: 'setLobby';
297
+ public?: boolean;
298
+ queue?: string;
299
+ started?: boolean;
292
300
  }
293
301
  /**
294
302
  * D59: `room.bus.publish(channel, payload)`. Fire-and-forget by design — the room gets no
@@ -1,14 +1,15 @@
1
1
  import {
2
2
  migrateSnapshot
3
- } from "../chunk-ZNNTF7Y3.js";
3
+ } from "../chunk-DBGMG2S3.js";
4
4
  import {
5
5
  RoomCore,
6
6
  RoomFullError,
7
7
  initMatter,
8
8
  initPhysics,
9
+ initRapier2d,
9
10
  onFirstRapierStep,
10
11
  rapierHasStepped
11
- } from "../chunk-EXPFVRD4.js";
12
+ } from "../chunk-ZCD2TBXB.js";
12
13
 
13
14
  // src/worker/index.ts
14
15
  import { getHeapStatistics } from "v8";
@@ -79,6 +80,10 @@ function createWorkerHost(post) {
79
80
  setBackfill(open) {
80
81
  post({ t: "setBackfill", open });
81
82
  },
83
+ // ---- M6 lane H: lobby front door ----
84
+ setLobby(update) {
85
+ post({ t: "setLobby", ...update });
86
+ },
82
87
  setAlarm(name, atMs) {
83
88
  if (atMs === void 0) {
84
89
  post({ t: "setAlarm", name });
@@ -153,7 +158,8 @@ async function handleMessage(state, host, post, msg) {
153
158
  try {
154
159
  if (def.config.physics.engine === "matter2d") await initMatter();
155
160
  else {
156
- await initPhysics();
161
+ if (def.config.physics.engine === "rapier2d") await initRapier2d();
162
+ else await initPhysics();
157
163
  onFirstRapierStep(() => {
158
164
  const core = state.core;
159
165
  if (core) post(statsPayload(core.stats, void 0, core.profile()));
@@ -211,6 +217,11 @@ async function handleMessage(state, host, post, msg) {
211
217
  // `engine` is: `ready.config` replaces the supervisor's copy, so a flag threaded
212
218
  // only through `loadBundle` would be dropped the moment a worker started.
213
219
  backfill: c.backfill === true,
220
+ // ---- M6 lane H: lobby front door ----
221
+ // D75: the declared ceiling on `setLobby`, carried for exactly `backfill`'s reason.
222
+ // The supervisor re-checks it, because a check only the tenant's own worker makes is
223
+ // advice rather than a boundary.
224
+ lobby: c.lobby !== void 0,
214
225
  // D47: `ready.config` REPLACES the supervisor's copy of the bundle config, so an
215
226
  // engine threaded only through `loadBundle` would be dropped here the moment a
216
227
  // worker started. That is exactly how the first known-shape run read `engine: none`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/runtime",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "irtio room runtime: RoomCore, worker_threads host, in-process test harness",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -28,11 +28,12 @@
28
28
  "dist"
29
29
  ],
30
30
  "dependencies": {
31
+ "@dimforge/rapier2d-compat": "0.20.0",
31
32
  "@dimforge/rapier3d-compat": "0.20.0",
32
33
  "matter-js": "0.20.0",
33
- "@irtio/protocol": "0.7.0",
34
- "@irtio/schema": "0.7.0",
35
- "@irtio/server": "0.7.0"
34
+ "@irtio/protocol": "0.8.0",
35
+ "@irtio/schema": "0.8.0",
36
+ "@irtio/server": "0.8.0"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@types/matter-js": "0.20.2"