@irtio/runtime 0.2.0 → 0.4.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.
@@ -421,7 +421,10 @@ function applyWrite(core, clientId, payload) {
421
421
  return { ok: false };
422
422
  }
423
423
  const judged = core.clients.get(clientId);
424
- if (judged && delta.tick > judged.lastClientTick) judged.lastClientTick = delta.tick;
424
+ if (judged && delta.tick > judged.lastClientTick) {
425
+ judged.lastClientTick = delta.tick;
426
+ judged.lastAppliedTick = core.tick;
427
+ }
425
428
  for (const dc of delta.collections) {
426
429
  const c = collectionDescOf(core.ext, dc.name);
427
430
  if (!c) continue;
@@ -672,6 +675,7 @@ var Loop = class {
672
675
  this.fireDueTimers();
673
676
  checkTimeouts(this.core);
674
677
  const config = this.core.definition.config;
678
+ let failed;
675
679
  if (config.tick) {
676
680
  const fn = config.tick;
677
681
  const dt = 1 / config.tickRate;
@@ -679,26 +683,30 @@ var Loop = class {
679
683
  "tick",
680
684
  () => fn(this.core.anyState, dt, this.core.room)
681
685
  );
682
- if (ran.ok) this.consecutiveThrows = 0;
683
- else {
684
- this.consecutiveThrows++;
685
- if (this.consecutiveThrows >= CRASH_AFTER_THROWS) {
686
- this.core.flush();
687
- const reason = `tick() threw ${this.consecutiveThrows} times in a row`;
688
- this.core.stopped = true;
689
- this.stop();
690
- host.crashed(reason);
691
- return;
692
- }
693
- }
686
+ if (!ran.ok) failed = "tick()";
694
687
  }
695
688
  const physics = this.core.physics;
696
689
  if (physics) {
697
- this.core.tryRun("physics", () => {
690
+ const ran = this.core.tryRun("physics", () => {
698
691
  physics.reconcile();
699
692
  physics.step();
700
693
  physics.sync();
701
694
  });
695
+ if (!ran.ok) {
696
+ failed = failed === void 0 ? "the physics step" : `${failed} and the physics step`;
697
+ }
698
+ }
699
+ if (failed === void 0) this.consecutiveThrows = 0;
700
+ else {
701
+ this.consecutiveThrows++;
702
+ if (this.consecutiveThrows >= CRASH_AFTER_THROWS) {
703
+ this.core.flush();
704
+ const reason = `${failed} threw ${this.consecutiveThrows} times in a row`;
705
+ this.core.stopped = true;
706
+ this.stop();
707
+ host.crashed(reason);
708
+ return;
709
+ }
702
710
  }
703
711
  this.core.flush();
704
712
  const elapsed = Math.max(0, host.now() - started);
@@ -852,6 +860,25 @@ function decodePhysicsSection(bytes) {
852
860
  function bodyKey(collection, id) {
853
861
  return `${collection}\0${id}`;
854
862
  }
863
+ function planarLockWarning(spec) {
864
+ const boxed = (spec.colliders ?? []).some(
865
+ (c) => c.shape.type === CUBOID_SHAPE || c.shape.type === ROUND_CUBOID_SHAPE
866
+ );
867
+ if (!boxed) return void 0;
868
+ const b = spec.body;
869
+ const t = [b.translationsEnabledX, b.translationsEnabledY, b.translationsEnabledZ];
870
+ const r = [b.rotationsEnabledX, b.rotationsEnabledY, b.rotationsEnabledZ];
871
+ const axes = ["x", "y", "z"];
872
+ for (let i = 0; i < 3; i++) {
873
+ if (t[i]) continue;
874
+ if (r[(i + 1) % 3] || r[(i + 2) % 3]) continue;
875
+ const free = axes[i] === "z" ? "enabledRotations(true, false, true)" : "one of the other two rotations";
876
+ return `locks translation on ${axes[i]} and both rotations across that plane on a box-shaped body. That removes friction entirely: the box slides at a constant speed until it hits something. Free one out-of-plane rotation \u2014 in the xy plane that is ${free}, and it costs nothing, because geometry symmetric about the plane generates no torque about it.`;
877
+ }
878
+ return void 0;
879
+ }
880
+ var CUBOID_SHAPE = 1;
881
+ var ROUND_CUBOID_SHAPE = 12;
855
882
  var PhysicsRuntime = class {
856
883
  rapier;
857
884
  world;
@@ -862,6 +889,8 @@ var PhysicsRuntime = class {
862
889
  /** Physics-backed collections, in schema (name-sorted) order. */
863
890
  collections;
864
891
  bodies = /* @__PURE__ */ new Map();
892
+ /** Collections already warned about the friction-killing 2D lock recipe (bug 6). */
893
+ warnedPlanar = /* @__PURE__ */ new Set();
865
894
  /**
866
895
  * Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
867
896
  * to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
@@ -936,6 +965,11 @@ var PhysicsRuntime = class {
936
965
  );
937
966
  return void 0;
938
967
  }
968
+ const planar = planarLockWarning(spec);
969
+ if (planar !== void 0 && !this.warnedPlanar.has(desc.name)) {
970
+ this.warnedPlanar.add(desc.name);
971
+ this.core.log("warn", `irtio: physics.bodies.${desc.name} ${planar}`);
972
+ }
939
973
  const body = this.world.createRigidBody(spec.body);
940
974
  for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
941
975
  this.applyRecordToBody(desc, body, record);
@@ -1809,6 +1843,13 @@ var RoomCore = class _RoomCore {
1809
1843
  };
1810
1844
  tick = 0;
1811
1845
  stopped = false;
1846
+ /**
1847
+ * Called for every handler throw `tryRun` swallows, before it is logged. A test harness sets
1848
+ * this so a room that breaks fails the test that broke it (bug 2): the runtime's job is to
1849
+ * keep the room up in production, but under test that same guarding turns a broken handler
1850
+ * into a timeout in an unrelated assertion ten seconds later. Unset in production.
1851
+ */
1852
+ onHandlerError;
1812
1853
  seed;
1813
1854
  api;
1814
1855
  internals;
@@ -1925,6 +1966,10 @@ var RoomCore = class _RoomCore {
1925
1966
  `${name}: ${err instanceof Error ? err.message : String(err)}`
1926
1967
  );
1927
1968
  this.host.log("error", [`irtio: ${name} threw`, err]);
1969
+ try {
1970
+ this.onHandlerError?.(name, err);
1971
+ } catch {
1972
+ }
1928
1973
  return { ok: false };
1929
1974
  }
1930
1975
  }
@@ -2072,6 +2117,7 @@ var RoomCore = class _RoomCore {
2072
2117
  correction: void 0,
2073
2118
  accepted: /* @__PURE__ */ new Map(),
2074
2119
  lastClientTick: 0,
2120
+ lastAppliedTick: 0,
2075
2121
  pendingJoinAdds: void 0,
2076
2122
  spatialMembership: /* @__PURE__ */ new Map()
2077
2123
  };
@@ -2335,7 +2381,12 @@ var RoomCore = class _RoomCore {
2335
2381
  this.internals,
2336
2382
  membershipSafeDirty(this.ext, entry.correction, memberships)
2337
2383
  );
2338
- if (payload) this.send(entry.clientId, encodeCorrectFrame(payload, entry.lastClientTick));
2384
+ if (payload) {
2385
+ this.send(
2386
+ entry.clientId,
2387
+ encodeCorrectFrame(payload, entry.lastClientTick, entry.lastAppliedTick)
2388
+ );
2389
+ }
2339
2390
  }
2340
2391
  let delta;
2341
2392
  if (spatialDescs.length > 0) {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  parseHibernationBlob,
3
3
  writeHibernationBlob
4
- } from "./chunk-GKMD3ICD.js";
4
+ } from "./chunk-5ZDQAAFJ.js";
5
5
 
6
6
  // src/migrate.ts
7
7
  import { PRESENCE_COLLECTION, withBuiltins } from "@irtio/protocol";
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { E as EVENT_RING_SIZE, H as HOST_CALL_TIMEOUT_MS, a as HostCall, b as HostCallResult, J as JoinOptions, c as JoinResult, L as LogLevel, R as RoomCoreApi, d as RoomCoreOptions, e as RoomEvent, f as RoomEventKind, g as RoomFullError, h as RoomHost, i as RoomInspection, j as RoomStats, k as inspectState } from './contract-B8QSO0MH.js';
2
- export { C as CRASH_AFTER_THROWS, M as MAX_CATCHUP, a as Mulberry32, P as PhysicsSection, R as RoomCore, d as decodePhysicsSection, e as encodePhysicsSection, i as initPhysics, l as loadedPhysics, r as resetPhysicsForTests } from './room-BfALTh7M.js';
2
+ export { C as CRASH_AFTER_THROWS, M as MAX_CATCHUP, a as Mulberry32, P as PhysicsSection, R as RoomCore, d as decodePhysicsSection, e as encodePhysicsSection, i as initPhysics, l as loadedPhysics, r as resetPhysicsForTests } from './room-9ZQoy9yi.js';
3
3
  import { CollectionDesc, AnySchema, PlainState, DirtySet } from '@irtio/schema';
4
4
  import '@irtio/protocol';
5
5
  import '@irtio/server';
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  fromMigrationState,
3
3
  migrateSnapshot,
4
4
  toMigrationState
5
- } from "./chunk-FA4IOC2Z.js";
5
+ } from "./chunk-HFOMXKSO.js";
6
6
  import {
7
7
  CRASH_AFTER_THROWS,
8
8
  EVENT_RING_SIZE,
@@ -30,7 +30,7 @@ import {
30
30
  visibleNames,
31
31
  visibleTo,
32
32
  writeHibernationBlob
33
- } from "./chunk-GKMD3ICD.js";
33
+ } from "./chunk-5ZDQAAFJ.js";
34
34
  export {
35
35
  CRASH_AFTER_THROWS,
36
36
  EVENT_RING_SIZE,
@@ -47,6 +47,13 @@ interface ClientEntry {
47
47
  * unchanged — for a client that never wrote, that is `0`.
48
48
  */
49
49
  lastClientTick: number;
50
+ /**
51
+ * The room tick `lastClientTick`'s write was applied at, `0` before this client's first write.
52
+ * Sent alongside it on every `CORRECT` (bug 1). The two are a pair — one number on the
53
+ * client's stamp clock, one on the room's — and the client uses the gap between them to learn
54
+ * where on the server's tick stream its still-unjudged intents will land.
55
+ */
56
+ lastAppliedTick: number;
50
57
  /**
51
58
  * Set at `join`: this client's own record(s) — its presence row and whatever entities its own
52
59
  * `onJoin` added, owned by it — as they stood right after join, per collection. Consumed once
@@ -164,7 +171,18 @@ interface RoomInternals {
164
171
 
165
172
  /** Most ticks one wake may run before the backlog is dropped. */
166
173
  declare const MAX_CATCHUP = 5;
167
- /** Consecutive `tick()` throws before the host is told the room crashed. */
174
+ /**
175
+ * Consecutive ticks in which a guarded stage threw before the host is told the room crashed.
176
+ *
177
+ * Any of them counts, not just `tick()` (bug 2). A room that cannot step its physics is not
178
+ * running — it is a socket that answers with a frozen world — and the runtime is the only thing
179
+ * that can tell the difference, because it is the only thing that sees the throw. The specific
180
+ * way this was found is worth keeping: one Rapier getter called from a room handler at the wrong
181
+ * moment leaves the rigid-body set borrowed in WASM, and *every* Rapier call after it throws.
182
+ * Nothing in JS can un-poison that world, so the honest response is to stop claiming to be up:
183
+ * crashing hands the room to the supervisor, which restarts it from its snapshot with a fresh
184
+ * world, and gives up out loud if the same thing happens again (`maxRestartsPerMin`).
185
+ */
168
186
  declare const CRASH_AFTER_THROWS = 3;
169
187
  declare class Loop implements LoopApi {
170
188
  private readonly core;
@@ -280,6 +298,8 @@ declare class PhysicsRuntime {
280
298
  /** Physics-backed collections, in schema (name-sorted) order. */
281
299
  private readonly collections;
282
300
  private readonly bodies;
301
+ /** Collections already warned about the friction-killing 2D lock recipe (bug 6). */
302
+ private readonly warnedPlanar;
283
303
  /**
284
304
  * Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
285
305
  * to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
@@ -335,6 +355,13 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
335
355
  readonly stats: RoomStats;
336
356
  tick: number;
337
357
  stopped: boolean;
358
+ /**
359
+ * Called for every handler throw `tryRun` swallows, before it is logged. A test harness sets
360
+ * this so a room that breaks fails the test that broke it (bug 2): the runtime's job is to
361
+ * keep the room up in production, but under test that same guarding turns a broken handler
362
+ * into a timeout in an unrelated assertion ten seconds later. Unset in production.
363
+ */
364
+ onHandlerError: ((name: string, err: unknown) => void) | undefined;
338
365
  private readonly seed;
339
366
  private readonly api;
340
367
  private readonly internals;
@@ -1,5 +1,5 @@
1
- import { R as RoomCore } from '../room-BfALTh7M.js';
2
- export { i as initPhysics } from '../room-BfALTh7M.js';
1
+ import { R as RoomCore } from '../room-9ZQoy9yi.js';
2
+ export { i as initPhysics } from '../room-9ZQoy9yi.js';
3
3
  import { ErrorCodeName, FrameType } from '@irtio/protocol';
4
4
  import { h as RoomHost, L as LogLevel, R as RoomCoreApi, a as HostCall, b as HostCallResult, j as RoomStats } from '../contract-B8QSO0MH.js';
5
5
  import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
@@ -3,7 +3,7 @@ import {
3
3
  createVisibilityPolicy,
4
4
  initPhysics,
5
5
  visibleNames
6
- } from "../chunk-GKMD3ICD.js";
6
+ } from "../chunk-5ZDQAAFJ.js";
7
7
 
8
8
  // src/test/clock.ts
9
9
  var FakeClock = class {
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  migrateSnapshot
3
- } from "../chunk-FA4IOC2Z.js";
3
+ } from "../chunk-HFOMXKSO.js";
4
4
  import {
5
5
  RoomCore,
6
6
  RoomFullError,
7
7
  initPhysics
8
- } from "../chunk-GKMD3ICD.js";
8
+ } from "../chunk-5ZDQAAFJ.js";
9
9
 
10
10
  // src/worker/index.ts
11
11
  import { isMainThread, parentPort } from "worker_threads";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/runtime",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "irtio room runtime: RoomCore, worker_threads host, in-process test harness",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -29,9 +29,9 @@
29
29
  ],
30
30
  "dependencies": {
31
31
  "@dimforge/rapier3d-compat": "0.20.0",
32
- "@irtio/protocol": "0.2.0",
33
- "@irtio/schema": "0.2.0",
34
- "@irtio/server": "0.2.0"
32
+ "@irtio/protocol": "0.4.0",
33
+ "@irtio/server": "0.4.0",
34
+ "@irtio/schema": "0.4.0"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsup",