@irtio/runtime 0.10.0 → 0.11.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,17 @@ function inspectState(schema, plain) {
39
214
 
40
215
  // src/core/rpc.ts
41
216
  import {
42
- FrameType,
217
+ FrameType as FrameType2,
43
218
  decodeCall,
44
219
  decodeReply,
45
220
  encodeCall,
46
- encodeFrame,
221
+ encodeFrame as encodeFrame2,
47
222
  encodeReply,
48
223
  rpcByIdOf,
49
224
  rpcTable
50
225
  } from "@irtio/protocol";
51
226
  import {
52
- SERVER_OWNER,
227
+ SERVER_OWNER as SERVER_OWNER2,
53
228
  decodeFields,
54
229
  encodeFields,
55
230
  validateValue
@@ -81,15 +256,251 @@ function deepEqual(a, b) {
81
256
  return true;
82
257
  }
83
258
 
259
+ // src/core/ownership.ts
260
+ import { SERVER_OWNER, defaultRecord } from "@irtio/schema";
261
+ var states2 = /* @__PURE__ */ new WeakMap();
262
+ function stateOf2(core) {
263
+ let s = states2.get(core);
264
+ if (!s) {
265
+ s = { holds: /* @__PURE__ */ new Map(), askWarned: false };
266
+ states2.set(core, s);
267
+ }
268
+ return s;
269
+ }
270
+ function keyOf(collection, id) {
271
+ return `${collection} ${id}`;
272
+ }
273
+ function policyCollections(core) {
274
+ return core.ext.collections.filter(
275
+ (c) => c.kind === "entity" && c.ownership !== void 0
276
+ );
277
+ }
278
+ function wallNow(core) {
279
+ return core.wallNow();
280
+ }
281
+ function holdOn(core, collection, id) {
282
+ return states2.get(core)?.holds.get(keyOf(collection, id));
283
+ }
284
+ function holdCount(core) {
285
+ return states2.get(core)?.holds.size ?? 0;
286
+ }
287
+ function placeHolds(core, clientId, playerId) {
288
+ const s = stateOf2(core);
289
+ const now = wallNow(core);
290
+ for (const c of policyCollections(core)) {
291
+ const policy = c.ownership;
292
+ const coll = plainEntity(core.plain, c.name);
293
+ for (const id of [...coll.ids()]) {
294
+ if (coll.ownerOf(id) !== clientId) continue;
295
+ const hold = {
296
+ collection: c.name,
297
+ id,
298
+ holder: playerId,
299
+ deadlineMs: now + policy.holdMs,
300
+ // `then` is the D77 spec's own name for the leave action; a hold is plain data nothing
301
+ // ever awaits.
302
+ // biome-ignore lint/suspicious/noThenProperty: see above
303
+ then: policy.then
304
+ };
305
+ if (policy.holdMs <= 0) {
306
+ act(core, hold);
307
+ continue;
308
+ }
309
+ s.holds.set(keyOf(c.name, id), hold);
310
+ }
311
+ }
312
+ }
313
+ function reclaimHolds(core, clientId, playerId) {
314
+ const s = states2.get(core);
315
+ if (!s || s.holds.size === 0) return 0;
316
+ let reclaimed = 0;
317
+ for (const [key, hold] of [...s.holds]) {
318
+ if (hold.holder !== playerId) continue;
319
+ s.holds.delete(key);
320
+ const coll = plainEntity(core.plain, hold.collection);
321
+ if (!coll.has(hold.id)) continue;
322
+ trackedEntity(core, hold.collection).setOwner(hold.id, clientId);
323
+ reclaimed++;
324
+ }
325
+ return reclaimed;
326
+ }
327
+ function expireHolds(core) {
328
+ const s = states2.get(core);
329
+ if (!s || s.holds.size === 0) return;
330
+ const now = wallNow(core);
331
+ for (const [key, hold] of [...s.holds]) {
332
+ if (hold.deadlineMs > now) continue;
333
+ s.holds.delete(key);
334
+ act(core, hold);
335
+ }
336
+ }
337
+ function act(core, hold) {
338
+ const coll = plainEntity(core.plain, hold.collection);
339
+ if (!coll.has(hold.id)) return;
340
+ const tracked = trackedEntity(core, hold.collection);
341
+ switch (hold.then) {
342
+ case "remove":
343
+ tracked.remove(hold.id);
344
+ return;
345
+ case "release":
346
+ tracked.setOwner(hold.id, SERVER_OWNER);
347
+ return;
348
+ case "nearest": {
349
+ const to = nearestConnected(core, hold.collection, hold.id);
350
+ tracked.setOwner(hold.id, to ?? SERVER_OWNER);
351
+ return;
352
+ }
353
+ case "random": {
354
+ const candidates = connectedClients(core);
355
+ if (candidates.length === 0) {
356
+ tracked.setOwner(hold.id, SERVER_OWNER);
357
+ return;
358
+ }
359
+ const pick = candidates[Math.floor(core.rng.next() * candidates.length)];
360
+ tracked.setOwner(hold.id, pick);
361
+ return;
362
+ }
363
+ default:
364
+ return;
365
+ }
366
+ }
367
+ function connectedClients(core) {
368
+ const out = [];
369
+ for (const entry of core.clients.values()) if (entry.connected) out.push(entry.clientId);
370
+ return out;
371
+ }
372
+ function numberAt(record, field) {
373
+ if (!record) return void 0;
374
+ const v = record[field];
375
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
376
+ }
377
+ function distanceTo(core, policy, object, clientId) {
378
+ const position = policy.position;
379
+ if (!position || !object) return void 0;
380
+ const players = plainEntity(core.plain, position.players);
381
+ const player = players.get(clientId);
382
+ const px = numberAt(player, position.x);
383
+ const py = numberAt(player, position.y);
384
+ const ox = numberAt(object, position.x);
385
+ const oy = numberAt(object, position.y);
386
+ if (px === void 0 || py === void 0 || ox === void 0 || oy === void 0)
387
+ return void 0;
388
+ return Math.hypot(px - ox, py - oy);
389
+ }
390
+ function nearestConnected(core, collection, id) {
391
+ const c = core.ext.collections.find((x) => x.name === collection);
392
+ const policy = c?.ownership;
393
+ if (!policy?.position) return void 0;
394
+ const object = plainEntity(core.plain, collection).get(id);
395
+ let best;
396
+ let bestDistance = Number.POSITIVE_INFINITY;
397
+ for (const clientId of connectedClients(core)) {
398
+ const d = distanceTo(core, policy, object, clientId);
399
+ if (d === void 0 || d >= bestDistance) continue;
400
+ bestDistance = d;
401
+ best = clientId;
402
+ }
403
+ return best;
404
+ }
405
+ function addPerPlayerRecords(core, clientId) {
406
+ for (const c of core.ext.collections) {
407
+ if (c.kind !== "entity" || !c.perPlayer) continue;
408
+ const coll = plainEntity(core.plain, c.name);
409
+ if (coll.has(clientId)) {
410
+ if (coll.ownerOf(clientId) !== clientId) {
411
+ trackedEntity(core, c.name).setOwner(clientId, clientId);
412
+ }
413
+ continue;
414
+ }
415
+ try {
416
+ trackedEntity(core, c.name).add(clientId, defaultRecord(c), { owner: clientId });
417
+ } catch (err) {
418
+ core.log("error", `perPlayer add on ${c.name} failed for ${clientId}:`, err);
419
+ }
420
+ }
421
+ }
422
+ function resolveTransfer(core, clientId, c, id) {
423
+ const policy = c.ownership;
424
+ const coll = plainEntity(core.plain, c.name);
425
+ const owner = coll.ownerOf(id);
426
+ if (owner === clientId) return "granted";
427
+ const hold = holdOn(core, c.name, id);
428
+ if (hold) {
429
+ const playerId = core.clients.get(clientId)?.playerId ?? clientId;
430
+ if (hold.holder !== playerId) return "refused";
431
+ reclaimHolds(core, clientId, playerId);
432
+ return coll.ownerOf(id) === clientId ? "granted" : "refused";
433
+ }
434
+ switch (policy.transfer) {
435
+ case "never":
436
+ return "refused";
437
+ case "free":
438
+ trackedEntity(core, c.name).setOwner(id, clientId);
439
+ return "granted";
440
+ case "closer": {
441
+ if (owner === void 0 || owner === SERVER_OWNER) {
442
+ trackedEntity(core, c.name).setOwner(id, clientId);
443
+ return "granted";
444
+ }
445
+ const object = coll.get(id);
446
+ const ownerDistance = distanceTo(core, policy, object, owner);
447
+ if (ownerDistance === void 0) {
448
+ trackedEntity(core, c.name).setOwner(id, clientId);
449
+ return "granted";
450
+ }
451
+ const mine = distanceTo(core, policy, object, clientId);
452
+ if (mine === void 0) return "refused";
453
+ if (mine + policy.by >= ownerDistance) return "refused";
454
+ trackedEntity(core, c.name).setOwner(id, clientId);
455
+ return "granted";
456
+ }
457
+ case "ask": {
458
+ const s = stateOf2(core);
459
+ if (!s.askWarned) {
460
+ s.askWarned = true;
461
+ core.log(
462
+ "warn",
463
+ `${c.name}: ownership transfer 'ask' is not yet forwarded to owners; requests are refused (the same answer an owner who does not reply gives). Use 'closer' or 'free' for now.`
464
+ );
465
+ }
466
+ return "refused";
467
+ }
468
+ default:
469
+ return "refused";
470
+ }
471
+ }
472
+ function serializeHolds(core) {
473
+ const s = states2.get(core);
474
+ if (!s || s.holds.size === 0) return void 0;
475
+ const json = JSON.stringify([...s.holds.values()]);
476
+ return new TextEncoder().encode(json);
477
+ }
478
+ function restoreHolds(core, bytes) {
479
+ const s = stateOf2(core);
480
+ s.holds.clear();
481
+ if (!bytes || bytes.length === 0) return;
482
+ try {
483
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
484
+ for (const h of parsed) {
485
+ if (typeof h?.collection !== "string" || typeof h.id !== "string" || typeof h.holder !== "string" || typeof h.deadlineMs !== "number") {
486
+ continue;
487
+ }
488
+ s.holds.set(keyOf(h.collection, h.id), h);
489
+ }
490
+ } catch (err) {
491
+ core.log("warn", "hold deadlines did not decode; the room wakes holding nothing:", err);
492
+ }
493
+ }
494
+
84
495
  // src/core/rpc.ts
85
496
  var RPC_TIMEOUT_MS = 5e3;
86
497
  var REQUEST_OWNERSHIP = "requestOwnership";
87
- var states = /* @__PURE__ */ new WeakMap();
88
- function stateOf(core) {
89
- let s = states.get(core);
498
+ var states3 = /* @__PURE__ */ new WeakMap();
499
+ function stateOf3(core) {
500
+ let s = states3.get(core);
90
501
  if (!s) {
91
502
  s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
92
- states.set(core, s);
503
+ states3.set(core, s);
93
504
  }
94
505
  return s;
95
506
  }
@@ -101,7 +512,7 @@ function errorText(code, detail) {
101
512
  }
102
513
  function sendReply(core, clientId, reqId, result, error) {
103
514
  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));
515
+ core.send(clientId, encodeFrame2(FrameType2.REPLY, payload));
105
516
  }
106
517
  function handleCall(core, clientId, payload) {
107
518
  let call;
@@ -184,6 +595,9 @@ function runRequestOwnership(core, clientId, ctx, params) {
184
595
  if (!plain.has(id)) return { granted: false };
185
596
  const handler = core.definition.config.onOwnershipRequest;
186
597
  const tracked = trackedEntity(core, name);
598
+ if (!handler && c.ownership) {
599
+ return { granted: resolveTransfer(core, clientId, c, id) === "granted" };
600
+ }
187
601
  if (handler) {
188
602
  const ran = core.tryRun("onOwnershipRequest", () => handler(core.anyState, name, id, ctx));
189
603
  if (ran.ok && ran.value === true && plain.ownerOf(id) !== clientId) {
@@ -193,7 +607,7 @@ function runRequestOwnership(core, clientId, ctx, params) {
193
607
  }
194
608
  const owner = plain.ownerOf(id);
195
609
  if (owner === clientId) return { granted: true };
196
- if (owner !== SERVER_OWNER) return { granted: false };
610
+ if (owner !== SERVER_OWNER2) return { granted: false };
197
611
  tracked.setOwner(id, clientId);
198
612
  return { granted: true };
199
613
  }
@@ -206,13 +620,13 @@ function descFor(core, name) {
206
620
  return found;
207
621
  }
208
622
  function sendCall(core, clientId, desc, params) {
209
- const s = stateOf(core);
623
+ const s = stateOf3(core);
210
624
  const reqId = s.nextReqId;
211
625
  s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
212
626
  const bytes = encodeFields(desc.params, params);
213
627
  core.send(
214
628
  clientId,
215
- encodeFrame(FrameType.CALL, encodeCall({ reqId, rpcId: desc.index, params: bytes }))
629
+ encodeFrame2(FrameType2.CALL, encodeCall({ reqId, rpcId: desc.index, params: bytes }))
216
630
  );
217
631
  return reqId;
218
632
  }
@@ -232,7 +646,7 @@ function createCallProxy(core, clientId) {
232
646
  return;
233
647
  }
234
648
  const reqId = sendCall(core, clientId, desc, params ?? {});
235
- const s = stateOf(core);
649
+ const s = stateOf3(core);
236
650
  s.pending.set(pendingKey(clientId, reqId), {
237
651
  clientId,
238
652
  name: desc.name,
@@ -280,7 +694,7 @@ function handleReply(core, clientId, payload) {
280
694
  core.log("warn", `REPLY from ${clientId} failed to decode:`, err);
281
695
  return false;
282
696
  }
283
- const s = stateOf(core);
697
+ const s = stateOf3(core);
284
698
  const key = pendingKey(clientId, reply.reqId);
285
699
  const pending = s.pending.get(key);
286
700
  if (!pending) {
@@ -304,7 +718,7 @@ function handleReply(core, clientId, payload) {
304
718
  return true;
305
719
  }
306
720
  function rejectPendingFor(core, clientId, reason) {
307
- const s = stateOf(core);
721
+ const s = stateOf3(core);
308
722
  for (const [key, p] of [...s.pending]) {
309
723
  if (p.clientId !== clientId) continue;
310
724
  s.pending.delete(key);
@@ -312,7 +726,7 @@ function rejectPendingFor(core, clientId, reason) {
312
726
  }
313
727
  }
314
728
  function checkTimeouts(core) {
315
- const s = stateOf(core);
729
+ const s = stateOf3(core);
316
730
  if (s.pending.size === 0) return;
317
731
  const now = core.host.now();
318
732
  for (const [key, p] of [...s.pending]) {
@@ -322,7 +736,7 @@ function checkTimeouts(core) {
322
736
  }
323
737
  }
324
738
  function rejectAllPending(core, reason) {
325
- const s = stateOf(core);
739
+ const s = stateOf3(core);
326
740
  for (const [key, p] of [...s.pending]) {
327
741
  s.pending.delete(key);
328
742
  p.reject(new Error(reason));
@@ -330,15 +744,15 @@ function rejectAllPending(core, reason) {
330
744
  }
331
745
 
332
746
  // src/core/loop.ts
333
- import { FrameType as FrameType2 } from "@irtio/protocol";
747
+ import { FrameType as FrameType3 } from "@irtio/protocol";
334
748
 
335
749
  // src/core/host-calls.ts
336
- var states2 = /* @__PURE__ */ new WeakMap();
337
- function stateOf2(core) {
338
- let s = states2.get(core);
750
+ var states4 = /* @__PURE__ */ new WeakMap();
751
+ function stateOf4(core) {
752
+ let s = states4.get(core);
339
753
  if (!s) {
340
754
  s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
341
- states2.set(core, s);
755
+ states4.set(core, s);
342
756
  }
343
757
  return s;
344
758
  }
@@ -351,7 +765,7 @@ function startHostCall(core, call, map) {
351
765
  return rejectAsEvent(core, new Error(`room.${call.kind}: the room is stopped`));
352
766
  }
353
767
  return new Promise((resolve, reject2) => {
354
- const s = stateOf2(core);
768
+ const s = stateOf4(core);
355
769
  const reqId = s.nextReqId;
356
770
  s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
357
771
  s.pending.set(reqId, {
@@ -375,7 +789,7 @@ function startHostCall(core, call, map) {
375
789
  });
376
790
  }
377
791
  function completeHostCall(core, reqId, result) {
378
- const s = stateOf2(core);
792
+ const s = stateOf4(core);
379
793
  const pending = s.pending.get(reqId);
380
794
  if (!pending) return false;
381
795
  s.pending.delete(reqId);
@@ -384,7 +798,7 @@ function completeHostCall(core, reqId, result) {
384
798
  return true;
385
799
  }
386
800
  function checkHostCallTimeouts(core) {
387
- const s = stateOf2(core);
801
+ const s = stateOf4(core);
388
802
  if (s.pending.size === 0) return;
389
803
  const now = core.host.now();
390
804
  for (const [reqId, p] of [...s.pending]) {
@@ -394,7 +808,7 @@ function checkHostCallTimeouts(core) {
394
808
  }
395
809
  }
396
810
  function rejectAllHostCalls(core, reason) {
397
- const s = stateOf2(core);
811
+ const s = stateOf4(core);
398
812
  for (const [reqId, p] of [...s.pending]) {
399
813
  s.pending.delete(reqId);
400
814
  p.reject(new Error(reason));
@@ -403,7 +817,7 @@ function rejectAllHostCalls(core, reason) {
403
817
 
404
818
  // src/core/writes.ts
405
819
  import {
406
- SERVER_OWNER as SERVER_OWNER2,
820
+ SERVER_OWNER as SERVER_OWNER3,
407
821
  cloneValue,
408
822
  decodeDelta,
409
823
  encodeDelta,
@@ -493,6 +907,9 @@ function applyWrite(core, clientId, payload) {
493
907
  core.log("warn", `WRITE from ${clientId} failed to decode:`, err);
494
908
  return { ok: false };
495
909
  }
910
+ let ops = 0;
911
+ for (const dc of delta.collections) ops += dc.ops.length;
912
+ if (!admitWrite(core, clientId, core.host.now(), ops)) return { ok: true };
496
913
  const judged = core.clients.get(clientId);
497
914
  if (judged && delta.tick > judged.lastClientTick) {
498
915
  judged.lastClientTick = delta.tick;
@@ -513,8 +930,19 @@ function reject(core, clientId, c, op, reason) {
513
930
  correctLeaves(core, clientId, c.name, op.id, leafPaths(fieldList(c), op.mask));
514
931
  }
515
932
  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`);
933
+ if (op.op === "add" || op.op === "remove") {
934
+ if (c.kind !== "entity" || c.serverOwned || !c.clientCreate) {
935
+ reject(
936
+ core,
937
+ clientId,
938
+ c,
939
+ op,
940
+ `op '${op.op}' is not allowed in a WRITE (${c.name} does not declare clientCreate)`
941
+ );
942
+ return;
943
+ }
944
+ if (op.op === "add") applyAdd(core, clientId, c, op);
945
+ else applyRemove(core, clientId, c, op);
518
946
  return;
519
947
  }
520
948
  if (c.kind !== "entity") {
@@ -537,6 +965,8 @@ function applyOp(core, clientId, c, op) {
537
965
  return;
538
966
  }
539
967
  const fields = fieldList(c);
968
+ const growth = updateGrowth(c, leafPaths(fields, op.mask));
969
+ if (growth > 0 && !admitGrowth(core, clientId, growth)) return;
540
970
  const prev = cloneValue(current);
541
971
  const next = cloneValue(current);
542
972
  applyMaskedPatch(fields, next, op.patch, op.mask);
@@ -594,6 +1024,66 @@ function applyOp(core, clientId, c, op) {
594
1024
  );
595
1025
  correctLeaves(core, clientId, c.name, op.id, corrected);
596
1026
  }
1027
+ function applyAdd(core, clientId, c, op) {
1028
+ const plainColl = plainEntity(core.plain, c.name);
1029
+ if (plainColl.has(op.id)) {
1030
+ reject(core, clientId, c, op, `id ${JSON.stringify(op.id)} already exists`);
1031
+ return;
1032
+ }
1033
+ const cap = recordCap(core);
1034
+ if (plainColl.size >= cap) {
1035
+ refuseByName(core, clientId, "E_RECORD_CAP", { entity: c.name, limit: cap });
1036
+ return;
1037
+ }
1038
+ if (!admitGrowth(core, clientId, maxRecordBytes(c))) return;
1039
+ try {
1040
+ trackedEntity(core, c.name).add(op.id, op.value, { owner: clientId });
1041
+ } catch (err) {
1042
+ reject(core, clientId, c, op, `add rejected: ${String(err)}`);
1043
+ return;
1044
+ }
1045
+ core.recordEvent("write", clientId, `${c.name}: added ${op.id}`);
1046
+ }
1047
+ function applyRemove(core, clientId, c, op) {
1048
+ const plainColl = plainEntity(core.plain, c.name);
1049
+ if (!plainColl.has(op.id)) return;
1050
+ if (plainColl.ownerOf(op.id) !== clientId) {
1051
+ reject(core, clientId, c, op, "not the owner");
1052
+ return;
1053
+ }
1054
+ trackedEntity(core, c.name).remove(op.id);
1055
+ core.recordEvent("write", clientId, `${c.name}: removed ${op.id}`);
1056
+ }
1057
+ function updateGrowth(c, leaves) {
1058
+ let growth = 0;
1059
+ for (const leaf of leaves) {
1060
+ const first = leaf.path[0];
1061
+ if (first === void 0) continue;
1062
+ const field = c.fields[first];
1063
+ if (!field) continue;
1064
+ const bound = maxTypeBytes(field.type);
1065
+ if (isFixedWidth(field.type)) continue;
1066
+ growth += bound;
1067
+ }
1068
+ return growth;
1069
+ }
1070
+ function isFixedWidth(t) {
1071
+ switch (t.kind) {
1072
+ case "bool":
1073
+ case "u8":
1074
+ case "u16":
1075
+ case "u32":
1076
+ case "i32":
1077
+ case "f32":
1078
+ case "f64":
1079
+ case "enum":
1080
+ return t.opt !== true;
1081
+ case "struct":
1082
+ return Object.values(t.fields).every(isFixedWidth) && t.opt !== true;
1083
+ default:
1084
+ return false;
1085
+ }
1086
+ }
597
1087
  function serverWinsCorrections(core, dirty) {
598
1088
  const ext = core.ext;
599
1089
  for (const [name, cd] of dirty) {
@@ -604,7 +1094,7 @@ function serverWinsCorrections(core, dirty) {
604
1094
  const fields = fieldList(c);
605
1095
  for (const [id, rd] of cd.updated) {
606
1096
  const owner = coll.ownerOf(id);
607
- if (owner === void 0 || owner === SERVER_OWNER2) continue;
1097
+ if (owner === void 0 || owner === SERVER_OWNER3) continue;
608
1098
  const entry = core.clients.get(owner);
609
1099
  if (!entry || !entry.connected) continue;
610
1100
  const value = coll.get(id);
@@ -700,8 +1190,8 @@ var Loop = class {
700
1190
  }
701
1191
  /** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
702
1192
  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);
1193
+ if (f.type === FrameType3.WRITE) return applyWrite(this.core, f.clientId, f.payload).ok;
1194
+ if (f.type === FrameType3.CALL) return handleCall(this.core, f.clientId, f.payload);
705
1195
  return true;
706
1196
  }
707
1197
  // -------------------------------------------------------------------------
@@ -2088,7 +2578,7 @@ import {
2088
2578
  createDirtySet,
2089
2579
  createFieldMask,
2090
2580
  encodeDelta as encodeDelta2,
2091
- encodeSnapshot,
2581
+ encodeSnapshot as encodeSnapshot2,
2092
2582
  filterDirty,
2093
2583
  isDirtyEmpty,
2094
2584
  markAdd,
@@ -2195,7 +2685,7 @@ function viewKeyFor(ext, role) {
2195
2685
  return infoOf(ext).scoped.has(role) ? role : "all";
2196
2686
  }
2197
2687
  function encodeViewSnapshot(ext, plain, role, tick, memberships) {
2198
- return encodeSnapshot(
2688
+ return encodeSnapshot2(
2199
2689
  ext,
2200
2690
  plain,
2201
2691
  { tick },
@@ -2325,7 +2815,7 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
2325
2815
  import { withBuiltins } from "@irtio/protocol";
2326
2816
  import { ByteReader as ByteReader3, ByteWriter as ByteWriter3, decodeSnapshot } from "@irtio/schema";
2327
2817
  var SNAPSHOT_FORMAT_VERSION = 3;
2328
- var READABLE_SNAPSHOT_VERSIONS = [1, 2, 3];
2818
+ var READABLE_SNAPSHOT_VERSIONS = [1, 2, 3, 4];
2329
2819
  function parseHibernationBlob(bytes) {
2330
2820
  const r = new ByteReader3(bytes);
2331
2821
  const version = r.u8();
@@ -2342,7 +2832,12 @@ function parseHibernationBlob(bytes) {
2342
2832
  const section = r.blob();
2343
2833
  if (section.length > 0) physics = section;
2344
2834
  }
2345
- return { version, seed, rngState, tick, mode, schemaHash8, physics, snapshot: r.rest() };
2835
+ let holds;
2836
+ if (version >= 4) {
2837
+ const section = r.blob();
2838
+ if (section.length > 0) holds = section;
2839
+ }
2840
+ return { version, seed, rngState, tick, mode, schemaHash8, physics, holds, snapshot: r.rest() };
2346
2841
  }
2347
2842
  function sameSchemaHash(a, b) {
2348
2843
  if (!a || !b) return true;
@@ -2350,10 +2845,10 @@ function sameSchemaHash(a, b) {
2350
2845
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2351
2846
  return true;
2352
2847
  }
2353
- function writeHibernationBlob(header, snapshot, physics) {
2848
+ function writeHibernationBlob(header, snapshot, physics, holds) {
2354
2849
  const hash = header.schemaHash8;
2355
- const version = hash ? 3 : physics ? 2 : 1;
2356
- const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 32);
2850
+ const version = holds && holds.length > 0 ? 4 : hash ? 3 : physics ? 2 : 1;
2851
+ const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + (holds?.length ?? 0) + 40);
2357
2852
  w.u8(version);
2358
2853
  w.u32(header.seed >>> 0);
2359
2854
  w.u32(header.rngState >>> 0);
@@ -2361,6 +2856,7 @@ function writeHibernationBlob(header, snapshot, physics) {
2361
2856
  w.u8(header.mode === "event" ? 1 : 0);
2362
2857
  if (hash) w.bytes(hash);
2363
2858
  if (version >= 2) w.blob(physics ?? new Uint8Array(0));
2859
+ if (version >= 4) w.blob(holds ?? new Uint8Array(0));
2364
2860
  w.bytes(snapshot);
2365
2861
  return w.finish();
2366
2862
  }
@@ -2434,13 +2930,13 @@ var TimelineRecorder = class {
2434
2930
 
2435
2931
  // src/core/room.ts
2436
2932
  import {
2437
- FrameType as FrameType5,
2933
+ FrameType as FrameType6,
2438
2934
  PRESENCE_COLLECTION,
2439
2935
  ProfileLedger,
2440
2936
  busChannelProblem as busChannelProblem2,
2441
2937
  decodeFrame,
2442
2938
  encodeCorrectFrame,
2443
- encodeFrame as encodeFrame4,
2939
+ encodeFrame as encodeFrame5,
2444
2940
  withBuiltins as withBuiltins2
2445
2941
  } from "@irtio/protocol";
2446
2942
  import {
@@ -2448,9 +2944,9 @@ import {
2448
2944
  createDirtySet as createDirtySet2,
2449
2945
  createState,
2450
2946
  decodeSnapshot as decodeSnapshot2,
2451
- encodeDelta as encodeDelta4,
2452
- encodeSnapshot as encodeSnapshot2,
2453
- isDirtyEmpty as isDirtyEmpty3,
2947
+ encodeDelta as encodeDelta5,
2948
+ encodeSnapshot as encodeSnapshot4,
2949
+ isDirtyEmpty as isDirtyEmpty4,
2454
2950
  track,
2455
2951
  validateForDeploy
2456
2952
  } from "@irtio/schema";
@@ -2709,12 +3205,15 @@ function historyOf(config) {
2709
3205
  }
2710
3206
  return { depth, channels };
2711
3207
  }
2712
- function keyOf(collection, id) {
3208
+ function keyOf2(collection, id) {
2713
3209
  return `${collection} ${id}`;
2714
3210
  }
2715
3211
  function angleOf(qz, qw) {
2716
3212
  return 2 * Math.atan2(qz, qw);
2717
3213
  }
3214
+ function mixSignature(h, v) {
3215
+ return Math.imul(h ^ (Math.round(v * 1024) | 0), 2654435761);
3216
+ }
2718
3217
  var BaseScratch = class {
2719
3218
  constructor(physics, depth, tickNow) {
2720
3219
  this.physics = physics;
@@ -2731,7 +3230,7 @@ var BaseScratch = class {
2731
3230
  this.sync();
2732
3231
  this.present.clear();
2733
3232
  for (let i = 0; i < entry.count; i++) {
2734
- const key = keyOf(entry.collections[i], entry.ids[i]);
3233
+ const key = keyOf2(entry.collections[i], entry.ids[i]);
2735
3234
  const e = this.bodies.get(key);
2736
3235
  if (!e) continue;
2737
3236
  poseAt(entry, i, this.pose);
@@ -2745,7 +3244,8 @@ var BaseScratch = class {
2745
3244
  }
2746
3245
  /**
2747
3246
  * Brings the scratch's body set in step with the live world: a double for every live body that
2748
- * has none, a rebuild for one whose collider (or part) count changed, and a prune for one that
3247
+ * has none, a rebuild for one whose collider layout changed (count, handles, body-local
3248
+ * offsets, shape type or extents, sensor flags — see `signatureOf`), and a prune for one that
2749
3249
  * has been gone longer than the history is deep and so can no longer appear in any entry.
2750
3250
  *
2751
3251
  * The prune is conservative rather than exact: `lastLive` only advances when a rewind happens,
@@ -2756,11 +3256,11 @@ var BaseScratch = class {
2756
3256
  const now = this.tickNow();
2757
3257
  const seen = /* @__PURE__ */ new Set();
2758
3258
  this.physics.eachTrackedBody((collection, id, body) => {
2759
- const key = keyOf(collection, id);
3259
+ const key = keyOf2(collection, id);
2760
3260
  seen.add(key);
2761
- const parts = this.partsOf(body);
3261
+ const signature = this.signatureOf(body);
2762
3262
  const existing = this.bodies.get(key);
2763
- if (existing && existing.parts === parts) {
3263
+ if (existing && existing.signature === signature) {
2764
3264
  existing.lastLive = now;
2765
3265
  return;
2766
3266
  }
@@ -2768,7 +3268,7 @@ var BaseScratch = class {
2768
3268
  this.bodies.delete(key);
2769
3269
  this.destroy(existing);
2770
3270
  }
2771
- const made = this.clone(collection, id, body, parts);
3271
+ const made = this.clone(collection, id, body, signature);
2772
3272
  if (!made) return;
2773
3273
  made.lastLive = now;
2774
3274
  this.bodies.set(key, made);
@@ -2809,16 +3309,46 @@ var RapierScratch = class extends BaseScratch {
2809
3309
  this.world = new this.rapier.World({ x: 0, y: 0, z: 0 });
2810
3310
  this.world.timestep = 0;
2811
3311
  }
2812
- partsOf(body) {
2813
- return body.numColliders();
3312
+ signatureOf(body) {
3313
+ const live = body;
3314
+ const n = live.numColliders();
3315
+ const origin = live.translation();
3316
+ const inv = qConj(live.rotation());
3317
+ let h = mixSignature(0, n);
3318
+ for (let i = 0; i < n; i++) {
3319
+ const c = live.collider(i);
3320
+ h = mixSignature(h, c.handle);
3321
+ h = mixSignature(h, c.isSensor() ? 1 : 0);
3322
+ const w = c.translation();
3323
+ const local = qRotate(inv, { x: w.x - origin.x, y: w.y - origin.y, z: w.z - origin.z });
3324
+ h = mixSignature(h, local.x);
3325
+ h = mixSignature(h, local.y);
3326
+ h = mixSignature(h, local.z);
3327
+ const r = qMul(inv, c.rotation());
3328
+ h = mixSignature(h, r.x);
3329
+ h = mixSignature(h, r.y);
3330
+ h = mixSignature(h, r.z);
3331
+ h = mixSignature(h, r.w);
3332
+ const s = c.shape;
3333
+ h = mixSignature(h, s.type);
3334
+ h = mixSignature(h, s.radius ?? 0);
3335
+ h = mixSignature(h, s.halfHeight ?? 0);
3336
+ if (s.halfExtents) {
3337
+ h = mixSignature(h, s.halfExtents.x);
3338
+ h = mixSignature(h, s.halfExtents.y);
3339
+ h = mixSignature(h, s.halfExtents.z);
3340
+ }
3341
+ }
3342
+ return h;
2814
3343
  }
2815
- clone(collection, id, body, parts) {
3344
+ clone(collection, id, body, signature) {
2816
3345
  const live = body;
2817
3346
  this.trackedLive.add(live.handle);
2818
3347
  const made = this.world.createRigidBody(this.rapier.RigidBodyDesc.fixed());
2819
3348
  const origin = live.translation();
2820
3349
  const rot = live.rotation();
2821
3350
  const inv = qConj(rot);
3351
+ const parts = live.numColliders();
2822
3352
  for (let i = 0; i < parts; i++) {
2823
3353
  const c = live.collider(i);
2824
3354
  const desc = new this.rapier.ColliderDesc(c.shape);
@@ -2830,7 +3360,7 @@ var RapierScratch = class extends BaseScratch {
2830
3360
  this.world.createCollider(desc, made);
2831
3361
  }
2832
3362
  this.owners.set(made.handle, { collection, id });
2833
- return { collection, id, body: made, parts, lastLive: -1 };
3363
+ return { collection, id, body: made, signature, lastLive: -1 };
2834
3364
  }
2835
3365
  place(entry, pose) {
2836
3366
  const b = entry.body;
@@ -2906,10 +3436,36 @@ var Rapier2dScratch = class extends BaseScratch {
2906
3436
  this.world = new this.rapier.World({ x: 0, y: 0 });
2907
3437
  this.world.timestep = 0;
2908
3438
  }
2909
- partsOf(body) {
2910
- return body.numColliders();
3439
+ signatureOf(body) {
3440
+ const live = body;
3441
+ const n = live.numColliders();
3442
+ const origin = live.translation();
3443
+ const angle = live.rotation();
3444
+ const cos = Math.cos(-angle);
3445
+ const sin = Math.sin(-angle);
3446
+ let h = mixSignature(0, n);
3447
+ for (let i = 0; i < n; i++) {
3448
+ const c = live.collider(i);
3449
+ h = mixSignature(h, c.handle);
3450
+ h = mixSignature(h, c.isSensor() ? 1 : 0);
3451
+ const w = c.translation();
3452
+ const dx = w.x - origin.x;
3453
+ const dy = w.y - origin.y;
3454
+ h = mixSignature(h, dx * cos - dy * sin);
3455
+ h = mixSignature(h, dx * sin + dy * cos);
3456
+ h = mixSignature(h, c.rotation() - angle);
3457
+ const s = c.shape;
3458
+ h = mixSignature(h, s.type);
3459
+ h = mixSignature(h, s.radius ?? 0);
3460
+ h = mixSignature(h, s.halfHeight ?? 0);
3461
+ if (s.halfExtents) {
3462
+ h = mixSignature(h, s.halfExtents.x);
3463
+ h = mixSignature(h, s.halfExtents.y);
3464
+ }
3465
+ }
3466
+ return h;
2911
3467
  }
2912
- clone(collection, id, body, parts) {
3468
+ clone(collection, id, body, signature) {
2913
3469
  const live = body;
2914
3470
  this.trackedLive.add(live.handle);
2915
3471
  const made = this.world.createRigidBody(this.rapier.RigidBodyDesc.fixed());
@@ -2917,6 +3473,7 @@ var Rapier2dScratch = class extends BaseScratch {
2917
3473
  const angle = live.rotation();
2918
3474
  const cos = Math.cos(-angle);
2919
3475
  const sin = Math.sin(-angle);
3476
+ const parts = live.numColliders();
2920
3477
  for (let i = 0; i < parts; i++) {
2921
3478
  const c = live.collider(i);
2922
3479
  const desc = new this.rapier.ColliderDesc(c.shape);
@@ -2929,7 +3486,7 @@ var Rapier2dScratch = class extends BaseScratch {
2929
3486
  this.world.createCollider(desc, made);
2930
3487
  }
2931
3488
  this.owners.set(made.handle, { collection, id });
2932
- return { collection, id, body: made, parts, lastLive: -1 };
3489
+ return { collection, id, body: made, signature, lastLive: -1 };
2933
3490
  }
2934
3491
  place(entry, pose) {
2935
3492
  const b = entry.body;
@@ -2991,10 +3548,23 @@ var MatterScratch = class extends BaseScratch {
2991
3548
  super(physics, depth, tickNow);
2992
3549
  this.matter = physics.matter;
2993
3550
  }
2994
- partsOf(body) {
2995
- return body.parts.length;
2996
- }
2997
- clone(collection, id, body, parts) {
3551
+ signatureOf(body) {
3552
+ const live = body;
3553
+ const cos = Math.cos(-live.angle);
3554
+ const sin = Math.sin(-live.angle);
3555
+ let h = mixSignature(0, live.parts.length);
3556
+ for (const p of live.parts) {
3557
+ h = mixSignature(h, p.area);
3558
+ h = mixSignature(h, p.vertices.length);
3559
+ h = mixSignature(h, p.isSensor ? 1 : 0);
3560
+ const dx = p.position.x - live.position.x;
3561
+ const dy = p.position.y - live.position.y;
3562
+ h = mixSignature(h, dx * cos - dy * sin);
3563
+ h = mixSignature(h, dx * sin + dy * cos);
3564
+ }
3565
+ return h;
3566
+ }
3567
+ clone(collection, id, body, signature) {
2998
3568
  const M = this.matter;
2999
3569
  const live = body;
3000
3570
  const made = live.parts.length > 1 ? M.Body.create({ parts: live.parts.slice(1).map((p) => this.clonePart(p, live.angle)) }) : this.clonePart(live, live.angle);
@@ -3002,7 +3572,7 @@ var MatterScratch = class extends BaseScratch {
3002
3572
  made.angle = 0;
3003
3573
  for (const p of made.parts) p.angle = 0;
3004
3574
  this.owners.set(made, { collection, id });
3005
- return { collection, id, body: made, parts, lastLive: -1 };
3575
+ return { collection, id, body: made, signature, lastLive: -1 };
3006
3576
  }
3007
3577
  /** One part, un-rotated by the parent's angle so the double starts at angle 0. */
3008
3578
  clonePart(part, parentAngle) {
@@ -3131,13 +3701,13 @@ function lobbyConfigOf(core) {
3131
3701
  if (!schemaHasLobby(core.definition.schema)) return void 0;
3132
3702
  return declared;
3133
3703
  }
3134
- function stateOf3(core) {
3704
+ function stateOf5(core) {
3135
3705
  return core.anyState[LOBBY_STATE];
3136
3706
  }
3137
3707
  function evaluateLobby(core) {
3138
3708
  const config = lobbyConfigOf(core);
3139
3709
  if (config === void 0) return;
3140
- const state = stateOf3(core);
3710
+ const state = stateOf5(core);
3141
3711
  if (state === void 0) return;
3142
3712
  const members = trackedEntity(core, LOBBY_MEMBERS);
3143
3713
  const policy = config.start ?? "when-full";
@@ -3176,7 +3746,7 @@ function evaluateLobby(core) {
3176
3746
  function startLobby(core, why) {
3177
3747
  const runtime = runtimeOf(core);
3178
3748
  if (runtime.started) return;
3179
- const state = stateOf3(core);
3749
+ const state = stateOf5(core);
3180
3750
  if (state === void 0 || state.phase === "started") {
3181
3751
  runtime.started = true;
3182
3752
  return;
@@ -3199,7 +3769,7 @@ function makeLobby(core) {
3199
3769
  "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"
3200
3770
  );
3201
3771
  }
3202
- const state = stateOf3(core);
3772
+ const state = stateOf5(core);
3203
3773
  const members = trackedEntity(core, LOBBY_MEMBERS);
3204
3774
  return {
3205
3775
  get phase() {
@@ -3237,7 +3807,7 @@ function makeLobby(core) {
3237
3807
  function setLobbyPublic(core, value) {
3238
3808
  const config = lobbyConfigOf(core);
3239
3809
  if (config === void 0) return;
3240
- const state = stateOf3(core);
3810
+ const state = stateOf5(core);
3241
3811
  if (state === void 0) return;
3242
3812
  if (state.phase === "started") {
3243
3813
  core.log(
@@ -3256,7 +3826,7 @@ function setLobbyPublic(core, value) {
3256
3826
  }
3257
3827
 
3258
3828
  // src/core/messages.ts
3259
- import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
3829
+ import { FrameType as FrameType4, decodeMsg, encodeFrame as encodeFrame3, encodeMsg } from "@irtio/protocol";
3260
3830
  import { decodeFields as decodeFields2 } from "@irtio/schema";
3261
3831
  function toRoomTarget(target) {
3262
3832
  switch (target.kind) {
@@ -3336,8 +3906,8 @@ function handleMsg(core, clientId, payload) {
3336
3906
  if (!ran.ok || ran.value === false) return true;
3337
3907
  }
3338
3908
  if (msg.target.kind === "server") return true;
3339
- const frame = encodeFrame2(
3340
- FrameType3.MSG,
3909
+ const frame = encodeFrame3(
3910
+ FrameType4.MSG,
3341
3911
  encodeMsg({
3342
3912
  target: { kind: "client", clientId },
3343
3913
  payload: msg.payload,
@@ -3351,28 +3921,306 @@ function wireTargetOf(target) {
3351
3921
  return target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
3352
3922
  }
3353
3923
  function sendMessage(core, target, bytes) {
3354
- const frame = encodeFrame2(
3355
- FrameType3.MSG,
3924
+ const frame = encodeFrame3(
3925
+ FrameType4.MSG,
3356
3926
  encodeMsg({ target: { kind: "server" }, payload: bytes })
3357
3927
  );
3358
3928
  deliver(core, wireTargetOf(target), frame);
3359
3929
  }
3360
3930
  function sendTypedMessage(core, index, target, payload) {
3361
- const frame = encodeFrame2(
3362
- FrameType3.MSG,
3931
+ const frame = encodeFrame3(
3932
+ FrameType4.MSG,
3363
3933
  encodeMsg({ target: { kind: "server" }, payload, typed: { index } })
3364
3934
  );
3365
3935
  deliver(core, wireTargetOf(target), frame);
3366
3936
  }
3367
3937
 
3938
+ // src/core/replay.ts
3939
+ import {
3940
+ CLIP_MAX_BYTES,
3941
+ encodeClip
3942
+ } from "@irtio/protocol";
3943
+ import {
3944
+ encodeDelta as encodeDelta3,
3945
+ encodeSnapshot as encodeSnapshot3,
3946
+ isDirtyEmpty as isDirtyEmpty2
3947
+ } from "@irtio/schema";
3948
+ var REPLAY_DEFAULT_SECONDS = 60;
3949
+ var REPLAY_MAX_SECONDS = 300;
3950
+ var REPLAY_RING_MAX_BYTES = 8 * 1024 * 1024;
3951
+ var REPLAY_KEYFRAME_SECONDS = 2;
3952
+ var CLIP_COOLDOWN_MS = 1e3;
3953
+ var ReplayRing = class {
3954
+ constructor(bounds) {
3955
+ this.bounds = bounds;
3956
+ }
3957
+ bounds;
3958
+ items = [];
3959
+ total = 0;
3960
+ /** How many times an oversized segment has emptied the ring. Read through `append`'s return. */
3961
+ wipes = 0;
3962
+ /** The tick the newest keyframe went in at; `-1` while the ring is empty. */
3963
+ lastKeyframeTick = -1;
3964
+ get frames() {
3965
+ return this.items;
3966
+ }
3967
+ get bytes() {
3968
+ return this.total;
3969
+ }
3970
+ get empty() {
3971
+ return this.items.length === 0;
3972
+ }
3973
+ /** How many oversized segments have emptied the ring. A test's handle on the degenerate case. */
3974
+ get wipeCount() {
3975
+ return this.wipes;
3976
+ }
3977
+ /**
3978
+ * Appends a frame and trims. Returns `true` when the trim had to throw the *whole* ring away
3979
+ * (an oversized segment with nowhere to cut), which the caller has to know about: the ring is
3980
+ * now empty, so anything it was about to append next would head the ring without a keyframe.
3981
+ */
3982
+ append(frame) {
3983
+ this.items.push(frame);
3984
+ this.total += frame.bytes.length;
3985
+ if (frame.keyframe) this.lastKeyframeTick = frame.tick;
3986
+ const before = this.wipes;
3987
+ this.trim();
3988
+ return this.wipes !== before;
3989
+ }
3990
+ /** Is a keyframe due at `tick`? Always true while the ring is empty. */
3991
+ keyframeDue(tick) {
3992
+ return this.empty || tick - this.lastKeyframeTick >= this.bounds.keyframeEvery;
3993
+ }
3994
+ /** The frames from the newest keyframe at or before `fromTick` to the end. */
3995
+ tail(fromTick) {
3996
+ let start = 0;
3997
+ for (let i = 0; i < this.items.length; i++) {
3998
+ const f = this.items[i];
3999
+ if (f.keyframe && f.tick <= fromTick) start = i;
4000
+ }
4001
+ return this.items.slice(start);
4002
+ }
4003
+ trim() {
4004
+ const last = this.items[this.items.length - 1];
4005
+ if (!last) return;
4006
+ for (; ; ) {
4007
+ const head = this.items[0];
4008
+ if (!head) return;
4009
+ const overBytes = this.total > this.bounds.maxBytes;
4010
+ const overWindow = last.tick - head.tick > this.bounds.windowTicks;
4011
+ if (!overBytes && !overWindow) return;
4012
+ let next = -1;
4013
+ for (let i = 1; i < this.items.length; i++) {
4014
+ if (this.items[i]?.keyframe === true) {
4015
+ next = i;
4016
+ break;
4017
+ }
4018
+ }
4019
+ if (next === -1) {
4020
+ if (!overBytes) return;
4021
+ this.items = [];
4022
+ this.total = 0;
4023
+ this.lastKeyframeTick = -1;
4024
+ this.wipes++;
4025
+ return;
4026
+ }
4027
+ for (let i = 0; i < next; i++) this.total -= this.items[i].bytes.length;
4028
+ this.items = this.items.slice(next);
4029
+ }
4030
+ }
4031
+ };
4032
+ var states5 = /* @__PURE__ */ new WeakMap();
4033
+ function configOf(core) {
4034
+ const config = core.definition.config;
4035
+ return config.replay;
4036
+ }
4037
+ function replayArmed(core) {
4038
+ return configOf(core) !== void 0;
4039
+ }
4040
+ function stateOf6(core) {
4041
+ const declared = configOf(core);
4042
+ if (declared === void 0) return void 0;
4043
+ let s = states5.get(core);
4044
+ if (!s) {
4045
+ const tickRate = core.definition.config.tickRate ?? 20;
4046
+ const seconds = Math.min(
4047
+ REPLAY_MAX_SECONDS,
4048
+ Math.max(1, Math.floor(declared.seconds ?? REPLAY_DEFAULT_SECONDS))
4049
+ );
4050
+ s = {
4051
+ seconds,
4052
+ view: declared.view ?? "",
4053
+ ring: new ReplayRing({
4054
+ windowTicks: Math.max(1, Math.ceil(seconds * tickRate)),
4055
+ keyframeEvery: Math.max(1, Math.round(REPLAY_KEYFRAME_SECONDS * tickRate)),
4056
+ maxBytes: REPLAY_RING_MAX_BYTES
4057
+ }),
4058
+ lastClipAt: Number.NEGATIVE_INFINITY,
4059
+ clipsTaken: 0,
4060
+ disarmed: false
4061
+ };
4062
+ states5.set(core, s);
4063
+ }
4064
+ return s;
4065
+ }
4066
+ function omniscientMemberships(core) {
4067
+ const out = /* @__PURE__ */ new Map();
4068
+ for (const desc of core.ext.collections) {
4069
+ if (desc.visibility !== "spatial-grid") continue;
4070
+ out.set(desc.name, new Set(plainEntity(core.plain, desc.name).ids()));
4071
+ }
4072
+ return out;
4073
+ }
4074
+ function hasRoleScoped(core) {
4075
+ return core.ext.collections.some((c) => c.visibility === "role");
4076
+ }
4077
+ function hasSpatial(core) {
4078
+ return core.ext.collections.some(
4079
+ (c) => c.visibility === "spatial-grid"
4080
+ );
4081
+ }
4082
+ function recorderSnapshot(core, state) {
4083
+ if (state.view === "") {
4084
+ return encodeSnapshot3(core.ext, core.plain, { tick: core.tick });
4085
+ }
4086
+ return encodeViewSnapshot(
4087
+ core.ext,
4088
+ core.plain,
4089
+ state.view,
4090
+ core.tick,
4091
+ omniscientMemberships(core)
4092
+ );
4093
+ }
4094
+ function recorderDelta(core, state, dirty) {
4095
+ if (state.view === "") {
4096
+ if (isDirtyEmpty2(dirty)) return null;
4097
+ return encodeDelta3(core.ext, core.plain, dirty, { tick: core.tick });
4098
+ }
4099
+ return encodeViewDelta(core.ext, core.plain, dirty, state.view, core.tick);
4100
+ }
4101
+ function cacheKeyFor(core, state) {
4102
+ if (hasSpatial(core)) return void 0;
4103
+ if (state.view === "") return hasRoleScoped(core) ? void 0 : "all";
4104
+ return viewKeyFor(core.ext, state.view);
4105
+ }
4106
+ function recordFlush(core, dirty, byView) {
4107
+ const state = stateOf6(core);
4108
+ if (!state || state.disarmed) return;
4109
+ const ring = state.ring;
4110
+ if (!ring.empty && isDirtyEmpty2(dirty)) return;
4111
+ if (ring.keyframeDue(core.tick)) {
4112
+ const wiped = ring.append({
4113
+ tick: core.tick,
4114
+ keyframe: true,
4115
+ bytes: recorderSnapshot(core, state)
4116
+ });
4117
+ if (wiped) {
4118
+ disarm(core, state);
4119
+ return;
4120
+ }
4121
+ }
4122
+ const key = cacheKeyFor(core, state);
4123
+ const cached = key === void 0 ? void 0 : byView.get(key);
4124
+ const delta = cached !== void 0 ? cached : recorderDelta(core, state, dirty);
4125
+ if (!delta) return;
4126
+ ring.append({ tick: core.tick, keyframe: false, bytes: delta });
4127
+ }
4128
+ function disarm(core, state) {
4129
+ state.disarmed = true;
4130
+ core.host.log("warn", [
4131
+ `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.`
4132
+ ]);
4133
+ }
4134
+ function assembleClip(core, seconds) {
4135
+ const state = stateOf6(core);
4136
+ if (!state || state.disarmed || state.ring.empty) return void 0;
4137
+ const tickRate = core.definition.config.tickRate ?? 20;
4138
+ const want = Math.min(state.seconds, Math.max(1, Math.floor(seconds ?? state.seconds)));
4139
+ const ringFrames = state.ring.frames;
4140
+ const last = ringFrames[ringFrames.length - 1];
4141
+ const frames = state.ring.tail(last.tick - Math.ceil(want * tickRate)).map((f) => ({
4142
+ tick: f.tick,
4143
+ kind: f.keyframe ? "keyframe" : "delta",
4144
+ bytes: f.bytes
4145
+ }));
4146
+ const head = frames[0];
4147
+ if (head === void 0 || head.kind !== "keyframe") return void 0;
4148
+ const clip = {
4149
+ // The GAME schema's hash, not the extended one's. That is what a client puts in its HELLO,
4150
+ // so it is the value playback can actually compare against — and it is the hash a deploy
4151
+ // changes, which is the whole thing the pin exists to catch.
4152
+ schemaHash8: core.definition.schema.hash8,
4153
+ tickRate,
4154
+ startTick: head.tick,
4155
+ durationTicks: last.tick - head.tick,
4156
+ view: state.view,
4157
+ frames
4158
+ };
4159
+ return encodeClip(clip);
4160
+ }
4161
+ function takeClip(core, seconds) {
4162
+ if (!replayArmed(core)) {
4163
+ return rejectAsEvent(
4164
+ core,
4165
+ new Error(
4166
+ "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."
4167
+ )
4168
+ );
4169
+ }
4170
+ const state = stateOf6(core);
4171
+ const now = core.host.now();
4172
+ if (now - state.lastClipAt < CLIP_COOLDOWN_MS) {
4173
+ return rejectAsEvent(
4174
+ core,
4175
+ new Error(
4176
+ `E_CLIP_COOLDOWN: room.replay.clip may be called once every ${CLIP_COOLDOWN_MS}ms per room; this call came too soon after the last one`
4177
+ )
4178
+ );
4179
+ }
4180
+ if (state.disarmed) {
4181
+ return rejectAsEvent(
4182
+ core,
4183
+ new Error(
4184
+ `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`
4185
+ )
4186
+ );
4187
+ }
4188
+ let bytes;
4189
+ try {
4190
+ bytes = assembleClip(core, seconds);
4191
+ } catch (err) {
4192
+ return rejectAsEvent(
4193
+ core,
4194
+ new Error(`E_CLIP_ENCODE: the replay ring could not be encoded: ${String(err)}`)
4195
+ );
4196
+ }
4197
+ if (bytes === void 0) {
4198
+ return rejectAsEvent(
4199
+ core,
4200
+ new Error("E_CLIP_EMPTY: the replay ring is empty \u2014 this room has not flushed a change yet")
4201
+ );
4202
+ }
4203
+ if (bytes.length > CLIP_MAX_BYTES) {
4204
+ return rejectAsEvent(
4205
+ core,
4206
+ new Error(
4207
+ `E_CLIP_TOO_LARGE: this clip is ${bytes.length} bytes and the limit is ${CLIP_MAX_BYTES}`
4208
+ )
4209
+ );
4210
+ }
4211
+ state.lastClipAt = now;
4212
+ state.clipsTaken++;
4213
+ return startHostCall(core, { kind: "clip", bytes }, (value) => ({ id: value ?? "" }));
4214
+ }
4215
+
3368
4216
  // src/core/room-api.ts
3369
4217
  import {
3370
- FrameType as FrameType4,
4218
+ FrameType as FrameType5,
3371
4219
  busChannelProblem,
3372
4220
  busPayloadProblem,
3373
- encodeFrame as encodeFrame3
4221
+ encodeFrame as encodeFrame4
3374
4222
  } from "@irtio/protocol";
3375
- import { encodeDelta as encodeDelta3, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
4223
+ import { encodeDelta as encodeDelta4, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty3 } from "@irtio/schema";
3376
4224
  function makePhysicsApi(p) {
3377
4225
  return {
3378
4226
  get rapier() {
@@ -3722,6 +4570,20 @@ function createRoomApi(core, publicUrl) {
3722
4570
  get lobby() {
3723
4571
  return makeLobby(core);
3724
4572
  },
4573
+ // ---- M6 lane N: replays ----
4574
+ // D81: one getter, and deliberately only one. Everything replays do — the ring, the
4575
+ // eviction invariant, the recorder view, the clip assembly, the cooldown — lives in
4576
+ // `core/replay.ts` behind these two calls, so this file stays what it says it is: a facade
4577
+ // that reads through.
4578
+ get replay() {
4579
+ return {
4580
+ get armed() {
4581
+ return replayArmed(core);
4582
+ },
4583
+ clip: (seconds) => takeClip(core, seconds)
4584
+ };
4585
+ },
4586
+ // ---- end M6 lane N ----
3725
4587
  alarm(name, atMs) {
3726
4588
  if (!isAlarmName(core, name, "room.alarm")) return;
3727
4589
  if (!Number.isFinite(atMs)) {
@@ -3804,10 +4666,10 @@ function setRole(core, clientId, role) {
3804
4666
  }
3805
4667
  if (entry.role === role) return;
3806
4668
  const dirty = catchUpDirty(core.ext, core.plain, entry.role, role);
3807
- if (!isDirtyEmpty2(dirty)) {
4669
+ if (!isDirtyEmpty3(dirty)) {
3808
4670
  core.send(
3809
4671
  clientId,
3810
- encodeFrame3(FrameType4.DELTA, encodeDelta3(core.ext, core.plain, dirty, { tick: core.tick }))
4672
+ encodeFrame4(FrameType5.DELTA, encodeDelta4(core.ext, core.plain, dirty, { tick: core.tick }))
3811
4673
  );
3812
4674
  }
3813
4675
  entry.role = role;
@@ -3910,6 +4772,10 @@ var RoomCore = class _RoomCore {
3910
4772
  };
3911
4773
  tick = 0;
3912
4774
  stopped = false;
4775
+ // ---- M6 lane K: stateful relay ----
4776
+ /** D77: the wall clock hold deadlines are measured against. See `RoomCoreOptions.wallNow`. */
4777
+ wallNow;
4778
+ // ---- end M6 lane K ----
3913
4779
  /**
3914
4780
  * Called for every handler throw `tryRun` swallows, before it is logged. A test harness sets
3915
4781
  * this so a room that breaks fails the test that broke it (bug 2): the runtime's job is to
@@ -3986,6 +4852,9 @@ var RoomCore = class _RoomCore {
3986
4852
  const historySetup = this.physics ? historyOf(definition.config.physics) : void 0;
3987
4853
  this.rewindState = historySetup === void 0 ? void 0 : new RewindState(historySetup);
3988
4854
  this.subscribeDeclaredChannels();
4855
+ installCaps(self, options.caps ?? {});
4856
+ this.wallNow = options.wallNow ?? (() => Date.now());
4857
+ restoreHolds(self, restored?.holds);
3989
4858
  if (restored) {
3990
4859
  const onWake = definition.config.onWake;
3991
4860
  if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
@@ -4326,8 +5195,13 @@ var RoomCore = class _RoomCore {
4326
5195
  mode: this.mode,
4327
5196
  schemaHash8: this.ext.hash8
4328
5197
  },
4329
- encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
4330
- physics
5198
+ encodeSnapshot4(this.ext, this.plain, { tick: this.tick }),
5199
+ physics,
5200
+ // ---- M6 lane K: stateful relay ----
5201
+ // D77: hold deadlines are state, so they go where the state goes. `undefined` for a room
5202
+ // holding nothing, which keeps its blob byte-identical to the one it wrote before D77.
5203
+ serializeHolds(this.internals)
5204
+ // ---- end M6 lane K ----
4331
5205
  );
4332
5206
  }
4333
5207
  serialize() {
@@ -4402,6 +5276,18 @@ var RoomCore = class _RoomCore {
4402
5276
  // a key join `ctx.playerId` is exactly as durable as the resume token and no more. A JWT
4403
5277
  // join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
4404
5278
  // and nothing else here changes.
5279
+ //
5280
+ // ---- M6 lane K: stateful relay ----
5281
+ // An existing entry keeps the `playerId` it was created with: this is `existing ?? {...}`,
5282
+ // so a rejoin never overwrites it. That is deliberate and it is the safe direction — a
5283
+ // session's identity cannot be changed by anything a later HELLO on the same seat says. It
5284
+ // has one visible consequence, and D77's holds are where it shows: a client that joins
5285
+ // anonymously and presents an assertion later in the same session keeps the resume token's
5286
+ // client id as its `playerId`, so its holds stay bound to the token rather than moving to
5287
+ // the verified subject. Its next fresh join binds them to the subject. Said in
5288
+ // `authority-models.md` too, because a developer meets it as "my holds did not follow me to
5289
+ // the other tab" rather than as a line in this file.
5290
+ // ---- end M6 lane K ----
4405
5291
  playerId: options.playerId ?? clientId,
4406
5292
  npc: options.npc === true,
4407
5293
  role,
@@ -4419,6 +5305,8 @@ var RoomCore = class _RoomCore {
4419
5305
  entry.connected = true;
4420
5306
  this.clients.set(clientId, entry);
4421
5307
  this.invalidateClients();
5308
+ reclaimHolds(this.internals, clientId, entry.playerId);
5309
+ addPerPlayerRecords(this.internals, clientId);
4422
5310
  const onJoin = this.definition.config.onJoin;
4423
5311
  if (onJoin) {
4424
5312
  const ctx = this.ctxFor(clientId, reconnecting);
@@ -4452,7 +5340,7 @@ var RoomCore = class _RoomCore {
4452
5340
  /** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
4453
5341
  eventFlush() {
4454
5342
  if (this.mode !== "event" || this.stopped) return;
4455
- if (isDirtyEmpty3(this.tracked.dirty)) return;
5343
+ if (isDirtyEmpty4(this.tracked.dirty)) return;
4456
5344
  this.tick++;
4457
5345
  this.flush();
4458
5346
  this.captureTimeline();
@@ -4468,6 +5356,8 @@ var RoomCore = class _RoomCore {
4468
5356
  const ctx = this.ctxFor(clientId);
4469
5357
  this.guard("onLeave", () => onLeave(this.state, ctx, reason));
4470
5358
  }
5359
+ placeHolds(this.internals, clientId, entry.playerId);
5360
+ forgetWriter(this.internals, clientId);
4471
5361
  this.presence.remove(clientId);
4472
5362
  this.recordEvent("leave", clientId, reason);
4473
5363
  rejectPendingFor(this.internals, clientId, "client disconnected");
@@ -4601,6 +5491,16 @@ var RoomCore = class _RoomCore {
4601
5491
  * `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
4602
5492
  * `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
4603
5493
  */
5494
+ // ---- M6 lane K: stateful relay ----
5495
+ /** D77: the encoded state size, when a host set the state cap. See `caps.ts`. */
5496
+ stateBytes() {
5497
+ return stateBytesOf(this.internals);
5498
+ }
5499
+ /** D77: how many records are held for players who have left. */
5500
+ holdCount() {
5501
+ return holdCount(this.internals);
5502
+ }
5503
+ // ---- end M6 lane K ----
4604
5504
  profile() {
4605
5505
  return this.ledger?.snapshot();
4606
5506
  }
@@ -4631,20 +5531,20 @@ var RoomCore = class _RoomCore {
4631
5531
  return;
4632
5532
  }
4633
5533
  switch (type) {
4634
- case FrameType5.WRITE:
4635
- case FrameType5.CALL: {
4636
- if (type === FrameType5.WRITE) this.recordEvent("write", clientId);
5534
+ case FrameType6.WRITE:
5535
+ case FrameType6.CALL: {
5536
+ if (type === FrameType6.WRITE) this.recordEvent("write", clientId);
4637
5537
  if (this.mode === "tick") {
4638
5538
  this.loop.enqueue({ clientId, type, payload: payload.slice() });
4639
5539
  } else if (!this.loop.applyEvent({ clientId, type, payload })) {
4640
5540
  this.badFrame(
4641
5541
  clientId,
4642
- `malformed ${type === FrameType5.WRITE ? "WRITE" : "CALL"} payload`
5542
+ `malformed ${type === FrameType6.WRITE ? "WRITE" : "CALL"} payload`
4643
5543
  );
4644
5544
  }
4645
5545
  return;
4646
5546
  }
4647
- case FrameType5.REPLY: {
5547
+ case FrameType6.REPLY: {
4648
5548
  if (!handleReply(this.internals, clientId, payload)) {
4649
5549
  this.badFrame(clientId, "malformed REPLY payload");
4650
5550
  } else {
@@ -4652,7 +5552,7 @@ var RoomCore = class _RoomCore {
4652
5552
  }
4653
5553
  return;
4654
5554
  }
4655
- case FrameType5.MSG: {
5555
+ case FrameType6.MSG: {
4656
5556
  if (this.mode === "event") this.tick++;
4657
5557
  const ok = handleMsg(this.internals, clientId, payload);
4658
5558
  if (this.mode === "event") this.flush();
@@ -4670,6 +5570,7 @@ var RoomCore = class _RoomCore {
4670
5570
  */
4671
5571
  flush() {
4672
5572
  evaluateLobby(this.internals);
5573
+ expireHolds(this.internals);
4673
5574
  const dirty = this.tracked.flush();
4674
5575
  serverWinsCorrections(this.internals, dirty);
4675
5576
  this.ledger?.newFlush();
@@ -4727,14 +5628,14 @@ var RoomCore = class _RoomCore {
4727
5628
  let shared;
4728
5629
  if (entry.pendingJoinAdds) {
4729
5630
  const sharedDirty = sharedViewDirty(this.ext, sourceDirty, entry.role);
4730
- shared = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
5631
+ shared = isDirtyEmpty4(sharedDirty) ? null : encodeDelta5(this.ext, this.plain, sharedDirty, { tick: this.tick });
4731
5632
  if (shared) this.stats.encodesLastFlush++;
4732
5633
  } else {
4733
5634
  const key = viewKeyFor(this.ext, entry.role);
4734
5635
  let cached = byView.get(key);
4735
5636
  if (cached === void 0) {
4736
5637
  const sharedDirty = sharedViewDirty(this.ext, dirty, entry.role);
4737
- cached = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
5638
+ cached = isDirtyEmpty4(sharedDirty) ? null : encodeDelta5(this.ext, this.plain, sharedDirty, { tick: this.tick });
4738
5639
  if (cached) this.stats.encodesLastFlush++;
4739
5640
  byView.set(key, cached);
4740
5641
  }
@@ -4743,7 +5644,7 @@ var RoomCore = class _RoomCore {
4743
5644
  if (shared) {
4744
5645
  this.send(
4745
5646
  entry.clientId,
4746
- encodeFrame4(FrameType5.DELTA, shared),
5647
+ encodeFrame5(FrameType6.DELTA, shared),
4747
5648
  this.ledger ? { shared } : void 0
4748
5649
  );
4749
5650
  }
@@ -4754,7 +5655,7 @@ var RoomCore = class _RoomCore {
4754
5655
  memberships
4755
5656
  );
4756
5657
  const encodeStarted = performance.now();
4757
- delta = isDirtyEmpty3(aoiDirty) ? null : encodeDelta4(this.ext, this.plain, aoiDirty, { tick: this.tick });
5658
+ delta = isDirtyEmpty4(aoiDirty) ? null : encodeDelta5(this.ext, this.plain, aoiDirty, { tick: this.tick });
4758
5659
  this.stats.aoiEncodeMs += performance.now() - encodeStarted;
4759
5660
  if (delta) {
4760
5661
  this.stats.encodesLastFlush++;
@@ -4785,7 +5686,7 @@ var RoomCore = class _RoomCore {
4785
5686
  if (delta) {
4786
5687
  this.send(
4787
5688
  entry.clientId,
4788
- encodeFrame4(FrameType5.DELTA, delta),
5689
+ encodeFrame5(FrameType6.DELTA, delta),
4789
5690
  this.ledger ? {
4790
5691
  ...churn ? { churn } : {},
4791
5692
  ...spatialDescs.length > 0 ? {} : { shared: delta }
@@ -4796,6 +5697,7 @@ var RoomCore = class _RoomCore {
4796
5697
  entry.correction = void 0;
4797
5698
  entry.accepted.clear();
4798
5699
  }
5700
+ recordFlush(this.internals, dirty, byView);
4799
5701
  }
4800
5702
  };
4801
5703
  function encodeSection(physics) {
@@ -4811,6 +5713,11 @@ function encodeSection(physics) {
4811
5713
  export {
4812
5714
  HOST_CALL_TIMEOUT_MS,
4813
5715
  RoomFullError,
5716
+ RELAY_STATE_BYTES,
5717
+ RELAY_RECORDS_PER_COLLECTION,
5718
+ RELAY_WRITES_PER_SECOND,
5719
+ DEFAULT_RECORDS_PER_COLLECTION,
5720
+ RELAY_WRITE_CAPS,
4814
5721
  EVENT_RING_SIZE,
4815
5722
  inspectState,
4816
5723
  RPC_TIMEOUT_MS,