@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.
@@ -9,6 +9,181 @@ var RoomFullError = class extends Error {
9
9
  name = "RoomFullError";
10
10
  };
11
11
 
12
+ // src/core/caps.ts
13
+ import {
14
+ ErrorCode,
15
+ FrameType,
16
+ encodeErrorPayload,
17
+ encodeFrame,
18
+ formatError
19
+ } from "@irtio/protocol";
20
+ import { encodeSnapshot } from "@irtio/schema";
21
+ var RELAY_STATE_BYTES = 512 * 1024;
22
+ var RELAY_RECORDS_PER_COLLECTION = 1024;
23
+ var RELAY_WRITES_PER_SECOND = 35;
24
+ var DEFAULT_RECORDS_PER_COLLECTION = RELAY_RECORDS_PER_COLLECTION;
25
+ var RELAY_WRITE_CAPS = {
26
+ stateBytes: RELAY_STATE_BYTES,
27
+ recordsPerCollection: RELAY_RECORDS_PER_COLLECTION,
28
+ writesPerSecond: RELAY_WRITES_PER_SECOND
29
+ };
30
+ function refuseByName(core, clientId, code, vars) {
31
+ const message = formatError(code, vars);
32
+ core.recordEvent("write-rejected", clientId, `${code}: ${message}`);
33
+ core.send(
34
+ clientId,
35
+ encodeFrame(
36
+ FrameType.ERROR,
37
+ encodeErrorPayload({ code: ErrorCode[code].code, message, fatal: false })
38
+ )
39
+ );
40
+ }
41
+ function maxTypeBytes(t) {
42
+ const optional = t.opt ? 1 : 0;
43
+ switch (t.kind) {
44
+ case "bool":
45
+ case "u8":
46
+ return optional + 1;
47
+ case "u16":
48
+ return optional + 2;
49
+ case "u32":
50
+ case "i32":
51
+ case "f32":
52
+ return optional + 4;
53
+ case "f64":
54
+ return optional + 8;
55
+ case "enum":
56
+ return optional + 2;
57
+ case "str":
58
+ return optional + 5 + t.max * 4;
59
+ case "ref":
60
+ return optional + 5 + 64 * 4;
61
+ case "list":
62
+ return optional + 5 + t.max * maxTypeBytes(t.item);
63
+ case "struct": {
64
+ let sum = optional;
65
+ for (const f of Object.values(t.fields)) sum += maxTypeBytes(f);
66
+ return sum;
67
+ }
68
+ default:
69
+ return optional + 8;
70
+ }
71
+ }
72
+ function maxRecordBytes(c) {
73
+ let sum = 5 + 64 * 4 + 5 + 64 * 4;
74
+ for (const f of c.fields) sum += maxTypeBytes(f.type);
75
+ return sum;
76
+ }
77
+ var STALE_MEASURE_TICKS = 300;
78
+ var StateMeter = class {
79
+ constructor(core, cap) {
80
+ this.core = core;
81
+ this.cap = cap;
82
+ this.figure = this.measure();
83
+ }
84
+ core;
85
+ cap;
86
+ /** The running figure: the last exact measure plus every accepted write's upper bound since. */
87
+ figure;
88
+ measuredAtTick = -1;
89
+ measure() {
90
+ const bytes = encodeSnapshot(this.core.ext, this.core.plain, { tick: this.core.tick });
91
+ this.measuredAtTick = this.core.tick;
92
+ return bytes.length;
93
+ }
94
+ /** Has the figure gone long enough without an exact measure to be worth distrusting? */
95
+ stale() {
96
+ return this.core.tick - this.measuredAtTick >= STALE_MEASURE_TICKS;
97
+ }
98
+ /**
99
+ * The truth, if it is worth paying for: over half the cap, or stale, and at most once a tick.
100
+ * A small room that is being written to steadily encodes once every {@link STALE_MEASURE_TICKS}
101
+ * ticks and otherwise uses the running figure, which is why it costs next to nothing.
102
+ */
103
+ refresh() {
104
+ if (this.measuredAtTick === this.core.tick) return;
105
+ if (this.figure * 2 <= this.cap && !this.stale()) return;
106
+ this.figure = this.measure();
107
+ }
108
+ /** `true` when a write that could add `growth` bytes must be refused. */
109
+ wouldExceed(growth) {
110
+ if (this.stale()) this.refresh();
111
+ if (this.figure + growth <= this.cap) return false;
112
+ this.refresh();
113
+ return this.figure + growth > this.cap;
114
+ }
115
+ /** Records an accepted write's upper bound. */
116
+ grew(growth) {
117
+ this.figure += growth;
118
+ }
119
+ /** The current figure, for the room row and `irtio rooms`. Exact when it matters. */
120
+ bytes() {
121
+ this.refresh();
122
+ return this.figure;
123
+ }
124
+ };
125
+ var states = /* @__PURE__ */ new WeakMap();
126
+ function installCaps(core, caps) {
127
+ states.set(core, {
128
+ caps,
129
+ meter: caps.stateBytes !== void 0 ? new StateMeter(core, caps.stateBytes) : void 0,
130
+ buckets: /* @__PURE__ */ new Map()
131
+ });
132
+ }
133
+ function stateOf(core) {
134
+ let s = states.get(core);
135
+ if (!s) {
136
+ s = { caps: {}, meter: void 0, buckets: /* @__PURE__ */ new Map() };
137
+ states.set(core, s);
138
+ }
139
+ return s;
140
+ }
141
+ function recordCap(core) {
142
+ return stateOf(core).caps.recordsPerCollection ?? DEFAULT_RECORDS_PER_COLLECTION;
143
+ }
144
+ function admitGrowth(core, clientId, growth) {
145
+ const meter = stateOf(core).meter;
146
+ if (!meter) return true;
147
+ if (meter.wouldExceed(growth)) {
148
+ refuseByName(core, clientId, "E_STATE_CAP", { limit: meter.cap });
149
+ return false;
150
+ }
151
+ meter.grew(growth);
152
+ return true;
153
+ }
154
+ function stateBytesOf(core) {
155
+ return stateOf(core).meter?.bytes();
156
+ }
157
+ function admitWrite(core, clientId, nowMs, ops = 1) {
158
+ const s = stateOf(core);
159
+ const limit = s.caps.writesPerSecond;
160
+ if (limit === void 0) return true;
161
+ const cost = Math.max(1, ops);
162
+ let bucket = s.buckets.get(clientId);
163
+ if (!bucket) {
164
+ bucket = { tokens: limit, at: nowMs, told: false };
165
+ s.buckets.set(clientId, bucket);
166
+ }
167
+ const elapsed = nowMs - bucket.at;
168
+ if (elapsed > 0) {
169
+ bucket.tokens = Math.min(limit, bucket.tokens + elapsed * limit / 1e3);
170
+ bucket.at = nowMs;
171
+ }
172
+ if (bucket.tokens >= cost) {
173
+ bucket.tokens -= cost;
174
+ bucket.told = false;
175
+ return true;
176
+ }
177
+ if (!bucket.told) {
178
+ bucket.told = true;
179
+ refuseByName(core, clientId, "E_WRITE_RATE", { limit });
180
+ }
181
+ return false;
182
+ }
183
+ function forgetWriter(core, clientId) {
184
+ states.get(core)?.buckets.delete(clientId);
185
+ }
186
+
12
187
  // src/core/inspect.ts
13
188
  import { EntityCollection } from "@irtio/schema";
14
189
  var EVENT_RING_SIZE = 100;
