@irtio/runtime 0.10.1 → 0.11.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.
@@ -100,6 +100,68 @@ interface RoomInspection {
100
100
  readonly recent: readonly RoomEvent[];
101
101
  }
102
102
 
103
+ /**
104
+ * M6 lane K (D77 / O28): the write core's caps, and the named refusals that enforce them.
105
+ *
106
+ * ## Why these are here and not in the relay host
107
+ *
108
+ * The relay host is the reason they exist — it serves many tenants from one process, so a room
109
+ * that grows without limit is a room taking a box down with it — but the checks belong on the
110
+ * write path, which is the runtime's. Putting them here means a client `add` cannot get past them
111
+ * whichever host is running the room, and it means a test can drive them without a socket.
112
+ *
113
+ * ## They are walls, not budgets
114
+ *
115
+ * A write that would cross one is refused. The room carries on; every other client is unaffected;
116
+ * nothing is evicted, degraded, truncated or silently dropped. The refusal goes back to the writer
117
+ * as a **non-fatal** `ERROR` naming the cap it met ({@link ErrorCode.E_STATE_CAP},
118
+ * `E_RECORD_CAP`, `E_WRITE_RATE`), so a developer can tell a sizing decision from a bug without
119
+ * reading a log they may not have.
120
+ *
121
+ * ## What a room that did not ask for them pays
122
+ *
123
+ * Nothing. `stateBytes` and `writesPerSecond` are unset unless a host sets them, and the host that
124
+ * sets them is the relay host. A coded room in its own VM has its own memory ceiling and its own
125
+ * tick budget and does not need a second copy of either. The record cap is the exception and it is
126
+ * on by default, because client `add` is a new surface: no room written before this could regress
127
+ * on a limit that only applies to a thing it could not do.
128
+ *
129
+ * ## How state size is measured, and the error that leaves
130
+ *
131
+ * Encoding 512 KB of state on every write to find out how big it is would cost more than the write
132
+ * loop it guards. So: an exact measure (`encodeSnapshot(...).length`) is taken at most once a tick,
133
+ * and only once the running figure is over half the cap **or has gone `STALE_MEASURE_TICKS`
134
+ * without one**; between measures the figure is grown by a **conservative upper bound** of what
135
+ * each accepted write could add (a record's maximum encoded size, a string field's maximum encoded
136
+ * size).
137
+ *
138
+ * The invariant that buys, stated exactly: the figure is an upper bound on the truth *for
139
+ * everything it can see*, which is client writes, and it is re-anchored to the truth at least
140
+ * every `STALE_MEASURE_TICKS` ticks. It is emphatically **not** an upper bound between
141
+ * re-anchorings for what it cannot see — a `perPlayer` record added on join, an ownership change,
142
+ * a coded room's own handler writing state — and the staleness bound is what closes that: a leak
143
+ * the meter is blind to can outrun the figure for ten seconds at 30 Hz and no longer. Above the
144
+ * truth it can be by one tick of accepted client writes, which is what a slightly early refusal
145
+ * costs. A room under half the cap pays one measure per stale window and nothing else.
146
+ */
147
+
148
+ /** O28's numbers, as the relay host sets them. One place, so lane J can move them once. */
149
+ declare const RELAY_STATE_BYTES: number;
150
+ declare const RELAY_RECORDS_PER_COLLECTION = 1024;
151
+ declare const RELAY_WRITES_PER_SECOND = 35;
152
+ /** The record cap every room gets, because client `add` is a surface no old room had. */
153
+ declare const DEFAULT_RECORDS_PER_COLLECTION = 1024;
154
+ interface WriteCaps {
155
+ /** Encoded state bytes. Unset (the default) means no wall: the host has its own memory ceiling. */
156
+ readonly stateBytes?: number;
157
+ /** Records one entity collection may hold. Defaults to {@link DEFAULT_RECORDS_PER_COLLECTION}. */
158
+ readonly recordsPerCollection?: number;
159
+ /** `WRITE` frames one client may send a second. Unset means no bucket. */
160
+ readonly writesPerSecond?: number;
161
+ }
162
+ /** What the relay host passes. Named so a test can assert the numbers rather than repeat them. */
163
+ declare const RELAY_WRITE_CAPS: WriteCaps;
164
+
103
165
  type LogLevel = 'info' | 'warn' | 'error';
