@irtio/runtime 0.1.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.
@@ -0,0 +1,1573 @@
1
+ // src/contract.ts
2
+ var RoomFullError = class extends Error {
3
+ constructor(maxClients) {
4
+ super(`room is full (maxClients=${maxClients})`);
5
+ this.maxClients = maxClients;
6
+ }
7
+ maxClients;
8
+ name = "RoomFullError";
9
+ };
10
+
11
+ // src/core/inspect.ts
12
+ import { EntityCollection } from "@irtio/schema";
13
+ var EVENT_RING_SIZE = 100;
14
+ var EventRing = class {
15
+ items = [];
16
+ push(e) {
17
+ this.items.push(e);
18
+ if (this.items.length > EVENT_RING_SIZE) this.items.shift();
19
+ }
20
+ list() {
21
+ return this.items;
22
+ }
23
+ };
24
+ function inspectState(schema, plain) {
25
+ const out = {};
26
+ for (const c of schema.collections) {
27
+ const v = plain[c.name];
28
+ if (v instanceof EntityCollection) {
29
+ const recs = {};
30
+ for (const [id, value] of v) recs[id] = { owner: v.ownerOf(id), value };
31
+ out[c.name] = recs;
32
+ } else {
33
+ out[c.name] = v;
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ // src/core/rpc.ts
40
+ import {
41
+ FrameType,
42
+ decodeCall,
43
+ decodeReply,
44
+ encodeCall,
45
+ encodeFrame,
46
+ encodeReply,
47
+ rpcByIdOf,
48
+ rpcTable
49
+ } from "@irtio/protocol";
50
+ import {
51
+ SERVER_OWNER,
52
+ decodeFields,
53
+ encodeFields,
54
+ validateValue
55
+ } from "@irtio/schema";
56
+
57
+ // src/core/internals.ts
58
+ function collectionDescOf(ext, name) {
59
+ return ext.collections.find((c) => c.name === name);
60
+ }
61
+ function plainEntity(plain, name) {
62
+ return plain[name];
63
+ }
64
+ function trackedEntity(core, name) {
65
+ return core.anyState[name];
66
+ }
67
+ function isPlainObject(v) {
68
+ return typeof v === "object" && v !== null && !Array.isArray(v);
69
+ }
70
+ function deepEqual(a, b) {
71
+ if (Object.is(a, b)) return true;
72
+ if (Array.isArray(a) || Array.isArray(b)) {
73
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
74
+ for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
75
+ return true;
76
+ }
77
+ if (!isPlainObject(a) || !isPlainObject(b)) return false;
78
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
79
+ for (const k of keys) if (!deepEqual(a[k], b[k])) return false;
80
+ return true;
81
+ }
82
+
83
+ // src/core/rpc.ts
84
+ var RPC_TIMEOUT_MS = 5e3;
85
+ var REQUEST_OWNERSHIP = "requestOwnership";
86
+ var states = /* @__PURE__ */ new WeakMap();
87
+ function stateOf(core) {
88
+ let s = states.get(core);
89
+ if (!s) {
90
+ s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
91
+ states.set(core, s);
92
+ }
93
+ return s;
94
+ }
95
+ function pendingKey(clientId, reqId) {
96
+ return `${clientId} ${reqId}`;
97
+ }
98
+ function errorText(code, detail) {
99
+ return detail === void 0 ? code : `${code}: ${detail}`;
100
+ }
101
+ function sendReply(core, clientId, reqId, result, error) {
102
+ const payload = result === null ? encodeReply({ reqId, ok: false, error: error ?? errorText("E_INTERNAL") }) : encodeReply({ reqId, ok: true, result });
103
+ core.send(clientId, encodeFrame(FrameType.REPLY, payload));
104
+ }
105
+ function handleCall(core, clientId, payload) {
106
+ let call;
107
+ try {
108
+ call = decodeCall(payload);
109
+ } catch (err) {
110
+ core.log("warn", `CALL from ${clientId} failed to decode:`, err);
111
+ return false;
112
+ }
113
+ let desc;
114
+ try {
115
+ desc = rpcByIdOf(core.definition.schema, call.rpcId);
116
+ core.recordEvent("call", clientId, desc.name);
117
+ } catch {
118
+ sendReply(core, clientId, call.reqId, null, errorText("E_RPC_UNKNOWN", `rpcId ${call.rpcId}`));
119
+ return true;
120
+ }
121
+ if (desc.direction !== "server") {
122
+ sendReply(
123
+ core,
124
+ clientId,
125
+ call.reqId,
126
+ null,
127
+ errorText("E_RPC_REJECTED", `${desc.name} is a client-direction rpc`)
128
+ );
129
+ return true;
130
+ }
131
+ let params;
132
+ try {
133
+ params = decodeFields(desc.params, call.params);
134
+ } catch (err) {
135
+ sendReply(core, clientId, call.reqId, null, errorText("E_RPC_BAD_PARAMS", String(err)));
136
+ return true;
137
+ }
138
+ for (const f of desc.params) {
139
+ const problem = validateValue(f.type, params[f.name], f.name);
140
+ if (problem) {
141
+ sendReply(core, clientId, call.reqId, null, errorText("E_RPC_BAD_PARAMS", problem));
142
+ return true;
143
+ }
144
+ }
145
+ const ctx = core.ctxFor(clientId);
146
+ let result;
147
+ if (desc.name === REQUEST_OWNERSHIP) {
148
+ result = runRequestOwnership(core, clientId, params);
149
+ } else {
150
+ const impl = core.definition.config.rpc?.[desc.name];
151
+ if (!impl) {
152
+ sendReply(core, clientId, call.reqId, null, errorText("E_RPC_UNKNOWN", desc.name));
153
+ return true;
154
+ }
155
+ try {
156
+ result = impl(core.anyState, params, ctx);
157
+ } catch (err) {
158
+ core.stats.handlerErrors++;
159
+ core.log("error", `rpc.${desc.name} threw:`, err);
160
+ sendReply(core, clientId, call.reqId, null, err instanceof Error ? err.message : String(err));
161
+ return true;
162
+ }
163
+ }
164
+ if (!desc.returns) {
165
+ sendReply(core, clientId, call.reqId, new Uint8Array(0));
166
+ return true;
167
+ }
168
+ try {
169
+ sendReply(core, clientId, call.reqId, encodeFields(desc.returns, result ?? {}));
170
+ } catch (err) {
171
+ core.log("error", `rpc.${desc.name} returned a value that failed to encode:`, err);
172
+ core.stats.handlerErrors++;
173
+ sendReply(core, clientId, call.reqId, null, errorText("E_INTERNAL", `rpc ${desc.name}`));
174
+ }
175
+ return true;
176
+ }
177
+ function runRequestOwnership(core, clientId, params) {
178
+ const name = String(params.entity ?? "");
179
+ const id = String(params.id ?? "");
180
+ const c = collectionDescOf(core.ext, name);
181
+ if (!c || c.kind !== "entity" || c.serverOwned) return { granted: false };
182
+ const plain = plainEntity(core.plain, name);
183
+ if (!plain.has(id)) return { granted: false };
184
+ const handler = core.definition.config.onOwnershipRequest;
185
+ const tracked = trackedEntity(core, name);
186
+ if (handler) {
187
+ const ran = core.tryRun(
188
+ "onOwnershipRequest",
189
+ () => handler(core.anyState, name, id, core.ctxFor(clientId))
190
+ );
191
+ if (ran.ok && ran.value === true && plain.ownerOf(id) !== clientId) {
192
+ tracked.setOwner(id, clientId);
193
+ }
194
+ return { granted: plain.ownerOf(id) === clientId };
195
+ }
196
+ const owner = plain.ownerOf(id);
197
+ if (owner === clientId) return { granted: true };
198
+ if (owner !== SERVER_OWNER) return { granted: false };
199
+ tracked.setOwner(id, clientId);
200
+ return { granted: true };
201
+ }
202
+ function descFor(core, name) {
203
+ const found = rpcTable(core.definition.schema).find((r) => r.name === name);
204
+ if (!found) throw new Error(`room.call: unknown rpc ${JSON.stringify(name)}`);
205
+ if (found.direction !== "client") {
206
+ throw new Error(`room.call: ${name} is a client\u2192server rpc; call it from the client`);
207
+ }
208
+ return found;
209
+ }
210
+ function sendCall(core, clientId, desc, params) {
211
+ const s = stateOf(core);
212
+ const reqId = s.nextReqId;
213
+ s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
214
+ const bytes = encodeFields(desc.params, params);
215
+ core.send(
216
+ clientId,
217
+ encodeFrame(FrameType.CALL, encodeCall({ reqId, rpcId: desc.index, params: bytes }))
218
+ );
219
+ return reqId;
220
+ }
221
+ function createCallProxy(core, clientId) {
222
+ return new Proxy(
223
+ {},
224
+ {
225
+ get(_t, prop) {
226
+ if (typeof prop !== "string") return void 0;
227
+ return (params) => {
228
+ const desc = descFor(core, prop);
229
+ return new Promise((resolve, reject2) => {
230
+ const entry = core.clients.get(clientId);
231
+ if (!entry || !entry.connected) {
232
+ reject2(new Error(`room.call: ${clientId} is not connected`));
233
+ return;
234
+ }
235
+ const reqId = sendCall(core, clientId, desc, params ?? {});
236
+ const s = stateOf(core);
237
+ s.pending.set(pendingKey(clientId, reqId), {
238
+ clientId,
239
+ name: desc.name,
240
+ returns: desc.returns,
241
+ deadline: core.host.now() + RPC_TIMEOUT_MS,
242
+ resolve,
243
+ reject: reject2
244
+ });
245
+ if (core.mode === "event") {
246
+ core.loop.after(RPC_TIMEOUT_MS, () => checkTimeouts(core));
247
+ }
248
+ });
249
+ };
250
+ }
251
+ }
252
+ );
253
+ }
254
+ function createBroadcastProxy(core) {
255
+ return new Proxy(
256
+ {},
257
+ {
258
+ get(_t, prop) {
259
+ if (typeof prop !== "string") return void 0;
260
+ return (params) => {
261
+ const desc = descFor(core, prop);
262
+ for (const entry of core.clients.values()) {
263
+ if (entry.connected) sendCall(core, entry.clientId, desc, params ?? {});
264
+ }
265
+ };
266
+ }
267
+ }
268
+ );
269
+ }
270
+ function handleReply(core, clientId, payload) {
271
+ let reply;
272
+ try {
273
+ reply = decodeReply(payload);
274
+ } catch (err) {
275
+ core.log("warn", `REPLY from ${clientId} failed to decode:`, err);
276
+ return false;
277
+ }
278
+ const s = stateOf(core);
279
+ const key = pendingKey(clientId, reply.reqId);
280
+ const pending = s.pending.get(key);
281
+ if (!pending) {
282
+ core.log("warn", `REPLY from ${clientId} for unknown reqId ${reply.reqId}`);
283
+ return true;
284
+ }
285
+ s.pending.delete(key);
286
+ if (!reply.ok) {
287
+ pending.reject(new Error(reply.error));
288
+ return true;
289
+ }
290
+ if (!pending.returns) {
291
+ pending.resolve(void 0);
292
+ return true;
293
+ }
294
+ try {
295
+ pending.resolve(decodeFields(pending.returns, reply.result));
296
+ } catch (err) {
297
+ pending.reject(new Error(`${pending.name}: bad reply payload: ${String(err)}`));
298
+ }
299
+ return true;
300
+ }
301
+ function rejectPendingFor(core, clientId, reason) {
302
+ const s = stateOf(core);
303
+ for (const [key, p] of [...s.pending]) {
304
+ if (p.clientId !== clientId) continue;
305
+ s.pending.delete(key);
306
+ p.reject(new Error(reason));
307
+ }
308
+ }
309
+ function checkTimeouts(core) {
310
+ const s = stateOf(core);
311
+ if (s.pending.size === 0) return;
312
+ const now = core.host.now();
313
+ for (const [key, p] of [...s.pending]) {
314
+ if (p.deadline > now) continue;
315
+ s.pending.delete(key);
316
+ p.reject(new Error(errorText("E_RPC_TIMEOUT", `rpc ${p.name} timed out`)));
317
+ }
318
+ }
319
+ function rejectAllPending(core, reason) {
320
+ const s = stateOf(core);
321
+ for (const [key, p] of [...s.pending]) {
322
+ s.pending.delete(key);
323
+ p.reject(new Error(reason));
324
+ }
325
+ }
326
+
327
+ // src/core/loop.ts
328
+ import { FrameType as FrameType2 } from "@irtio/protocol";
329
+
330
+ // src/core/writes.ts
331
+ import {
332
+ SERVER_OWNER as SERVER_OWNER2,
333
+ cloneValue,
334
+ decodeDelta,
335
+ encodeDelta,
336
+ markField,
337
+ normalizeRecord
338
+ } from "@irtio/schema";
339
+ function structFieldList(desc) {
340
+ return Object.entries(desc.fields).map(([name, type]) => ({ name, type }));
341
+ }
342
+ function leafPaths(fields, mask) {
343
+ const out = [];
344
+ const walk = (list, m, path, names) => {
345
+ for (const idx of [...m.fields].sort((a, b) => a - b)) {
346
+ const f = list[idx];
347
+ if (!f) continue;
348
+ const nested = m.nested.get(idx);
349
+ if (nested && f.type.kind === "struct") {
350
+ walk(structFieldList(f.type), nested, [...path, idx], [...names, f.name]);
351
+ continue;
352
+ }
353
+ out.push({ path: [...path, idx], names: [...names, f.name] });
354
+ }
355
+ };
356
+ walk(fields, mask, [], []);
357
+ return out;
358
+ }
359
+ function leafKey(leaf) {
360
+ return leaf.path.join(".");
361
+ }
362
+ function valueAt(record, names) {
363
+ let cur = record;
364
+ for (const n of names) {
365
+ if (!isPlainObject(cur)) return void 0;
366
+ cur = cur[n];
367
+ }
368
+ return cur;
369
+ }
370
+ function fieldList(c) {
371
+ return c.fields.map((f) => ({ name: f.name, type: f.type }));
372
+ }
373
+ function applyMaskedPatch(fields, target, patch, mask) {
374
+ for (const idx of [...mask.fields].sort((a, b) => a - b)) {
375
+ const f = fields[idx];
376
+ if (!f) continue;
377
+ const value = patch[f.name];
378
+ const nested = mask.nested.get(idx);
379
+ if (nested && f.type.kind === "struct" && isPlainObject(value)) {
380
+ const current = target[f.name];
381
+ if (isPlainObject(current)) {
382
+ applyMaskedPatch(structFieldList(f.type), current, value, nested);
383
+ continue;
384
+ }
385
+ }
386
+ target[f.name] = cloneValue(value);
387
+ }
388
+ }
389
+ function assignChanged(desc, target, key, prev, next) {
390
+ if (desc.kind === "struct" && isPlainObject(prev) && isPlainObject(next)) {
391
+ const sub = target[key];
392
+ if (isPlainObject(sub)) {
393
+ for (const f of structFieldList(desc))
394
+ assignChanged(f.type, sub, f.name, prev[f.name], next[f.name]);
395
+ return;
396
+ }
397
+ }
398
+ if (!deepEqual(prev, next)) target[key] = next;
399
+ }
400
+ function applyChanged(fields, target, prev, next) {
401
+ for (const f of fields) assignChanged(f.type, target, f.name, prev[f.name], next[f.name]);
402
+ }
403
+ function correctLeaves(core, clientId, collection, id, leaves) {
404
+ if (leaves.length === 0) return;
405
+ const dirty = core.correctionFor(clientId);
406
+ if (!dirty) return;
407
+ for (const leaf of leaves) markField(dirty, collection, id, leaf.path);
408
+ core.stats.corrections++;
409
+ }
410
+ function recordOf(plain, c, id) {
411
+ if (c.kind === "singleton") return plain[c.name];
412
+ return plainEntity(plain, c.name).get(id);
413
+ }
414
+ function applyWrite(core, clientId, payload) {
415
+ let delta;
416
+ try {
417
+ delta = decodeDelta(core.ext, payload);
418
+ } catch (err) {
419
+ core.log("warn", `WRITE from ${clientId} failed to decode:`, err);
420
+ return { ok: false };
421
+ }
422
+ const judged = core.clients.get(clientId);
423
+ if (judged && delta.tick > judged.lastClientTick) judged.lastClientTick = delta.tick;
424
+ for (const dc of delta.collections) {
425
+ const c = collectionDescOf(core.ext, dc.name);
426
+ if (!c) continue;
427
+ for (const op of dc.ops) applyOp(core, clientId, c, op);
428
+ }
429
+ return { ok: true };
430
+ }
431
+ function reject(core, clientId, c, op, reason) {
432
+ core.recordEvent("write-rejected", clientId, `${c.name}: ${reason}`);
433
+ core.log("warn", `WRITE rejected (${reason}) from ${clientId} on ${c.name}`);
434
+ if (op.op !== "update") return;
435
+ if (!recordOf(core.plain, c, op.id)) return;
436
+ correctLeaves(core, clientId, c.name, op.id, leafPaths(fieldList(c), op.mask));
437
+ }
438
+ function applyOp(core, clientId, c, op) {
439
+ if (op.op !== "update") {
440
+ reject(core, clientId, c, op, `op '${op.op}' is not allowed in a WRITE`);
441
+ return;
442
+ }
443
+ if (c.kind !== "entity") {
444
+ reject(core, clientId, c, op, "not an entity collection");
445
+ return;
446
+ }
447
+ if (c.serverOwned) {
448
+ reject(core, clientId, c, op, "collection is serverOwned");
449
+ return;
450
+ }
451
+ if (op.owner !== void 0) {
452
+ reject(core, clientId, c, op, "owner changes are not allowed in a WRITE");
453
+ return;
454
+ }
455
+ const plainColl = plainEntity(core.plain, c.name);
456
+ const current = plainColl.get(op.id);
457
+ if (current === void 0) return;
458
+ if (plainColl.ownerOf(op.id) !== clientId) {
459
+ reject(core, clientId, c, op, "not the owner");
460
+ return;
461
+ }
462
+ const fields = fieldList(c);
463
+ const prev = cloneValue(current);
464
+ const next = cloneValue(current);
465
+ applyMaskedPatch(fields, next, op.patch, op.mask);
466
+ const validator = core.definition.config.validate?.[c.name];
467
+ let result = next;
468
+ if (validator) {
469
+ const ctx = core.ctxFor(clientId);
470
+ const ran = core.tryRun(`validate.${c.name}`, () => validator(prev, next, ctx));
471
+ if (!ran.ok) result = prev;
472
+ else {
473
+ const returned = ran.value;
474
+ try {
475
+ result = normalizeRecord(c, returned ?? next);
476
+ } catch (err) {
477
+ core.log("error", `validate.${c.name} returned an invalid record; write rejected:`, err);
478
+ result = prev;
479
+ }
480
+ }
481
+ }
482
+ const tracked = trackedEntity(core, c.name).get(op.id);
483
+ if (tracked) applyChanged(fields, tracked, prev, result);
484
+ const entry = core.clients.get(clientId);
485
+ const written = op.owner === void 0 ? leafPaths(fields, op.mask) : [];
486
+ if (entry) {
487
+ const key = `${c.name}\0${op.id}`;
488
+ let map = entry.accepted.get(key);
489
+ if (!map) {
490
+ map = /* @__PURE__ */ new Map();
491
+ entry.accepted.set(key, map);
492
+ }
493
+ for (const leaf of written) map.set(leafKey(leaf), cloneValue(valueAt(result, leaf.names)));
494
+ }
495
+ const corrected = written.filter(
496
+ (leaf) => !deepEqual(valueAt(result, leaf.names), valueAt(next, leaf.names))
497
+ );
498
+ correctLeaves(core, clientId, c.name, op.id, corrected);
499
+ }
500
+ function serverWinsCorrections(core, dirty) {
501
+ const ext = core.ext;
502
+ for (const [name, cd] of dirty) {
503
+ if (cd.updated.size === 0) continue;
504
+ const c = collectionDescOf(ext, name);
505
+ if (!c || c.kind !== "entity" || c.serverOwned) continue;
506
+ const coll = plainEntity(core.plain, name);
507
+ const fields = fieldList(c);
508
+ for (const [id, rd] of cd.updated) {
509
+ const owner = coll.ownerOf(id);
510
+ if (owner === void 0 || owner === SERVER_OWNER2) continue;
511
+ const entry = core.clients.get(owner);
512
+ if (!entry || !entry.connected) continue;
513
+ const value = coll.get(id);
514
+ if (value === void 0) continue;
515
+ const accepted = entry.accepted.get(`${name}\0${id}`);
516
+ const stale = [];
517
+ for (const leaf of leafPaths(fields, rd.mask)) {
518
+ const key = leafKey(leaf);
519
+ if (accepted?.has(key) && deepEqual(accepted.get(key), valueAt(value, leaf.names)))
520
+ continue;
521
+ stale.push(leaf);
522
+ }
523
+ correctLeaves(core, owner, name, id, stale);
524
+ }
525
+ }
526
+ }
527
+ function encodeCorrection(core, dirty) {
528
+ let empty = true;
529
+ for (const cd of dirty.values()) {
530
+ if (cd.added.size || cd.removed.size || cd.updated.size) {
531
+ empty = false;
532
+ break;
533
+ }
534
+ }
535
+ if (empty) return null;
536
+ return encodeDelta(core.ext, core.plain, dirty, { tick: core.tick });
537
+ }
538
+
539
+ // src/core/loop.ts
540
+ var MAX_CATCHUP = 5;
541
+ var CRASH_AFTER_THROWS = 3;
542
+ var Loop = class {
543
+ constructor(core) {
544
+ this.core = core;
545
+ }
546
+ core;
547
+ inbound = [];
548
+ timers = /* @__PURE__ */ new Map();
549
+ internal = /* @__PURE__ */ new Set();
550
+ nextTimerId = 1;
551
+ tickHandle;
552
+ idleHandle;
553
+ running = false;
554
+ lastWake = 0;
555
+ accumulator = 0;
556
+ overrunLogged = false;
557
+ consecutiveThrows = 0;
558
+ lastActivity = 0;
559
+ slept = false;
560
+ get intervalMs() {
561
+ return 1e3 / this.core.definition.config.tickRate;
562
+ }
563
+ start() {
564
+ if (this.running || this.core.stopped) return;
565
+ this.running = true;
566
+ this.lastWake = this.core.host.now();
567
+ this.lastActivity = this.lastWake;
568
+ this.accumulator = 0;
569
+ if (this.core.mode === "tick") this.scheduleTick();
570
+ else this.armIdle(this.core.definition.config.idleMs);
571
+ }
572
+ stop() {
573
+ this.running = false;
574
+ if (this.tickHandle !== void 0) {
575
+ this.core.host.clearTimeout(this.tickHandle);
576
+ this.tickHandle = void 0;
577
+ }
578
+ if (this.idleHandle !== void 0) {
579
+ this.core.host.clearTimeout(this.idleHandle);
580
+ this.idleHandle = void 0;
581
+ }
582
+ for (const h of this.internal) this.core.host.clearTimeout(h);
583
+ this.internal.clear();
584
+ this.clearAllTimers();
585
+ }
586
+ // -------------------------------------------------------------------------
587
+ // Inbound queue (tick mode)
588
+ // -------------------------------------------------------------------------
589
+ enqueue(frame) {
590
+ this.inbound.push(frame);
591
+ }
592
+ dropFramesFor(clientId) {
593
+ for (let i = this.inbound.length - 1; i >= 0; i--) {
594
+ if (this.inbound[i].clientId === clientId) this.inbound.splice(i, 1);
595
+ }
596
+ }
597
+ drainInbound() {
598
+ while (this.inbound.length > 0) {
599
+ const f = this.inbound.shift();
600
+ if (!this.core.clients.has(f.clientId)) continue;
601
+ this.applyFrame(f);
602
+ }
603
+ }
604
+ /** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
605
+ applyFrame(f) {
606
+ if (f.type === FrameType2.WRITE) return applyWrite(this.core, f.clientId, f.payload).ok;
607
+ if (f.type === FrameType2.CALL) return handleCall(this.core, f.clientId, f.payload);
608
+ return true;
609
+ }
610
+ // -------------------------------------------------------------------------
611
+ // Tick mode
612
+ // -------------------------------------------------------------------------
613
+ scheduleTick() {
614
+ this.tickHandle = this.core.host.setTimeout(() => this.onWake(), this.intervalMs);
615
+ }
616
+ onWake() {
617
+ this.tickHandle = void 0;
618
+ if (!this.running || this.core.stopped) return;
619
+ const now = this.core.host.now();
620
+ this.accumulator += Math.max(0, now - this.lastWake);
621
+ this.lastWake = now;
622
+ let steps = Math.floor(this.accumulator / this.intervalMs);
623
+ if (steps <= 0) steps = 1;
624
+ if (steps > MAX_CATCHUP) {
625
+ this.core.stats.overruns++;
626
+ if (!this.overrunLogged) {
627
+ this.overrunLogged = true;
628
+ this.core.log(
629
+ "warn",
630
+ `tick overrun: ${steps} ticks behind, dropping the backlog (tickRate=${this.core.definition.config.tickRate})`
631
+ );
632
+ }
633
+ steps = MAX_CATCHUP;
634
+ this.accumulator = 0;
635
+ } else {
636
+ this.overrunLogged = false;
637
+ this.accumulator -= steps * this.intervalMs;
638
+ }
639
+ for (let i = 0; i < steps; i++) {
640
+ if (!this.running || this.core.stopped) break;
641
+ this.runTick();
642
+ }
643
+ if (this.running && !this.core.stopped) this.scheduleTick();
644
+ }
645
+ /** One tick: inbound → room timers → `tick(state, dt, room)` → flush. */
646
+ runTick() {
647
+ const host = this.core.host;
648
+ const started = host.now();
649
+ this.core.tick++;
650
+ this.drainInbound();
651
+ this.fireDueTimers();
652
+ checkTimeouts(this.core);
653
+ const config = this.core.definition.config;
654
+ if (config.tick) {
655
+ const fn = config.tick;
656
+ const dt = 1 / config.tickRate;
657
+ const ran = this.core.tryRun(
658
+ "tick",
659
+ () => fn(this.core.anyState, dt, this.core.room)
660
+ );
661
+ if (ran.ok) this.consecutiveThrows = 0;
662
+ else {
663
+ this.consecutiveThrows++;
664
+ if (this.consecutiveThrows >= CRASH_AFTER_THROWS) {
665
+ this.core.flush();
666
+ const reason = `tick() threw ${this.consecutiveThrows} times in a row`;
667
+ this.core.stopped = true;
668
+ this.stop();
669
+ host.crashed(reason);
670
+ return;
671
+ }
672
+ }
673
+ }
674
+ this.core.flush();
675
+ const elapsed = Math.max(0, host.now() - started);
676
+ const stats = this.core.stats;
677
+ stats.ticks++;
678
+ stats.lastTickMs = elapsed;
679
+ if (elapsed > stats.maxTickMs) stats.maxTickMs = elapsed;
680
+ }
681
+ fireDueTimers() {
682
+ for (const t of [...this.timers.values()]) {
683
+ if (t.dueTick > this.core.tick) continue;
684
+ if (t.repeat) t.dueTick = this.core.tick + t.periodTicks;
685
+ else this.timers.delete(t.id);
686
+ this.core.guard("room timer", t.fn);
687
+ }
688
+ }
689
+ // -------------------------------------------------------------------------
690
+ // Event mode
691
+ // -------------------------------------------------------------------------
692
+ /** Applies one frame as its own event: tick++, apply, flush. */
693
+ applyEvent(f) {
694
+ this.core.tick++;
695
+ const ok = this.applyFrame(f);
696
+ this.core.flush();
697
+ return ok;
698
+ }
699
+ noteActivity() {
700
+ if (this.core.mode !== "event") return;
701
+ this.lastActivity = this.core.host.now();
702
+ this.slept = false;
703
+ if (this.running && this.idleHandle === void 0) {
704
+ this.armIdle(this.core.definition.config.idleMs);
705
+ }
706
+ }
707
+ armIdle(ms) {
708
+ this.idleHandle = this.core.host.setTimeout(() => this.onIdleCheck(), Math.max(0, ms));
709
+ }
710
+ onIdleCheck() {
711
+ this.idleHandle = void 0;
712
+ if (!this.running || this.core.stopped) return;
713
+ const idleMs = this.core.definition.config.idleMs;
714
+ const waited = this.core.host.now() - this.lastActivity;
715
+ if (waited >= idleMs) {
716
+ if (!this.slept) {
717
+ this.slept = true;
718
+ this.core.host.sleep();
719
+ }
720
+ return;
721
+ }
722
+ this.armIdle(idleMs - waited);
723
+ }
724
+ // -------------------------------------------------------------------------
725
+ // Room timers
726
+ // -------------------------------------------------------------------------
727
+ setTimer(ms, fn, repeat) {
728
+ const id = this.nextTimerId++;
729
+ const periodTicks = Math.max(1, Math.ceil(ms * this.core.definition.config.tickRate / 1e3));
730
+ const timer = {
731
+ id,
732
+ fn,
733
+ repeat,
734
+ periodTicks,
735
+ periodMs: Math.max(0, ms),
736
+ dueTick: this.core.tick + periodTicks,
737
+ handle: void 0
738
+ };
739
+ this.timers.set(id, timer);
740
+ if (this.core.mode === "event") this.armHostTimer(timer);
741
+ return id;
742
+ }
743
+ armHostTimer(timer) {
744
+ timer.handle = this.core.host.setTimeout(() => {
745
+ timer.handle = void 0;
746
+ if (this.core.stopped) return;
747
+ if (!this.timers.has(timer.id)) return;
748
+ if (timer.repeat) this.armHostTimer(timer);
749
+ else this.timers.delete(timer.id);
750
+ this.core.tick++;
751
+ this.core.guard("room timer", timer.fn);
752
+ this.core.flush();
753
+ }, timer.periodMs);
754
+ }
755
+ clearTimer(handle) {
756
+ const t = this.timers.get(handle);
757
+ if (!t) return;
758
+ this.timers.delete(handle);
759
+ if (t.handle !== void 0) this.core.host.clearTimeout(t.handle);
760
+ }
761
+ clearAllTimers() {
762
+ for (const t of this.timers.values()) {
763
+ if (t.handle !== void 0) this.core.host.clearTimeout(t.handle);
764
+ }
765
+ this.timers.clear();
766
+ }
767
+ after(ms, fn) {
768
+ const handle = this.core.host.setTimeout(() => {
769
+ this.internal.delete(handle);
770
+ if (this.core.stopped) return;
771
+ fn();
772
+ }, ms);
773
+ this.internal.add(handle);
774
+ }
775
+ };
776
+
777
+ // src/core/random.ts
778
+ var Mulberry32 = class {
779
+ /** Current internal state (u32). Survives hibernation. */
780
+ state;
781
+ constructor(seed) {
782
+ this.state = seed >>> 0;
783
+ }
784
+ /** Next float in `[0, 1)`. */
785
+ next() {
786
+ this.state = this.state + 1831565813 >>> 0;
787
+ let t = this.state;
788
+ t = Math.imul(t ^ t >>> 15, 1 | t);
789
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
790
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
791
+ }
792
+ };
793
+
794
+ // src/core/views.ts
795
+ import {
796
+ collectionDirty,
797
+ createDirtySet,
798
+ createFieldMask,
799
+ encodeDelta as encodeDelta2,
800
+ encodeSnapshot,
801
+ filterDirty,
802
+ isDirtyEmpty,
803
+ markAdd,
804
+ markPath,
805
+ markRemove
806
+ } from "@irtio/schema";
807
+ var cache = /* @__PURE__ */ new WeakMap();
808
+ function infoOf(ext) {
809
+ let i = cache.get(ext);
810
+ if (!i) {
811
+ const scoped = /* @__PURE__ */ new Set();
812
+ for (const c of ext.collections) {
813
+ if (c.visibility === "role") for (const r of c.roles ?? []) scoped.add(r);
814
+ }
815
+ i = { scoped, names: /* @__PURE__ */ new Map() };
816
+ cache.set(ext, i);
817
+ }
818
+ return i;
819
+ }
820
+ function isVisible(c, role) {
821
+ return c.visibility !== "role" || (c.roles ?? []).includes(role);
822
+ }
823
+ function visibleTo(role) {
824
+ return (c) => isVisible(c, role);
825
+ }
826
+ function visibleNames(ext, role) {
827
+ const info = infoOf(ext);
828
+ let set = info.names.get(role);
829
+ if (!set) {
830
+ set = new Set(
831
+ ext.collections.filter((c) => isVisible(c, role)).map((c) => c.name)
832
+ );
833
+ info.names.set(role, set);
834
+ }
835
+ return set;
836
+ }
837
+ function viewKeyFor(ext, role) {
838
+ return infoOf(ext).scoped.has(role) ? role : "all";
839
+ }
840
+ function encodeViewSnapshot(ext, plain, role, tick) {
841
+ return encodeSnapshot(ext, plain, { tick }, { collections: visibleTo(role) });
842
+ }
843
+ function encodeViewDelta(ext, plain, dirty, role, tick) {
844
+ const keep = visibleNames(ext, role);
845
+ const filtered = filterDirty(dirty, (name) => keep.has(name));
846
+ if (isDirtyEmpty(filtered)) return null;
847
+ return encodeDelta2(ext, plain, filtered, { tick });
848
+ }
849
+ function stripAdds(plain, dirty, suppress) {
850
+ if (suppress.size === 0) return dirty;
851
+ const out = /* @__PURE__ */ new Map();
852
+ for (const [name, cd] of dirty) {
853
+ const captured = suppress.get(name);
854
+ if (!captured || captured.size === 0 || cd.added.size === 0) {
855
+ out.set(name, cd);
856
+ continue;
857
+ }
858
+ const coll = plainEntity(plain, name);
859
+ let added = cd.added;
860
+ for (const id of captured.keys()) {
861
+ if (!added.has(id)) continue;
862
+ const c = captured.get(id);
863
+ if (coll.ownerOf(id) !== c.owner || !deepEqual(coll.get(id), c.value)) continue;
864
+ if (added === cd.added) added = new Set(cd.added);
865
+ added.delete(id);
866
+ }
867
+ out.set(name, added === cd.added ? cd : { added, removed: cd.removed, updated: cd.updated });
868
+ }
869
+ return out;
870
+ }
871
+ function catchUpDirty(ext, plain, fromRole, toRole) {
872
+ const dirty = createDirtySet();
873
+ for (const c of ext.collections) {
874
+ const was = isVisible(c, fromRole);
875
+ const now = isVisible(c, toRole);
876
+ if (was === now) continue;
877
+ if (c.kind === "singleton") {
878
+ if (!now) continue;
879
+ const mask = createFieldMask();
880
+ for (const f of c.fields) markPath(mask, [f.index]);
881
+ collectionDirty(dirty, c.name).updated.set("", { mask, owner: false });
882
+ continue;
883
+ }
884
+ const coll = plainEntity(plain, c.name);
885
+ for (const id of [...coll.ids()]) {
886
+ if (now) markAdd(dirty, c.name, id);
887
+ else markRemove(dirty, c.name, id);
888
+ }
889
+ }
890
+ return dirty;
891
+ }
892
+
893
+ // src/core/snapshot.ts
894
+ import { ByteReader, ByteWriter } from "@irtio/schema";
895
+ var SNAPSHOT_FORMAT_VERSION = 1;
896
+ function parseHibernationBlob(bytes) {
897
+ const r = new ByteReader(bytes);
898
+ const version = r.u8();
899
+ if (version !== SNAPSHOT_FORMAT_VERSION) {
900
+ throw new Error(`RoomCore.restore: unsupported snapshot format version ${version}`);
901
+ }
902
+ const seed = r.u32();
903
+ const rngState = r.u32();
904
+ const tick = r.u32();
905
+ const mode = r.u8() === 1 ? "event" : "tick";
906
+ return { seed, rngState, tick, mode, snapshot: r.rest() };
907
+ }
908
+ function writeHibernationBlob(header, snapshot) {
909
+ const w = new ByteWriter(snapshot.length + 16);
910
+ w.u8(SNAPSHOT_FORMAT_VERSION);
911
+ w.u32(header.seed >>> 0);
912
+ w.u32(header.rngState >>> 0);
913
+ w.u32(header.tick >>> 0);
914
+ w.u8(header.mode === "event" ? 1 : 0);
915
+ w.bytes(snapshot);
916
+ return w.finish();
917
+ }
918
+
919
+ // src/core/room.ts
920
+ import {
921
+ FrameType as FrameType5,
922
+ PRESENCE_COLLECTION,
923
+ decodeFrame,
924
+ encodeCorrectFrame,
925
+ encodeFrame as encodeFrame4,
926
+ withBuiltins
927
+ } from "@irtio/protocol";
928
+ import {
929
+ cloneValue as cloneValue2,
930
+ createDirtySet as createDirtySet2,
931
+ createState,
932
+ decodeSnapshot,
933
+ encodeSnapshot as encodeSnapshot2,
934
+ isDirtyEmpty as isDirtyEmpty3,
935
+ track,
936
+ validateForDeploy
937
+ } from "@irtio/schema";
938
+
939
+ // src/core/messages.ts
940
+ import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
941
+ function toRoomTarget(target) {
942
+ switch (target.kind) {
943
+ case "all":
944
+ return "all";
945
+ case "client":
946
+ return target.clientId;
947
+ case "role":
948
+ return { role: target.role };
949
+ case "server":
950
+ return "server";
951
+ }
952
+ }
953
+ function deliver(core, target, frame, exclude) {
954
+ for (const entry of core.clients.values()) {
955
+ if (!entry.connected) continue;
956
+ if (entry.clientId === exclude) continue;
957
+ if (target.kind === "client" && entry.clientId !== target.clientId) continue;
958
+ if (target.kind === "role" && entry.role !== target.role) continue;
959
+ core.send(entry.clientId, frame);
960
+ }
961
+ }
962
+ function handleMsg(core, clientId, payload) {
963
+ core.recordEvent("msg", clientId);
964
+ let msg;
965
+ try {
966
+ msg = decodeMsg(payload);
967
+ } catch (err) {
968
+ core.log("warn", `MSG from ${clientId} failed to decode:`, err);
969
+ return false;
970
+ }
971
+ const onMessage = core.definition.config.onMessage;
972
+ if (onMessage) {
973
+ const ran = core.tryRun(
974
+ "onMessage",
975
+ () => onMessage(
976
+ core.anyState,
977
+ clientId,
978
+ toRoomTarget(msg.target),
979
+ msg.payload,
980
+ core.ctxFor(clientId)
981
+ )
982
+ );
983
+ if (!ran.ok || ran.value === false) return true;
984
+ }
985
+ if (msg.target.kind === "server") return true;
986
+ const frame = encodeFrame2(
987
+ FrameType3.MSG,
988
+ encodeMsg({ target: { kind: "client", clientId }, payload: msg.payload })
989
+ );
990
+ deliver(core, msg.target, frame, clientId);
991
+ return true;
992
+ }
993
+ function sendMessage(core, target, bytes) {
994
+ const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
995
+ const frame = encodeFrame2(
996
+ FrameType3.MSG,
997
+ encodeMsg({ target: { kind: "server" }, payload: bytes })
998
+ );
999
+ deliver(core, wire, frame);
1000
+ }
1001
+
1002
+ // src/core/room-api.ts
1003
+ import { FrameType as FrameType4, encodeFrame as encodeFrame3 } from "@irtio/protocol";
1004
+ import { encodeDelta as encodeDelta3, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
1005
+ function createRoomApi(core, publicUrl) {
1006
+ let cached;
1007
+ let lastNow = Number.NEGATIVE_INFINITY;
1008
+ const link = () => {
1009
+ const sep = publicUrl.includes("?") ? "&" : "?";
1010
+ return `${publicUrl}${sep}room=${encodeURIComponent(core.roomId)}`;
1011
+ };
1012
+ const room = {
1013
+ get id() {
1014
+ return core.roomId;
1015
+ },
1016
+ get link() {
1017
+ return link();
1018
+ },
1019
+ get tick() {
1020
+ return core.tick;
1021
+ },
1022
+ get now() {
1023
+ const raw = core.host.now();
1024
+ if (raw > lastNow) lastNow = raw;
1025
+ return lastNow;
1026
+ },
1027
+ get clients() {
1028
+ if (!cached) {
1029
+ cached = [...core.clients.values()].map((c) => ({
1030
+ clientId: c.clientId,
1031
+ role: c.role,
1032
+ name: c.name,
1033
+ connected: c.connected
1034
+ }));
1035
+ }
1036
+ return cached;
1037
+ },
1038
+ random() {
1039
+ return core.rng.next();
1040
+ },
1041
+ send(target, bytes) {
1042
+ sendMessage(core, target, bytes);
1043
+ },
1044
+ setRole(clientId, role) {
1045
+ setRole(core, clientId, role);
1046
+ cached = void 0;
1047
+ },
1048
+ kick(clientId, reason) {
1049
+ core.host.kick(clientId, "E_KICKED", reason);
1050
+ },
1051
+ close(reason) {
1052
+ core.host.close(reason);
1053
+ },
1054
+ sleep() {
1055
+ if (core.mode !== "event") {
1056
+ core.log("warn", "room.sleep() is event-mode only; ignored in tick mode");
1057
+ return;
1058
+ }
1059
+ core.host.sleep();
1060
+ },
1061
+ log(...args) {
1062
+ core.host.log("info", args);
1063
+ },
1064
+ setTimeout(ms, fn) {
1065
+ return core.loop.setTimer(ms, fn, false);
1066
+ },
1067
+ setInterval(ms, fn) {
1068
+ return core.loop.setTimer(ms, fn, true);
1069
+ },
1070
+ clearTimeout(handle) {
1071
+ core.loop.clearTimer(handle);
1072
+ },
1073
+ clearInterval(handle) {
1074
+ core.loop.clearTimer(handle);
1075
+ },
1076
+ call(clientId) {
1077
+ return createCallProxy(core, clientId);
1078
+ },
1079
+ get broadcast() {
1080
+ return broadcast;
1081
+ }
1082
+ };
1083
+ const broadcast = createBroadcastProxy(core);
1084
+ return {
1085
+ room,
1086
+ invalidate() {
1087
+ cached = void 0;
1088
+ }
1089
+ };
1090
+ }
1091
+ function setRole(core, clientId, role) {
1092
+ const entry = core.clients.get(clientId);
1093
+ if (!entry) {
1094
+ core.log("warn", `room.setRole: unknown client ${clientId}`);
1095
+ return;
1096
+ }
1097
+ const roles = core.definition.schema.roles;
1098
+ if (roles.length > 0 && !roles.includes(role)) {
1099
+ core.log("warn", `room.setRole: ${JSON.stringify(role)} is not a schema role; ignored`);
1100
+ return;
1101
+ }
1102
+ if (entry.role === role) return;
1103
+ const dirty = catchUpDirty(core.ext, core.plain, entry.role, role);
1104
+ if (!isDirtyEmpty2(dirty)) {
1105
+ core.send(
1106
+ clientId,
1107
+ encodeFrame3(FrameType4.DELTA, encodeDelta3(core.ext, core.plain, dirty, { tick: core.tick }))
1108
+ );
1109
+ }
1110
+ entry.role = role;
1111
+ const presence = core.anyState.clients.get(clientId);
1112
+ if (presence) presence.role = role;
1113
+ core.invalidateClients();
1114
+ }
1115
+
1116
+ // src/core/room.ts
1117
+ var DEFAULT_PUBLIC_URL = "http://localhost/";
1118
+ function ownJoinCapture(plain, name, added, clientId) {
1119
+ const coll = plainEntity(plain, name);
1120
+ let mine;
1121
+ for (const id of added) {
1122
+ const owner = coll.ownerOf(id);
1123
+ if (name === PRESENCE_COLLECTION ? id !== clientId : owner !== clientId) continue;
1124
+ (mine ??= /* @__PURE__ */ new Map()).set(id, { value: cloneValue2(coll.get(id)), owner });
1125
+ }
1126
+ return mine;
1127
+ }
1128
+ var RoomCore = class _RoomCore {
1129
+ definition;
1130
+ ext;
1131
+ host;
1132
+ roomId;
1133
+ mode;
1134
+ plain;
1135
+ tracked;
1136
+ anyState;
1137
+ rng;
1138
+ clients = /* @__PURE__ */ new Map();
1139
+ loop;
1140
+ stats = {
1141
+ ticks: 0,
1142
+ lastTickMs: 0,
1143
+ maxTickMs: 0,
1144
+ overruns: 0,
1145
+ framesIn: 0,
1146
+ framesOut: 0,
1147
+ bytesOut: 0,
1148
+ bytesOutByClient: /* @__PURE__ */ new Map(),
1149
+ handlerErrors: 0,
1150
+ encodesLastFlush: 0,
1151
+ corrections: 0
1152
+ };
1153
+ tick = 0;
1154
+ stopped = false;
1155
+ seed;
1156
+ api;
1157
+ internals;
1158
+ started = false;
1159
+ constructor(definition, host, options) {
1160
+ this.definition = definition;
1161
+ this.host = host;
1162
+ this.roomId = options.roomId;
1163
+ this.mode = definition.config.mode;
1164
+ this.ext = withBuiltins(definition.schema);
1165
+ for (const issue of validateForDeploy(this.ext)) {
1166
+ if (issue.level === "error") throw new Error(`RoomCore: ${issue.message}`);
1167
+ host.log("warn", [`irtio: ${issue.message}`]);
1168
+ }
1169
+ let restored;
1170
+ let plain;
1171
+ if (options.restoreFrom) {
1172
+ restored = parseHibernationBlob(options.restoreFrom);
1173
+ plain = decodeSnapshot(this.ext, restored.snapshot).state;
1174
+ const presence = plainEntity(plain, PRESENCE_COLLECTION);
1175
+ for (const id of [...presence.ids()]) presence.remove(id);
1176
+ if (restored.mode !== this.mode) {
1177
+ host.log("warn", [
1178
+ `irtio: restoring a '${restored.mode}' snapshot into a '${this.mode}' room`
1179
+ ]);
1180
+ }
1181
+ } else {
1182
+ plain = createState(this.ext);
1183
+ }
1184
+ this.plain = plain;
1185
+ this.tracked = track(this.ext, plain);
1186
+ this.anyState = this.tracked.state;
1187
+ this.seed = restored?.seed ?? options.seed ?? 1;
1188
+ this.rng = new Mulberry32(restored?.rngState ?? this.seed);
1189
+ this.tick = restored?.tick ?? 0;
1190
+ const self = this;
1191
+ this.internals = self;
1192
+ this.loop = new Loop(self);
1193
+ this.api = createRoomApi(self, options.publicUrl ?? DEFAULT_PUBLIC_URL);
1194
+ if (restored) {
1195
+ const onWake = definition.config.onWake;
1196
+ if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
1197
+ } else {
1198
+ const onCreate = definition.config.onCreate;
1199
+ if (onCreate) this.guard("onCreate", () => onCreate(this.state, this.room));
1200
+ }
1201
+ }
1202
+ /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
1203
+ static restore(definition, bytes, host, options) {
1204
+ return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
1205
+ }
1206
+ get schema() {
1207
+ return this.definition.schema;
1208
+ }
1209
+ get config() {
1210
+ return this.definition.config;
1211
+ }
1212
+ get state() {
1213
+ return this.tracked.state;
1214
+ }
1215
+ get room() {
1216
+ return this.api.room;
1217
+ }
1218
+ // -------------------------------------------------------------------------
1219
+ // Handler safety
1220
+ // -------------------------------------------------------------------------
1221
+ tryRun(name, fn) {
1222
+ try {
1223
+ return { ok: true, value: fn() };
1224
+ } catch (err) {
1225
+ this.stats.handlerErrors++;
1226
+ this.recordEvent(
1227
+ "error",
1228
+ void 0,
1229
+ `${name}: ${err instanceof Error ? err.message : String(err)}`
1230
+ );
1231
+ this.host.log("error", [`irtio: ${name} threw`, err]);
1232
+ return { ok: false };
1233
+ }
1234
+ }
1235
+ events = new EventRing();
1236
+ recordEvent(kind, clientId, detail) {
1237
+ const e = {
1238
+ tick: this.tick,
1239
+ kind
1240
+ };
1241
+ if (clientId !== void 0) e.clientId = clientId;
1242
+ if (detail !== void 0) e.detail = detail;
1243
+ this.events.push(e);
1244
+ }
1245
+ /** Live JSON view of the room for the dev page / supervisor admin API. */
1246
+ inspect() {
1247
+ return {
1248
+ tick: this.tick,
1249
+ state: inspectState(this.ext, this.plain),
1250
+ recent: this.events.list()
1251
+ };
1252
+ }
1253
+ guard(name, fn) {
1254
+ const r = this.tryRun(name, fn);
1255
+ return r.ok ? r.value : void 0;
1256
+ }
1257
+ log(level, ...args) {
1258
+ this.host.log(level, args);
1259
+ }
1260
+ // -------------------------------------------------------------------------
1261
+ // Lifecycle
1262
+ // -------------------------------------------------------------------------
1263
+ start() {
1264
+ if (this.stopped) {
1265
+ this.log("warn", "RoomCore.start() after stop(); ignored");
1266
+ return;
1267
+ }
1268
+ if (this.started) return;
1269
+ this.started = true;
1270
+ this.loop.start();
1271
+ }
1272
+ stop() {
1273
+ if (this.stopped) return;
1274
+ this.stopped = true;
1275
+ this.started = false;
1276
+ this.loop.stop();
1277
+ rejectAllPending(this.internals, "room stopped");
1278
+ }
1279
+ serialize() {
1280
+ const onSleep = this.definition.config.onSleep;
1281
+ if (onSleep) this.guard("onSleep", () => onSleep(this.state, this.room));
1282
+ this.loop.clearAllTimers();
1283
+ rejectAllPending(this.internals, "room is hibernating");
1284
+ return writeHibernationBlob(
1285
+ { seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
1286
+ encodeSnapshot2(this.ext, this.plain, { tick: this.tick })
1287
+ );
1288
+ }
1289
+ // -------------------------------------------------------------------------
1290
+ // Clients
1291
+ // -------------------------------------------------------------------------
1292
+ get presence() {
1293
+ return this.anyState[PRESENCE_COLLECTION];
1294
+ }
1295
+ resolveRole(requested, existing) {
1296
+ const roles = this.definition.schema.roles;
1297
+ if (requested === void 0) return existing ?? roles[0] ?? "";
1298
+ if (roles.length === 0) {
1299
+ if (requested !== "") {
1300
+ this.log(
1301
+ "warn",
1302
+ `join: role ${JSON.stringify(requested)} but the schema declares no roles`
1303
+ );
1304
+ }
1305
+ return "";
1306
+ }
1307
+ if (roles.includes(requested)) return requested;
1308
+ this.log(
1309
+ "warn",
1310
+ `join: unknown role ${JSON.stringify(requested)}; using ${JSON.stringify(roles[0])}`
1311
+ );
1312
+ return roles[0] ?? "";
1313
+ }
1314
+ join(clientId, options = {}) {
1315
+ const existing = this.clients.get(clientId);
1316
+ if (this.stopped) {
1317
+ this.log("warn", `join(${clientId}) after stop(); ignored`);
1318
+ const role2 = this.resolveRole(options.role, existing?.role);
1319
+ return {
1320
+ tick: this.tick,
1321
+ role: role2,
1322
+ snapshot: encodeViewSnapshot(this.ext, this.plain, role2, this.tick)
1323
+ };
1324
+ }
1325
+ const presence = this.presence;
1326
+ const maxClients = this.definition.config.maxClients;
1327
+ if (!presence.has(clientId) && presence.size >= maxClients) {
1328
+ throw new RoomFullError(maxClients);
1329
+ }
1330
+ const reconnecting = options.reconnecting === true;
1331
+ const role = this.resolveRole(options.role, existing?.role);
1332
+ const name = options.name ?? existing?.name ?? "";
1333
+ const record = presence.get(clientId);
1334
+ if (record) {
1335
+ if (record.role !== role) record.role = role;
1336
+ if (record.name !== name) record.name = name;
1337
+ if (record.connected !== true) record.connected = true;
1338
+ } else {
1339
+ presence.add(clientId, { clientId, role, name, connected: true });
1340
+ }
1341
+ const entry = existing ?? {
1342
+ clientId,
1343
+ role,
1344
+ name,
1345
+ connected: true,
1346
+ correction: void 0,
1347
+ accepted: /* @__PURE__ */ new Map(),
1348
+ lastClientTick: 0,
1349
+ pendingJoinAdds: void 0
1350
+ };
1351
+ entry.role = role;
1352
+ entry.name = name;
1353
+ entry.connected = true;
1354
+ this.clients.set(clientId, entry);
1355
+ this.invalidateClients();
1356
+ const onJoin = this.definition.config.onJoin;
1357
+ if (onJoin) {
1358
+ const ctx = this.ctxFor(clientId, reconnecting);
1359
+ this.guard("onJoin", () => onJoin(this.state, ctx));
1360
+ }
1361
+ this.loop.noteActivity();
1362
+ this.recordEvent("join", clientId, reconnecting ? "reconnect" : void 0);
1363
+ const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick);
1364
+ const pending = /* @__PURE__ */ new Map();
1365
+ for (const [name2, cd] of this.tracked.dirty) {
1366
+ if (cd.added.size === 0) continue;
1367
+ const mine = ownJoinCapture(this.plain, name2, cd.added, clientId);
1368
+ if (mine) pending.set(name2, mine);
1369
+ }
1370
+ entry.pendingJoinAdds = pending.size > 0 ? pending : void 0;
1371
+ this.eventFlush();
1372
+ return { tick: this.tick, role: entry.role, snapshot };
1373
+ }
1374
+ /** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
1375
+ eventFlush() {
1376
+ if (this.mode !== "event" || this.stopped) return;
1377
+ if (isDirtyEmpty3(this.tracked.dirty)) return;
1378
+ this.tick++;
1379
+ this.flush();
1380
+ }
1381
+ leave(clientId, reason) {
1382
+ const entry = this.clients.get(clientId);
1383
+ if (!entry) {
1384
+ this.log("warn", `leave(${clientId}): not joined`);
1385
+ return;
1386
+ }
1387
+ const onLeave = this.definition.config.onLeave;
1388
+ if (onLeave) {
1389
+ const ctx = this.ctxFor(clientId);
1390
+ this.guard("onLeave", () => onLeave(this.state, ctx, reason));
1391
+ }
1392
+ this.presence.remove(clientId);
1393
+ this.recordEvent("leave", clientId, reason);
1394
+ rejectPendingFor(this.internals, clientId, "client disconnected");
1395
+ this.loop.dropFramesFor(clientId);
1396
+ this.clients.delete(clientId);
1397
+ this.stats.bytesOutByClient.delete(clientId);
1398
+ this.invalidateClients();
1399
+ this.eventFlush();
1400
+ }
1401
+ markDisconnected(clientId) {
1402
+ const entry = this.clients.get(clientId);
1403
+ if (!entry) {
1404
+ this.log("warn", `markDisconnected(${clientId}): not joined`);
1405
+ return;
1406
+ }
1407
+ entry.connected = false;
1408
+ entry.correction = void 0;
1409
+ const record = this.presence.get(clientId);
1410
+ if (record && record.connected !== false) record.connected = false;
1411
+ this.invalidateClients();
1412
+ this.eventFlush();
1413
+ }
1414
+ ctxFor(clientId, reconnecting = false) {
1415
+ const entry = this.clients.get(clientId);
1416
+ return {
1417
+ clientId,
1418
+ role: entry?.role ?? "",
1419
+ name: entry?.name ?? "",
1420
+ tick: this.tick,
1421
+ reconnecting,
1422
+ room: this.room
1423
+ };
1424
+ }
1425
+ correctionFor(clientId) {
1426
+ const entry = this.clients.get(clientId);
1427
+ if (!entry) return void 0;
1428
+ if (!entry.correction) entry.correction = createDirtySet2();
1429
+ return entry.correction;
1430
+ }
1431
+ invalidateClients() {
1432
+ this.api.invalidate();
1433
+ }
1434
+ // -------------------------------------------------------------------------
1435
+ // Frames
1436
+ // -------------------------------------------------------------------------
1437
+ send(clientId, frame) {
1438
+ if (this.stopped) return;
1439
+ const entry = this.clients.get(clientId);
1440
+ if (!entry || !entry.connected) return;
1441
+ this.host.send(clientId, frame);
1442
+ this.stats.framesOut++;
1443
+ this.stats.bytesOut += frame.length;
1444
+ const by = this.stats.bytesOutByClient;
1445
+ by.set(clientId, (by.get(clientId) ?? 0) + frame.length);
1446
+ }
1447
+ badFrame(clientId, reason) {
1448
+ this.log("warn", `bad frame from ${clientId}: ${reason}`);
1449
+ this.host.kick(clientId, "E_BAD_FRAME", reason);
1450
+ }
1451
+ receive(clientId, frame) {
1452
+ if (this.stopped) {
1453
+ this.log("warn", `receive(${clientId}) after stop(); ignored`);
1454
+ return;
1455
+ }
1456
+ if (!this.clients.has(clientId)) {
1457
+ this.log("warn", `receive(${clientId}): not joined; frame ignored`);
1458
+ return;
1459
+ }
1460
+ this.stats.framesIn++;
1461
+ this.loop.noteActivity();
1462
+ let type;
1463
+ let payload;
1464
+ try {
1465
+ const decoded = decodeFrame(frame);
1466
+ type = decoded.type;
1467
+ payload = decoded.payload;
1468
+ } catch (err) {
1469
+ this.badFrame(clientId, String(err));
1470
+ return;
1471
+ }
1472
+ switch (type) {
1473
+ case FrameType5.WRITE:
1474
+ case FrameType5.CALL: {
1475
+ if (type === FrameType5.WRITE) this.recordEvent("write", clientId);
1476
+ if (this.mode === "tick") {
1477
+ this.loop.enqueue({ clientId, type, payload: payload.slice() });
1478
+ } else if (!this.loop.applyEvent({ clientId, type, payload })) {
1479
+ this.badFrame(
1480
+ clientId,
1481
+ `malformed ${type === FrameType5.WRITE ? "WRITE" : "CALL"} payload`
1482
+ );
1483
+ }
1484
+ return;
1485
+ }
1486
+ case FrameType5.REPLY: {
1487
+ if (!handleReply(this.internals, clientId, payload)) {
1488
+ this.badFrame(clientId, "malformed REPLY payload");
1489
+ } else if (this.mode === "event") {
1490
+ queueMicrotask(() => {
1491
+ if (this.stopped || isDirtyEmpty3(this.tracked.dirty)) return;
1492
+ this.tick++;
1493
+ this.flush();
1494
+ });
1495
+ }
1496
+ return;
1497
+ }
1498
+ case FrameType5.MSG: {
1499
+ if (this.mode === "event") this.tick++;
1500
+ const ok = handleMsg(this.internals, clientId, payload);
1501
+ if (this.mode === "event") this.flush();
1502
+ if (!ok) this.badFrame(clientId, "malformed MSG payload");
1503
+ return;
1504
+ }
1505
+ default:
1506
+ this.badFrame(clientId, `unexpected frame type ${type}`);
1507
+ }
1508
+ }
1509
+ /**
1510
+ * Hands the tracked dirty set out: server-wins corrections first, then per connected client its
1511
+ * pending `CORRECT` (before the delta, so it sees the correction and then the broadcast) and
1512
+ * its view's `DELTA` — encoded once per distinct view.
1513
+ */
1514
+ flush() {
1515
+ const dirty = this.tracked.flush();
1516
+ serverWinsCorrections(this.internals, dirty);
1517
+ this.stats.encodesLastFlush = 0;
1518
+ const byView = /* @__PURE__ */ new Map();
1519
+ for (const entry of this.clients.values()) {
1520
+ if (entry.connected) {
1521
+ if (entry.correction) {
1522
+ const payload = encodeCorrection(this.internals, entry.correction);
1523
+ if (payload) this.send(entry.clientId, encodeCorrectFrame(payload, entry.lastClientTick));
1524
+ }
1525
+ let delta;
1526
+ if (entry.pendingJoinAdds) {
1527
+ delta = encodeViewDelta(
1528
+ this.ext,
1529
+ this.plain,
1530
+ stripAdds(this.plain, dirty, entry.pendingJoinAdds),
1531
+ entry.role,
1532
+ this.tick
1533
+ );
1534
+ if (delta) this.stats.encodesLastFlush++;
1535
+ entry.pendingJoinAdds = void 0;
1536
+ } else {
1537
+ const key = viewKeyFor(this.ext, entry.role);
1538
+ let cached = byView.get(key);
1539
+ if (cached === void 0) {
1540
+ cached = encodeViewDelta(this.ext, this.plain, dirty, entry.role, this.tick);
1541
+ if (cached) this.stats.encodesLastFlush++;
1542
+ byView.set(key, cached);
1543
+ }
1544
+ delta = cached;
1545
+ }
1546
+ if (delta) this.send(entry.clientId, encodeFrame4(FrameType5.DELTA, delta));
1547
+ }
1548
+ entry.correction = void 0;
1549
+ entry.accepted.clear();
1550
+ }
1551
+ }
1552
+ };
1553
+
1554
+ export {
1555
+ RoomFullError,
1556
+ EVENT_RING_SIZE,
1557
+ inspectState,
1558
+ RPC_TIMEOUT_MS,
1559
+ MAX_CATCHUP,
1560
+ CRASH_AFTER_THROWS,
1561
+ Mulberry32,
1562
+ isVisible,
1563
+ visibleTo,
1564
+ visibleNames,
1565
+ viewKeyFor,
1566
+ encodeViewSnapshot,
1567
+ encodeViewDelta,
1568
+ catchUpDirty,
1569
+ SNAPSHOT_FORMAT_VERSION,
1570
+ parseHibernationBlob,
1571
+ writeHibernationBlob,
1572
+ RoomCore
1573
+ };