@irtio/runtime 0.1.0 → 0.3.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,9 @@
1
1
  import {
2
2
  RoomCore,
3
+ createVisibilityPolicy,
4
+ initPhysics,
3
5
  visibleNames
4
- } from "../chunk-X5S364FY.js";
6
+ } from "../chunk-5ZDQAAFJ.js";
5
7
 
6
8
  // src/test/clock.ts
7
9
  var FakeClock = class {
@@ -66,6 +68,32 @@ var HarnessHost = class {
66
68
  /** Set by the harness: where an outbound frame goes. */
67
69
  onSend = () => {
68
70
  };
71
+ /**
72
+ * The core this host answers. Set by whoever built the pair — the host has to call back into
73
+ * `completeHostCall`/`fireAlarm`, which runs in the same direction `receive()` does.
74
+ */
75
+ core;
76
+ /** Every `room.save()` / `room.kv.*` the room asked for, in order. */
77
+ hostCalls = [];
78
+ /** Fake ms between a host call and its answer. 0 still means "a later event", not "inline". */
79
+ hostCallDelayMs = 0;
80
+ /** The in-memory player KV, keyed the way the real table's composite primary key is. */
81
+ kv = /* @__PURE__ */ new Map();
82
+ /** Save generations this host minted: `saveId` -> the bytes it was handed. */
83
+ saves = /* @__PURE__ */ new Map();
84
+ /** What `room.save()` serializes. The harness sets it to `() => core.snapshot()` — the bytes
85
+ * without hibernation's side effects, exactly as the supervisor asks for them. */
86
+ serializeForSave;
87
+ /**
88
+ * Override the host-call backend. Return `undefined` to fall through to the built-in in-memory
89
+ * one; return a result to answer it yourself — how the limit and outage tests are written.
90
+ */
91
+ handleHostCall;
92
+ nextSaveId = 1;
93
+ /** D26: armed alarms, `name` -> due time on the fake clock. Host state, never room state. */
94
+ armedAlarms = /* @__PURE__ */ new Map();
95
+ /** Every alarm that fired, in order, as `name@dueMs`. */
96
+ firedAlarms = [];
69
97
  /** `true` once the runtime has asked to hibernate at least once. */
70
98
  get slept() {
71
99
  return this.sleeps > 0;
@@ -110,11 +138,76 @@ var HarnessHost = class {
110
138
  crashed(reason) {
111
139
  this.crashes.push(reason);
112
140
  }
141
+ // -------------------------------------------------------------------------
142
+ // Week 12: host calls (D24 save, D25 KV)
143
+ // -------------------------------------------------------------------------
144
+ hostCall(reqId, call) {
145
+ this.hostCalls.push({ reqId, call });
146
+ this.clock.setTimeout(() => {
147
+ const result = this.handleHostCall?.(call) ?? this.runHostCall(call);
148
+ this.core?.completeHostCall(reqId, result);
149
+ }, this.hostCallDelayMs);
150
+ }
151
+ runHostCall(call) {
152
+ switch (call.kind) {
153
+ case "save": {
154
+ const serialize = this.serializeForSave;
155
+ if (!serialize) {
156
+ return { ok: false, code: "E_SAVE_UNSUPPORTED", message: "no serializer wired" };
157
+ }
158
+ const saveId = String(this.nextSaveId++).padStart(6, "0");
159
+ this.saves.set(saveId, serialize());
160
+ return { ok: true, value: saveId };
161
+ }
162
+ case "kvGet": {
163
+ const value = this.kv.get(kvKey(call.playerId, call.key));
164
+ return value === void 0 ? { ok: true } : { ok: true, value };
165
+ }
166
+ case "kvSet":
167
+ this.kv.set(kvKey(call.playerId, call.key), call.value);
168
+ return { ok: true };
169
+ case "kvDelete":
170
+ this.kv.delete(kvKey(call.playerId, call.key));
171
+ return { ok: true };
172
+ }
173
+ }
174
+ // -------------------------------------------------------------------------
175
+ // Week 12: durable alarms (D26)
176
+ // -------------------------------------------------------------------------
177
+ setAlarm(name, atMs) {
178
+ if (atMs === void 0) this.armedAlarms.delete(name);
179
+ else this.armedAlarms.set(name, atMs);
180
+ }
181
+ /**
182
+ * Fires every alarm due at or before `now`, in **name order** (D34 determinism), removing each
183
+ * before it runs so a handler that re-arms its own name arms the *next* one rather than having
184
+ * this pass cancel it straight back out.
185
+ */
186
+ fireDue(now = this.clock.now()) {
187
+ const due = [...this.armedAlarms.entries()].filter(([, at]) => at <= now).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
188
+ const fired = [];
189
+ for (const [name, at] of due) {
190
+ this.armedAlarms.delete(name);
191
+ this.firedAlarms.push(`${name}@${at}`);
192
+ fired.push(name);
193
+ this.core?.fireAlarm(name);
194
+ }
195
+ return fired;
196
+ }
197
+ /** The earliest due time across armed alarms — what the supervisor reports to control. */
198
+ get dueAlarmAt() {
199
+ let best;
200
+ for (const at of this.armedAlarms.values()) if (best === void 0 || at < best) best = at;
201
+ return best;
202
+ }
113
203
  /** Logged messages of one level, flattened to strings (handy in assertions). */
114
204
  logsOf(level) {
115
205
  return this.logs.filter((l) => l.level === level).map((l) => l.args.map(String).join(" "));
116
206
  }
117
207
  };
208
+ function kvKey(playerId, key) {
209
+ return `${playerId}\0${key}`;
210
+ }
118
211
 
119
212
  // src/test/harness.ts
120
213
  import {
@@ -361,16 +454,25 @@ var FakeClientImpl = class {
361
454
  }
362
455
  /** Flags any collection the frame named that this client's role may not see. */
363
456
  checkFrameVisibility(delta) {
364
- const keep = this.bridge.visible(this.role);
365
457
  for (const dc of delta.collections) {
366
- if (keep.has(dc.name) || dc.ops.length === 0) continue;
458
+ const spatial = this.bridge.ext.collection(dc.name).visibility === "spatial-grid";
459
+ const leaked = dc.ops.filter((op) => {
460
+ if (!this.bridge.visible(this.role, this.id, dc.name)) return true;
461
+ if (!spatial) return false;
462
+ if (op.op === "remove") return !entityOf(this.view, dc.name).has(op.id);
463
+ return !this.bridge.visible(this.role, this.id, dc.name, op.id);
464
+ });
465
+ if (leaked.length === 0) continue;
466
+ const ids = leaked.map((o) => o.id);
467
+ const detail = this.bridge.spatialDetail(this.role, this.id, dc.name, ids);
367
468
  this.bridge.noteLeak({
368
469
  clientId: this.id,
369
470
  role: this.role,
370
471
  collection: dc.name,
371
472
  kind: "frame",
372
- ids: dc.ops.map((o) => o.id),
373
- tick: delta.tick
473
+ ids,
474
+ tick: delta.tick,
475
+ ...detail ? { detail } : {}
374
476
  });
375
477
  }
376
478
  }
@@ -489,6 +591,9 @@ function deepEqual(a, b) {
489
591
  for (const k of keys) if (!deepEqual(ra[k], rb[k])) return false;
490
592
  return true;
491
593
  }
594
+ function detailOf(detail) {
595
+ return detail ? { detail } : {};
596
+ }
492
597
  var Harness = class {
493
598
  constructor(definition, options) {
494
599
  this.definition = definition;
@@ -501,6 +606,8 @@ var Harness = class {
501
606
  ...options.publicUrl !== void 0 ? { publicUrl: options.publicUrl } : {},
502
607
  ...options.restoreFrom !== void 0 ? { restoreFrom: options.restoreFrom } : {}
503
608
  });
609
+ this.host.core = this.coreRef;
610
+ this.host.serializeForSave = () => this.coreRef.snapshot();
504
611
  this.coreRef.start();
505
612
  const self = this;
506
613
  this.bridge = {
@@ -523,7 +630,17 @@ var Harness = class {
523
630
  noteLeak: (leak) => {
524
631
  this.frameLeaks.push(leak);
525
632
  },
526
- visible: (role) => visibleNames(this.coreRef.ext, role)
633
+ visible: (role, clientId, collection, id) => {
634
+ const desc = this.coreRef.ext.collection(collection);
635
+ const policy = createVisibilityPolicy(this.coreRef.ext, this.coreRef.plain, clientId, role);
636
+ return id === void 0 ? policy.maySeeCollection(desc) : policy.maySeeEntity(desc, id);
637
+ },
638
+ spatialDetail: (role, clientId, collection, ids) => createVisibilityPolicy(
639
+ this.coreRef.ext,
640
+ this.coreRef.plain,
641
+ clientId,
642
+ role
643
+ ).describeSpatial(this.coreRef.ext.collection(collection), ids)
527
644
  };
528
645
  }
529
646
  definition;
@@ -711,7 +828,24 @@ var Harness = class {
711
828
  const ext = this.coreRef.ext;
712
829
  for (const client of this.clientList) {
713
830
  const keep = visibleNames(ext, client.role);
831
+ const policy = createVisibilityPolicy(ext, this.coreRef.plain, client.id, client.role);
714
832
  for (const c of ext.collections) {
833
+ if (c.visibility === "spatial-grid") {
834
+ const coll = client.view[c.name];
835
+ const ids = coll ? [...coll.ids()].filter((id) => !policy.maySeeEntity(c, id)) : [];
836
+ if (ids.length > 0) {
837
+ leaks.push({
838
+ clientId: client.id,
839
+ role: client.role,
840
+ collection: c.name,
841
+ kind: "view",
842
+ ids,
843
+ tick: this.coreRef.tick,
844
+ ...detailOf(policy.describeSpatial(c, ids))
845
+ });
846
+ }
847
+ continue;
848
+ }
715
849
  if (keep.has(c.name)) continue;
716
850
  if (c.kind === "entity") {
717
851
  const coll = client.view[c.name];
@@ -770,5 +904,6 @@ export {
770
904
  FakeClock,
771
905
  HarnessHost,
772
906
  createRoomHarness,
773
- frameTypeName
907
+ frameTypeName,
908
+ initPhysics
774
909
  };
@@ -1,4 +1,4 @@
1
- import { L as LogLevel, h as RoomStats, R as RoomCoreApi, f as RoomHost } from '../contract-BhD88PGb.js';
1
+ import { L as LogLevel, j as RoomStats, a as HostCall, b as HostCallResult, R as RoomCoreApi, h as RoomHost } from '../contract-B8QSO0MH.js';
2
2
  import { ErrorCodeName } from '@irtio/protocol';
3
3
  import { LeaveReason } from '@irtio/server';
4
4
  import '@irtio/schema';
@@ -40,6 +40,9 @@ type ToWorker = {
40
40
  clientId: string;
41
41
  role?: string;
42
42
  name?: string;
43
+ /** Week 13 (D27): the verified JWT subject — becomes `JoinOptions.playerId` and therefore
44
+ * `ctx.playerId`. Absent for key joins, whose playerId stays the client id. */
45
+ playerId?: string;
43
46
  reconnecting?: boolean;
44
47
  } | {
45
48
  t: 'leave';
@@ -52,14 +55,33 @@ type ToWorker = {
52
55
  t: 'frame';
53
56
  clientId: string;
54
57
  bytes: Uint8Array;
55
- } | {
58
+ }
59
+ /** `forSave` (D24) asks for the bytes only: no `onSleep`, no timers cleared, nothing
60
+ * rejected — the room keeps running. Absent means hibernation, as it always did. */
61
+ | {
56
62
  t: 'serialize';
57
63
  reqId: number;
64
+ forSave?: boolean;
58
65
  } | {
59
66
  t: 'inspect';
60
67
  reqId: number;
61
68
  } | {
62
69
  t: 'stats';
70
+ }
71
+ /**
72
+ * Week 12: the supervisor answering a `hostCall` (a D24 save, a D25 KV operation). It arrives
73
+ * as an ordinary worker message, which is precisely what makes the room's continuation "its
74
+ * own event between ticks" rather than something that ran inside the handler that asked.
75
+ */
76
+ | {
77
+ t: 'hostResult';
78
+ reqId: number;
79
+ result: HostCallResult;
80
+ }
81
+ /** D26: a durable alarm came due. The supervisor owns the timer; the worker just runs it. */
82
+ | {
83
+ t: 'alarm';
84
+ name: string;
63
85
  } | {
64
86
  t: 'stop';
65
87
  };
@@ -141,6 +163,22 @@ type FromWorker = {
141
163
  stats: Omit<RoomStats, 'bytesOutByClient'> & {
142
164
  bytesOutByClient: [string, number][];
143
165
  };
166
+ }
167
+ /** Week 12: the room asked for host-side async work; the supervisor answers `hostResult`. */
168
+ | {
169
+ t: 'hostCall';
170
+ reqId: number;
171
+ call: HostCall;
172
+ }
173
+ /**
174
+ * D26: `room.alarm(name, atMs)` / `room.cancelAlarm(name)` (`atMs` absent = cancel). Alarm
175
+ * state lives in the supervisor's `RoomRecord`, never in the hibernation blob — which is why
176
+ * the blob format is still v2.
177
+ */
178
+ | {
179
+ t: 'setAlarm';
180
+ name: string;
181
+ atMs?: number;
144
182
  } | {
145
183
  t: 'stopped';
146
184
  };
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  migrateSnapshot
3
- } from "../chunk-3DQHT4CM.js";
3
+ } from "../chunk-HFOMXKSO.js";
4
4
  import {
5
5
  RoomCore,
6
- RoomFullError
7
- } from "../chunk-X5S364FY.js";
6
+ RoomFullError,
7
+ initPhysics
8
+ } from "../chunk-5ZDQAAFJ.js";
8
9
 
9
10
  // src/worker/index.ts
10
11
  import { isMainThread, parentPort } from "worker_threads";
@@ -55,6 +56,16 @@ function createWorkerHost(post) {
55
56
  },
56
57
  crashed(reason) {
57
58
  post({ t: "crashed", reason });
59
+ },
60
+ hostCall(reqId, call) {
61
+ post({ t: "hostCall", reqId, call });
62
+ },
63
+ setAlarm(name, atMs) {
64
+ if (atMs === void 0) {
65
+ post({ t: "setAlarm", name });
66
+ return;
67
+ }
68
+ post({ t: "setAlarm", name, atMs: Date.now() + (atMs - performance.now()) });
58
69
  }
59
70
  };
60
71
  }
@@ -100,6 +111,17 @@ async function handleMessage(state, host, post, msg) {
100
111
  post({ t: "initFailed", reason: String(err instanceof Error ? err.stack : err) });
101
112
  return;
102
113
  }
114
+ if (def.config.physics) {
115
+ try {
116
+ await initPhysics();
117
+ } catch (err) {
118
+ post({
119
+ t: "initFailed",
120
+ reason: `physics engine failed to load: ${String(err instanceof Error ? err.stack ?? err.message : err)}`
121
+ });
122
+ return;
123
+ }
124
+ }
103
125
  let snapshot = msg.snapshot;
104
126
  let migrated;
105
127
  if (snapshot && msg.migrate && msg.migrate.length > 0) {
@@ -169,6 +191,7 @@ async function handleMessage(state, host, post, msg) {
169
191
  const joinOptions = {
170
192
  ...msg.role !== void 0 ? { role: msg.role } : {},
171
193
  ...msg.name !== void 0 ? { name: msg.name } : {},
194
+ ...msg.playerId !== void 0 ? { playerId: msg.playerId } : {},
172
195
  ...msg.reconnecting !== void 0 ? { reconnecting: msg.reconnecting } : {}
173
196
  };
174
197
  const result = core.join(msg.clientId, joinOptions);
@@ -212,7 +235,7 @@ async function handleMessage(state, host, post, msg) {
212
235
  case "serialize": {
213
236
  const core = state.core;
214
237
  if (!core) return;
215
- const bytes = ownBuffer(core.serialize());
238
+ const bytes = ownBuffer(msg.forSave === true ? core.snapshot() : core.serialize());
216
239
  post({ t: "serialized", reqId: msg.reqId, bytes }, [bytes.buffer]);
217
240
  return;
218
241
  }
@@ -236,6 +259,14 @@ async function handleMessage(state, host, post, msg) {
236
259
  post(statsPayload(core.stats));
237
260
  return;
238
261
  }
262
+ case "hostResult": {
263
+ state.core?.completeHostCall(msg.reqId, msg.result);
264
+ return;
265
+ }
266
+ case "alarm": {
267
+ state.core?.fireAlarm(msg.name);
268
+ return;
269
+ }
239
270
  case "stop": {
240
271
  state.core?.stop();
241
272
  post({ t: "stopped" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/runtime",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "irtio room runtime: RoomCore, worker_threads host, in-process test harness",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -28,9 +28,10 @@
28
28
  "dist"
29
29
  ],
30
30
  "dependencies": {
31
- "@irtio/protocol": "0.1.0",
32
- "@irtio/schema": "0.1.0",
33
- "@irtio/server": "0.1.0"
31
+ "@dimforge/rapier3d-compat": "0.20.0",
32
+ "@irtio/protocol": "0.3.0",
33
+ "@irtio/server": "0.3.0",
34
+ "@irtio/schema": "0.3.0"
34
35
  },
35
36
  "scripts": {
36
37
  "build": "tsup",
@@ -1,246 +0,0 @@
1
- import { DirtySet, AnySchema, PlainState, Tracked, State } from '@irtio/schema';
2
- import { RoomDefinition, RoomMode, Room, Ctx, ResolvedRoomConfig, LeaveReason } from '@irtio/server';
3
- import { f as RoomHost, h as RoomStats, L as LogLevel, R as RoomCoreApi, b as RoomCoreOptions, d as RoomEventKind, g as RoomInspection, J as JoinOptions, a as JoinResult } from './contract-BhD88PGb.js';
4
-
5
- /**
6
- * `room.random()` — mulberry32 over a u32 seed. Deterministic, tiny, and serializable: the
7
- * generator's whole state is one u32, so `serialize()`/`restore()` round-trip it exactly.
8
- */
9
- declare class Mulberry32 {
10
- /** Current internal state (u32). Survives hibernation. */
11
- state: number;
12
- constructor(seed: number);
13
- /** Next float in `[0, 1)`. */
14
- next(): number;
15
- }
16
-
17
- /**
18
- * The internal seam between `RoomCore` (core/room.ts) and the modules it delegates to
19
- * (`loop`, `views`, `writes`, `rpc`, `messages`, `room-api`). Nothing here is public API —
20
- * `src/contract.ts` is. `RoomCore` implements `RoomInternals` structurally, so the modules
21
- * take it as a parameter and never import `room.ts` (no cycles).
22
- */
23
-
24
- type AnyRecord = Record<string, unknown>;
25
- /** `${collection}\0${id}` → leaf-path key → the value the runtime wrote for that leaf. */
26
- type AcceptedWrites = Map<string, Map<string, unknown>>;
27
- /** One joined client. Presence is the wire truth; this is the runtime's bookkeeping. */
28
- interface ClientEntry {
29
- readonly clientId: string;
30
- role: string;
31
- name: string;
32
- connected: boolean;
33
- /** Pending `CORRECT` mask, encoded from current state at the next flush. */
34
- correction: DirtySet | undefined;
35
- /** Leaves this client's own accepted `WRITE`s produced during the current flush window. */
36
- readonly accepted: AcceptedWrites;
37
- /**
38
- * The tick stamped on the last `WRITE` this client sent (the client-local write counter in the
39
- * delta header — week 8, D19), `0` before its first write. Every `CORRECT` sent to this client
40
- * carries it as `clientTick`: "your writes through this tick are reflected in these values".
41
- * An unprompted correction (a server-wins overwrite with no triggering write) echoes it
42
- * unchanged — for a client that never wrote, that is `0`.
43
- */
44
- lastClientTick: number;
45
- /**
46
- * Set at `join`: this client's own record(s) — its presence row and whatever entities its own
47
- * `onJoin` added, owned by it — as they stood right after join, per collection. Consumed once
48
- * by the very next `flush()`, which strips exactly the ids whose value (and owner) still match
49
- * this capture from this client's delta only — an id mutated again before that flush (e.g. a
50
- * same-window `room.setRole`) is left alone, since the join snapshot never saw that value.
51
- * Every other client's delta is unaffected.
52
- */
53
- pendingJoinAdds: Map<string, ReadonlyMap<string, JoinAddCapture>> | undefined;
54
- }
55
- /** What `join` captured for one of a client's own just-added records, for `flush`'s comparison. */
56
- interface JoinAddCapture {
57
- readonly value: unknown;
58
- readonly owner: string | undefined;
59
- }
60
- type GuardResult<T> = {
61
- readonly ok: true;
62
- readonly value: T;
63
- } | {
64
- readonly ok: false;
65
- };
66
- /** One queued inbound frame (tick mode). */
67
- interface QueuedFrame {
68
- readonly clientId: string;
69
- readonly type: number;
70
- readonly payload: Uint8Array;
71
- }
72
- interface LoopApi {
73
- start(): void;
74
- stop(): void;
75
- /** Event mode: an inbound frame or a join re-arms the idle timer. */
76
- noteActivity(): void;
77
- /** `room.setTimeout` / `room.setInterval`. Returns a numeric handle. */
78
- setTimer(ms: number, fn: () => void, repeat: boolean): number;
79
- clearTimer(handle: number): void;
80
- /** Drops every room timer (hibernation, stop). */
81
- clearAllTimers(): void;
82
- /** Internal one-shot on the host clock (RPC timeouts in event mode). */
83
- after(ms: number, fn: () => void): void;
84
- }
85
- /** What the core modules are allowed to see of `RoomCore`. */
86
- interface RoomInternals {
87
- readonly definition: RoomDefinition;
88
- /** `withBuiltins(definition.schema)` — what every frame and the codec use. */
89
- readonly ext: AnySchema;
90
- readonly host: RoomHost;
91
- readonly roomId: string;
92
- readonly mode: RoomMode;
93
- /** The plain state the tracked proxies wrap (what the codec reads). */
94
- readonly plain: PlainState;
95
- readonly tracked: Tracked<AnySchema>;
96
- /** The tracked proxy tree, loosely typed for internal use. */
97
- readonly anyState: AnyRecord;
98
- readonly room: Room;
99
- readonly rng: Mulberry32;
100
- readonly stats: RoomStats;
101
- /** Joined clients in join order. */
102
- readonly clients: Map<string, ClientEntry>;
103
- readonly loop: LoopApi;
104
- tick: number;
105
- stopped: boolean;
106
- /** Runs a room handler; a throw is logged and counted, never rethrown. */
107
- guard<T>(name: string, fn: () => T): T | undefined;
108
- recordEvent(kind: 'join' | 'leave' | 'write' | 'write-rejected' | 'correct' | 'call' | 'reply' | 'msg' | 'error', clientId?: string, detail?: string): void;
109
- /** `guard` that also reports whether the handler threw (the tick loop needs this). */
110
- tryRun<T>(name: string, fn: () => T): GuardResult<T>;
111
- log(level: LogLevel, ...args: unknown[]): void;
112
- /** Sends one framed protocol frame to a connected client and counts it. */
113
- send(clientId: string, frame: Uint8Array): void;
114
- ctxFor(clientId: string, reconnecting?: boolean): Ctx;
115
- /** The client's pending `CORRECT` dirty set, created on demand. */
116
- correctionFor(clientId: string): DirtySet | undefined;
117
- /** Flushes the tracked dirty set: corrections first, then one `DELTA` per distinct view. */
118
- flush(): void;
119
- /** Invalidates the cached `room.clients` array. */
120
- invalidateClients(): void;
121
- }
122
-
123
- /**
124
- * Scheduling: the tick-mode fixed-step loop with catch-up, event-mode idle detection, and
125
- * `room.setTimeout` / `room.setInterval`.
126
- *
127
- * Tick mode: one host timeout re-armed every `1000 / tickRate` ms. Each wake accumulates real
128
- * elapsed time and runs up to `MAX_CATCHUP` ticks; a bigger backlog is dropped and counted as an
129
- * overrun (logged once per burst). Room timers are tick-granular: `setTimeout(ms)` fires on the
130
- * first tick at or after `now + ms`.
131
- *
132
- * Event mode: no tick timer. Room timers fire on the host clock, each firing advancing the tick
133
- * and flushing. After `idleMs` with no inbound frames and no connected clients the host is asked
134
- * to hibernate exactly once (re-armed by the next frame or join).
135
- */
136
-
137
- /** Most ticks one wake may run before the backlog is dropped. */
138
- declare const MAX_CATCHUP = 5;
139
- /** Consecutive `tick()` throws before the host is told the room crashed. */
140
- declare const CRASH_AFTER_THROWS = 3;
141
- declare class Loop implements LoopApi {
142
- private readonly core;
143
- readonly inbound: QueuedFrame[];
144
- private readonly timers;
145
- private readonly internal;
146
- private nextTimerId;
147
- private tickHandle;
148
- private idleHandle;
149
- private running;
150
- private lastWake;
151
- private accumulator;
152
- private overrunLogged;
153
- private consecutiveThrows;
154
- private lastActivity;
155
- private slept;
156
- constructor(core: RoomInternals);
157
- get intervalMs(): number;
158
- start(): void;
159
- stop(): void;
160
- enqueue(frame: QueuedFrame): void;
161
- dropFramesFor(clientId: string): void;
162
- private drainInbound;
163
- /** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
164
- applyFrame(f: QueuedFrame): boolean;
165
- private scheduleTick;
166
- private onWake;
167
- /** One tick: inbound → room timers → `tick(state, dt, room)` → flush. */
168
- runTick(): void;
169
- private fireDueTimers;
170
- /** Applies one frame as its own event: tick++, apply, flush. */
171
- applyEvent(f: QueuedFrame): boolean;
172
- noteActivity(): void;
173
- private armIdle;
174
- private onIdleCheck;
175
- setTimer(ms: number, fn: () => void, repeat: boolean): number;
176
- private armHostTimer;
177
- clearTimer(handle: number): void;
178
- clearAllTimers(): void;
179
- after(ms: number, fn: () => void): void;
180
- }
181
-
182
- /**
183
- * `RoomCore` — the host-agnostic room runtime. It owns the extended schema, the
184
- * tracked authority state, presence, the loop, and the frame dispatch; everything outside comes
185
- * through `RoomHost` (`src/contract.ts`). The worker host and the test harness are adapters over
186
- * exactly this surface and produce byte-identical frames.
187
- */
188
-
189
- declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S> {
190
- readonly definition: RoomDefinition<S>;
191
- readonly ext: AnySchema;
192
- readonly host: RoomHost;
193
- readonly roomId: string;
194
- readonly mode: RoomMode;
195
- readonly plain: PlainState;
196
- readonly tracked: Tracked<AnySchema>;
197
- readonly anyState: AnyRecord;
198
- readonly rng: Mulberry32;
199
- readonly clients: Map<string, ClientEntry>;
200
- readonly loop: Loop;
201
- readonly stats: RoomStats;
202
- tick: number;
203
- stopped: boolean;
204
- private readonly seed;
205
- private readonly api;
206
- private readonly internals;
207
- private started;
208
- constructor(definition: RoomDefinition<S>, host: RoomHost, options: RoomCoreOptions);
209
- /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
210
- static restore<S2 extends AnySchema>(definition: RoomDefinition<S2>, bytes: Uint8Array, host: RoomHost, options: Omit<RoomCoreOptions, 'restoreFrom'>): RoomCore<S2>;
211
- get schema(): S;
212
- get config(): ResolvedRoomConfig<S>;
213
- get state(): State<S>;
214
- get room(): Room<S>;
215
- tryRun<T>(name: string, fn: () => T): GuardResult<T>;
216
- private readonly events;
217
- recordEvent(kind: RoomEventKind, clientId?: string, detail?: string): void;
218
- /** Live JSON view of the room for the dev page / supervisor admin API. */
219
- inspect(): RoomInspection;
220
- guard<T>(name: string, fn: () => T): T | undefined;
221
- log(level: LogLevel, ...args: unknown[]): void;
222
- start(): void;
223
- stop(): void;
224
- serialize(): Uint8Array;
225
- private get presence();
226
- private resolveRole;
227
- join(clientId: string, options?: JoinOptions): JoinResult;
228
- /** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
229
- private eventFlush;
230
- leave(clientId: string, reason: LeaveReason): void;
231
- markDisconnected(clientId: string): void;
232
- ctxFor(clientId: string, reconnecting?: boolean): Ctx;
233
- correctionFor(clientId: string): DirtySet | undefined;
234
- invalidateClients(): void;
235
- send(clientId: string, frame: Uint8Array): void;
236
- private badFrame;
237
- receive(clientId: string, frame: Uint8Array): void;
238
- /**
239
- * Hands the tracked dirty set out: server-wins corrections first, then per connected client its
240
- * pending `CORRECT` (before the delta, so it sees the correction and then the broadcast) and
241
- * its view's `DELTA` — encoded once per distinct view.
242
- */
243
- flush(): void;
244
- }
245
-
246
- export { CRASH_AFTER_THROWS as C, MAX_CATCHUP as M, RoomCore as R, Mulberry32 as a };