@irtio/runtime 0.10.1 → 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,7 +3205,7 @@ 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) {
@@ -2734,7 +3230,7 @@ var BaseScratch = class {
2734
3230
  this.sync();
2735
3231
  this.present.clear();
2736
3232
  for (let i = 0; i < entry.count; i++) {
2737
- const key = keyOf(entry.collections[i], entry.ids[i]);
3233
+ const key = keyOf2(entry.collections[i], entry.ids[i]);
2738
3234
  const e = this.bodies.get(key);
2739
3235
  if (!e) continue;
2740
3236
  poseAt(entry, i, this.pose);
@@ -2760,7 +3256,7 @@ var BaseScratch = class {
2760
3256
  const now = this.tickNow();
2761
3257
  const seen = /* @__PURE__ */ new Set();
2762
3258
  this.physics.eachTrackedBody((collection, id, body) => {
2763
- const key = keyOf(collection, id);
3259
+ const key = keyOf2(collection, id);
2764
3260
  seen.add(key);
2765
3261
  const signature = this.signatureOf(body);
2766
3262
  const existing = this.bodies.get(key);
@@ -3205,13 +3701,13 @@ function lobbyConfigOf(core) {
3205
3701
  if (!schemaHasLobby(core.definition.schema)) return void 0;
3206
3702
  return declared;
3207
3703
  }
3208
- function stateOf3(core) {
3704
+ function stateOf5(core) {
3209
3705
  return core.anyState[LOBBY_STATE];
3210
3706
  }
3211
3707
  function evaluateLobby(core) {
3212
3708
  const config = lobbyConfigOf(core);
3213
3709
  if (config === void 0) return;
3214
- const state = stateOf3(core);
3710
+ const state = stateOf5(core);
3215
3711
  if (state === void 0) return;
3216
3712
  const members = trackedEntity(core, LOBBY_MEMBERS);
3217
3713
  const policy = config.start ?? "when-full";
@@ -3250,7 +3746,7 @@ function evaluateLobby(core) {
3250
3746
  function startLobby(core, why) {
3251
3747
  const runtime = runtimeOf(core);
3252
3748
  if (runtime.started) return;
3253
- const state = stateOf3(core);
3749
+ const state = stateOf5(core);
3254
3750
  if (state === void 0 || state.phase === "started") {
3255
3751
  runtime.started = true;
3256
3752
  return;
@@ -3273,7 +3769,7 @@ function makeLobby(core) {
3273
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"
3274
3770
  );
3275
3771
  }
3276
- const state = stateOf3(core);
3772
+ const state = stateOf5(core);
3277
3773
  const members = trackedEntity(core, LOBBY_MEMBERS);
3278
3774
  return {
3279
3775
  get phase() {
@@ -3311,7 +3807,7 @@ function makeLobby(core) {
3311
3807
  function setLobbyPublic(core, value) {
3312
3808
  const config = lobbyConfigOf(core);
3313
3809
  if (config === void 0) return;
3314
- const state = stateOf3(core);
3810
+ const state = stateOf5(core);
3315
3811
  if (state === void 0) return;
3316
3812
  if (state.phase === "started") {
3317
3813
  core.log(
@@ -3330,7 +3826,7 @@ function setLobbyPublic(core, value) {
3330
3826
  }
3331
3827
 
3332
3828
  // src/core/messages.ts
3333
- 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";
3334
3830
  import { decodeFields as decodeFields2 } from "@irtio/schema";
3335
3831
  function toRoomTarget(target) {
3336
3832
  switch (target.kind) {
@@ -3410,8 +3906,8 @@ function handleMsg(core, clientId, payload) {
3410
3906
  if (!ran.ok || ran.value === false) return true;
3411
3907
  }
3412
3908
  if (msg.target.kind === "server") return true;
3413
- const frame = encodeFrame2(
3414
- FrameType3.MSG,
3909
+ const frame = encodeFrame3(
3910
+ FrameType4.MSG,
3415
3911
  encodeMsg({
3416
3912
  target: { kind: "client", clientId },
3417
3913
  payload: msg.payload,
@@ -3425,28 +3921,306 @@ function wireTargetOf(target) {
3425
3921
  return target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
3426
3922
  }
3427
3923
  function sendMessage(core, target, bytes) {
3428
- const frame = encodeFrame2(
3429
- FrameType3.MSG,
3924
+ const frame = encodeFrame3(
3925
+ FrameType4.MSG,
3430
3926
  encodeMsg({ target: { kind: "server" }, payload: bytes })
3431
3927
  );
3432
3928
  deliver(core, wireTargetOf(target), frame);
3433
3929
  }
3434
3930
  function sendTypedMessage(core, index, target, payload) {
3435
- const frame = encodeFrame2(
3436
- FrameType3.MSG,
3931
+ const frame = encodeFrame3(
3932
+ FrameType4.MSG,
3437
3933
  encodeMsg({ target: { kind: "server" }, payload, typed: { index } })
3438
3934
  );
3439
3935
  deliver(core, wireTargetOf(target), frame);
3440
3936
  }
3441
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
+
3442
4216
  // src/core/room-api.ts
3443
4217
  import {
3444
- FrameType as FrameType4,
4218
+ FrameType as FrameType5,
3445
4219
  busChannelProblem,
3446
4220
  busPayloadProblem,
3447
- encodeFrame as encodeFrame3
4221
+ encodeFrame as encodeFrame4
3448
4222
  } from "@irtio/protocol";
3449
- 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";
3450
4224
  function makePhysicsApi(p) {
3451
4225
  return {
3452
4226
  get rapier() {
@@ -3796,6 +4570,20 @@ function createRoomApi(core, publicUrl) {
3796
4570
  get lobby() {
3797
4571
  return makeLobby(core);
3798
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 ----
3799
4587
  alarm(name, atMs) {
3800
4588
  if (!isAlarmName(core, name, "room.alarm")) return;
3801
4589
  if (!Number.isFinite(atMs)) {
@@ -3878,10 +4666,10 @@ function setRole(core, clientId, role) {
3878
4666
  }
3879
4667
  if (entry.role === role) return;
3880
4668
  const dirty = catchUpDirty(core.ext, core.plain, entry.role, role);
3881
- if (!isDirtyEmpty2(dirty)) {
4669
+ if (!isDirtyEmpty3(dirty)) {
3882
4670
  core.send(
3883
4671
  clientId,
3884
- 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 }))
3885
4673
  );
3886
4674
  }
3887
4675
  entry.role = role;
@@ -3984,6 +4772,10 @@ var RoomCore = class _RoomCore {
3984
4772
  };
3985
4773
  tick = 0;
3986
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 ----
3987
4779
  /**
3988
4780
  * Called for every handler throw `tryRun` swallows, before it is logged. A test harness sets
3989
4781
  * this so a room that breaks fails the test that broke it (bug 2): the runtime's job is to
@@ -4060,6 +4852,9 @@ var RoomCore = class _RoomCore {
4060
4852
  const historySetup = this.physics ? historyOf(definition.config.physics) : void 0;
4061
4853
  this.rewindState = historySetup === void 0 ? void 0 : new RewindState(historySetup);
4062
4854
  this.subscribeDeclaredChannels();
4855
+ installCaps(self, options.caps ?? {});
4856
+ this.wallNow = options.wallNow ?? (() => Date.now());
4857
+ restoreHolds(self, restored?.holds);
4063
4858
  if (restored) {
4064
4859
  const onWake = definition.config.onWake;
4065
4860
  if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
@@ -4400,8 +5195,13 @@ var RoomCore = class _RoomCore {
4400
5195
  mode: this.mode,
4401
5196
  schemaHash8: this.ext.hash8
4402
5197
  },
4403
- encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
4404
- 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 ----
4405
5205
  );
4406
5206
  }
4407
5207
  serialize() {
@@ -4476,6 +5276,18 @@ var RoomCore = class _RoomCore {
4476
5276
  // a key join `ctx.playerId` is exactly as durable as the resume token and no more. A JWT
4477
5277
  // join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
4478
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 ----
4479
5291
  playerId: options.playerId ?? clientId,
4480
5292
  npc: options.npc === true,
4481
5293
  role,
@@ -4493,6 +5305,8 @@ var RoomCore = class _RoomCore {
4493
5305
  entry.connected = true;
4494
5306
  this.clients.set(clientId, entry);
4495
5307
  this.invalidateClients();
5308
+ reclaimHolds(this.internals, clientId, entry.playerId);
5309
+ addPerPlayerRecords(this.internals, clientId);
4496
5310
  const onJoin = this.definition.config.onJoin;
4497
5311
  if (onJoin) {
4498
5312
  const ctx = this.ctxFor(clientId, reconnecting);
@@ -4526,7 +5340,7 @@ var RoomCore = class _RoomCore {
4526
5340
  /** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
4527
5341
  eventFlush() {
4528
5342
  if (this.mode !== "event" || this.stopped) return;
4529
- if (isDirtyEmpty3(this.tracked.dirty)) return;
5343
+ if (isDirtyEmpty4(this.tracked.dirty)) return;
4530
5344
  this.tick++;
4531
5345
  this.flush();
4532
5346
  this.captureTimeline();
@@ -4542,6 +5356,8 @@ var RoomCore = class _RoomCore {
4542
5356
  const ctx = this.ctxFor(clientId);
4543
5357
  this.guard("onLeave", () => onLeave(this.state, ctx, reason));
4544
5358
  }
5359
+ placeHolds(this.internals, clientId, entry.playerId);
5360
+ forgetWriter(this.internals, clientId);
4545
5361
  this.presence.remove(clientId);
4546
5362
  this.recordEvent("leave", clientId, reason);
4547
5363
  rejectPendingFor(this.internals, clientId, "client disconnected");
@@ -4675,6 +5491,16 @@ var RoomCore = class _RoomCore {
4675
5491
  * `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
4676
5492
  * `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
4677
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 ----
4678
5504
  profile() {
4679
5505
  return this.ledger?.snapshot();
4680
5506
  }
@@ -4705,20 +5531,20 @@ var RoomCore = class _RoomCore {
4705
5531
  return;
4706
5532
  }
4707
5533
  switch (type) {
4708
- case FrameType5.WRITE:
4709
- case FrameType5.CALL: {
4710
- 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);
4711
5537
  if (this.mode === "tick") {
4712
5538
  this.loop.enqueue({ clientId, type, payload: payload.slice() });
4713
5539
  } else if (!this.loop.applyEvent({ clientId, type, payload })) {
4714
5540
  this.badFrame(
4715
5541
  clientId,
4716
- `malformed ${type === FrameType5.WRITE ? "WRITE" : "CALL"} payload`
5542
+ `malformed ${type === FrameType6.WRITE ? "WRITE" : "CALL"} payload`
4717
5543
  );
4718
5544
  }
4719
5545
  return;
4720
5546
  }
4721
- case FrameType5.REPLY: {
5547
+ case FrameType6.REPLY: {
4722
5548
  if (!handleReply(this.internals, clientId, payload)) {
4723
5549
  this.badFrame(clientId, "malformed REPLY payload");
4724
5550
  } else {
@@ -4726,7 +5552,7 @@ var RoomCore = class _RoomCore {
4726
5552
  }
4727
5553
  return;
4728
5554
  }
4729
- case FrameType5.MSG: {
5555
+ case FrameType6.MSG: {
4730
5556
  if (this.mode === "event") this.tick++;
4731
5557
  const ok = handleMsg(this.internals, clientId, payload);
4732
5558
  if (this.mode === "event") this.flush();
@@ -4744,6 +5570,7 @@ var RoomCore = class _RoomCore {
4744
5570
  */
4745
5571
  flush() {
4746
5572
  evaluateLobby(this.internals);
5573
+ expireHolds(this.internals);
4747
5574
  const dirty = this.tracked.flush();
4748
5575
  serverWinsCorrections(this.internals, dirty);
4749
5576
  this.ledger?.newFlush();
@@ -4801,14 +5628,14 @@ var RoomCore = class _RoomCore {
4801
5628
  let shared;
4802
5629
  if (entry.pendingJoinAdds) {
4803
5630
  const sharedDirty = sharedViewDirty(this.ext, sourceDirty, entry.role);
4804
- 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 });
4805
5632
  if (shared) this.stats.encodesLastFlush++;
4806
5633
  } else {
4807
5634
  const key = viewKeyFor(this.ext, entry.role);
4808
5635
  let cached = byView.get(key);
4809
5636
  if (cached === void 0) {
4810
5637
  const sharedDirty = sharedViewDirty(this.ext, dirty, entry.role);
4811
- 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 });
4812
5639
  if (cached) this.stats.encodesLastFlush++;
4813
5640
  byView.set(key, cached);
4814
5641
  }
@@ -4817,7 +5644,7 @@ var RoomCore = class _RoomCore {
4817
5644
  if (shared) {
4818
5645
  this.send(
4819
5646
  entry.clientId,
4820
- encodeFrame4(FrameType5.DELTA, shared),
5647
+ encodeFrame5(FrameType6.DELTA, shared),
4821
5648
  this.ledger ? { shared } : void 0
4822
5649
  );
4823
5650
  }
@@ -4828,7 +5655,7 @@ var RoomCore = class _RoomCore {
4828
5655
  memberships
4829
5656
  );
4830
5657
  const encodeStarted = performance.now();
4831
- 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 });
4832
5659
  this.stats.aoiEncodeMs += performance.now() - encodeStarted;
4833
5660
  if (delta) {
4834
5661
  this.stats.encodesLastFlush++;
@@ -4859,7 +5686,7 @@ var RoomCore = class _RoomCore {
4859
5686
  if (delta) {
4860
5687
  this.send(
4861
5688
  entry.clientId,
4862
- encodeFrame4(FrameType5.DELTA, delta),
5689
+ encodeFrame5(FrameType6.DELTA, delta),
4863
5690
  this.ledger ? {
4864
5691
  ...churn ? { churn } : {},
4865
5692
  ...spatialDescs.length > 0 ? {} : { shared: delta }
@@ -4870,6 +5697,7 @@ var RoomCore = class _RoomCore {
4870
5697
  entry.correction = void 0;
4871
5698
  entry.accepted.clear();
4872
5699
  }
5700
+ recordFlush(this.internals, dirty, byView);
4873
5701
  }
4874
5702
  };
4875
5703
  function encodeSection(physics) {
@@ -4885,6 +5713,11 @@ function encodeSection(physics) {
4885
5713
  export {
4886
5714
  HOST_CALL_TIMEOUT_MS,
4887
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,
4888
5721
  EVENT_RING_SIZE,
4889
5722
  inspectState,
4890
5723
  RPC_TIMEOUT_MS,