104
166
  /**
105
167
  * Week 12: host-side async work a room asked for — a save generation (D24) or a player-KV
@@ -194,6 +256,21 @@ type HostCall = {
194
256
  readonly playerId: string;
195
257
  readonly rating: number;
196
258
  readonly deviation?: number;
259
+ }
260
+ /**
261
+ * D81: `room.replay.clip(seconds)` — the assembled clip blob, on its way to storage.
262
+ *
263
+ * The one crossing this lane adds, and a host call for the reason every other one here is: the
264
+ * room has no network and no store. It is also the only host call that carries bulk bytes, so
265
+ * it is the only one with a size wall on the far side (`CLIP_MAX_BYTES`, checked by the
266
+ * supervisor before anything is written) — the ring's own bound already implies a smaller
267
+ * number, but that bound lives on the side of the seam the tenant's own code owns.
268
+ *
269
+ * The result's `value` is the minted clip id.
270
+ */
271
+ | {
272
+ readonly kind: 'clip';
273
+ readonly bytes: Uint8Array;
197
274
  };
198
275
  /**
199
276
  * What a `HostCall` produced. `value` carries the save id for `save` and the stored string for
@@ -318,6 +395,21 @@ interface RoomCoreOptions {
318
395
  * `testRoom({ profile: true })` are what turn it on.
319
396
  */
320
397
  readonly profile?: boolean;
398
+ /**
399
+ * D77 / O28: the write core's caps — encoded state bytes, records per collection, writes per
400
+ * client per second. A host that sets none gets the record cap and nothing else, which is what
401
+ * every coded room in its own VM wants: it already has a memory ceiling and a tick budget. The
402
+ * relay host sets all three, because it serves many tenants from one process.
403
+ */
404
+ readonly caps?: WriteCaps;
405
+ /**
406
+ * D77: the wall clock, for hold deadlines and nothing else.
407
+ *
408
+ * A hold has to keep running while a room is hibernated, and `RoomHost.now()` is monotonic
409
+ * within one process, so it cannot answer "is this five-minute hold over?" for a room that slept
410
+ * through it. Defaults to `Date.now`; injectable so a test can move a long hold without waiting.
411
+ */
412
+ readonly wallNow?: () => number;
321
413
  }
