@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.
- package/dist/{chunk-EENJ37KK.js → chunk-LT46QFLW.js} +1626 -108
- package/dist/{chunk-WNAEE4EP.js → chunk-YWDRE77H.js} +1 -1
- package/dist/{contract-C5aqs49-.d.ts → contract-UEKTud1Z.d.ts} +145 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +12 -2
- package/dist/{room-Db3iPBKM.d.ts → room-uhj3b-8E.d.ts} +32 -2
- package/dist/test/index.d.ts +32 -38
- package/dist/test/index.js +77 -4
- package/dist/worker/index.d.ts +37 -3
- package/dist/worker/index.js +34 -3
- package/package.json +4 -4
|
@@ -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'
|
|
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 {
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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 {
|
|
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;
|
package/dist/test/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { R as RoomCore } from '../room-
|
|
2
|
-
export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-
|
|
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 {
|
|
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. */
|
package/dist/test/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
initPhysics,
|
|
6
6
|
initRapier2d,
|
|
7
7
|
visibleNames
|
|
8
|
-
} from "../chunk-
|
|
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
|
-
|
|
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);
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { L as LogLevel, T as TimelineDump,
|
|
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 };
|