@irtio/runtime 0.10.1 → 1.0.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-EENJ37KK.js";
4
+ } from "./chunk-LT46QFLW.js";
5
5
 
6
6
  // src/migrate.ts
7
7
  import { PRESENCE_COLLECTION, withBuiltins } from "@irtio/protocol";
@@ -1,6 +1,6 @@
1
1
  import { AnySchema, PlainState, State, Tracked } from '@irtio/schema';
2
2
  import * as _irtio_protocol from '@irtio/protocol';
3
- import { ErrorCodeName } from '@irtio/protocol';
3
+ import { PlatformNotice, ErrorCodeName } from '@irtio/protocol';
4
4
  import { RoomDefinition, Room, LeaveReason, NpcConfig } from '@irtio/server';
5
5
 
6
6
  /**
@@ -84,7 +84,9 @@ declare class TimelineRecorder {
84
84
 
85
85
  type RoomEventKind = 'join' | 'leave' | 'write' | 'write-rejected' | 'correct' | 'call' | 'reply' | 'msg' | 'alarm'
86
86
  /** D59: a bus event (a published message) or a directed bus send being delivered. */
87
- | 'bus' | 'error';
87
+ | 'bus'
88
+ /** M7 D-P2: a platform notice reaching `onPlatformNotice`; the detail is the phase. */
89
+ | 'notice' | 'error';
88
90
  interface RoomEvent {
89
91
  readonly tick: number;
90
92
  readonly kind: RoomEventKind;
@@ -100,6 +102,68 @@ interface RoomInspection {
100
102
  readonly recent: readonly RoomEvent[];
101
103
  }
102
104
 
105
+ /**
106
+ * M6 lane K (D77 / O28): the write core's caps, and the named refusals that enforce them.
107
+ *
108
+ * ## Why these are here and not in the relay host
109
+ *
110
+ * The relay host is the reason they exist — it serves many tenants from one process, so a room
111
+ * that grows without limit is a room taking a box down with it — but the checks belong on the
112
+ * write path, which is the runtime's. Putting them here means a client `add` cannot get past them
113
+ * whichever host is running the room, and it means a test can drive them without a socket.
114
+ *
115
+ * ## They are walls, not budgets
116
+ *
117
+ * A write that would cross one is refused. The room carries on; every other client is unaffected;
118
+ * nothing is evicted, degraded, truncated or silently dropped. The refusal goes back to the writer
119
+ * as a **non-fatal** `ERROR` naming the cap it met ({@link ErrorCode.E_STATE_CAP},
120
+ * `E_RECORD_CAP`, `E_WRITE_RATE`), so a developer can tell a sizing decision from a bug without
121
+ * reading a log they may not have.
122
+ *
123
+ * ## What a room that did not ask for them pays
124
+ *
125
+ * Nothing. `stateBytes` and `writesPerSecond` are unset unless a host sets them, and the host that
126
+ * sets them is the relay host. A coded room in its own VM has its own memory ceiling and its own
127
+ * tick budget and does not need a second copy of either. The record cap is the exception and it is
128
+ * on by default, because client `add` is a new surface: no room written before this could regress
129
+ * on a limit that only applies to a thing it could not do.
130
+ *
131
+ * ## How state size is measured, and the error that leaves
132
+ *
133
+ * Encoding 512 KB of state on every write to find out how big it is would cost more than the write
134
+ * loop it guards. So: an exact measure (`encodeSnapshot(...).length`) is taken at most once a tick,
135
+ * and only once the running figure is over half the cap **or has gone `STALE_MEASURE_TICKS`
136
+ * without one**; between measures the figure is grown by a **conservative upper bound** of what
137
+ * each accepted write could add (a record's maximum encoded size, a string field's maximum encoded
138
+ * size).
139
+ *
140
+ * The invariant that buys, stated exactly: the figure is an upper bound on the truth *for
141
+ * everything it can see*, which is client writes, and it is re-anchored to the truth at least
142
+ * every `STALE_MEASURE_TICKS` ticks. It is emphatically **not** an upper bound between
143
+ * re-anchorings for what it cannot see — a `perPlayer` record added on join, an ownership change,
144
+ * a coded room's own handler writing state — and the staleness bound is what closes that: a leak
145
+ * the meter is blind to can outrun the figure for ten seconds at 30 Hz and no longer. Above the
146
+ * truth it can be by one tick of accepted client writes, which is what a slightly early refusal
147
+ * costs. A room under half the cap pays one measure per stale window and nothing else.
148
+ */
149
+
150
+ /** O28's numbers, as the relay host sets them. One place, so lane J can move them once. */
151
+ declare const RELAY_STATE_BYTES: number;
152
+ declare const RELAY_RECORDS_PER_COLLECTION = 1024;
153
+ declare const RELAY_WRITES_PER_SECOND = 35;
154
+ /** The record cap every room gets, because client `add` is a surface no old room had. */
155
+ declare const DEFAULT_RECORDS_PER_COLLECTION = 1024;
156
+ interface WriteCaps {
157
+ /** Encoded state bytes. Unset (the default) means no wall: the host has its own memory ceiling. */
158
+ readonly stateBytes?: number;
159
+ /** Records one entity collection may hold. Defaults to {@link DEFAULT_RECORDS_PER_COLLECTION}. */
160
+ readonly recordsPerCollection?: number;
161
+ /** `WRITE` frames one client may send a second. Unset means no bucket. */
162
+ readonly writesPerSecond?: number;
163
+ }
164
+ /** What the relay host passes. Named so a test can assert the numbers rather than repeat them. */
165
+ declare const RELAY_WRITE_CAPS: WriteCaps;
166
+
103
167
  type LogLevel = 'info' | 'warn' | 'error';
104
168
  /**
105
169
  * Week 12: host-side async work a room asked for — a save generation (D24) or a player-KV
@@ -194,6 +258,53 @@ type HostCall = {
194
258
  readonly playerId: string;
195
259
  readonly rating: number;
196
260
  readonly deviation?: number;
261
+ }
262
+ /**
263
+ * D81: `room.replay.clip(seconds)` — the assembled clip blob, on its way to storage.
264
+ *
265
+ * The one crossing this lane adds, and a host call for the reason every other one here is: the
266
+ * room has no network and no store. It is also the only host call that carries bulk bytes, so
267
+ * it is the only one with a size wall on the far side (`CLIP_MAX_BYTES`, checked by the
268
+ * supervisor before anything is written) — the ring's own bound already implies a smaller
269
+ * number, but that bound lives on the side of the seam the tenant's own code owns.
270
+ *
271
+ * The result's `value` is the minted clip id.
272
+ *
273
+ * M6 clip-TTL packet: `expiresAtEpochS` is the unix second this clip should die, computed here
274
+ * because this is where `replay.ttl` is declared and read. The supervisor clamps it into
275
+ * `[now + 1m, now + 3650d]` before minting an id from it, so choosing it inside the tenant's
276
+ * own worker buys a hostile room nothing an honest one cannot declare.
277
+ */
278
+ | {
279
+ readonly kind: 'clip';
280
+ readonly bytes: Uint8Array;
281
+ readonly expiresAtEpochS: number;
282
+ }
283
+ /**
284
+ * D81's `record: true`: one numbered chunk of a continuous recording, on its way to storage.
285
+ *
286
+ * The chunk is the ring's own evictions, kept — so this call carries bulk bytes for the same
287
+ * reason `clip` does, is walled on the far side for the same reason, and is the only other host
288
+ * call that does either.
289
+ *
290
+ * `recId` is `undefined` on the first chunk of a recording and the supervisor's reply carries
291
+ * the id it minted; every chunk after it names that id. The mint is on the supervisor's side
292
+ * because that is where the entropy and the key algebra live (the `clip` precedent), and the
293
+ * runtime never invents one — which is also what keeps two rooms recording in one project from
294
+ * ever sharing a prefix.
295
+ *
296
+ * M6 clip-TTL packet: `expiresAtEpochS` is the expiry computed when the recording STARTED, and
297
+ * every chunk carries that same number for the life of the recording. It only decides anything
298
+ * on the first chunk — the mint — because every chunk after it is addressed by the `recId` the
299
+ * mint produced, which is what makes a 40-minute match expire atomically instead of losing its
300
+ * opening while its ending is still being watched.
301
+ */
302
+ | {
303
+ readonly kind: 'record-chunk';
304
+ readonly recId: string | undefined;
305
+ readonly seq: number;
306
+ readonly bytes: Uint8Array;
307
+ readonly expiresAtEpochS: number;
197
308
  };
198
309
  /**
199
310
  * What a `HostCall` produced. `value` carries the save id for `save` and the stored string for
@@ -318,6 +429,21 @@ interface RoomCoreOptions {
318
429
  * `testRoom({ profile: true })` are what turn it on.
319
430
  */
320
431
  readonly profile?: boolean;
432
+ /**
433
+ * D77 / O28: the write core's caps — encoded state bytes, records per collection, writes per
434
+ * client per second. A host that sets none gets the record cap and nothing else, which is what
435
+ * every coded room in its own VM wants: it already has a memory ceiling and a tick budget. The
436
+ * relay host sets all three, because it serves many tenants from one process.
437
+ */
438
+ readonly caps?: WriteCaps;
439
+ /**
440
+ * D77: the wall clock, for hold deadlines and nothing else.
441
+ *
442
+ * A hold has to keep running while a room is hibernated, and `RoomHost.now()` is monotonic
443
+ * within one process, so it cannot answer "is this five-minute hold over?" for a room that slept
444
+ * through it. Defaults to `Date.now`; injectable so a test can move a long hold without waiting.
445
+ */
446
+ readonly wallNow?: () => number;
321
447
  }