322
414
  interface JoinOptions {
323
415
  /** Requested role; unknown/missing → `roles[0]` (logged) or `''` when the schema has no roles. */
@@ -443,6 +535,14 @@ interface RoomCoreApi<S extends AnySchema = AnySchema> {
443
535
  deliverBusEvent(channel: string, from: string, payload: string): void;
444
536
  /** D59: deliver one directed `room.bus.send`. At-least-once, so this may repeat a message. */
445
537
  deliverBusMessage(from: string, payload: string): void;
538
+ /**
539
+ * D77: the encoded size of this room's state, or `undefined` when no host asked for the state
540
+ * cap and nothing is metering it. What the room row and `irtio rooms` report, and the reason a
541
+ * developer sees the number climb long before the wall.
542
+ */
543
+ stateBytes(): number | undefined;
544
+ /** D77: how many records this room is holding for players who have left. */
545
+ holdCount(): number;
446
546
  }
447
547
 
448
- export { DEFAULT_TIMELINE_MAX_RECORDS as D, EVENT_RING_SIZE as E, HOST_CALL_TIMEOUT_MS as H, type JoinOptions as J, type LogLevel as L, type RoomCoreApi as R, type TimelineDump as T, DEFAULT_TIMELINE_MAX_TICKS as a, type HostCall as b, type HostCallResult as c, type JoinResult as d, type RoomCoreOptions as e, type RoomEvent as f, type RoomEventKind as g, RoomFullError as h, type RoomHost as i, type RoomInspection as j, type RoomStats as k, type TimelineFrame as l, TimelineRecorder as m, type TimelineRecorderOptions as n, inspectState as o };
548
+ export { DEFAULT_RECORDS_PER_COLLECTION as D, EVENT_RING_SIZE as E, HOST_CALL_TIMEOUT_MS as H, type JoinOptions as J, type LogLevel as L, RELAY_RECORDS_PER_COLLECTION as R, type TimelineDump as T, type WriteCaps as W, DEFAULT_TIMELINE_MAX_RECORDS as a, DEFAULT_TIMELINE_MAX_TICKS as b, type HostCall as c, type HostCallResult as d, type JoinResult as e, RELAY_STATE_BYTES as f, RELAY_WRITES_PER_SECOND as g, RELAY_WRITE_CAPS as h, type RoomCoreApi as i, type RoomCoreOptions as j, type RoomEvent as k, type RoomEventKind as l, RoomFullError as m, type RoomHost as n, type RoomInspection as o, type RoomStats as p, type TimelineFrame as q, TimelineRecorder as r, type TimelineRecorderOptions as s, inspectState as t };
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-C5aqs49-.js';
2
- export { C as CRASH_AFTER_THROWS, M as MAX_CATCHUP, a as MatterBodyRecord, b as MatterSection, c as Mulberry32, P as PhysicsEngineTag, d as PhysicsSection, R as RoomCore, e as decodeMatterBodies, f as decodeMatterSectionEnvelope, g as decodePhysicsSection, h as decodeRapier2dSectionEnvelope, i as encodeMatterBodies, j as encodeMatterSectionEnvelope, k as encodePhysicsSection, l as encodeRapier2dSectionEnvelope, m as initMatter, n as initPhysics, o as initRapier2d, p as loadedMatter, q as loadedPhysics, r as loadedRapier2d, s as physicsSectionEngine, t as resetMatterForTests, u as resetPhysicsForTests, v as resetRapier2dForTests } from './room-Db3iPBKM.js';
1
+ export { D as DEFAULT_RECORDS_PER_COLLECTION, a as DEFAULT_TIMELINE_MAX_RECORDS, b as DEFAULT_TIMELINE_MAX_TICKS, E as EVENT_RING_SIZE, H as HOST_CALL_TIMEOUT_MS, c as HostCall, d as HostCallResult, J as JoinOptions, e as JoinResult, L as LogLevel, R as RELAY_RECORDS_PER_COLLECTION, f as RELAY_STATE_BYTES, g as RELAY_WRITES_PER_SECOND, h as RELAY_WRITE_CAPS, i as RoomCoreApi, j as RoomCoreOptions, k as RoomEvent, l as RoomEventKind, m as RoomFullError, n as RoomHost, o as RoomInspection, p as RoomStats, T as TimelineDump, q as TimelineFrame, r as TimelineRecorder, s as TimelineRecorderOptions, W as WriteCaps, t as inspectState } from './contract-Hk4SkDtW.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-0FZBGyke.js';
3
3
  import { CollectionDesc, AnySchema, PlainState, DirtySet } from '@irtio/schema';
4
4
  import '@irtio/protocol';
5
5
  import '@irtio/server';
@@ -118,11 +118,13 @@ interface ParsedSnapshot extends SnapshotHeader {
118
118
  readonly version: number;
119
119
  /** The encoded physics section, or `undefined` for a v1 blob / a room with no world. */
120
120
  readonly physics: Uint8Array | undefined;
121
+ /** M6 lane K (D77): the hold deadlines section, or `undefined` when the room held nothing. */
122
+ readonly holds: Uint8Array | undefined;
121
123
  /** The codec snapshot, encoded under `withBuiltins(schema)`. */
122
124
  readonly snapshot: Uint8Array;
123
125
  }
124
126
  declare function parseHibernationBlob(bytes: Uint8Array): ParsedSnapshot;
125
- declare function writeHibernationBlob(header: SnapshotHeader, snapshot: Uint8Array, physics?: Uint8Array): Uint8Array;
127
+ declare function writeHibernationBlob(header: SnapshotHeader, snapshot: Uint8Array, physics?: Uint8Array, holds?: Uint8Array): Uint8Array;
126
128
  /** What {@link decodeSave} recovers from a room save's bytes. */
127
129
  interface DecodedSave extends SnapshotHeader {
128
130
  /** The blob's own format version byte: 1 (no physics section) or 2. */
package/dist/index.js CHANGED
@@ -2,9 +2,10 @@ import {
2
2
  fromMigrationState,
3
3
  migrateSnapshot,
4
4
  toMigrationState
5
- } from "./chunk-WNAEE4EP.js";
5
+ } from "./chunk-ACC3SDIO.js";
6
6
  import {
7
7
  CRASH_AFTER_THROWS,
8
+ DEFAULT_RECORDS_PER_COLLECTION,
8
9
  DEFAULT_TIMELINE_MAX_RECORDS,
9
10
  DEFAULT_TIMELINE_MAX_TICKS,
10
11
  EVENT_RING_SIZE,
@@ -12,6 +13,10 @@ import {
12
13
  MAX_CATCHUP,
13
14
  Mulberry32,
14
15
  READABLE_SNAPSHOT_VERSIONS,
16
+ RELAY_RECORDS_PER_COLLECTION,
17
+ RELAY_STATE_BYTES,
18
+ RELAY_WRITES_PER_SECOND,
19
+ RELAY_WRITE_CAPS,
15
20
  RPC_TIMEOUT_MS,
16
21
  RoomCore,
17
22
  RoomFullError,
@@ -47,9 +52,10 @@ import {
47
52
  visibleNames,
48
53
  visibleTo,
49
54
  writeHibernationBlob
50
- } from "./chunk-EENJ37KK.js";
55
+ } from "./chunk-GYPUNTQN.js";
51
56
  export {
52
57
  CRASH_AFTER_THROWS,
58
+ DEFAULT_RECORDS_PER_COLLECTION,
53
59
  DEFAULT_TIMELINE_MAX_RECORDS,
54
60
  DEFAULT_TIMELINE_MAX_TICKS,
55
61
  EVENT_RING_SIZE,
@@ -57,6 +63,10 @@ export {
57
63
  MAX_CATCHUP,
58
64
  Mulberry32,
59
65
  READABLE_SNAPSHOT_VERSIONS,
66
+ RELAY_RECORDS_PER_COLLECTION,
67
+ RELAY_STATE_BYTES,
68
+ RELAY_WRITES_PER_SECOND,
69
+ RELAY_WRITE_CAPS,
60
70
  RPC_TIMEOUT_MS,
61
71
  RoomCore,
62
72
  RoomFullError,
@@ -1,7 +1,7 @@
1
1
  import { AttributeOptions, ProfileSnapshot } from '@irtio/protocol';
2
2
  import { DirtySet, AnySchema, PlainState, Tracked, State } from '@irtio/schema';
3
3
  import { RoomDefinition, RoomMode, Room, RewindView, Ctx, MatterModule, MatterEngine, MatterBody, RapierModule, RapierWorld, RapierRigidBody, Rapier2dModule, Rapier2dWorld, Rapier2dRigidBody, ResolvedRoomConfig, LeaveReason } from '@irtio/server';
4
- import { i as RoomHost, k as RoomStats, L as LogLevel, R as RoomCoreApi, e as RoomCoreOptions, g as RoomEventKind, n as TimelineRecorderOptions, T as TimelineDump, j as RoomInspection, J as JoinOptions, d as JoinResult, c as HostCallResult } from './contract-C5aqs49-.js';
4
+ import { n as RoomHost, p as RoomStats, L as LogLevel, i as RoomCoreApi, j as RoomCoreOptions, l as RoomEventKind, s as TimelineRecorderOptions, T as TimelineDump, o as RoomInspection, J as JoinOptions, e as JoinResult, d as HostCallResult } from './contract-Hk4SkDtW.js';
5
5
 
6
6
  declare class Mulberry32 {
7
7
  /** Current internal state (u32). Survives hibernation. */
@@ -153,6 +153,8 @@ interface RoomInternals {
153
153
  readonly physics: PhysicsApi | undefined;
154
154
  tick: number;
155
155
  stopped: boolean;
156
+ /** D77: the wall clock hold deadlines are measured against. See `RoomCoreOptions.wallNow`. */
157
+ wallNow(): number;
156
158
  /** Runs a room handler; a throw is logged and counted, never rethrown. */
157
159
  guard<T>(name: string, fn: () => T): T | undefined;
158
160
  /** D41: record this tick on the authoritative timeline. A no-op while the recorder is unarmed. */
@@ -696,6 +698,8 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
696
698
  readonly stats: RoomStats;
697
699
  tick: number;
698
700
  stopped: boolean;
701
+ /** D77: the wall clock hold deadlines are measured against. See `RoomCoreOptions.wallNow`. */
702
+ readonly wallNow: () => number;
699
703
  /**
700
704
  * Called for every handler throw `tryRun` swallows, before it is logged. A test harness sets
701
705
  * this so a room that breaks fails the test that broke it (bug 2): the runtime's job is to
@@ -873,6 +877,10 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
873
877
  * `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
874
878
  * `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
875
879
  */
880
+ /** D77: the encoded state size, when a host set the state cap. See `caps.ts`. */
881
+ stateBytes(): number | undefined;
882
+ /** D77: how many records are held for players who have left. */
883
+ holdCount(): number;
876
884
  profile(): ProfileSnapshot | undefined;
877
885
  private badFrame;
878
886
  receive(clientId: string, frame: Uint8Array): void;
@@ -1,8 +1,8 @@
1
- import { R as RoomCore } from '../room-Db3iPBKM.js';
2
- export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-Db3iPBKM.js';
1
+ import { R as RoomCore } from '../room-0FZBGyke.js';
2
+ export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-0FZBGyke.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-C5aqs49-.js';
5
+ import { n as RoomHost, L as LogLevel, i as RoomCoreApi, c as HostCall, d as HostCallResult, W as WriteCaps, p as RoomStats } from '../contract-Hk4SkDtW.js';
6
6
  import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
7
7
 
8
8
  /**
@@ -138,6 +138,9 @@ declare class HarnessHost implements RoomHost {
138
138
  crashed(reason: string): void;
139
139
  hostCall(reqId: number, call: HostCall): void;
140
140
  private runHostCall;
141
+ /** D81: every clip the room wrote, in order: `clipId` -> the blob it was handed. */
142
+ readonly clips: Map<string, Uint8Array<ArrayBufferLike>>;
143
+ private nextClipId;
141
144
  /**
142
145
  * D44: the in-process harness has no supervisor, no sockets and therefore no loopback session
143
146
  * to open. Recording the request rather than faking a session is the honest option: a test that
@@ -267,6 +270,16 @@ interface FakeClient<S extends AnySchema = AnySchema> {
267
270
  readonly joinTick: number;
268
271
  /** Sends a `WRITE` update for `entity[id]` masking exactly the patch's leaves. */
269
272
  write(entity: string, id: string, patch: AnyRecord): void;
273
+ /** D77: sends a `WRITE` `add` — what a client of a `clientCreate` collection sends. */
274
+ add(entity: string, id: string, value: AnyRecord): void;
275
+ /** D77: sends a `WRITE` `remove`. */
276
+ remove(entity: string, id: string): void;
277
+ /** Non-fatal `ERROR` frames this client was sent, in order. The caps are read from here. */
278
+ readonly errors: readonly {
279
+ code: number;
280
+ message: string;
281
+ fatal: boolean;
282
+ }[];
270
283
  /** Sends an arbitrary framed payload (protocol-violation tests). */
271
284
  writeRaw(frame: Uint8Array): void;
272
285
  /** Client → server RPC. Settles when the `REPLY` is delivered — `h.tick()` first, then `await`. */
@@ -295,41 +308,6 @@ interface FakeClient<S extends AnySchema = AnySchema> {
295
308
  entity(name: string): EntityCollection;
296
309
  }
297
310
 
298
- /**
299
- * `createRoomHarness` — the in-process room harness. It runs a real `RoomCore`
300
- * against a fake clock and fake clients, in one synchronous, deterministic process. No sockets,
301
- * no worker, no Node built-ins: it runs anywhere Vitest does.
302
- *
303
- * ## Timing model
304
- *
305
- * The only clock is `FakeClock`. `h.tick(n)` advances exactly `n` tick intervals in tick mode
306
- * (running `n` ticks and delivering every frame they produce) and runs due timers without moving
307
- * the clock in event mode. `h.run(ms)` advances the clock by `ms`, firing ticks and timers in
308
- * order. Both return only after everything due has fired and every frame has been delivered.
309
- *
310
- * ## Frame delivery
311
- *
312
- * Frames the room sends are delivered to the `FakeClient` immediately (decoded, applied to its
313
- * view, traced). Frames a client sends are queued and handed to `RoomCore.receive` at the next
314
- * safe point — never re-entrantly inside the core — which is before `tick()`/`run()`/`join()`
315
- * returns, or immediately if nothing is currently executing.
316
- *
317
- * ## Promises
318
- *
319
- * `client.call()` settles when the `REPLY` is delivered, which happens inside a later
320
- * `h.tick()` / `h.run()`. Tests therefore read:
321
- *
322
- * ```ts
323
- * const p = a.call('start');
324
- * h.tick();
325
- * await expect(p).resolves.toEqual(...);
326
- * ```
327
- *
328
- * An **async** `onCall` implementation cannot reply synchronously: its `REPLY` is sent when the
329
- * promise settles, i.e. in a microtask. Tests must `await` (anything — `await null` is enough)
330
- * and then `h.tick()` so the room sees the reply.
331
- */
332
-
333
311
  interface HarnessOptions {
334
312
  /** `room.random()` seed; defaults to a hash of `roomId`, exactly as `RoomCore` does. */
335
313
  readonly seed?: number;
@@ -339,12 +317,22 @@ interface HarnessOptions {
339
317
  readonly roomId?: string;
340
318
  /** Bytes from a previous `serialize()`: the room wakes instead of being created. */
341
319
  readonly restoreFrom?: Uint8Array;
320
+ /** D77 / O28: the write-core caps this room runs under. Default: the record cap only. */
321
+ readonly caps?: WriteCaps;
322
+ /** D77: the wall clock hold deadlines are measured against. Default `Date.now`. */
323
+ readonly wallNow?: () => number;
342
324
  }
343
325
  interface JoinSpec {
344
326
  readonly role?: string;
345
327
  readonly name?: string;
346
328
  /** Default: `c1`, `c2`, … in join order. */
347
329
  readonly id?: string;
330
+ /**
331
+ * D77: the identity a hold binds to. Defaults to the client id, exactly as a real join does
332
+ * when the client presented no assertion — which is what makes the "another tab reclaims" and
333
+ * "a new browser does not" pair testable without a socket.
334
+ */
335
+ readonly playerId?: string;
348
336
  }
349
337
  interface UntilOptions {
350
338
  /** Default 1000. */
@@ -5,7 +5,7 @@ import {
5
5
  initPhysics,
6
6
  initRapier2d,
7
7
  visibleNames
8
- } from "../chunk-EENJ37KK.js";
8
+ } from "../chunk-GYPUNTQN.js";
9
9
 
10
10
  // src/test/clock.ts
11
11
  var FakeClock = class {
@@ -203,8 +203,19 @@ var HarnessHost = class {
203
203
  if (existing === void 0 || call.score > existing) this.scores.set(key, call.score);
204
204
  return { ok: true };
205
205
  }
206
+ // ---- M6 lane N: replays ----
207
+ case "clip": {
208
+ const clipId = `clip${String(this.nextClipId++).padStart(4, "0")}`;
209
+ this.clips.set(clipId, call.bytes);
210
+ return { ok: true, value: clipId };
211
+ }
206
212
  }
207
213
  }
214
+ // ---- M6 lane N: replays ----
215
+ /** D81: every clip the room wrote, in order: `clipId` -> the blob it was handed. */
216
+ clips = /* @__PURE__ */ new Map();
217
+ nextClipId = 1;
218
+ // ---- end M6 lane N ----
208
219
  // -------------------------------------------------------------------------
209
220
  // Week 12: durable alarms (D26)
210
221
  // -------------------------------------------------------------------------
@@ -293,6 +304,7 @@ import {
293
304
  import {
294
305
  FrameType,
295
306
  decodeCall,
307
+ decodeErrorPayload,
296
308
  decodeMsg,
297
309
  decodeReply,
298
310
  encodeCall,
@@ -311,7 +323,9 @@ import {
311
323
  decodeSnapshot,
312
324
  encodeDelta,
313
325
  encodeFields,
314
- markField
326
+ markAdd,
327
+ markField,
328
+ markRemove
315
329
  } from "@irtio/schema";
316
330
  function structFields(t) {
317
331
  return Object.entries(t.fields).map(([name, type]) => ({ name, type }));
@@ -427,6 +441,34 @@ var FakeClientImpl = class {
427
441
  writeRaw(frame) {
428
442
  this.bridge.toCore(this.id, frame);
429
443
  }
444
+ // ---- M6 lane K: stateful relay ----
445
+ errors = [];
446
+ add(entity, id, value) {
447
+ const scratch = createState(this.bridge.ext);
448
+ entityOf(scratch, entity).add(id, value, { owner: this.id });
449
+ const dirty = createDirtySet();
450
+ markAdd(dirty, entity, id);
451
+ this.bridge.toCore(
452
+ this.id,
453
+ encodeFrame(
454
+ FrameType.WRITE,
455
+ encodeDelta(this.bridge.ext, scratch, dirty, { tick: this.bridge.tick })
456
+ )
457
+ );
458
+ }
459
+ remove(entity, id) {
460
+ const scratch = createState(this.bridge.ext);
461
+ const dirty = createDirtySet();
462
+ markRemove(dirty, entity, id);
463
+ this.bridge.toCore(
464
+ this.id,
465
+ encodeFrame(
466
+ FrameType.WRITE,
467
+ encodeDelta(this.bridge.ext, scratch, dirty, { tick: this.bridge.tick })
468
+ )
469
+ );
470
+ }
471
+ // ---- end M6 lane K ----
430
472
  call(name, params = {}) {
431
473
  const desc = this.descOf(name);
432
474
  const reqId = this.nextReqId++;
@@ -505,6 +547,13 @@ var FakeClientImpl = class {
505
547
  case FrameType.MSG:
506
548
  this.handleMsg(payload);
507
549
  return;
550
+ // ---- M6 lane K: stateful relay ----
551
+ case FrameType.ERROR: {
552
+ const e = decodeErrorPayload(payload);
553
+ this.errors.push({ code: e.code, message: e.message, fatal: e.fatal });
554
+ return;
555
+ }
556
+ // ---- end M6 lane K ----
508
557
  default:
509
558
  return;
510
559
  }
@@ -679,7 +728,11 @@ var Harness = class {
679
728
  roomId: options.roomId ?? "test-room",
680
729
  ...options.seed !== void 0 ? { seed: options.seed } : {},
681
730
  ...options.publicUrl !== void 0 ? { publicUrl: options.publicUrl } : {},
682
- ...options.restoreFrom !== void 0 ? { restoreFrom: options.restoreFrom } : {}
731
+ ...options.restoreFrom !== void 0 ? { restoreFrom: options.restoreFrom } : {},
732
+ // ---- M6 lane K: stateful relay ----
733
+ ...options.caps !== void 0 ? { caps: options.caps } : {},
734
+ ...options.wallNow !== void 0 ? { wallNow: options.wallNow } : {}
735
+ // ---- end M6 lane K ----
683
736
  });
684
737
  this.host.core = this.coreRef;
685
738
  this.host.serializeForSave = () => this.coreRef.snapshot();
@@ -881,7 +934,10 @@ var Harness = class {
881
934
  const role = spec.role ?? roles[0] ?? "";
882
935
  const options = {
883
936
  role,
884
- ...spec.name !== void 0 ? { name: spec.name } : {}
937
+ ...spec.name !== void 0 ? { name: spec.name } : {},
938
+ // ---- M6 lane K: stateful relay ----
939
+ ...spec.playerId !== void 0 ? { playerId: spec.playerId } : {}
940
+ // ---- end M6 lane K ----
885
941
  };
886
942
  const client = new FakeClientImpl(this.bridge, id, options);
887
943
  this.byId.set(id, client);
@@ -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-C5aqs49-.js';
1
+ import { L as LogLevel, T as TimelineDump, p as RoomStats, c as HostCall, d as HostCallResult, i as RoomCoreApi, n as RoomHost } from '../contract-Hk4SkDtW.js';
2
2
  import { ErrorCodeName, ProfileSnapshot } from '@irtio/protocol';
3
3
  import { NpcConfig, LeaveReason } from '@irtio/server';
4
4
  import '@irtio/schema';
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  migrateSnapshot
3
- } from "../chunk-WNAEE4EP.js";
3
+ } from "../chunk-ACC3SDIO.js";
4
4
  import {
5
5
  RoomCore,
6
6
  RoomFullError,
@@ -9,7 +9,7 @@ import {
9
9
  initRapier2d,
10
10
  onFirstRapierStep,
11
11
  rapierHasStepped
12
- } from "../chunk-EENJ37KK.js";
12
+ } from "../chunk-GYPUNTQN.js";
13
13
 
14
14
  // src/worker/index.ts
15
15
  import { getHeapStatistics } from "v8";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/runtime",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "irtio room runtime: RoomCore, worker_threads host, in-process test harness",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -31,9 +31,9 @@
31
31
  "@dimforge/rapier2d-compat": "0.20.0",
32
32
  "@dimforge/rapier3d-compat": "0.20.0",
33
33
  "matter-js": "0.20.0",
34
- "@irtio/protocol": "0.10.1",
35
- "@irtio/schema": "0.10.1",
36
- "@irtio/server": "0.10.1"
34
+ "@irtio/protocol": "0.11.0",
35
+ "@irtio/schema": "0.11.0",
36
+ "@irtio/server": "0.11.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/matter-js": "0.20.2"