@@ -39,17 +214,20 @@ function inspectState(schema, plain) {
39
214
 
40
215
  // src/core/rpc.ts
41
216
  import {
42
- FrameType,
217
+ ErrorCode as ErrorCode2,
218
+ FrameType as FrameType2,
219
+ OWNERSHIP_ASK,
43
220
  decodeCall,
44
221
  decodeReply,
45
222
  encodeCall,
46
- encodeFrame,
223
+ encodeFrame as encodeFrame2,
47
224
  encodeReply,
225
+ isReservedRpcName,
48
226
  rpcByIdOf,
49
227
  rpcTable
50
228
  } from "@irtio/protocol";
51
229
  import {
52
- SERVER_OWNER,
230
+ SERVER_OWNER as SERVER_OWNER2,
53
231
  decodeFields,
54
232
  encodeFields,
55
233
  validateValue
@@ -81,15 +259,279 @@ function deepEqual(a, b) {
81
259
  return true;
82
260
  }
83
261
 
262
+ // src/core/ownership.ts
263
+ import { SERVER_OWNER, defaultRecord } from "@irtio/schema";
264
+ var ASK_WINDOW_MS = 5e3;
265
+ var states2 = /* @__PURE__ */ new WeakMap();
266
+ function stateOf2(core) {
267
+ let s = states2.get(core);
268
+ if (!s) {
269
+ s = { holds: /* @__PURE__ */ new Map(), asks: /* @__PURE__ */ new Map(), askUnknownWarned: false };
270
+ states2.set(core, s);
271
+ }
272
+ return s;
273
+ }
274
+ function keyOf(collection, id) {
275
+ return `${collection} ${id}`;
276
+ }
277
+ function policyCollections(core) {
278
+ return core.ext.collections.filter(
279
+ (c) => c.kind === "entity" && c.ownership !== void 0
280
+ );
281
+ }
282
+ function wallNow(core) {
283
+ return core.wallNow();
284
+ }
285
+ function holdOn(core, collection, id) {
286
+ return states2.get(core)?.holds.get(keyOf(collection, id));
287
+ }
288
+ function holdCount(core) {
289
+ return states2.get(core)?.holds.size ?? 0;
290
+ }
291
+ function placeHolds(core, clientId, playerId) {
292
+ const s = stateOf2(core);
293
+ const now = wallNow(core);
294
+ for (const c of policyCollections(core)) {
295
+ const policy = c.ownership;
296
+ const coll = plainEntity(core.plain, c.name);
297
+ for (const id of [...coll.ids()]) {
298
+ if (coll.ownerOf(id) !== clientId) continue;
299
+ const hold = {
300
+ collection: c.name,
301
+ id,
302
+ holder: playerId,
303
+ deadlineMs: now + policy.holdMs,
304
+ // `then` is the D77 spec's own name for the leave action; a hold is plain data nothing
305
+ // ever awaits.
306
+ // biome-ignore lint/suspicious/noThenProperty: see above
307
+ then: policy.then
308
+ };
309
+ if (policy.holdMs <= 0) {
310
+ act(core, hold);
311
+ continue;
312
+ }
313
+ s.holds.set(keyOf(c.name, id), hold);
314
+ }
315
+ }
316
+ }
317
+ function reclaimHolds(core, clientId, playerId) {
318
+ const s = states2.get(core);
319
+ if (!s || s.holds.size === 0) return 0;
320
+ let reclaimed = 0;
321
+ for (const [key, hold] of [...s.holds]) {
322
+ if (hold.holder !== playerId) continue;
323
+ s.holds.delete(key);
324
+ const coll = plainEntity(core.plain, hold.collection);
325
+ if (!coll.has(hold.id)) continue;
326
+ trackedEntity(core, hold.collection).setOwner(hold.id, clientId);
327
+ reclaimed++;
328
+ }
329
+ return reclaimed;
330
+ }
331
+ function expireHolds(core) {
332
+ const s = states2.get(core);
333
+ if (!s || s.holds.size === 0) return;
334
+ const now = wallNow(core);
335
+ for (const [key, hold] of [...s.holds]) {
336
+ if (hold.deadlineMs > now) continue;
337
+ s.holds.delete(key);
338
+ act(core, hold);
339
+ }
340
+ }
341
+ function act(core, hold) {
342
+ const coll = plainEntity(core.plain, hold.collection);
343
+ if (!coll.has(hold.id)) return;
344
+ const tracked = trackedEntity(core, hold.collection);
345
+ switch (hold.then) {
346
+ case "remove":
347
+ tracked.remove(hold.id);
348
+ return;
349
+ case "release":
350
+ tracked.setOwner(hold.id, SERVER_OWNER);
351
+ return;
352
+ case "nearest": {
353
+ const to = nearestConnected(core, hold.collection, hold.id);
354
+ tracked.setOwner(hold.id, to ?? SERVER_OWNER);
355
+ return;
356
+ }
357
+ case "random": {
358
+ const candidates = connectedClients(core);
359
+ if (candidates.length === 0) {
360
+ tracked.setOwner(hold.id, SERVER_OWNER);
361
+ return;
362
+ }
363
+ const pick = candidates[Math.floor(core.rng.next() * candidates.length)];
364
+ tracked.setOwner(hold.id, pick);
365
+ return;
366
+ }
367
+ default:
368
+ return;
369
+ }
370
+ }
371
+ function connectedClients(core) {
372
+ const out = [];
373
+ for (const entry of core.clients.values()) if (entry.connected) out.push(entry.clientId);
374
+ return out;
375
+ }
376
+ function numberAt(record, field) {
377
+ if (!record) return void 0;
378
+ const v = record[field];
379
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
380
+ }
381
+ function distanceTo(core, policy, object, clientId) {
382
+ const position = policy.position;
383
+ if (!position || !object) return void 0;
384
+ const players = plainEntity(core.plain, position.players);
385
+ const player = players.get(clientId);
386
+ const px = numberAt(player, position.x);
387
+ const py = numberAt(player, position.y);
388
+ const ox = numberAt(object, position.x);
389
+ const oy = numberAt(object, position.y);
390
+ if (px === void 0 || py === void 0 || ox === void 0 || oy === void 0)
391
+ return void 0;
392
+ return Math.hypot(px - ox, py - oy);
393
+ }
394
+ function nearestConnected(core, collection, id) {
395
+ const c = core.ext.collections.find((x) => x.name === collection);
396
+ const policy = c?.ownership;
397
+ if (!policy?.position) return void 0;
398
+ const object = plainEntity(core.plain, collection).get(id);
399
+ let best;
400
+ let bestDistance = Number.POSITIVE_INFINITY;
401
+ for (const clientId of connectedClients(core)) {
402
+ const d = distanceTo(core, policy, object, clientId);
403
+ if (d === void 0 || d >= bestDistance) continue;
404
+ bestDistance = d;
405
+ best = clientId;
406
+ }
407
+ return best;
408
+ }
409
+ function addPerPlayerRecords(core, clientId) {
410
+ for (const c of core.ext.collections) {
411
+ if (c.kind !== "entity" || !c.perPlayer) continue;
412
+ const coll = plainEntity(core.plain, c.name);
413
+ if (coll.has(clientId)) {
414
+ if (coll.ownerOf(clientId) !== clientId) {
415
+ trackedEntity(core, c.name).setOwner(clientId, clientId);
416
+ }
417
+ continue;
418
+ }
419
+ try {
420
+ trackedEntity(core, c.name).add(clientId, defaultRecord(c), { owner: clientId });
421
+ } catch (err) {
422
+ core.log("error", `perPlayer add on ${c.name} failed for ${clientId}:`, err);
423
+ }
424
+ }
425
+ }
426
+ function askOn(core, collection, id) {
427
+ return states2.get(core)?.asks.get(keyOf(collection, id));
428
+ }
429
+ function trackAsk(core, ask) {
430
+ stateOf2(core).asks.set(keyOf(ask.collection, ask.id), ask);
431
+ }
432
+ function clearAsk(core, collection, id) {
433
+ states2.get(core)?.asks.delete(keyOf(collection, id));
434
+ }
435
+ function asksInFlight(core) {
436
+ const s = states2.get(core);
437
+ return s ? [...s.asks.values()] : [];
438
+ }
439
+ function askCount(core) {
440
+ return states2.get(core)?.asks.size ?? 0;
441
+ }
442
+ function warnSdkPredatesAsk(core, clientId) {
443
+ const s = stateOf2(core);
444
+ if (s.askUnknownWarned) return;
445
+ s.askUnknownWarned = true;
446
+ core.log("warn", `client ${clientId}'s SDK predates $ownership.ask; ask auto-declined`);
447
+ }
448
+ function grantOwnership(core, collection, id, clientId) {
449
+ trackedEntity(core, collection).setOwner(id, clientId);
450
+ }
451
+ function resolveTransfer(core, clientId, c, id) {
452
+ const policy = c.ownership;
453
+ const coll = plainEntity(core.plain, c.name);
454
+ const owner = coll.ownerOf(id);
455
+ if (owner === clientId) return "granted";
456
+ const hold = holdOn(core, c.name, id);
457
+ if (hold) {
458
+ const playerId = core.clients.get(clientId)?.playerId ?? clientId;
459
+ if (hold.holder !== playerId) return "refused";
460
+ reclaimHolds(core, clientId, playerId);
461
+ return coll.ownerOf(id) === clientId ? "granted" : "refused";
462
+ }
463
+ switch (policy.transfer) {
464
+ case "never":
465
+ return "refused";
466
+ case "free":
467
+ grantOwnership(core, c.name, id, clientId);
468
+ return "granted";
469
+ case "closer": {
470
+ if (owner === void 0 || owner === SERVER_OWNER) {
471
+ grantOwnership(core, c.name, id, clientId);
472
+ return "granted";
473
+ }
474
+ const object = coll.get(id);
475
+ const ownerDistance = distanceTo(core, policy, object, owner);
476
+ if (ownerDistance === void 0) {
477
+ grantOwnership(core, c.name, id, clientId);
478
+ return "granted";
479
+ }
480
+ const mine = distanceTo(core, policy, object, clientId);
481
+ if (mine === void 0) return "refused";
482
+ if (mine + policy.by >= ownerDistance) return "refused";
483
+ grantOwnership(core, c.name, id, clientId);
484
+ return "granted";
485
+ }
486
+ // ---- M6 ask packet ----
487
+ case "ask": {
488
+ if (owner === void 0 || owner === SERVER_OWNER) {
489
+ grantOwnership(core, c.name, id, clientId);
490
+ return "granted";
491
+ }
492
+ if (core.clients.get(owner)?.connected !== true) return "gone";
493
+ return "pending";
494
+ }
495
+ // ---- end M6 ask packet ----
496
+ default:
497
+ return "refused";
498
+ }
499
+ }
500
+ function serializeHolds(core) {
501
+ const s = states2.get(core);
502
+ if (!s || s.holds.size === 0) return void 0;
503
+ const json = JSON.stringify([...s.holds.values()]);
504
+ return new TextEncoder().encode(json);
505
+ }
506
+ function restoreHolds(core, bytes) {
507
+ const s = stateOf2(core);
508
+ s.holds.clear();
509
+ if (!bytes || bytes.length === 0) return;
510
+ try {
511
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
512
+ for (const h of parsed) {
513
+ if (typeof h?.collection !== "string" || typeof h.id !== "string" || typeof h.holder !== "string" || typeof h.deadlineMs !== "number") {
514
+ continue;
515
+ }
516
+ s.holds.set(keyOf(h.collection, h.id), h);
517
+ }
518
+ } catch (err) {
519
+ core.log("warn", "hold deadlines did not decode; the room wakes holding nothing:", err);
520
+ }
521
+ }
522
+
84
523
  // src/core/rpc.ts
85
524
  var RPC_TIMEOUT_MS = 5e3;
86
525
  var REQUEST_OWNERSHIP = "requestOwnership";
87
- var states = /* @__PURE__ */ new WeakMap();
88
- function stateOf(core) {
89
- let s = states.get(core);
526
+ var RpcTimeoutError = class extends Error {
527
+ };
528
+ var DEFERRED = /* @__PURE__ */ Symbol("deferred reply");
529
+ var states3 = /* @__PURE__ */ new WeakMap();
530
+ function stateOf3(core) {
531
+ let s = states3.get(core);
90
532
  if (!s) {
91
533
  s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
92
- states.set(core, s);
534
+ states3.set(core, s);
93
535
  }
94
536
  return s;
95
537
  }
@@ -101,7 +543,7 @@ function errorText(code, detail) {
101
543
  }
102
544
  function sendReply(core, clientId, reqId, result, error) {
103
545
  const payload = result === null ? encodeReply({ reqId, ok: false, error: error ?? errorText("E_INTERNAL") }) : encodeReply({ reqId, ok: true, result });
104
- core.send(clientId, encodeFrame(FrameType.REPLY, payload));
546
+ core.send(clientId, encodeFrame2(FrameType2.REPLY, payload));
105
547
  }
106
548
  function handleCall(core, clientId, payload) {
107
549
  let call;
@@ -119,6 +561,16 @@ function handleCall(core, clientId, payload) {
119
561
  sendReply(core, clientId, call.reqId, null, errorText("E_RPC_UNKNOWN", `rpcId ${call.rpcId}`));
120
562
  return true;
121
563
  }
564
+ if (isReservedRpcName(desc.name)) {
565
+ sendReply(
566
+ core,
567
+ clientId,
568
+ call.reqId,
569
+ null,
570
+ errorText("E_RPC_REJECTED", `${desc.name} is reserved by irtio`)
571
+ );
572
+ return true;
573
+ }
122
574
  if (desc.direction !== "server") {
123
575
  sendReply(
124
576
  core,
@@ -146,7 +598,8 @@ function handleCall(core, clientId, payload) {
146
598
  const ctx = { ...core.ctxFor(clientId), clientTick: call.clientTick || void 0 };
147
599
  let result;
148
600
  if (desc.name === REQUEST_OWNERSHIP) {
149
- result = runRequestOwnership(core, clientId, ctx, params);
601
+ result = runRequestOwnership(core, clientId, ctx, params, call.reqId, desc);
602
+ if (result === DEFERRED) return true;
150
603
  } else {
151
604
  const impl = core.definition.config.rpc?.[desc.name];
152
605
  if (!impl) {
@@ -175,29 +628,126 @@ function handleCall(core, clientId, payload) {
175
628
  }
176
629
  return true;
177
630
  }
178
- function runRequestOwnership(core, clientId, ctx, params) {
631
+ function runRequestOwnership(core, clientId, ctx, params, reqId, desc) {
179
632
  const name = String(params.entity ?? "");
180
633
  const id = String(params.id ?? "");
181
634
  const c = collectionDescOf(core.ext, name);
182
- if (!c || c.kind !== "entity" || c.serverOwned) return { granted: false };
635
+ if (!c || c.kind !== "entity" || c.serverOwned) return refused("gone");
183
636
  const plain = plainEntity(core.plain, name);
184
- if (!plain.has(id)) return { granted: false };
637
+ if (!plain.has(id)) return refused("gone");
185
638
  const handler = core.definition.config.onOwnershipRequest;
186
639
  const tracked = trackedEntity(core, name);
640
+ if (!handler && c.ownership) {
641
+ if (askOn(core, name, id)) return refused("pending");
642
+ const verdict = resolveTransfer(core, clientId, c, id);
643
+ if (verdict === "pending") return beginAsk(core, clientId, name, id, reqId, desc);
644
+ if (verdict === "gone") return refused("gone");
645
+ return verdict === "granted" ? granted() : refused("");
646
+ }
187
647
  if (handler) {
188
648
  const ran = core.tryRun("onOwnershipRequest", () => handler(core.anyState, name, id, ctx));
189
649
  if (ran.ok && ran.value === true && plain.ownerOf(id) !== clientId) {
190
650
  tracked.setOwner(id, clientId);
191
651
  }
192
- return { granted: plain.ownerOf(id) === clientId };
652
+ return plain.ownerOf(id) === clientId ? granted() : refused("");
193
653
  }
194
654
  const owner = plain.ownerOf(id);
195
- if (owner === clientId) return { granted: true };
196
- if (owner !== SERVER_OWNER) return { granted: false };
655
+ if (owner === clientId) return granted();
656
+ if (owner !== SERVER_OWNER2) return refused("");
197
657
  tracked.setOwner(id, clientId);
198
- return { granted: true };
658
+ return granted();
659
+ }
660
+ function granted() {
661
+ return { granted: true, reason: "" };
662
+ }
663
+ function refused(reason) {
664
+ return { granted: false, reason };
665
+ }
666
+ var UNKNOWN_RPC_PREFIX = ErrorCode2.E_RPC_UNKNOWN.message.split("{")[0] ?? "";
667
+ function isUnknownRpcReply(err) {
668
+ return err.message.startsWith(UNKNOWN_RPC_PREFIX) || err.message.startsWith("E_RPC_UNKNOWN");
669
+ }
670
+ function replyToAsker(core, clientId, reqId, desc, result) {
671
+ if (!desc.returns) {
672
+ sendReply(core, clientId, reqId, new Uint8Array(0));
673
+ return;
674
+ }
675
+ sendReply(core, clientId, reqId, encodeFields(desc.returns, result));
676
+ }
677
+ function beginAsk(core, asker, collection, id, askerReqId, askerDesc) {
678
+ const owner = plainEntity(core.plain, collection).ownerOf(id);
679
+ if (owner === void 0 || owner === SERVER_OWNER2) return refused("gone");
680
+ const askDesc = rpcTable(core.definition.schema).find((r) => r.name === OWNERSHIP_ASK);
681
+ if (!askDesc) {
682
+ core.log("error", `${OWNERSHIP_ASK} is missing from the rpc table; the ask cannot be sent`);
683
+ return refused("declined");
684
+ }
685
+ let settled = false;
686
+ const answer = (grant, reason) => {
687
+ if (settled) return;
688
+ settled = true;
689
+ clearAsk(core, collection, id);
690
+ if (grant) grantOwnership(core, collection, id, asker);
691
+ replyToAsker(core, asker, askerReqId, askerDesc, grant ? granted() : refused(reason));
692
+ };
693
+ const ownerReqId = callClient(
694
+ core,
695
+ owner,
696
+ askDesc,
697
+ { entity: collection, id, from: asker },
698
+ {
699
+ timeoutMs: ASK_WINDOW_MS,
700
+ resolve: (value) => {
701
+ const say = value?.granted === true;
702
+ if (!say) {
703
+ answer(false, "declined");
704
+ return;
705
+ }
706
+ const coll = plainEntity(core.plain, collection);
707
+ const stillTheirs = coll.has(id) && coll.ownerOf(id) === owner;
708
+ const askerHere = core.clients.get(asker)?.connected === true;
709
+ if (!stillTheirs || !askerHere) {
710
+ answer(false, "gone");
711
+ return;
712
+ }
713
+ answer(true, "");
714
+ },
715
+ reject: (err) => {
716
+ if (isUnknownRpcReply(err)) warnSdkPredatesAsk(core, owner);
717
+ answer(false, err instanceof RpcTimeoutError ? "timeout" : "declined");
718
+ }
719
+ }
720
+ );
721
+ if (settled) return DEFERRED;
722
+ const ask = {
723
+ collection,
724
+ id,
725
+ asker,
726
+ owner,
727
+ ownerReqId,
728
+ deadlineMs: core.host.now() + ASK_WINDOW_MS,
729
+ answer
730
+ };
731
+ trackAsk(core, ask);
732
+ return DEFERRED;
733
+ }
734
+ function cancelAsksFor(core, clientId) {
735
+ for (const ask of asksInFlight(core)) {
736
+ if (ask.asker !== clientId && ask.owner !== clientId) continue;
737
+ stateOf3(core).pending.delete(pendingKey(ask.owner, ask.ownerReqId));
738
+ ask.answer(false, "declined");
739
+ }
740
+ }
741
+ function cancelAllAsks(core) {
742
+ for (const ask of asksInFlight(core)) {
743
+ stateOf3(core).pending.delete(pendingKey(ask.owner, ask.ownerReqId));
744
+ ask.answer(false, "declined");
745
+ }
199
746
  }
200
747
  function descFor(core, name) {
748
+ if (isReservedRpcName(name)) {
749
+ throw new Error(`room.call: ${name} is reserved by irtio and cannot be called from room code`);
750
+ }
201
751
  const found = rpcTable(core.definition.schema).find((r) => r.name === name);
202
752
  if (!found) throw new Error(`room.call: unknown rpc ${JSON.stringify(name)}`);
203
753
  if (found.direction !== "client") {
@@ -206,16 +756,38 @@ function descFor(core, name) {
206
756
  return found;
207
757
  }
208
758
  function sendCall(core, clientId, desc, params) {
209
- const s = stateOf(core);
759
+ const s = stateOf3(core);
210
760
  const reqId = s.nextReqId;
211
761
  s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
212
762
  const bytes = encodeFields(desc.params, params);
213
763
  core.send(
214
764
  clientId,
215
- encodeFrame(FrameType.CALL, encodeCall({ reqId, rpcId: desc.index, params: bytes }))
765
+ encodeFrame2(FrameType2.CALL, encodeCall({ reqId, rpcId: desc.index, params: bytes }))
216
766
  );
217
767
  return reqId;
218
768
  }
769
+ function callClient(core, clientId, desc, params, settle) {
770
+ const entry = core.clients.get(clientId);
771
+ if (!entry || !entry.connected) {
772
+ core.scheduleContinuationFlush();
773
+ settle.reject(new Error(`room.call: ${clientId} is not connected`));
774
+ return 0;
775
+ }
776
+ const reqId = sendCall(core, clientId, desc, params);
777
+ const s = stateOf3(core);
778
+ s.pending.set(pendingKey(clientId, reqId), {
779
+ clientId,
780
+ name: desc.name,
781
+ returns: desc.returns,
782
+ deadline: core.host.now() + settle.timeoutMs,
783
+ resolve: settle.resolve,
784
+ reject: settle.reject
785
+ });
786
+ if (core.mode === "event") {
787
+ core.loop.after(settle.timeoutMs, () => checkTimeouts(core));
788
+ }
789
+ return reqId;
790
+ }
219
791
  function createCallProxy(core, clientId) {
220
792
  return new Proxy(
221
793
  {},
@@ -225,25 +797,11 @@ function createCallProxy(core, clientId) {
225
797
  return (params) => {
226
798
  const desc = descFor(core, prop);
227
799
  return new Promise((resolve, reject2) => {
228
- const entry = core.clients.get(clientId);
229
- if (!entry || !entry.connected) {
230
- core.scheduleContinuationFlush();
231
- reject2(new Error(`room.call: ${clientId} is not connected`));
232
- return;
233
- }
234
- const reqId = sendCall(core, clientId, desc, params ?? {});
235
- const s = stateOf(core);
236
- s.pending.set(pendingKey(clientId, reqId), {
237
- clientId,
238
- name: desc.name,
239
- returns: desc.returns,
240
- deadline: core.host.now() + RPC_TIMEOUT_MS,
800
+ callClient(core, clientId, desc, params ?? {}, {
801
+ timeoutMs: RPC_TIMEOUT_MS,
241
802
  resolve,
242
803
  reject: reject2
243
804
  });
244
- if (core.mode === "event") {
245
- core.loop.after(RPC_TIMEOUT_MS, () => checkTimeouts(core));
246
- }
247
805
  });
248
806
  };
249
807
  }
@@ -280,7 +838,7 @@ function handleReply(core, clientId, payload) {
280
838
  core.log("warn", `REPLY from ${clientId} failed to decode:`, err);
281
839
  return false;
282
840
  }
283
- const s = stateOf(core);
841
+ const s = stateOf3(core);
284
842
  const key = pendingKey(clientId, reply.reqId);
285
843
  const pending = s.pending.get(key);
286
844
  if (!pending) {
@@ -304,7 +862,7 @@ function handleReply(core, clientId, payload) {
304
862
  return true;
305
863
  }
306
864
  function rejectPendingFor(core, clientId, reason) {
307
- const s = stateOf(core);
865
+ const s = stateOf3(core);
308
866
  for (const [key, p] of [...s.pending]) {
309
867
  if (p.clientId !== clientId) continue;
310
868
  s.pending.delete(key);
@@ -312,17 +870,17 @@ function rejectPendingFor(core, clientId, reason) {
312
870
  }
313
871
  }
314
872
  function checkTimeouts(core) {
315
- const s = stateOf(core);
873
+ const s = stateOf3(core);
316
874
  if (s.pending.size === 0) return;
317
875
  const now = core.host.now();
318
876
  for (const [key, p] of [...s.pending]) {
319
877
  if (p.deadline > now) continue;
320
878
  s.pending.delete(key);
321
- p.reject(new Error(errorText("E_RPC_TIMEOUT", `rpc ${p.name} timed out`)));
879
+ p.reject(new RpcTimeoutError(errorText("E_RPC_TIMEOUT", `rpc ${p.name} timed out`)));
322
880
  }
323
881
  }
324
882
  function rejectAllPending(core, reason) {
325
- const s = stateOf(core);
883
+ const s = stateOf3(core);
326
884
  for (const [key, p] of [...s.pending]) {
327
885
  s.pending.delete(key);
328
886
  p.reject(new Error(reason));
@@ -330,15 +888,15 @@ function rejectAllPending(core, reason) {
330
888
  }
331
889
 
332
890
  // src/core/loop.ts
333
- import { FrameType as FrameType2 } from "@irtio/protocol";
891
+ import { FrameType as FrameType3 } from "@irtio/protocol";
334
892
 
335
893
  // src/core/host-calls.ts
336
- var states2 = /* @__PURE__ */ new WeakMap();
337
- function stateOf2(core) {
338
- let s = states2.get(core);
894
+ var states4 = /* @__PURE__ */ new WeakMap();
895
+ function stateOf4(core) {
896
+ let s = states4.get(core);
339
897
  if (!s) {
340
898
  s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
341
- states2.set(core, s);
899
+ states4.set(core, s);
342
900
  }
343
901
  return s;
344
902
  }
@@ -346,12 +904,16 @@ function rejectAsEvent(core, err) {
346
904
  core.scheduleContinuationFlush();
347
905
  return Promise.reject(err);
348
906
  }
907
+ function resolveAsEvent(core, value) {
908
+ core.scheduleContinuationFlush();
909
+ return Promise.resolve(value);
910
+ }
349
911
  function startHostCall(core, call, map) {
350
912
  if (core.stopped) {
351
913
  return rejectAsEvent(core, new Error(`room.${call.kind}: the room is stopped`));
352
914
  }
353
915
  return new Promise((resolve, reject2) => {
354
- const s = stateOf2(core);
916
+ const s = stateOf4(core);
355
917
  const reqId = s.nextReqId;
356
918
  s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
357
919
  s.pending.set(reqId, {
@@ -375,7 +937,7 @@ function startHostCall(core, call, map) {
375
937
  });
376
938
  }
377
939
  function completeHostCall(core, reqId, result) {
378
- const s = stateOf2(core);
940
+ const s = stateOf4(core);
379
941
  const pending = s.pending.get(reqId);
380
942
  if (!pending) return false;
381
943
  s.pending.delete(reqId);
@@ -384,7 +946,7 @@ function completeHostCall(core, reqId, result) {
384
946
  return true;
385
947
  }
386
948
  function checkHostCallTimeouts(core) {
387
- const s = stateOf2(core);
949
+ const s = stateOf4(core);
388
950
  if (s.pending.size === 0) return;
389
951
  const now = core.host.now();
390
952
  for (const [reqId, p] of [...s.pending]) {
@@ -394,7 +956,7 @@ function checkHostCallTimeouts(core) {
394
956
  }
395
957
  }
396
958
  function rejectAllHostCalls(core, reason) {
397
- const s = stateOf2(core);
959
+ const s = stateOf4(core);
398
960
  for (const [reqId, p] of [...s.pending]) {
399
961
  s.pending.delete(reqId);
400
962
  p.reject(new Error(reason));
@@ -403,7 +965,7 @@ function rejectAllHostCalls(core, reason) {
403
965
 
404
966
  // src/core/writes.ts
405
967
  import {
406
- SERVER_OWNER as SERVER_OWNER2,
968
+ SERVER_OWNER as SERVER_OWNER3,
407
969
  cloneValue,
408
970
  decodeDelta,
409
971
  encodeDelta,
@@ -493,6 +1055,9 @@ function applyWrite(core, clientId, payload) {
493
1055
  core.log("warn", `WRITE from ${clientId} failed to decode:`, err);
494
1056
  return { ok: false };
495
1057
  }
1058
+ let ops = 0;
1059
+ for (const dc of delta.collections) ops += dc.ops.length;
1060
+ if (!admitWrite(core, clientId, core.host.now(), ops)) return { ok: true };
496
1061
  const judged = core.clients.get(clientId);
497
1062
  if (judged && delta.tick > judged.lastClientTick) {
498
1063
  judged.lastClientTick = delta.tick;
@@ -513,8 +1078,19 @@ function reject(core, clientId, c, op, reason) {
513
1078
  correctLeaves(core, clientId, c.name, op.id, leafPaths(fieldList(c), op.mask));
514
1079
  }
515
1080
  function applyOp(core, clientId, c, op) {
516
- if (op.op !== "update") {
517
- reject(core, clientId, c, op, `op '${op.op}' is not allowed in a WRITE`);
1081
+ if (op.op === "add" || op.op === "remove") {
1082
+ if (c.kind !== "entity" || c.serverOwned || !c.clientCreate) {
1083
+ reject(
1084
+ core,
1085
+ clientId,
1086
+ c,
1087
+ op,
1088
+ `op '${op.op}' is not allowed in a WRITE (${c.name} does not declare clientCreate)`
1089
+ );
1090
+ return;
1091
+ }
1092
+ if (op.op === "add") applyAdd(core, clientId, c, op);
1093
+ else applyRemove(core, clientId, c, op);
518
1094
  return;
519
1095
  }
520
1096
  if (c.kind !== "entity") {
@@ -537,26 +1113,28 @@ function applyOp(core, clientId, c, op) {
537
1113
  return;
538
1114
  }
539
1115
  const fields = fieldList(c);
1116
+ const growth = updateGrowth(c, leafPaths(fields, op.mask));
1117
+ if (growth > 0 && !admitGrowth(core, clientId, growth)) return;
540
1118
  const prev = cloneValue(current);
541
1119
  const next = cloneValue(current);
542
1120
  applyMaskedPatch(fields, next, op.patch, op.mask);
543
1121
  const requested = cloneValue(next);
544
1122
  const simulated = c.physics;
545
1123
  if (simulated) {
546
- const refused = [];
1124
+ const refused2 = [];
547
1125
  for (const leaf of leafPaths(fields, op.mask)) {
548
1126
  const field = leaf.names[0];
549
1127
  if (simulated.intents.includes(field)) continue;
550
1128
  next[field] = cloneValue(prev[field]);
551
- refused.push(
1129
+ refused2.push(
552
1130
  simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`
553
1131
  );
554
1132
  }
555
- if (refused.length > 0) {
556
- core.recordEvent("write-rejected", clientId, `${c.name}: ${refused.join(", ")}`);
1133
+ if (refused2.length > 0) {
1134
+ core.recordEvent("write-rejected", clientId, `${c.name}: ${refused2.join(", ")}`);
557
1135
  core.log(
558
1136
  "warn",
559
- `WRITE to ${c.name}.${op.id} from ${clientId} touched fields the owner does not write: ${refused.join(", ")}`
1137
+ `WRITE to ${c.name}.${op.id} from ${clientId} touched fields the owner does not write: ${refused2.join(", ")}`
560
1138
  );
561
1139
  }
562
1140
  }
@@ -594,6 +1172,66 @@ function applyOp(core, clientId, c, op) {
594
1172
  );
595
1173
  correctLeaves(core, clientId, c.name, op.id, corrected);
596
1174
  }
1175
+ function applyAdd(core, clientId, c, op) {
1176
+ const plainColl = plainEntity(core.plain, c.name);
1177
+ if (plainColl.has(op.id)) {
1178
+ reject(core, clientId, c, op, `id ${JSON.stringify(op.id)} already exists`);
1179
+ return;
1180
+ }
1181
+ const cap = recordCap(core);
1182
+ if (plainColl.size >= cap) {
1183
+ refuseByName(core, clientId, "E_RECORD_CAP", { entity: c.name, limit: cap });
1184
+ return;
1185
+ }
1186
+ if (!admitGrowth(core, clientId, maxRecordBytes(c))) return;
1187
+ try {
1188
+ trackedEntity(core, c.name).add(op.id, op.value, { owner: clientId });
1189
+ } catch (err) {
1190
+ reject(core, clientId, c, op, `add rejected: ${String(err)}`);
1191
+ return;
1192
+ }
1193
+ core.recordEvent("write", clientId, `${c.name}: added ${op.id}`);
1194
+ }
1195
+ function applyRemove(core, clientId, c, op) {
1196
+ const plainColl = plainEntity(core.plain, c.name);
1197
+ if (!plainColl.has(op.id)) return;
1198
+ if (plainColl.ownerOf(op.id) !== clientId) {
1199
+ reject(core, clientId, c, op, "not the owner");
1200
+ return;
1201
+ }
1202
+ trackedEntity(core, c.name).remove(op.id);
1203
+ core.recordEvent("write", clientId, `${c.name}: removed ${op.id}`);
1204
+ }
1205
+ function updateGrowth(c, leaves) {
1206
+ let growth = 0;
1207
+ for (const leaf of leaves) {
1208
+ const first = leaf.path[0];
1209
+ if (first === void 0) continue;
1210
+ const field = c.fields[first];
1211
+ if (!field) continue;
1212
+ const bound = maxTypeBytes(field.type);
1213
+ if (isFixedWidth(field.type)) continue;
1214
+ growth += bound;
1215
+ }
1216
+ return growth;
1217
+ }
1218
+ function isFixedWidth(t) {
1219
+ switch (t.kind) {
1220
+ case "bool":
1221
+ case "u8":
1222
+ case "u16":
1223
+ case "u32":
1224
+ case "i32":
1225
+ case "f32":
1226
+ case "f64":
1227
+ case "enum":
1228
+ return t.opt !== true;
1229
+ case "struct":
1230
+ return Object.values(t.fields).every(isFixedWidth) && t.opt !== true;
1231
+ default:
1232
+ return false;
1233
+ }
1234
+ }
597
1235
  function serverWinsCorrections(core, dirty) {
598
1236
  const ext = core.ext;
599
1237
  for (const [name, cd] of dirty) {
@@ -604,7 +1242,7 @@ function serverWinsCorrections(core, dirty) {
604
1242
  const fields = fieldList(c);
605
1243
  for (const [id, rd] of cd.updated) {
606
1244
  const owner = coll.ownerOf(id);
607
- if (owner === void 0 || owner === SERVER_OWNER2) continue;
1245
+ if (owner === void 0 || owner === SERVER_OWNER3) continue;
608
1246
  const entry = core.clients.get(owner);
609
1247
  if (!entry || !entry.connected) continue;
610
1248
  const value = coll.get(id);
@@ -700,8 +1338,8 @@ var Loop = class {
700
1338
  }
701
1339
  /** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
702
1340
  applyFrame(f) {
703
- if (f.type === FrameType2.WRITE) return applyWrite(this.core, f.clientId, f.payload).ok;
704
- if (f.type === FrameType2.CALL) return handleCall(this.core, f.clientId, f.payload);
1341
+ if (f.type === FrameType3.WRITE) return applyWrite(this.core, f.clientId, f.payload).ok;
1342
+ if (f.type === FrameType3.CALL) return handleCall(this.core, f.clientId, f.payload);
705
1343
  return true;
706
1344
  }
707
1345
  // -------------------------------------------------------------------------
@@ -2088,7 +2726,7 @@ import {
2088
2726
  createDirtySet,
2089
2727
  createFieldMask,
2090
2728
  encodeDelta as encodeDelta2,
2091
- encodeSnapshot,
2729
+ encodeSnapshot as encodeSnapshot2,
2092
2730
  filterDirty,
2093
2731
  isDirtyEmpty,
2094
2732
  markAdd,
@@ -2195,7 +2833,7 @@ function viewKeyFor(ext, role) {
2195
2833
  return infoOf(ext).scoped.has(role) ? role : "all";
2196
2834
  }
2197
2835
  function encodeViewSnapshot(ext, plain, role, tick, memberships) {
2198
- return encodeSnapshot(
2836
+ return encodeSnapshot2(
2199
2837
  ext,
2200
2838
  plain,
2201
2839
  { tick },
@@ -2325,7 +2963,7 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
2325
2963
  import { withBuiltins } from "@irtio/protocol";
2326
2964
  import { ByteReader as ByteReader3, ByteWriter as ByteWriter3, decodeSnapshot } from "@irtio/schema";
2327
2965
  var SNAPSHOT_FORMAT_VERSION = 3;
2328
- var READABLE_SNAPSHOT_VERSIONS = [1, 2, 3];
2966
+ var READABLE_SNAPSHOT_VERSIONS = [1, 2, 3, 4];
2329
2967
  function parseHibernationBlob(bytes) {
2330
2968
  const r = new ByteReader3(bytes);
2331
2969
  const version = r.u8();
@@ -2342,7 +2980,12 @@ function parseHibernationBlob(bytes) {
2342
2980
  const section = r.blob();
2343
2981
  if (section.length > 0) physics = section;
2344
2982
  }
2345
- return { version, seed, rngState, tick, mode, schemaHash8, physics, snapshot: r.rest() };
2983
+ let holds;
2984
+ if (version >= 4) {
2985
+ const section = r.blob();
2986
+ if (section.length > 0) holds = section;
2987
+ }
2988
+ return { version, seed, rngState, tick, mode, schemaHash8, physics, holds, snapshot: r.rest() };
2346
2989
  }
2347
2990
  function sameSchemaHash(a, b) {
2348
2991
  if (!a || !b) return true;
@@ -2350,10 +2993,10 @@ function sameSchemaHash(a, b) {
2350
2993
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2351
2994
  return true;
2352
2995
  }
2353
- function writeHibernationBlob(header, snapshot, physics) {
2996
+ function writeHibernationBlob(header, snapshot, physics, holds) {
2354
2997
  const hash = header.schemaHash8;
2355
- const version = hash ? 3 : physics ? 2 : 1;
2356
- const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 32);
2998
+ const version = holds && holds.length > 0 ? 4 : hash ? 3 : physics ? 2 : 1;
2999
+ const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + (holds?.length ?? 0) + 40);
2357
3000
  w.u8(version);
2358
3001
  w.u32(header.seed >>> 0);
2359
3002
  w.u32(header.rngState >>> 0);
@@ -2361,6 +3004,7 @@ function writeHibernationBlob(header, snapshot, physics) {
2361
3004
  w.u8(header.mode === "event" ? 1 : 0);
2362
3005
  if (hash) w.bytes(hash);
2363
3006
  if (version >= 2) w.blob(physics ?? new Uint8Array(0));
3007
+ if (version >= 4) w.blob(holds ?? new Uint8Array(0));
2364
3008
  w.bytes(snapshot);
2365
3009
  return w.finish();
2366
3010
  }
@@ -2434,23 +3078,23 @@ var TimelineRecorder = class {
2434
3078
 
2435
3079
  // src/core/room.ts
2436
3080
  import {
2437
- FrameType as FrameType5,
3081
+ FrameType as FrameType6,
2438
3082
  PRESENCE_COLLECTION,
2439
3083
  ProfileLedger,
2440
3084
  busChannelProblem as busChannelProblem2,
2441
3085
  decodeFrame,
2442
3086
  encodeCorrectFrame,
2443
- encodeFrame as encodeFrame4,
3087
+ encodeFrame as encodeFrame5,
2444
3088
  withBuiltins as withBuiltins2
2445
3089
  } from "@irtio/protocol";
2446
3090
  import {
2447
3091
  cloneValue as cloneValue2,
2448
3092
  createDirtySet as createDirtySet2,
2449
3093
  createState,
2450
- decodeSnapshot as decodeSnapshot2,
2451
- encodeDelta as encodeDelta4,
2452
- encodeSnapshot as encodeSnapshot2,
2453
- isDirtyEmpty as isDirtyEmpty3,
3094
+ decodeSnapshot as decodeSnapshot3,
3095
+ encodeDelta as encodeDelta5,
3096
+ encodeSnapshot as encodeSnapshot4,
3097
+ isDirtyEmpty as isDirtyEmpty4,
2454
3098
  track,
2455
3099
  validateForDeploy
2456
3100
  } from "@irtio/schema";
@@ -2555,7 +3199,7 @@ var PoseHistory = class {
2555
3199
  const scratch = spec.scratch;
2556
3200
  scratch.fill(0);
2557
3201
  try {
2558
- spec.read(instance, scratch);
3202
+ spec.read(instance, scratch, id);
2559
3203
  entry.channels.set(scratch, o2);
2560
3204
  } catch (err) {
2561
3205
  if (!this.hookFailed.has(collection)) {
@@ -2709,7 +3353,7 @@ function historyOf(config) {
2709
3353
  }
2710
3354
  return { depth, channels };
2711
3355
  }
2712
- function keyOf(collection, id) {
3356
+ function keyOf2(collection, id) {
2713
3357
  return `${collection} ${id}`;
2714
3358
  }
2715
3359
  function angleOf(qz, qw) {
@@ -2734,7 +3378,7 @@ var BaseScratch = class {
2734
3378
  this.sync();
2735
3379
  this.present.clear();
2736
3380
  for (let i = 0; i < entry.count; i++) {
2737
- const key = keyOf(entry.collections[i], entry.ids[i]);
3381
+ const key = keyOf2(entry.collections[i], entry.ids[i]);
2738
3382
  const e = this.bodies.get(key);
2739
3383
  if (!e) continue;
2740
3384
  poseAt(entry, i, this.pose);
@@ -2760,7 +3404,7 @@ var BaseScratch = class {
2760
3404
  const now = this.tickNow();
2761
3405
  const seen = /* @__PURE__ */ new Set();
2762
3406
  this.physics.eachTrackedBody((collection, id, body) => {
2763
- const key = keyOf(collection, id);
3407
+ const key = keyOf2(collection, id);
2764
3408
  seen.add(key);
2765
3409
  const signature = this.signatureOf(body);
2766
3410
  const existing = this.bodies.get(key);
@@ -3205,13 +3849,13 @@ function lobbyConfigOf(core) {
3205
3849
  if (!schemaHasLobby(core.definition.schema)) return void 0;
3206
3850
  return declared;
3207
3851
  }
3208
- function stateOf3(core) {
3852
+ function stateOf5(core) {
3209
3853
  return core.anyState[LOBBY_STATE];
3210
3854
  }
3211
3855
  function evaluateLobby(core) {
3212
3856
  const config = lobbyConfigOf(core);
3213
3857
  if (config === void 0) return;
3214
- const state = stateOf3(core);
3858
+ const state = stateOf5(core);
3215
3859
  if (state === void 0) return;
3216
3860
  const members = trackedEntity(core, LOBBY_MEMBERS);
3217
3861
  const policy = config.start ?? "when-full";
@@ -3250,7 +3894,7 @@ function evaluateLobby(core) {
3250
3894
  function startLobby(core, why) {
3251
3895
  const runtime = runtimeOf(core);
3252
3896
  if (runtime.started) return;
3253
- const state = stateOf3(core);
3897
+ const state = stateOf5(core);
3254
3898
  if (state === void 0 || state.phase === "started") {
3255
3899
  runtime.started = true;
3256
3900
  return;
@@ -3273,7 +3917,7 @@ function makeLobby(core) {
3273
3917
  "room.lobby: this room has no lobby. Spread lobbyCollections into the schema and declare lobby: {} in defineRoom \u2014 both are needed, and defineRoom refuses either one alone"
3274
3918
  );
3275
3919
  }
3276
- const state = stateOf3(core);
3920
+ const state = stateOf5(core);
3277
3921
  const members = trackedEntity(core, LOBBY_MEMBERS);
3278
3922
  return {
3279
3923
  get phase() {
@@ -3311,7 +3955,7 @@ function makeLobby(core) {
3311
3955
  function setLobbyPublic(core, value) {
3312
3956
  const config = lobbyConfigOf(core);
3313
3957
  if (config === void 0) return;
3314
- const state = stateOf3(core);
3958
+ const state = stateOf5(core);
3315
3959
  if (state === void 0) return;
3316
3960
  if (state.phase === "started") {
3317
3961
  core.log(
@@ -3330,7 +3974,15 @@ function setLobbyPublic(core, value) {
3330
3974
  }
3331
3975
 
3332
3976
  // src/core/messages.ts
3333
- import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
3977
+ import {
3978
+ FrameType as FrameType4,
3979
+ decodeChatMsg,
3980
+ decodeMsg,
3981
+ encodeChatMsg,
3982
+ encodeFrame as encodeFrame3,
3983
+ encodeMsg,
3984
+ isChatMsg
3985
+ } from "@irtio/protocol";
3334
3986
  import { decodeFields as decodeFields2 } from "@irtio/schema";
3335
3987
  function toRoomTarget(target) {
3336
3988
  switch (target.kind) {
@@ -3378,6 +4030,7 @@ function decodeTyped(core, clientId, index, payload) {
3378
4030
  }
3379
4031
  function handleMsg(core, clientId, payload) {
3380
4032
  core.recordEvent("msg", clientId);
4033
+ if (isChatMsg(payload)) return handleChat(core, clientId, payload);
3381
4034
  let msg;
3382
4035
  try {
3383
4036
  msg = decodeMsg(payload);
@@ -3410,8 +4063,8 @@ function handleMsg(core, clientId, payload) {
3410
4063
  if (!ran.ok || ran.value === false) return true;
3411
4064
  }
3412
4065
  if (msg.target.kind === "server") return true;
3413
- const frame = encodeFrame2(
3414
- FrameType3.MSG,
4066
+ const frame = encodeFrame3(
4067
+ FrameType4.MSG,
3415
4068
  encodeMsg({
3416
4069
  target: { kind: "client", clientId },
3417
4070
  payload: msg.payload,
@@ -3421,32 +4074,785 @@ function handleMsg(core, clientId, payload) {
3421
4074
  deliver(core, msg.target, frame, clientId);
3422
4075
  return true;
3423
4076
  }
4077
+ function handleChat(core, clientId, payload) {
4078
+ let chat;
4079
+ try {
4080
+ chat = decodeChatMsg(payload);
4081
+ } catch (err) {
4082
+ core.log("warn", `chat from ${clientId} failed to decode:`, err);
4083
+ return false;
4084
+ }
4085
+ if (chat.target.kind !== "all" && chat.target.kind !== "client") {
4086
+ core.stats.messagesDropped++;
4087
+ return true;
4088
+ }
4089
+ const onChat = core.definition.config.onChat;
4090
+ if (onChat) {
4091
+ const line = {
4092
+ from: clientId,
4093
+ text: chat.text,
4094
+ target: chat.target.kind === "all" ? "all" : chat.target.clientId
4095
+ };
4096
+ const ran = core.tryRun("onChat", () => onChat(core.anyState, line, core.ctxFor(clientId)));
4097
+ if (!ran.ok || ran.value === false) return true;
4098
+ }
4099
+ const frame = encodeFrame3(
4100
+ FrameType4.MSG,
4101
+ encodeChatMsg({ target: { kind: "client", clientId }, text: chat.text })
4102
+ );
4103
+ deliver(core, chat.target, frame, clientId);
4104
+ return true;
4105
+ }
3424
4106
  function wireTargetOf(target) {
3425
4107
  return target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
3426
4108
  }
3427
4109
  function sendMessage(core, target, bytes) {
3428
- const frame = encodeFrame2(
3429
- FrameType3.MSG,
4110
+ const frame = encodeFrame3(
4111
+ FrameType4.MSG,
3430
4112
  encodeMsg({ target: { kind: "server" }, payload: bytes })
3431
4113
  );
3432
4114
  deliver(core, wireTargetOf(target), frame);
3433
4115
  }
3434
4116
  function sendTypedMessage(core, index, target, payload) {
3435
- const frame = encodeFrame2(
3436
- FrameType3.MSG,
4117
+ const frame = encodeFrame3(
4118
+ FrameType4.MSG,
3437
4119
  encodeMsg({ target: { kind: "server" }, payload, typed: { index } })
3438
4120
  );
3439
4121
  deliver(core, wireTargetOf(target), frame);
3440
4122
  }
3441
4123
 
4124
+ // src/core/replay.ts
4125
+ import {
4126
+ CLIP_MAX_BYTES,
4127
+ CLIP_TTL_DEFAULT_MS,
4128
+ RECORDING_MAX_BYTES,
4129
+ clipExpiryEpochS,
4130
+ encodeClip,
4131
+ encodeRecordingChunk,
4132
+ parseRetention
4133
+ } from "@irtio/protocol";
4134
+ import {
4135
+ encodeDelta as encodeDelta3,
4136
+ encodeSnapshot as encodeSnapshot3,
4137
+ isDirtyEmpty as isDirtyEmpty2
4138
+ } from "@irtio/schema";
4139
+
4140
+ // src/core/replay-excerpt.ts
4141
+ import {
4142
+ applyDelta,
4143
+ decodeDelta as decodeDelta2,
4144
+ decodeSnapshot as decodeSnapshot2
4145
+ } from "@irtio/schema";
4146
+ var EXCERPT_MAX_SECONDS = 30;
4147
+ var EXCERPT_BURST = 4;
4148
+ var EXCERPT_COOLDOWN_MS = 1e3;
4149
+ var EXCERPT_MAX_BYTES = 4 * 1024 * 1024;
4150
+ var ExcerptTooLargeError = class extends Error {
4151
+ constructor(bytes) {
4152
+ super(`excerpt output passed ${EXCERPT_MAX_BYTES} bytes`);
4153
+ this.bytes = bytes;
4154
+ this.name = "ExcerptTooLargeError";
4155
+ }
4156
+ bytes;
4157
+ };
4158
+ function estimateValue(value) {
4159
+ if (typeof value === "string") return value.length + 2;
4160
+ if (Array.isArray(value)) {
4161
+ let n = 2;
4162
+ for (const v of value) n += estimateValue(v) + 1;
4163
+ return n;
4164
+ }
4165
+ if (typeof value === "object" && value !== null) {
4166
+ let n = 2;
4167
+ for (const [k, v] of Object.entries(value)) {
4168
+ n += k.length + 3 + estimateValue(v);
4169
+ }
4170
+ return n;
4171
+ }
4172
+ return 8;
4173
+ }
4174
+ function copyValue(value) {
4175
+ if (Array.isArray(value)) return value.map(copyValue);
4176
+ if (typeof value === "object" && value !== null) {
4177
+ const out = {};
4178
+ for (const [k, v] of Object.entries(value)) out[k] = copyValue(v);
4179
+ return out;
4180
+ }
4181
+ return value;
4182
+ }
4183
+ function projectTick(state, wanted, ids) {
4184
+ const entities = {};
4185
+ let bytes = 0;
4186
+ for (const desc of wanted) {
4187
+ const coll = state[desc.name];
4188
+ if (coll === void 0) continue;
4189
+ const rows = {};
4190
+ let any = false;
4191
+ for (const id of [...coll.ids()]) {
4192
+ if (ids !== void 0 && !ids.has(id)) continue;
4193
+ const record = coll.get(id);
4194
+ if (record === void 0) continue;
4195
+ const row = {};
4196
+ for (const field of desc.fields) {
4197
+ const v = record[field.name];
4198
+ if (v === void 0) continue;
4199
+ row[field.name] = copyValue(v);
4200
+ bytes += field.name.length + 3 + estimateValue(v);
4201
+ }
4202
+ bytes += id.length + 4;
4203
+ rows[id] = row;
4204
+ any = true;
4205
+ }
4206
+ if (any) {
4207
+ bytes += desc.name.length + 4;
4208
+ entities[desc.name] = rows;
4209
+ }
4210
+ }
4211
+ return { entities, bytes };
4212
+ }
4213
+ function wantedCollections(ext, names) {
4214
+ const all = ext.collections.filter((c) => c.kind === "entity");
4215
+ if (names === void 0) return [...all];
4216
+ const wanted = new Set(names);
4217
+ return all.filter((c) => wanted.has(c.name));
4218
+ }
4219
+ function unknownCollection(ext, options) {
4220
+ const names = options?.collections;
4221
+ if (names === void 0) return void 0;
4222
+ const known = new Set(
4223
+ ext.collections.filter((c) => c.kind === "entity").map((c) => c.name)
4224
+ );
4225
+ for (const name of names) {
4226
+ if (!known.has(name)) return String(name);
4227
+ }
4228
+ return void 0;
4229
+ }
4230
+ function walkExcerpt(ext, ring, tickRate, seconds, options) {
4231
+ const ringFrames = ring.frames;
4232
+ const last = ringFrames[ringFrames.length - 1];
4233
+ if (last === void 0) return { startTick: 0, endTick: 0, tickRate, frames: [] };
4234
+ const from = last.tick - Math.ceil(seconds * tickRate);
4235
+ const window = ring.tail(from);
4236
+ const head = window[0];
4237
+ if (head === void 0 || !head.keyframe) {
4238
+ return { startTick: 0, endTick: 0, tickRate, frames: [] };
4239
+ }
4240
+ const wanted = wantedCollections(ext, options?.collections);
4241
+ const ids = options?.ids === void 0 ? void 0 : new Set(options.ids);
4242
+ const state = decodeSnapshot2(ext, head.bytes).state;
4243
+ const frames = [];
4244
+ let bytes = 0;
4245
+ const emit = (tick) => {
4246
+ if (tick < from) return;
4247
+ const { entities, bytes: n } = projectTick(state, wanted, ids);
4248
+ bytes += n;
4249
+ if (bytes > EXCERPT_MAX_BYTES) throw new ExcerptTooLargeError(bytes);
4250
+ frames.push({ tick, entities });
4251
+ };
4252
+ if (window[1]?.tick !== head.tick) emit(head.tick);
4253
+ for (let i = 1; i < window.length; i++) {
4254
+ const f = window[i];
4255
+ if (f.keyframe) continue;
4256
+ applyDelta(ext, state, decodeDelta2(ext, f.bytes));
4257
+ emit(f.tick);
4258
+ }
4259
+ const first = frames[0];
4260
+ return {
4261
+ startTick: first?.tick ?? head.tick,
4262
+ endTick: frames[frames.length - 1]?.tick ?? head.tick,
4263
+ tickRate,
4264
+ frames
4265
+ };
4266
+ }
4267
+
4268
+ // src/core/replay.ts
4269
+ var REPLAY_DEFAULT_SECONDS = 60;
4270
+ var REPLAY_MAX_SECONDS = 300;
4271
+ var REPLAY_RING_MAX_BYTES = 8 * 1024 * 1024;
4272
+ var REPLAY_KEYFRAME_SECONDS = 2;
4273
+ var CLIP_COOLDOWN_MS = 1e3;
4274
+ var CLIP_BURST = 8;
4275
+ var ReplayRing = class {
4276
+ /**
4277
+ * M6 record packet: `onEvict` is handed every segment the ring drops, oldest first, at the
4278
+ * moment it drops it — which is the whole of what a recording is. It is optional and unset in
4279
+ * every room that did not ask to record, so the ring a clip-only room runs is the ring it ran
4280
+ * before this packet, allocation for allocation.
4281
+ *
4282
+ * A *wipe* deliberately does not call it. A wipe is one segment that outgrew the entire ring, so
4283
+ * emitting it would mean writing a chunk bigger than the bound the ring exists to keep. For a
4284
+ * clip that is "holds less footage than it asked for"; for a recording it is not, because the
4285
+ * frames after the wipe are deltas whose keyframe was never written, and playback skips
4286
+ * mid-stream keyframes rather than re-basing on them. So a wipe is not silently less footage: it
4287
+ * ENDS the recording (`append` returns `true` and the flush tap stops it), which turns wrong
4288
+ * footage into short footage.
4289
+ */
4290
+ constructor(bounds, onEvict) {
4291
+ this.bounds = bounds;
4292
+ this.onEvict = onEvict;
4293
+ }
4294
+ bounds;
4295
+ onEvict;
4296
+ items = [];
4297
+ total = 0;
4298
+ /** How many times an oversized segment has emptied the ring. Read through `append`'s return. */
4299
+ wipes = 0;
4300
+ /** The tick the newest keyframe went in at; `-1` while the ring is empty. */
4301
+ lastKeyframeTick = -1;
4302
+ get frames() {
4303
+ return this.items;
4304
+ }
4305
+ get bytes() {
4306
+ return this.total;
4307
+ }
4308
+ get empty() {
4309
+ return this.items.length === 0;
4310
+ }
4311
+ /** How many oversized segments have emptied the ring. A test's handle on the degenerate case. */
4312
+ get wipeCount() {
4313
+ return this.wipes;
4314
+ }
4315
+ /**
4316
+ * Appends a frame and trims. Returns `true` when the trim had to throw the *whole* ring away
4317
+ * (an oversized segment with nowhere to cut), which the caller has to know about: the ring is
4318
+ * now empty, so anything it was about to append next would head the ring without a keyframe.
4319
+ */
4320
+ append(frame) {
4321
+ this.items.push(frame);
4322
+ this.total += frame.bytes.length;
4323
+ if (frame.keyframe) this.lastKeyframeTick = frame.tick;
4324
+ const before = this.wipes;
4325
+ this.trim();
4326
+ return this.wipes !== before;
4327
+ }
4328
+ /** Is a keyframe due at `tick`? Always true while the ring is empty. */
4329
+ keyframeDue(tick) {
4330
+ return this.empty || tick - this.lastKeyframeTick >= this.bounds.keyframeEvery;
4331
+ }
4332
+ /** The frames from the newest keyframe at or before `fromTick` to the end. */
4333
+ tail(fromTick) {
4334
+ let start = 0;
4335
+ for (let i = 0; i < this.items.length; i++) {
4336
+ const f = this.items[i];
4337
+ if (f.keyframe && f.tick <= fromTick) start = i;
4338
+ }
4339
+ return this.items.slice(start);
4340
+ }
4341
+ trim() {
4342
+ const last = this.items[this.items.length - 1];
4343
+ if (!last) return;
4344
+ for (; ; ) {
4345
+ const head = this.items[0];
4346
+ if (!head) return;
4347
+ const overBytes = this.total > this.bounds.maxBytes;
4348
+ const overWindow = last.tick - head.tick > this.bounds.windowTicks;
4349
+ if (!overBytes && !overWindow) return;
4350
+ let next = -1;
4351
+ for (let i = 1; i < this.items.length; i++) {
4352
+ if (this.items[i]?.keyframe === true) {
4353
+ next = i;
4354
+ break;
4355
+ }
4356
+ }
4357
+ if (next === -1) {
4358
+ if (!overBytes) return;
4359
+ this.items = [];
4360
+ this.total = 0;
4361
+ this.lastKeyframeTick = -1;
4362
+ this.wipes++;
4363
+ return;
4364
+ }
4365
+ for (let i = 0; i < next; i++) this.total -= this.items[i].bytes.length;
4366
+ const evicted = this.items.slice(0, next);
4367
+ this.items = this.items.slice(next);
4368
+ this.onEvict?.(evicted);
4369
+ }
4370
+ }
4371
+ };
4372
+ var RECORD_QUEUE_MAX = 2;
4373
+ var states5 = /* @__PURE__ */ new WeakMap();
4374
+ function configOf(core) {
4375
+ const config = core.definition.config;
4376
+ return config.replay;
4377
+ }
4378
+ function replayArmed(core) {
4379
+ return configOf(core) !== void 0;
4380
+ }
4381
+ function stateOf6(core) {
4382
+ const declared = configOf(core);
4383
+ if (declared === void 0) return void 0;
4384
+ let s = states5.get(core);
4385
+ if (!s) {
4386
+ const tickRate = core.definition.config.tickRate ?? 20;
4387
+ const seconds = Math.min(
4388
+ REPLAY_MAX_SECONDS,
4389
+ Math.max(1, Math.floor(declared.seconds ?? REPLAY_DEFAULT_SECONDS))
4390
+ );
4391
+ const record = declared.record === true;
4392
+ const outbox = [];
4393
+ const ttlMs = parseRetention(declared.ttl) ?? CLIP_TTL_DEFAULT_MS;
4394
+ s = {
4395
+ seconds,
4396
+ ttlMs,
4397
+ recExpiresAtEpochS: void 0,
4398
+ view: declared.view ?? "",
4399
+ ring: new ReplayRing(
4400
+ {
4401
+ windowTicks: Math.max(1, Math.ceil(seconds * tickRate)),
4402
+ keyframeEvery: Math.max(1, Math.round(REPLAY_KEYFRAME_SECONDS * tickRate)),
4403
+ maxBytes: REPLAY_RING_MAX_BYTES
4404
+ },
4405
+ record ? (frames) => outbox.push(...frames) : void 0
4406
+ ),
4407
+ clipTokens: CLIP_BURST,
4408
+ lastRefillAt: Number.NEGATIVE_INFINITY,
4409
+ clipsTaken: 0,
4410
+ excerptTokens: EXCERPT_BURST,
4411
+ lastExcerptRefillAt: Number.NEGATIVE_INFINITY,
4412
+ excerptsTaken: 0,
4413
+ disarmed: false,
4414
+ record,
4415
+ outbox,
4416
+ queued: [],
4417
+ recId: void 0,
4418
+ nextSeq: 0,
4419
+ encoded: 0,
4420
+ recBytes: 0,
4421
+ inFlight: false,
4422
+ recStopped: false,
4423
+ recWarned: false,
4424
+ recExpiryWarned: false,
4425
+ finalDrained: false,
4426
+ dropInFlight: false,
4427
+ chunksOut: 0
4428
+ };
4429
+ states5.set(core, s);
4430
+ }
4431
+ return s;
4432
+ }
4433
+ function omniscientMemberships(core) {
4434
+ const out = /* @__PURE__ */ new Map();
4435
+ for (const desc of core.ext.collections) {
4436
+ if (desc.visibility !== "spatial-grid") continue;
4437
+ out.set(desc.name, new Set(plainEntity(core.plain, desc.name).ids()));
4438
+ }
4439
+ return out;
4440
+ }
4441
+ function hasRoleScoped(core) {
4442
+ return core.ext.collections.some((c) => c.visibility === "role");
4443
+ }
4444
+ function hasSpatial(core) {
4445
+ return core.ext.collections.some(
4446
+ (c) => c.visibility === "spatial-grid"
4447
+ );
4448
+ }
4449
+ function recorderSnapshot(core, state) {
4450
+ if (state.view === "") {
4451
+ return encodeSnapshot3(core.ext, core.plain, { tick: core.tick });
4452
+ }
4453
+ return encodeViewSnapshot(
4454
+ core.ext,
4455
+ core.plain,
4456
+ state.view,
4457
+ core.tick,
4458
+ omniscientMemberships(core)
4459
+ );
4460
+ }
4461
+ function recorderDelta(core, state, dirty) {
4462
+ if (state.view === "") {
4463
+ if (isDirtyEmpty2(dirty)) return null;
4464
+ return encodeDelta3(core.ext, core.plain, dirty, { tick: core.tick });
4465
+ }
4466
+ return encodeViewDelta(core.ext, core.plain, dirty, state.view, core.tick);
4467
+ }
4468
+ function cacheKeyFor(core, state) {
4469
+ if (hasSpatial(core)) return void 0;
4470
+ if (state.view === "") return hasRoleScoped(core) ? void 0 : "all";
4471
+ return viewKeyFor(core.ext, state.view);
4472
+ }
4473
+ function recordFlush(core, dirty, byView) {
4474
+ const state = stateOf6(core);
4475
+ if (!state || state.disarmed) return;
4476
+ appendFlush(core, state, dirty, byView);
4477
+ if (state.record) pumpDrain(core, state);
4478
+ }
4479
+ function appendFlush(core, state, dirty, byView) {
4480
+ const ring = state.ring;
4481
+ if (!ring.empty && isDirtyEmpty2(dirty)) return;
4482
+ if (ring.keyframeDue(core.tick)) {
4483
+ const wiped2 = ring.append({
4484
+ tick: core.tick,
4485
+ keyframe: true,
4486
+ bytes: recorderSnapshot(core, state)
4487
+ });
4488
+ if (wiped2) {
4489
+ disarm(core, state);
4490
+ return;
4491
+ }
4492
+ }
4493
+ const key = cacheKeyFor(core, state);
4494
+ const cached = key === void 0 ? void 0 : byView.get(key);
4495
+ const delta = cached ?? recorderDelta(core, state, dirty);
4496
+ if (!delta) return;
4497
+ const wiped = ring.append({ tick: core.tick, keyframe: false, bytes: delta });
4498
+ if (wiped && state.record) {
4499
+ stopRecording(
4500
+ core,
4501
+ state,
4502
+ `one replay segment was larger than the whole ${REPLAY_RING_MAX_BYTES}-byte ring, so the ring was emptied and the footage after it could not be re-based`
4503
+ );
4504
+ }
4505
+ }
4506
+ function disarm(core, state) {
4507
+ state.disarmed = true;
4508
+ if (state.record) stopRecording(core, state, "the replay ring disarmed");
4509
+ core.host.log("warn", [
4510
+ `irtio: room ${core.roomId}: a single replay keyframe is larger than the replay ring's ${REPLAY_RING_MAX_BYTES}-byte bound, so nothing could ever be held. Recording is off for the life of this room and room.replay.clip() will refuse. Record a narrower view (\`replay: { view: <role> }\`) or hold less state in the recorded collections.`
4511
+ ]);
4512
+ }
4513
+ function assembleClip(core, seconds) {
4514
+ const state = stateOf6(core);
4515
+ if (!state || state.disarmed || state.ring.empty) return void 0;
4516
+ const tickRate = core.definition.config.tickRate ?? 20;
4517
+ const want = Math.min(state.seconds, Math.max(1, Math.floor(seconds ?? state.seconds)));
4518
+ const ringFrames = state.ring.frames;
4519
+ const last = ringFrames[ringFrames.length - 1];
4520
+ const frames = state.ring.tail(last.tick - Math.ceil(want * tickRate)).map((f) => ({
4521
+ tick: f.tick,
4522
+ kind: f.keyframe ? "keyframe" : "delta",
4523
+ bytes: f.bytes
4524
+ }));
4525
+ const head = frames[0];
4526
+ if (head === void 0 || head.kind !== "keyframe") return void 0;
4527
+ const clip = {
4528
+ // The GAME schema's hash, not the extended one's. That is what a client puts in its HELLO,
4529
+ // so it is the value playback can actually compare against — and it is the hash a deploy
4530
+ // changes, which is the whole thing the pin exists to catch.
4531
+ schemaHash8: core.definition.schema.hash8,
4532
+ tickRate,
4533
+ startTick: head.tick,
4534
+ durationTicks: last.tick - head.tick,
4535
+ view: state.view,
4536
+ frames
4537
+ };
4538
+ return encodeClip(clip);
4539
+ }
4540
+ function takeClip(core, seconds) {
4541
+ if (!replayArmed(core)) {
4542
+ return rejectAsEvent(
4543
+ core,
4544
+ new Error(
4545
+ "room.replay.clip: this room type did not declare `replay:`, so nothing is being recorded. Add `replay: { seconds: 60 }` to the room config to arm the ring."
4546
+ )
4547
+ );
4548
+ }
4549
+ const state = stateOf6(core);
4550
+ const now = core.host.now();
4551
+ if (state.lastRefillAt === Number.NEGATIVE_INFINITY) state.lastRefillAt = now;
4552
+ const refilled = Math.floor((now - state.lastRefillAt) / CLIP_COOLDOWN_MS);
4553
+ if (refilled > 0) {
4554
+ state.clipTokens = Math.min(CLIP_BURST, state.clipTokens + refilled);
4555
+ state.lastRefillAt += refilled * CLIP_COOLDOWN_MS;
4556
+ }
4557
+ if (state.clipTokens <= 0) {
4558
+ return rejectAsEvent(
4559
+ core,
4560
+ new Error(
4561
+ `E_CLIP_COOLDOWN: room.replay.clip allows bursts of ${CLIP_BURST}, refilled one every ${CLIP_COOLDOWN_MS}ms per room; this room is out of clip tokens right now`
4562
+ )
4563
+ );
4564
+ }
4565
+ if (state.disarmed) {
4566
+ return rejectAsEvent(
4567
+ core,
4568
+ new Error(
4569
+ `E_CLIP_DISARMED: recording stopped in this room because a single replay keyframe was larger than the ${REPLAY_RING_MAX_BYTES}-byte replay ring; see the warning on this room's log`
4570
+ )
4571
+ );
4572
+ }
4573
+ let bytes;
4574
+ try {
4575
+ bytes = assembleClip(core, seconds);
4576
+ } catch (err) {
4577
+ return rejectAsEvent(
4578
+ core,
4579
+ new Error(`E_CLIP_ENCODE: the replay ring could not be encoded: ${String(err)}`)
4580
+ );
4581
+ }
4582
+ if (bytes === void 0) {
4583
+ return rejectAsEvent(
4584
+ core,
4585
+ new Error("E_CLIP_EMPTY: the replay ring is empty \u2014 this room has not flushed a change yet")
4586
+ );
4587
+ }
4588
+ if (bytes.length > CLIP_MAX_BYTES) {
4589
+ return rejectAsEvent(
4590
+ core,
4591
+ new Error(
4592
+ `E_CLIP_TOO_LARGE: this clip is ${bytes.length} bytes and the limit is ${CLIP_MAX_BYTES}`
4593
+ )
4594
+ );
4595
+ }
4596
+ state.clipTokens--;
4597
+ state.clipsTaken++;
4598
+ return startHostCall(
4599
+ core,
4600
+ { kind: "clip", bytes, expiresAtEpochS: clipExpiryEpochS(core.wallNow(), state.ttlMs) },
4601
+ (value) => ({ id: value ?? "" })
4602
+ );
4603
+ }
4604
+ function takeExcerpt(core, options) {
4605
+ if (!replayArmed(core)) {
4606
+ return rejectAsEvent(
4607
+ core,
4608
+ new Error(
4609
+ "E_REPLAY_UNARMED: room.replay.excerpt: this room type did not declare `replay:`, so nothing is being recorded. Add `replay: { seconds: 60 }` to the room config to arm the ring."
4610
+ )
4611
+ );
4612
+ }
4613
+ const seconds = options?.seconds;
4614
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) {
4615
+ return rejectAsEvent(
4616
+ core,
4617
+ new Error(
4618
+ "E_EXCERPT_BAD_ARGS: room.replay.excerpt needs { seconds } as a positive number, got " + JSON.stringify(seconds)
4619
+ )
4620
+ );
4621
+ }
4622
+ if (seconds > EXCERPT_MAX_SECONDS) {
4623
+ return rejectAsEvent(
4624
+ core,
4625
+ new Error(
4626
+ `E_EXCERPT_TOO_LONG: room.replay.excerpt decodes at most ${EXCERPT_MAX_SECONDS} seconds at a time and was asked for ${seconds}. An excerpt is a killcam, not a match; for a whole match use \`replay: { record: true }\`.`
4627
+ )
4628
+ );
4629
+ }
4630
+ const badName = unknownCollection(core.ext, options);
4631
+ if (badName !== void 0) {
4632
+ return rejectAsEvent(
4633
+ core,
4634
+ new Error(
4635
+ `E_EXCERPT_BAD_ARGS: room.replay.excerpt was asked for the collection ${JSON.stringify(
4636
+ badName
4637
+ )}, which is not an entity collection in this room's schema. Singleton collections are not excerptable.`
4638
+ )
4639
+ );
4640
+ }
4641
+ const state = stateOf6(core);
4642
+ if (state.disarmed) {
4643
+ return rejectAsEvent(
4644
+ core,
4645
+ new Error(
4646
+ `E_EXCERPT_DISARMED: recording stopped in this room because a single replay keyframe was larger than the ${REPLAY_RING_MAX_BYTES}-byte replay ring; see the warning on this room's log`
4647
+ )
4648
+ );
4649
+ }
4650
+ const now = core.host.now();
4651
+ if (state.lastExcerptRefillAt === Number.NEGATIVE_INFINITY) state.lastExcerptRefillAt = now;
4652
+ const refilled = Math.floor((now - state.lastExcerptRefillAt) / EXCERPT_COOLDOWN_MS);
4653
+ if (refilled > 0) {
4654
+ state.excerptTokens = Math.min(EXCERPT_BURST, state.excerptTokens + refilled);
4655
+ state.lastExcerptRefillAt += refilled * EXCERPT_COOLDOWN_MS;
4656
+ }
4657
+ if (state.excerptTokens <= 0) {
4658
+ return rejectAsEvent(
4659
+ core,
4660
+ new Error(
4661
+ `E_EXCERPT_COOLDOWN: room.replay.excerpt allows bursts of ${EXCERPT_BURST}, refilled one every ${EXCERPT_COOLDOWN_MS}ms per room; this room is out of excerpt tokens right now`
4662
+ )
4663
+ );
4664
+ }
4665
+ state.excerptTokens--;
4666
+ const tickRate = core.definition.config.tickRate ?? 20;
4667
+ let out;
4668
+ try {
4669
+ out = walkExcerpt(core.ext, state.ring, tickRate, seconds, options);
4670
+ } catch (err) {
4671
+ if (err instanceof ExcerptTooLargeError) {
4672
+ return rejectAsEvent(
4673
+ core,
4674
+ new Error(
4675
+ `E_EXCERPT_TOO_LARGE: this excerpt would materialize about ${err.bytes} bytes of frames and the limit is ${EXCERPT_MAX_BYTES}. Ask for fewer seconds, or name the collections (and ids) you actually need in \`collections\`/\`ids\`.`
4676
+ )
4677
+ );
4678
+ }
4679
+ return rejectAsEvent(
4680
+ core,
4681
+ new Error(`E_EXCERPT_DECODE: the replay ring could not be decoded: ${String(err)}`)
4682
+ );
4683
+ }
4684
+ state.excerptsTaken++;
4685
+ return resolveAsEvent(core, out);
4686
+ }
4687
+ var recordingCap = RECORDING_MAX_BYTES;
4688
+ function stopRecording(core, state, why) {
4689
+ if (state.recStopped) return;
4690
+ state.recStopped = true;
4691
+ state.outbox.length = 0;
4692
+ state.queued.length = 0;
4693
+ if (state.recWarned) return;
4694
+ state.recWarned = true;
4695
+ core.host.log("warn", [
4696
+ `irtio: room ${core.roomId}: full-match recording stopped (${why}). The chunks already written stay playable and room.replay.clip() still works; nothing further is recorded for the life of this room.`
4697
+ ]);
4698
+ }
4699
+ function pumpDrain(core, state) {
4700
+ if (state.recStopped) {
4701
+ state.outbox.length = 0;
4702
+ return;
4703
+ }
4704
+ if (state.outbox.length > 0) {
4705
+ const frames = state.outbox.splice(0, state.outbox.length);
4706
+ encodeAndQueue(core, state, frames);
4707
+ }
4708
+ sendNext(core, state);
4709
+ }
4710
+ function encodeAndQueue(core, state, frames) {
4711
+ if (frames.length === 0) return;
4712
+ if (state.recExpiresAtEpochS === void 0) {
4713
+ state.recExpiresAtEpochS = clipExpiryEpochS(core.wallNow(), state.ttlMs);
4714
+ }
4715
+ const first = frames[0];
4716
+ const header = state.encoded === 0 ? {
4717
+ // The GAME schema's hash, for `assembleClip`'s reason: it is what a client's HELLO
4718
+ // carries and what a deploy changes.
4719
+ schemaHash8: core.definition.schema.hash8,
4720
+ tickRate: core.definition.config.tickRate ?? 20,
4721
+ startTick: first.tick,
4722
+ view: state.view
4723
+ } : void 0;
4724
+ if (header !== void 0 && first.keyframe !== true) {
4725
+ stopRecording(core, state, "the first chunk would not have begun with a keyframe");
4726
+ return;
4727
+ }
4728
+ let bytes;
4729
+ try {
4730
+ bytes = encodeRecordingChunk(
4731
+ frames.map((f) => ({
4732
+ tick: f.tick,
4733
+ kind: f.keyframe ? "keyframe" : "delta",
4734
+ bytes: f.bytes
4735
+ })),
4736
+ header
4737
+ );
4738
+ } catch (err) {
4739
+ stopRecording(core, state, `the chunk could not be encoded: ${String(err)}`);
4740
+ return;
4741
+ }
4742
+ if (bytes.length === 0) return;
4743
+ if (state.recBytes + bytes.length > recordingCap) {
4744
+ stopRecording(
4745
+ core,
4746
+ state,
4747
+ `it reached the ${recordingCap}-byte per-recording limit after ${state.chunksOut} chunk(s)`
4748
+ );
4749
+ return;
4750
+ }
4751
+ state.encoded++;
4752
+ state.queued.push(bytes);
4753
+ if (state.queued.length > RECORD_QUEUE_MAX) {
4754
+ stopRecording(
4755
+ core,
4756
+ state,
4757
+ `the store is answering slower than the ring evicts: ${RECORD_QUEUE_MAX} chunk(s) were already waiting behind the one in flight, and dropping one would have left a gap nothing downstream could re-base on`
4758
+ );
4759
+ }
4760
+ }
4761
+ function sendNext(core, state, all = false) {
4762
+ if (state.recStopped && !state.finalDrained) return;
4763
+ const parallel = all && state.recId !== void 0;
4764
+ for (; ; ) {
4765
+ if (state.inFlight && !parallel) return;
4766
+ const bytes = state.queued.shift();
4767
+ if (bytes === void 0) return;
4768
+ sendChunk(core, state, bytes);
4769
+ if (!parallel) return;
4770
+ }
4771
+ }
4772
+ function warnRecordingOutlivedTtl(core, state) {
4773
+ if (state.recExpiryWarned) return;
4774
+ const expiresAtEpochS = state.recExpiresAtEpochS;
4775
+ if (expiresAtEpochS === void 0 || expiresAtEpochS * 1e3 > core.wallNow()) return;
4776
+ state.recExpiryWarned = true;
4777
+ core.host.log("warn", [
4778
+ `irtio: room ${core.roomId}: this full-match recording expired at ${expiresAtEpochS} (unix seconds) while it was still being written. A recording is kept for replay.ttl from its first stored chunk, so a ttl shorter than the match deletes the recording while the match is still running and its link answers 404. Set replay.ttl to at least the length of a match.`
4779
+ ]);
4780
+ }
4781
+ function sendChunk(core, state, bytes) {
4782
+ warnRecordingOutlivedTtl(core, state);
4783
+ const seq = state.nextSeq;
4784
+ state.nextSeq++;
4785
+ state.chunksOut++;
4786
+ state.recBytes += bytes.length;
4787
+ state.inFlight = true;
4788
+ startHostCall(
4789
+ core,
4790
+ {
4791
+ kind: "record-chunk",
4792
+ recId: state.recId,
4793
+ seq,
4794
+ bytes,
4795
+ // The first chunk's expiry, on every chunk. `encodeAndQueue` set it before anything could
4796
+ // reach here; the fallback is a value, not a second policy.
4797
+ expiresAtEpochS: state.recExpiresAtEpochS ?? clipExpiryEpochS(core.wallNow(), state.ttlMs)
4798
+ },
4799
+ (value) => value ?? ""
4800
+ ).then((id) => {
4801
+ state.inFlight = false;
4802
+ state.dropInFlight = false;
4803
+ if (state.recId === void 0 && id !== "") state.recId = id;
4804
+ sendNext(core, state, state.finalDrained);
4805
+ }).catch((err) => {
4806
+ state.inFlight = false;
4807
+ if (state.dropInFlight) {
4808
+ state.dropInFlight = false;
4809
+ if (state.recId === void 0) {
4810
+ stopRecording(
4811
+ core,
4812
+ state,
4813
+ "the reply carrying the recording's id was dropped before it arrived, and a second chunk would have been stored as a second recording"
4814
+ );
4815
+ return;
4816
+ }
4817
+ sendNext(core, state, state.finalDrained);
4818
+ return;
4819
+ }
4820
+ if (state.queued.length > 0) {
4821
+ const bytes2 = state.queued.reduce((n, b) => n + b.length, 0);
4822
+ core.host.log("warn", [
4823
+ `irtio: room ${core.roomId}: the full-match recording lost its last ${state.queued.length} chunk(s) (${bytes2} bytes). The room ended before the store answered the first chunk, so the recording's id was never known here and the tail had nowhere to go. Everything already stored plays.`
4824
+ ]);
4825
+ state.queued.length = 0;
4826
+ }
4827
+ stopRecording(core, state, `a chunk was not stored: ${String(err)}`);
4828
+ });
4829
+ }
4830
+ function drainRecordingFinal(core) {
4831
+ const state = states5.get(core);
4832
+ if (!state || !state.record || state.recStopped || state.finalDrained) return;
4833
+ state.finalDrained = true;
4834
+ const remaining = state.ring.frames;
4835
+ const frames = [...state.outbox.splice(0, state.outbox.length), ...remaining];
4836
+ if (frames.length > 0) encodeAndQueue(core, state, frames);
4837
+ sendNext(core, state, true);
4838
+ state.recStopped = true;
4839
+ }
4840
+ function recordingId(core) {
4841
+ return states5.get(core)?.recId;
4842
+ }
4843
+ function noteRecordingRepliesDropped(core) {
4844
+ const state = states5.get(core);
4845
+ if (state?.inFlight === true) state.dropInFlight = true;
4846
+ }
4847
+
3442
4848
  // src/core/room-api.ts
3443
4849
  import {
3444
- FrameType as FrameType4,
4850
+ FrameType as FrameType5,
3445
4851
  busChannelProblem,
3446
4852
  busPayloadProblem,
3447
- encodeFrame as encodeFrame3
4853
+ encodeFrame as encodeFrame4
3448
4854
  } from "@irtio/protocol";
3449
- import { encodeDelta as encodeDelta3, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
4855
+ import { encodeDelta as encodeDelta4, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty3 } from "@irtio/schema";
3450
4856
  function makePhysicsApi(p) {
3451
4857
  return {
3452
4858
  get rapier() {
@@ -3796,6 +5202,30 @@ function createRoomApi(core, publicUrl) {
3796
5202
  get lobby() {
3797
5203
  return makeLobby(core);
3798
5204
  },
5205
+ // ---- M6 lane N: replays ----
5206
+ // D81: one getter, and deliberately only one. Everything replays do — the ring, the
5207
+ // eviction invariant, the recorder view, the clip assembly, the cooldown — lives in
5208
+ // `core/replay.ts` behind these two calls, so this file stays what it says it is: a facade
5209
+ // that reads through.
5210
+ get replay() {
5211
+ return {
5212
+ get armed() {
5213
+ return replayArmed(core);
5214
+ },
5215
+ clip: (seconds) => takeClip(core, seconds),
5216
+ // M7 planned feature 5: the ring read back as plain frames. Same read-through shape as
5217
+ // `clip` — the walk, the projection and the whole budget live in `core/replay-excerpt.ts`
5218
+ // and `core/replay.ts`.
5219
+ excerpt: (options) => takeExcerpt(core, options),
5220
+ // M6 record packet: the id of the recording being written, once the first chunk has been
5221
+ // stored. A getter, like `armed`, so a game reading it late in a match sees the id rather
5222
+ // than the `undefined` it would have captured at the start of one.
5223
+ get recording() {
5224
+ return recordingId(core);
5225
+ }
5226
+ };
5227
+ },
5228
+ // ---- end M6 lane N ----
3799
5229
  alarm(name, atMs) {
3800
5230
  if (!isAlarmName(core, name, "room.alarm")) return;
3801
5231
  if (!Number.isFinite(atMs)) {
@@ -3878,10 +5308,10 @@ function setRole(core, clientId, role) {
3878
5308
  }
3879
5309
  if (entry.role === role) return;
3880
5310
  const dirty = catchUpDirty(core.ext, core.plain, entry.role, role);
3881
- if (!isDirtyEmpty2(dirty)) {
5311
+ if (!isDirtyEmpty3(dirty)) {
3882
5312
  core.send(
3883
5313
  clientId,
3884
- encodeFrame3(FrameType4.DELTA, encodeDelta3(core.ext, core.plain, dirty, { tick: core.tick }))
5314
+ encodeFrame4(FrameType5.DELTA, encodeDelta4(core.ext, core.plain, dirty, { tick: core.tick }))
3885
5315
  );
3886
5316
  }
3887
5317
  entry.role = role;
@@ -3984,6 +5414,10 @@ var RoomCore = class _RoomCore {
3984
5414
  };
3985
5415
  tick = 0;
3986
5416
  stopped = false;
5417
+ // ---- M6 lane K: stateful relay ----
5418
+ /** D77: the wall clock hold deadlines are measured against. See `RoomCoreOptions.wallNow`. */
5419
+ wallNow;
5420
+ // ---- end M6 lane K ----
3987
5421
  /**
3988
5422
  * Called for every handler throw `tryRun` swallows, before it is logged. A test harness sets
3989
5423
  * this so a room that breaks fails the test that broke it (bug 2): the runtime's job is to
@@ -4035,7 +5469,7 @@ var RoomCore = class _RoomCore {
4035
5469
  restored = candidate;
4036
5470
  }
4037
5471
  if (restored) {
4038
- plain = decodeSnapshot2(this.ext, restored.snapshot).state;
5472
+ plain = decodeSnapshot3(this.ext, restored.snapshot).state;
4039
5473
  const presence = plainEntity(plain, PRESENCE_COLLECTION);
4040
5474
  for (const id of [...presence.ids()]) presence.remove(id);
4041
5475
  if (restored.mode !== this.mode) {
@@ -4060,6 +5494,9 @@ var RoomCore = class _RoomCore {
4060
5494
  const historySetup = this.physics ? historyOf(definition.config.physics) : void 0;
4061
5495
  this.rewindState = historySetup === void 0 ? void 0 : new RewindState(historySetup);
4062
5496
  this.subscribeDeclaredChannels();
5497
+ installCaps(self, options.caps ?? {});
5498
+ this.wallNow = options.wallNow ?? (() => Date.now());
5499
+ restoreHolds(self, restored?.holds);
4063
5500
  if (restored) {
4064
5501
  const onWake = definition.config.onWake;
4065
5502
  if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
@@ -4083,6 +5520,37 @@ var RoomCore = class _RoomCore {
4083
5520
  get busConfig() {
4084
5521
  return this.definition.config.bus;
4085
5522
  }
5523
+ /**
5524
+ * M7 D-P2: the platform notice, and the room's veto.
5525
+ *
5526
+ * Answers whether the supervisor should broadcast. `false` — the veto — comes back for exactly
5527
+ * one input: a handler that ran and returned `false`. Every other path answers `true`, and each
5528
+ * one is a different way of the room having said nothing:
5529
+ *
5530
+ * - **No handler.** A room that never wrote this hook. The design's whole point is that its
5531
+ * players still hear the warning; a code-less room does not even reach this file.
5532
+ * - **A handler that threw.** `tryRun` and not `guard`, so the throw counts toward the crash
5533
+ * threshold like any other, and it still does not become a veto. This is the one place the
5534
+ * convention differs from `onChat`, where a throw refuses, and the reason is the asymmetry of
5535
+ * the two losses: a chat line nobody sees is small; a save-loss warning nobody sees is the
5536
+ * feature not existing.
5537
+ * - **A stopped room.** Nobody to ask, and for `closing` nobody left to veto for.
5538
+ *
5539
+ * The handler may also have written state, posted chat or started an end sequence. All of that
5540
+ * is ordinary handler work and is flushed here the way an alarm's is.
5541
+ */
5542
+ deliverPlatformNotice(notice) {
5543
+ if (this.stopped) return true;
5544
+ const handler = this.definition.config.onPlatformNotice;
5545
+ if (!handler) return true;
5546
+ this.recordEvent("notice", void 0, notice.phase);
5547
+ const ran = this.tryRun(
5548
+ "onPlatformNotice",
5549
+ () => handler(this.anyState, notice, this.room)
5550
+ );
5551
+ this.eventFlush();
5552
+ return !(ran.ok && ran.value === false);
5553
+ }
4086
5554
  /**
4087
5555
  * D59: one published message arriving on a channel this room is subscribed to.
4088
5556
  *
@@ -4371,6 +5839,8 @@ var RoomCore = class _RoomCore {
4371
5839
  }
4372
5840
  stop() {
4373
5841
  if (this.stopped) return;
5842
+ drainRecordingFinal(this.internals);
5843
+ cancelAllAsks(this.internals);
4374
5844
  this.stopped = true;
4375
5845
  this.started = false;
4376
5846
  this.loop.stop();
@@ -4400,15 +5870,22 @@ var RoomCore = class _RoomCore {
4400
5870
  mode: this.mode,
4401
5871
  schemaHash8: this.ext.hash8
4402
5872
  },
4403
- encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
4404
- physics
5873
+ encodeSnapshot4(this.ext, this.plain, { tick: this.tick }),
5874
+ physics,
5875
+ // ---- M6 lane K: stateful relay ----
5876
+ // D77: hold deadlines are state, so they go where the state goes. `undefined` for a room
5877
+ // holding nothing, which keeps its blob byte-identical to the one it wrote before D77.
5878
+ serializeHolds(this.internals)
5879
+ // ---- end M6 lane K ----
4405
5880
  );
4406
5881
  }
4407
5882
  serialize() {
4408
5883
  const onSleep = this.definition.config.onSleep;
4409
5884
  if (onSleep) this.guard("onSleep", () => onSleep(this.state, this.room));
4410
5885
  this.loop.clearAllTimers();
5886
+ cancelAllAsks(this.internals);
4411
5887
  rejectAllPending(this.internals, "room is hibernating");
5888
+ noteRecordingRepliesDropped(this.internals);
4412
5889
  rejectAllHostCalls(this.internals, "room is hibernating");
4413
5890
  return this.snapshot();
4414
5891
  }
@@ -4476,6 +5953,18 @@ var RoomCore = class _RoomCore {
4476
5953
  // a key join `ctx.playerId` is exactly as durable as the resume token and no more. A JWT
4477
5954
  // join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
4478
5955
  // and nothing else here changes.
5956
+ //
5957
+ // ---- M6 lane K: stateful relay ----
5958
+ // An existing entry keeps the `playerId` it was created with: this is `existing ?? {...}`,
5959
+ // so a rejoin never overwrites it. That is deliberate and it is the safe direction — a
5960
+ // session's identity cannot be changed by anything a later HELLO on the same seat says. It
5961
+ // has one visible consequence, and D77's holds are where it shows: a client that joins
5962
+ // anonymously and presents an assertion later in the same session keeps the resume token's
5963
+ // client id as its `playerId`, so its holds stay bound to the token rather than moving to
5964
+ // the verified subject. Its next fresh join binds them to the subject. Said in
5965
+ // `authority-models.md` too, because a developer meets it as "my holds did not follow me to
5966
+ // the other tab" rather than as a line in this file.
5967
+ // ---- end M6 lane K ----
4479
5968
  playerId: options.playerId ?? clientId,
4480
5969
  npc: options.npc === true,
4481
5970
  role,
@@ -4493,6 +5982,8 @@ var RoomCore = class _RoomCore {
4493
5982
  entry.connected = true;
4494
5983
  this.clients.set(clientId, entry);
4495
5984
  this.invalidateClients();
5985
+ reclaimHolds(this.internals, clientId, entry.playerId);
5986
+ addPerPlayerRecords(this.internals, clientId);
4496
5987
  const onJoin = this.definition.config.onJoin;
4497
5988
  if (onJoin) {
4498
5989
  const ctx = this.ctxFor(clientId, reconnecting);
@@ -4526,7 +6017,7 @@ var RoomCore = class _RoomCore {
4526
6017
  /** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
4527
6018
  eventFlush() {
4528
6019
  if (this.mode !== "event" || this.stopped) return;
4529
- if (isDirtyEmpty3(this.tracked.dirty)) return;
6020
+ if (isDirtyEmpty4(this.tracked.dirty)) return;
4530
6021
  this.tick++;
4531
6022
  this.flush();
4532
6023
  this.captureTimeline();
@@ -4542,6 +6033,9 @@ var RoomCore = class _RoomCore {
4542
6033
  const ctx = this.ctxFor(clientId);
4543
6034
  this.guard("onLeave", () => onLeave(this.state, ctx, reason));
4544
6035
  }
6036
+ cancelAsksFor(this.internals, clientId);
6037
+ placeHolds(this.internals, clientId, entry.playerId);
6038
+ forgetWriter(this.internals, clientId);
4545
6039
  this.presence.remove(clientId);
4546
6040
  this.recordEvent("leave", clientId, reason);
4547
6041
  rejectPendingFor(this.internals, clientId, "client disconnected");
@@ -4559,6 +6053,7 @@ var RoomCore = class _RoomCore {
4559
6053
  }
4560
6054
  entry.connected = false;
4561
6055
  entry.correction = void 0;
6056
+ cancelAsksFor(this.internals, clientId);
4562
6057
  const record = this.presence.get(clientId);
4563
6058
  if (record && record.connected !== false) record.connected = false;
4564
6059
  this.invalidateClients();
@@ -4675,6 +6170,22 @@ var RoomCore = class _RoomCore {
4675
6170
  * `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
4676
6171
  * `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
4677
6172
  */
6173
+ // ---- M6 lane K: stateful relay ----
6174
+ /** D77: the encoded state size, when a host set the state cap. See `caps.ts`. */
6175
+ stateBytes() {
6176
+ return stateBytesOf(this.internals);
6177
+ }
6178
+ /** D77: how many records are held for players who have left. */
6179
+ holdCount() {
6180
+ return holdCount(this.internals);
6181
+ }
6182
+ // ---- end M6 lane K ----
6183
+ // ---- M6 ask packet ----
6184
+ /** How many `transfer: 'ask'` requests are waiting on an owner's answer right now. */
6185
+ askCount() {
6186
+ return askCount(this.internals);
6187
+ }
6188
+ // ---- end M6 ask packet ----
4678
6189
  profile() {
4679
6190
  return this.ledger?.snapshot();
4680
6191
  }
@@ -4705,20 +6216,20 @@ var RoomCore = class _RoomCore {
4705
6216
  return;
4706
6217
  }
4707
6218
  switch (type) {
4708
- case FrameType5.WRITE:
4709
- case FrameType5.CALL: {
4710
- if (type === FrameType5.WRITE) this.recordEvent("write", clientId);
6219
+ case FrameType6.WRITE:
6220
+ case FrameType6.CALL: {
6221
+ if (type === FrameType6.WRITE) this.recordEvent("write", clientId);
4711
6222
  if (this.mode === "tick") {
4712
6223
  this.loop.enqueue({ clientId, type, payload: payload.slice() });
4713
6224
  } else if (!this.loop.applyEvent({ clientId, type, payload })) {
4714
6225
  this.badFrame(
4715
6226
  clientId,
4716
- `malformed ${type === FrameType5.WRITE ? "WRITE" : "CALL"} payload`
6227
+ `malformed ${type === FrameType6.WRITE ? "WRITE" : "CALL"} payload`
4717
6228
  );
4718
6229
  }
4719
6230
  return;
4720
6231
  }
4721
- case FrameType5.REPLY: {
6232
+ case FrameType6.REPLY: {
4722
6233
  if (!handleReply(this.internals, clientId, payload)) {
4723
6234
  this.badFrame(clientId, "malformed REPLY payload");
4724
6235
  } else {
@@ -4726,7 +6237,7 @@ var RoomCore = class _RoomCore {
4726
6237
  }
4727
6238
  return;
4728
6239
  }
4729
- case FrameType5.MSG: {
6240
+ case FrameType6.MSG: {
4730
6241
  if (this.mode === "event") this.tick++;
4731
6242
  const ok = handleMsg(this.internals, clientId, payload);
4732
6243
  if (this.mode === "event") this.flush();
@@ -4744,6 +6255,7 @@ var RoomCore = class _RoomCore {
4744
6255
  */
4745
6256
  flush() {
4746
6257
  evaluateLobby(this.internals);
6258
+ expireHolds(this.internals);
4747
6259
  const dirty = this.tracked.flush();
4748
6260
  serverWinsCorrections(this.internals, dirty);
4749
6261
  this.ledger?.newFlush();
@@ -4801,14 +6313,14 @@ var RoomCore = class _RoomCore {
4801
6313
  let shared;
4802
6314
  if (entry.pendingJoinAdds) {
4803
6315
  const sharedDirty = sharedViewDirty(this.ext, sourceDirty, entry.role);
4804
- shared = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
6316
+ shared = isDirtyEmpty4(sharedDirty) ? null : encodeDelta5(this.ext, this.plain, sharedDirty, { tick: this.tick });
4805
6317
  if (shared) this.stats.encodesLastFlush++;
4806
6318
  } else {
4807
6319
  const key = viewKeyFor(this.ext, entry.role);
4808
6320
  let cached = byView.get(key);
4809
6321
  if (cached === void 0) {
4810
6322
  const sharedDirty = sharedViewDirty(this.ext, dirty, entry.role);
4811
- cached = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
6323
+ cached = isDirtyEmpty4(sharedDirty) ? null : encodeDelta5(this.ext, this.plain, sharedDirty, { tick: this.tick });
4812
6324
  if (cached) this.stats.encodesLastFlush++;
4813
6325
  byView.set(key, cached);
4814
6326
  }
@@ -4817,7 +6329,7 @@ var RoomCore = class _RoomCore {
4817
6329
  if (shared) {
4818
6330
  this.send(
4819
6331
  entry.clientId,
4820
- encodeFrame4(FrameType5.DELTA, shared),
6332
+ encodeFrame5(FrameType6.DELTA, shared),
4821
6333
  this.ledger ? { shared } : void 0
4822
6334
  );
4823
6335
  }
@@ -4828,7 +6340,7 @@ var RoomCore = class _RoomCore {
4828
6340
  memberships
4829
6341
  );
4830
6342
  const encodeStarted = performance.now();
4831
- delta = isDirtyEmpty3(aoiDirty) ? null : encodeDelta4(this.ext, this.plain, aoiDirty, { tick: this.tick });
6343
+ delta = isDirtyEmpty4(aoiDirty) ? null : encodeDelta5(this.ext, this.plain, aoiDirty, { tick: this.tick });
4832
6344
  this.stats.aoiEncodeMs += performance.now() - encodeStarted;
4833
6345
  if (delta) {
4834
6346
  this.stats.encodesLastFlush++;
@@ -4859,7 +6371,7 @@ var RoomCore = class _RoomCore {
4859
6371
  if (delta) {
4860
6372
  this.send(
4861
6373
  entry.clientId,
4862
- encodeFrame4(FrameType5.DELTA, delta),
6374
+ encodeFrame5(FrameType6.DELTA, delta),
4863
6375
  this.ledger ? {
4864
6376
  ...churn ? { churn } : {},
4865
6377
  ...spatialDescs.length > 0 ? {} : { shared: delta }
@@ -4870,6 +6382,7 @@ var RoomCore = class _RoomCore {
4870
6382
  entry.correction = void 0;
4871
6383
  entry.accepted.clear();
4872
6384
  }
6385
+ recordFlush(this.internals, dirty, byView);
4873
6386
  }
4874
6387
  };
4875
6388
  function encodeSection(physics) {
@@ -4885,6 +6398,11 @@ function encodeSection(physics) {
4885
6398
  export {
4886
6399
  HOST_CALL_TIMEOUT_MS,
4887
6400
  RoomFullError,
6401
+ RELAY_STATE_BYTES,
6402
+ RELAY_RECORDS_PER_COLLECTION,
6403
+ RELAY_WRITES_PER_SECOND,
6404
+ DEFAULT_RECORDS_PER_COLLECTION,
6405
+ RELAY_WRITE_CAPS,
4888
6406
  EVENT_RING_SIZE,
4889
6407
  inspectState,
4890
6408
  RPC_TIMEOUT_MS,