322
448
  interface JoinOptions {
323
449
  /** Requested role; unknown/missing → `roles[0]` (logged) or `''` when the schema has no roles. */
@@ -441,8 +567,24 @@ interface RoomCoreApi<S extends AnySchema = AnySchema> {
441
567
  fireAlarm(name: string): void;
442
568
  /** D59: deliver one published message on a channel this room is subscribed to. */
443
569
  deliverBusEvent(channel: string, from: string, payload: string): void;
570
+ /**
571
+ * M7 D-P2: deliver a platform notice to `onPlatformNotice` and answer whether the supervisor
572
+ * should still broadcast it. `false` means the room vetoed; a throw, a missing handler and a
573
+ * stopped room all answer `true`.
574
+ */
575
+ deliverPlatformNotice(notice: PlatformNotice): boolean;
444
576
  /** D59: deliver one directed `room.bus.send`. At-least-once, so this may repeat a message. */
445
577
  deliverBusMessage(from: string, payload: string): void;
578
+ /**
579
+ * D77: the encoded size of this room's state, or `undefined` when no host asked for the state
580
+ * cap and nothing is metering it. What the room row and `irtio rooms` report, and the reason a
581
+ * developer sees the number climb long before the wall.
582
+ */
583
+ stateBytes(): number | undefined;
584
+ /** D77: how many records this room is holding for players who have left. */
585
+ holdCount(): number;
586
+ /** How many `transfer: 'ask'` requests are waiting on an owner's answer. Never survives sleep. */
587
+ askCount(): number;
446
588
  }
447
589
 
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 };
590
+ 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-UEKTud1Z.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-uhj3b-8E.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-YWDRE77H.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-LT46QFLW.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
- import { AttributeOptions, ProfileSnapshot } from '@irtio/protocol';
1
+ import { PlatformNotice, 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-UEKTud1Z.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
@@ -730,6 +734,26 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
730
734
  constructor(definition: RoomDefinition<S>, host: RoomHost, options: RoomCoreOptions);
731
735
  private subscribeDeclaredChannels;
732
736
  private get busConfig();
737
+ /**
738
+ * M7 D-P2: the platform notice, and the room's veto.
739
+ *
740
+ * Answers whether the supervisor should broadcast. `false` — the veto — comes back for exactly
741
+ * one input: a handler that ran and returned `false`. Every other path answers `true`, and each
742
+ * one is a different way of the room having said nothing:
743
+ *
744
+ * - **No handler.** A room that never wrote this hook. The design's whole point is that its
745
+ * players still hear the warning; a code-less room does not even reach this file.
746
+ * - **A handler that threw.** `tryRun` and not `guard`, so the throw counts toward the crash
747
+ * threshold like any other, and it still does not become a veto. This is the one place the
748
+ * convention differs from `onChat`, where a throw refuses, and the reason is the asymmetry of
749
+ * the two losses: a chat line nobody sees is small; a save-loss warning nobody sees is the
750
+ * feature not existing.
751
+ * - **A stopped room.** Nobody to ask, and for `closing` nobody left to veto for.
752
+ *
753
+ * The handler may also have written state, posted chat or started an end sequence. All of that
754
+ * is ordinary handler work and is flushed here the way an alarm's is.
755
+ */
756
+ deliverPlatformNotice(notice: PlatformNotice): boolean;
733
757
  /**
734
758
  * D59: one published message arriving on a channel this room is subscribed to.
735
759
  *
@@ -873,6 +897,12 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
873
897
  * `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
874
898
  * `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
875
899
  */
900
+ /** D77: the encoded state size, when a host set the state cap. See `caps.ts`. */
901
+ stateBytes(): number | undefined;
902
+ /** D77: how many records are held for players who have left. */
903
+ holdCount(): number;
904
+ /** How many `transfer: 'ask'` requests are waiting on an owner's answer right now. */
905
+ askCount(): number;
876
906
  profile(): ProfileSnapshot | undefined;
877
907
  private badFrame;
878
908
  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-uhj3b-8E.js';
2
+ export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-uhj3b-8E.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-UEKTud1Z.js';
6
6
  import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
7
7
 
8
8
  /**
@@ -138,6 +138,15 @@ 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;
144
+ /** D81: every recording this room wrote: `recId` -> its chunks in the order they were sent. */
145
+ readonly recordings: Map<string, {
146
+ seq: number;
147
+ bytes: Uint8Array;
148
+ }[]>;
149
+ private nextRecordingId;
141
150
  /**
142
151
  * D44: the in-process harness has no supervisor, no sockets and therefore no loopback session
143
152
  * to open. Recording the request rather than faking a session is the honest option: a test that
@@ -267,6 +276,16 @@ interface FakeClient<S extends AnySchema = AnySchema> {
267
276
  readonly joinTick: number;
268
277
  /** Sends a `WRITE` update for `entity[id]` masking exactly the patch's leaves. */
269
278
  write(entity: string, id: string, patch: AnyRecord): void;
279
+ /** D77: sends a `WRITE` `add` — what a client of a `clientCreate` collection sends. */
280
+ add(entity: string, id: string, value: AnyRecord): void;
281
+ /** D77: sends a `WRITE` `remove`. */
282
+ remove(entity: string, id: string): void;
283
+ /** Non-fatal `ERROR` frames this client was sent, in order. The caps are read from here. */
284
+ readonly errors: readonly {
285
+ code: number;
286
+ message: string;
287
+ fatal: boolean;
288
+ }[];
270
289
  /** Sends an arbitrary framed payload (protocol-violation tests). */
271
290
  writeRaw(frame: Uint8Array): void;
272
291
  /** Client → server RPC. Settles when the `REPLY` is delivered — `h.tick()` first, then `await`. */
@@ -295,41 +314,6 @@ interface FakeClient<S extends AnySchema = AnySchema> {
295
314
  entity(name: string): EntityCollection;
296
315
  }
297
316
 
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
317
  interface HarnessOptions {
334
318
  /** `room.random()` seed; defaults to a hash of `roomId`, exactly as `RoomCore` does. */
335
319
  readonly seed?: number;
@@ -339,12 +323,22 @@ interface HarnessOptions {
339
323
  readonly roomId?: string;
340
324
  /** Bytes from a previous `serialize()`: the room wakes instead of being created. */
341
325
  readonly restoreFrom?: Uint8Array;
326
+ /** D77 / O28: the write-core caps this room runs under. Default: the record cap only. */
327
+ readonly caps?: WriteCaps;
328
+ /** D77: the wall clock hold deadlines are measured against. Default `Date.now`. */
329
+ readonly wallNow?: () => number;
342
330
  }
343
331
  interface JoinSpec {
344
332
  readonly role?: string;
345
333
  readonly name?: string;
346
334
  /** Default: `c1`, `c2`, … in join order. */
347
335
  readonly id?: string;
336
+ /**
337
+ * D77: the identity a hold binds to. Defaults to the client id, exactly as a real join does
338
+ * when the client presented no assertion — which is what makes the "another tab reclaims" and
339
+ * "a new browser does not" pair testable without a socket.
340
+ */
341
+ readonly playerId?: string;
348
342
  }
349
343
  interface UntilOptions {
350
344
  /** Default 1000. */
@@ -5,7 +5,7 @@ import {
5
5
  initPhysics,
6
6
  initRapier2d,
7
7
  visibleNames
8
- } from "../chunk-EENJ37KK.js";
8
+ } from "../chunk-LT46QFLW.js";
9
9
 
10
10
  // src/test/clock.ts
11
11
  var FakeClock = class {
@@ -203,8 +203,36 @@ 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
+ }
212
+ // ---- end M6 lane N ----
213
+ // ---- M6 record packet ----
214
+ case "record-chunk": {
215
+ const recId = call.recId ?? `rec${String(this.nextRecordingId++).padStart(4, "0")}`;
216
+ let chunks = this.recordings.get(recId);
217
+ if (!chunks) {
218
+ chunks = [];
219
+ this.recordings.set(recId, chunks);
220
+ }
221
+ chunks.push({ seq: call.seq, bytes: call.bytes });
222
+ return { ok: true, value: recId };
223
+ }
206
224
  }
207
225
  }
226
+ // ---- M6 lane N: replays ----
227
+ /** D81: every clip the room wrote, in order: `clipId` -> the blob it was handed. */
228
+ clips = /* @__PURE__ */ new Map();
229
+ nextClipId = 1;
230
+ // ---- end M6 lane N ----
231
+ // ---- M6 record packet ----
232
+ /** D81: every recording this room wrote: `recId` -> its chunks in the order they were sent. */
233
+ recordings = /* @__PURE__ */ new Map();
234
+ nextRecordingId = 1;
235
+ // ---- end M6 record packet ----
208
236
  // -------------------------------------------------------------------------
209
237
  // Week 12: durable alarms (D26)
210
238
  // -------------------------------------------------------------------------
@@ -293,6 +321,7 @@ import {
293
321
  import {
294
322
  FrameType,
295
323
  decodeCall,
324
+ decodeErrorPayload,
296
325
  decodeMsg,
297
326
  decodeReply,
298
327
  encodeCall,
@@ -311,7 +340,9 @@ import {
311
340
  decodeSnapshot,
312
341
  encodeDelta,
313
342
  encodeFields,
314
- markField
343
+ markAdd,
344
+ markField,
345
+ markRemove
315
346
  } from "@irtio/schema";
316
347
  function structFields(t) {
317
348
  return Object.entries(t.fields).map(([name, type]) => ({ name, type }));
@@ -427,6 +458,34 @@ var FakeClientImpl = class {
427
458
  writeRaw(frame) {
428
459
  this.bridge.toCore(this.id, frame);
429
460
  }
461
+ // ---- M6 lane K: stateful relay ----
462
+ errors = [];
463
+ add(entity, id, value) {
464
+ const scratch = createState(this.bridge.ext);
465
+ entityOf(scratch, entity).add(id, value, { owner: this.id });
466
+ const dirty = createDirtySet();
467
+ markAdd(dirty, entity, id);
468
+ this.bridge.toCore(
469
+ this.id,
470
+ encodeFrame(
471
+ FrameType.WRITE,
472
+ encodeDelta(this.bridge.ext, scratch, dirty, { tick: this.bridge.tick })
473
+ )
474
+ );
475
+ }
476
+ remove(entity, id) {
477
+ const scratch = createState(this.bridge.ext);
478
+ const dirty = createDirtySet();
479
+ markRemove(dirty, entity, id);
480
+ this.bridge.toCore(
481
+ this.id,
482
+ encodeFrame(
483
+ FrameType.WRITE,
484
+ encodeDelta(this.bridge.ext, scratch, dirty, { tick: this.bridge.tick })
485
+ )
486
+ );
487
+ }
488
+ // ---- end M6 lane K ----
430
489
  call(name, params = {}) {
431
490
  const desc = this.descOf(name);
432
491
  const reqId = this.nextReqId++;
@@ -505,6 +564,13 @@ var FakeClientImpl = class {
505
564
  case FrameType.MSG:
506
565
  this.handleMsg(payload);
507
566
  return;
567
+ // ---- M6 lane K: stateful relay ----
568
+ case FrameType.ERROR: {
569
+ const e = decodeErrorPayload(payload);
570
+ this.errors.push({ code: e.code, message: e.message, fatal: e.fatal });
571
+ return;
572
+ }
573
+ // ---- end M6 lane K ----
508
574
  default:
509
575
  return;
510
576
  }
@@ -679,7 +745,11 @@ var Harness = class {
679
745
  roomId: options.roomId ?? "test-room",
680
746
  ...options.seed !== void 0 ? { seed: options.seed } : {},
681
747
  ...options.publicUrl !== void 0 ? { publicUrl: options.publicUrl } : {},
682
- ...options.restoreFrom !== void 0 ? { restoreFrom: options.restoreFrom } : {}
748
+ ...options.restoreFrom !== void 0 ? { restoreFrom: options.restoreFrom } : {},
749
+ // ---- M6 lane K: stateful relay ----
750
+ ...options.caps !== void 0 ? { caps: options.caps } : {},
751
+ ...options.wallNow !== void 0 ? { wallNow: options.wallNow } : {}
752
+ // ---- end M6 lane K ----
683
753
  });
684
754
  this.host.core = this.coreRef;
685
755
  this.host.serializeForSave = () => this.coreRef.snapshot();
@@ -881,7 +951,10 @@ var Harness = class {
881
951
  const role = spec.role ?? roles[0] ?? "";
882
952
  const options = {
883
953
  role,
884
- ...spec.name !== void 0 ? { name: spec.name } : {}
954
+ ...spec.name !== void 0 ? { name: spec.name } : {},
955
+ // ---- M6 lane K: stateful relay ----
956
+ ...spec.playerId !== void 0 ? { playerId: spec.playerId } : {}
957
+ // ---- end M6 lane K ----
885
958
  };
886
959
  const client = new FakeClientImpl(this.bridge, id, options);
887
960
  this.byId.set(id, client);
@@ -1,5 +1,5 @@
1
- import { L as LogLevel, T as TimelineDump, k as RoomStats, b as HostCall, c as HostCallResult, R as RoomCoreApi, i as RoomHost } from '../contract-C5aqs49-.js';
2
- import { ErrorCodeName, ProfileSnapshot } from '@irtio/protocol';
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-UEKTud1Z.js';
2
+ import { ErrorCodeName, ProfileSnapshot, PlatformNotice } from '@irtio/protocol';
3
3
  import { NpcConfig, LeaveReason } from '@irtio/server';
4
4
  import '@irtio/schema';
5
5
 
@@ -132,6 +132,17 @@ type ToWorker = {
132
132
  name: string;
133
133
  from: string;
134
134
  payload: string;
135
+ }
136
+ /**
137
+ * M7 D-P2: the platform has something to tell this room's players, and the room gets the first
138
+ * look. Carries a `reqId` because the answer decides whether the supervisor broadcasts, so this
139
+ * is a request and not a notification — a rare thing on this side of the wire, and the reason is
140
+ * that a veto only means anything before the frame goes out.
141
+ */
142
+ | {
143
+ t: 'platformNotice';
144
+ reqId: number;
145
+ notice: PlatformNotice;
135
146
  } | {
136
147
  t: 'stop';
137
148
  };
@@ -224,6 +235,17 @@ type FromWorker = {
224
235
  reqId: number;
225
236
  ok: boolean;
226
237
  }
238
+ /**
239
+ * M7 D-P2: the room's answer to a `platformNotice`. `broadcast: false` only when the handler
240
+ * returned exactly `false`. A handler that threw, a room with no handler, and a room whose core
241
+ * is not up all answer `true`, because the veto is a room saying "I will tell them myself" and
242
+ * none of those three said anything.
243
+ */
244
+ | {
245
+ t: 'platformNoticeResult';
246
+ reqId: number;
247
+ broadcast: boolean;
248
+ }
227
249
  /** D41: the recorded timeline. `dump` absent when nobody armed the recorder. */
228
250
  | {
229
251
  t: 'timeline';
@@ -368,5 +390,17 @@ interface WorkerState {
368
390
  * killing the worker.
369
391
  */
370
392
  declare function handleMessage(state: WorkerState, host: RoomHost, post: (msg: FromWorker, transfer?: ArrayBuffer[]) => void, msg: ToWorker): Promise<void>;
393
+ /**
394
+ * Replaces `Math.random` with sfc32 seeded from 16 host-supplied bytes.
395
+ *
396
+ * Box day 2 (2026-09-08) measured why this exists: two VMs restored from one golden snapshot
397
+ * handed their room workers **bit-for-bit identical `Math.random` streams** — V8 seeds a new
398
+ * isolate's PRNG from process state that a memory snapshot clones, and the kernel's VMGenID
399
+ * reseed does not reach it. `Math.random` is the only random primitive room code has (the
400
+ * sandbox refuses `node:crypto`), so an unseeded clone gives two different projects the same
401
+ * dice. The seed comes from the supervisor's clone-safe entropy mixer via `workerData`, so
402
+ * every worker — cold boot or restore, same VM or sibling clones — rolls its own.
403
+ */
404
+ declare function seedMathRandom(seedHex: string): void;
371
405
 
372
- export { type FromWorker, type MigrationChainStep, type ToWorker, type WorkerState, createWorkerHost, handleMessage, transferList };
406
+ export { type FromWorker, type MigrationChainStep, type ToWorker, type WorkerState, createWorkerHost, handleMessage, seedMathRandom, transferList };