@irtio/runtime 0.11.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -214,12 +214,15 @@ function inspectState(schema, plain) {
214
214
 
215
215
  // src/core/rpc.ts
216
216
  import {
217
+ ErrorCode as ErrorCode2,
217
218
  FrameType as FrameType2,
219
+ OWNERSHIP_ASK,
218
220
  decodeCall,
219
221
  decodeReply,
220
222
  encodeCall,
221
223
  encodeFrame as encodeFrame2,
222
224
  encodeReply,
225
+ isReservedRpcName,
223
226
  rpcByIdOf,
224
227
  rpcTable
225
228
  } from "@irtio/protocol";
@@ -258,11 +261,12 @@ function deepEqual(a, b) {
258
261
 
259
262
  // src/core/ownership.ts
260
263
  import { SERVER_OWNER, defaultRecord } from "@irtio/schema";
264
+ var ASK_WINDOW_MS = 5e3;
261
265
  var states2 = /* @__PURE__ */ new WeakMap();
262
266
  function stateOf2(core) {
263
267
  let s = states2.get(core);
264
268
  if (!s) {
265
- s = { holds: /* @__PURE__ */ new Map(), askWarned: false };
269
+ s = { holds: /* @__PURE__ */ new Map(), asks: /* @__PURE__ */ new Map(), askUnknownWarned: false };
266
270
  states2.set(core, s);
267
271
  }
268
272
  return s;
@@ -419,6 +423,31 @@ function addPerPlayerRecords(core, clientId) {
419
423
  }
420
424
  }
421
425
  }
426
+ function askOn(core, collection, id) {
427
+ return states2.get(core)?.asks.get(keyOf(collection, id));
428
+ }
429
+ function trackAsk(core, ask) {
430
+ stateOf2(core).asks.set(keyOf(ask.collection, ask.id), ask);
431
+ }
432
+ function clearAsk(core, collection, id) {
433
+ states2.get(core)?.asks.delete(keyOf(collection, id));
434
+ }
435
+ function asksInFlight(core) {
436
+ const s = states2.get(core);
437
+ return s ? [...s.asks.values()] : [];
438
+ }
439
+ function askCount(core) {
440
+ return states2.get(core)?.asks.size ?? 0;
441
+ }
442
+ function warnSdkPredatesAsk(core, clientId) {
443
+ const s = stateOf2(core);
444
+ if (s.askUnknownWarned) return;
445
+ s.askUnknownWarned = true;
446
+ core.log("warn", `client ${clientId}'s SDK predates $ownership.ask; ask auto-declined`);
447
+ }
448
+ function grantOwnership(core, collection, id, clientId) {
449
+ trackedEntity(core, collection).setOwner(id, clientId);
450
+ }
422
451
  function resolveTransfer(core, clientId, c, id) {
423
452
  const policy = c.ownership;
424
453
  const coll = plainEntity(core.plain, c.name);
@@ -435,36 +464,35 @@ function resolveTransfer(core, clientId, c, id) {
435
464
  case "never":
436
465
  return "refused";
437
466
  case "free":
438
- trackedEntity(core, c.name).setOwner(id, clientId);
467
+ grantOwnership(core, c.name, id, clientId);
439
468
  return "granted";
440
469
  case "closer": {
441
470
  if (owner === void 0 || owner === SERVER_OWNER) {
442
- trackedEntity(core, c.name).setOwner(id, clientId);
471
+ grantOwnership(core, c.name, id, clientId);
443
472
  return "granted";
444
473
  }
445
474
  const object = coll.get(id);
446
475
  const ownerDistance = distanceTo(core, policy, object, owner);
447
476
  if (ownerDistance === void 0) {
448
- trackedEntity(core, c.name).setOwner(id, clientId);
477
+ grantOwnership(core, c.name, id, clientId);
449
478
  return "granted";
450
479
  }
451
480
  const mine = distanceTo(core, policy, object, clientId);
452
481
  if (mine === void 0) return "refused";
453
482
  if (mine + policy.by >= ownerDistance) return "refused";
454
- trackedEntity(core, c.name).setOwner(id, clientId);
483
+ grantOwnership(core, c.name, id, clientId);
455
484
  return "granted";
456
485
  }
486
+ // ---- M6 ask packet ----
457
487
  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
- );
488
+ if (owner === void 0 || owner === SERVER_OWNER) {
489
+ grantOwnership(core, c.name, id, clientId);
490
+ return "granted";
465
491
  }
466
- return "refused";
492
+ if (core.clients.get(owner)?.connected !== true) return "gone";
493
+ return "pending";
467
494
  }
495
+ // ---- end M6 ask packet ----
468
496
  default:
469
497
  return "refused";
470
498
  }
@@ -495,6 +523,9 @@ function restoreHolds(core, bytes) {
495
523
  // src/core/rpc.ts
496
524
  var RPC_TIMEOUT_MS = 5e3;
497
525
  var REQUEST_OWNERSHIP = "requestOwnership";
526
+ var RpcTimeoutError = class extends Error {
527
+ };
528
+ var DEFERRED = /* @__PURE__ */ Symbol("deferred reply");
498
529
  var states3 = /* @__PURE__ */ new WeakMap();
499
530
  function stateOf3(core) {
500
531
  let s = states3.get(core);
@@ -530,6 +561,16 @@ function handleCall(core, clientId, payload) {
530
561
  sendReply(core, clientId, call.reqId, null, errorText("E_RPC_UNKNOWN", `rpcId ${call.rpcId}`));
531
562
  return true;
532
563
  }
564
+ if (isReservedRpcName(desc.name)) {
565
+ sendReply(
566
+ core,
567
+ clientId,
568
+ call.reqId,
569
+ null,
570
+ errorText("E_RPC_REJECTED", `${desc.name} is reserved by irtio`)
571
+ );
572
+ return true;
573
+ }
533
574
  if (desc.direction !== "server") {
534
575
  sendReply(
535
576
  core,
@@ -557,7 +598,8 @@ function handleCall(core, clientId, payload) {
557
598
  const ctx = { ...core.ctxFor(clientId), clientTick: call.clientTick || void 0 };
558
599
  let result;
559
600
  if (desc.name === REQUEST_OWNERSHIP) {
560
- result = runRequestOwnership(core, clientId, ctx, params);
601
+ result = runRequestOwnership(core, clientId, ctx, params, call.reqId, desc);
602
+ if (result === DEFERRED) return true;
561
603
  } else {
562
604
  const impl = core.definition.config.rpc?.[desc.name];
563
605
  if (!impl) {
@@ -586,32 +628,126 @@ function handleCall(core, clientId, payload) {
586
628
  }
587
629
  return true;
588
630
  }
589
- function runRequestOwnership(core, clientId, ctx, params) {
631
+ function runRequestOwnership(core, clientId, ctx, params, reqId, desc) {
590
632
  const name = String(params.entity ?? "");
591
633
  const id = String(params.id ?? "");
592
634
  const c = collectionDescOf(core.ext, name);
593
- if (!c || c.kind !== "entity" || c.serverOwned) return { granted: false };
635
+ if (!c || c.kind !== "entity" || c.serverOwned) return refused("gone");
594
636
  const plain = plainEntity(core.plain, name);
595
- if (!plain.has(id)) return { granted: false };
637
+ if (!plain.has(id)) return refused("gone");
596
638
  const handler = core.definition.config.onOwnershipRequest;
597
639
  const tracked = trackedEntity(core, name);
598
640
  if (!handler && c.ownership) {
599
- return { granted: resolveTransfer(core, clientId, c, id) === "granted" };
641
+ if (askOn(core, name, id)) return refused("pending");
642
+ const verdict = resolveTransfer(core, clientId, c, id);
643
+ if (verdict === "pending") return beginAsk(core, clientId, name, id, reqId, desc);
644
+ if (verdict === "gone") return refused("gone");
645
+ return verdict === "granted" ? granted() : refused("");
600
646
  }
601
647
  if (handler) {
602
648
  const ran = core.tryRun("onOwnershipRequest", () => handler(core.anyState, name, id, ctx));
603
649
  if (ran.ok && ran.value === true && plain.ownerOf(id) !== clientId) {
604
650
  tracked.setOwner(id, clientId);
605
651
  }
606
- return { granted: plain.ownerOf(id) === clientId };
652
+ return plain.ownerOf(id) === clientId ? granted() : refused("");
607
653
  }
608
654
  const owner = plain.ownerOf(id);
609
- if (owner === clientId) return { granted: true };
610
- if (owner !== SERVER_OWNER2) return { granted: false };
655
+ if (owner === clientId) return granted();
656
+ if (owner !== SERVER_OWNER2) return refused("");
611
657
  tracked.setOwner(id, clientId);
612
- return { granted: true };
658
+ return granted();
659
+ }
660
+ function granted() {
661
+ return { granted: true, reason: "" };
662
+ }
663
+ function refused(reason) {
664
+ return { granted: false, reason };
665
+ }
666
+ var UNKNOWN_RPC_PREFIX = ErrorCode2.E_RPC_UNKNOWN.message.split("{")[0] ?? "";
667
+ function isUnknownRpcReply(err) {
668
+ return err.message.startsWith(UNKNOWN_RPC_PREFIX) || err.message.startsWith("E_RPC_UNKNOWN");
669
+ }
670
+ function replyToAsker(core, clientId, reqId, desc, result) {
671
+ if (!desc.returns) {
672
+ sendReply(core, clientId, reqId, new Uint8Array(0));
673
+ return;
674
+ }
675
+ sendReply(core, clientId, reqId, encodeFields(desc.returns, result));
676
+ }
677
+ function beginAsk(core, asker, collection, id, askerReqId, askerDesc) {
678
+ const owner = plainEntity(core.plain, collection).ownerOf(id);
679
+ if (owner === void 0 || owner === SERVER_OWNER2) return refused("gone");
680
+ const askDesc = rpcTable(core.definition.schema).find((r) => r.name === OWNERSHIP_ASK);
681
+ if (!askDesc) {
682
+ core.log("error", `${OWNERSHIP_ASK} is missing from the rpc table; the ask cannot be sent`);
683
+ return refused("declined");
684
+ }
685
+ let settled = false;
686
+ const answer = (grant, reason) => {
687
+ if (settled) return;
688
+ settled = true;
689
+ clearAsk(core, collection, id);
690
+ if (grant) grantOwnership(core, collection, id, asker);
691
+ replyToAsker(core, asker, askerReqId, askerDesc, grant ? granted() : refused(reason));
692
+ };
693
+ const ownerReqId = callClient(
694
+ core,
695
+ owner,
696
+ askDesc,
697
+ { entity: collection, id, from: asker },
698
+ {
699
+ timeoutMs: ASK_WINDOW_MS,
700
+ resolve: (value) => {
701
+ const say = value?.granted === true;
702
+ if (!say) {
703
+ answer(false, "declined");
704
+ return;
705
+ }
706
+ const coll = plainEntity(core.plain, collection);
707
+ const stillTheirs = coll.has(id) && coll.ownerOf(id) === owner;
708
+ const askerHere = core.clients.get(asker)?.connected === true;
709
+ if (!stillTheirs || !askerHere) {
710
+ answer(false, "gone");
711
+ return;
712
+ }
713
+ answer(true, "");
714
+ },
715
+ reject: (err) => {
716
+ if (isUnknownRpcReply(err)) warnSdkPredatesAsk(core, owner);
717
+ answer(false, err instanceof RpcTimeoutError ? "timeout" : "declined");
718
+ }
719
+ }
720
+ );
721
+ if (settled) return DEFERRED;
722
+ const ask = {
723
+ collection,
724
+ id,
725
+ asker,
726
+ owner,
727
+ ownerReqId,
728
+ deadlineMs: core.host.now() + ASK_WINDOW_MS,
729
+ answer
730
+ };
731
+ trackAsk(core, ask);
732
+ return DEFERRED;
733
+ }
734
+ function cancelAsksFor(core, clientId) {
735
+ for (const ask of asksInFlight(core)) {
736
+ if (ask.asker !== clientId && ask.owner !== clientId) continue;
737
+ stateOf3(core).pending.delete(pendingKey(ask.owner, ask.ownerReqId));
738
+ ask.answer(false, "declined");
739
+ }
740
+ }
741
+ function cancelAllAsks(core) {
742
+ for (const ask of asksInFlight(core)) {
743
+ stateOf3(core).pending.delete(pendingKey(ask.owner, ask.ownerReqId));
744
+ ask.answer(false, "declined");
745
+ }
613
746
  }
614
747
  function descFor(core, name) {
748
+ if (isReservedRpcName(name)) {
749
+ throw new Error(`room.call: ${name} is reserved by irtio and cannot be called from room code`);
750
+ }
615
751
  const found = rpcTable(core.definition.schema).find((r) => r.name === name);
616
752
  if (!found) throw new Error(`room.call: unknown rpc ${JSON.stringify(name)}`);
617
753
  if (found.direction !== "client") {
@@ -630,6 +766,28 @@ function sendCall(core, clientId, desc, params) {
630
766
  );
631
767
  return reqId;
632
768
  }
769
+ function callClient(core, clientId, desc, params, settle) {
770
+ const entry = core.clients.get(clientId);
771
+ if (!entry || !entry.connected) {
772
+ core.scheduleContinuationFlush();
773
+ settle.reject(new Error(`room.call: ${clientId} is not connected`));
774
+ return 0;
775
+ }
776
+ const reqId = sendCall(core, clientId, desc, params);
777
+ const s = stateOf3(core);
778
+ s.pending.set(pendingKey(clientId, reqId), {
779
+ clientId,
780
+ name: desc.name,
781
+ returns: desc.returns,
782
+ deadline: core.host.now() + settle.timeoutMs,
783
+ resolve: settle.resolve,
784
+ reject: settle.reject
785
+ });
786
+ if (core.mode === "event") {
787
+ core.loop.after(settle.timeoutMs, () => checkTimeouts(core));
788
+ }
789
+ return reqId;
790
+ }
633
791
  function createCallProxy(core, clientId) {
634
792
  return new Proxy(
635
793
  {},
@@ -639,25 +797,11 @@ function createCallProxy(core, clientId) {
639
797
  return (params) => {
640
798
  const desc = descFor(core, prop);
641
799
  return new Promise((resolve, reject2) => {
642
- const entry = core.clients.get(clientId);
643
- if (!entry || !entry.connected) {
644
- core.scheduleContinuationFlush();
645
- reject2(new Error(`room.call: ${clientId} is not connected`));
646
- return;
647
- }
648
- const reqId = sendCall(core, clientId, desc, params ?? {});
649
- const s = stateOf3(core);
650
- s.pending.set(pendingKey(clientId, reqId), {
651
- clientId,
652
- name: desc.name,
653
- returns: desc.returns,
654
- deadline: core.host.now() + RPC_TIMEOUT_MS,
800
+ callClient(core, clientId, desc, params ?? {}, {
801
+ timeoutMs: RPC_TIMEOUT_MS,
655
802
  resolve,
656
803
  reject: reject2
657
804
  });
658
- if (core.mode === "event") {
659
- core.loop.after(RPC_TIMEOUT_MS, () => checkTimeouts(core));
660
- }
661
805
  });
662
806
  };
663
807
  }
@@ -732,7 +876,7 @@ function checkTimeouts(core) {
732
876
  for (const [key, p] of [...s.pending]) {
733
877
  if (p.deadline > now) continue;
734
878
  s.pending.delete(key);
735
- p.reject(new Error(errorText("E_RPC_TIMEOUT", `rpc ${p.name} timed out`)));
879
+ p.reject(new RpcTimeoutError(errorText("E_RPC_TIMEOUT", `rpc ${p.name} timed out`)));
736
880
  }
737
881
  }
738
882
  function rejectAllPending(core, reason) {
@@ -760,6 +904,10 @@ function rejectAsEvent(core, err) {
760
904
  core.scheduleContinuationFlush();
761
905
  return Promise.reject(err);
762
906
  }
907
+ function resolveAsEvent(core, value) {
908
+ core.scheduleContinuationFlush();
909
+ return Promise.resolve(value);
910
+ }
763
911
  function startHostCall(core, call, map) {
764
912
  if (core.stopped) {
765
913
  return rejectAsEvent(core, new Error(`room.${call.kind}: the room is stopped`));
@@ -973,20 +1121,20 @@ function applyOp(core, clientId, c, op) {
973
1121
  const requested = cloneValue(next);
974
1122
  const simulated = c.physics;
975
1123
  if (simulated) {
976
- const refused = [];
1124
+ const refused2 = [];
977
1125
  for (const leaf of leafPaths(fields, op.mask)) {
978
1126
  const field = leaf.names[0];
979
1127
  if (simulated.intents.includes(field)) continue;
980
1128
  next[field] = cloneValue(prev[field]);
981
- refused.push(
1129
+ refused2.push(
982
1130
  simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`
983
1131
  );
984
1132
  }
985
- if (refused.length > 0) {
986
- core.recordEvent("write-rejected", clientId, `${c.name}: ${refused.join(", ")}`);
1133
+ if (refused2.length > 0) {
1134
+ core.recordEvent("write-rejected", clientId, `${c.name}: ${refused2.join(", ")}`);
987
1135
  core.log(
988
1136
  "warn",
989
- `WRITE to ${c.name}.${op.id} from ${clientId} touched fields the owner does not write: ${refused.join(", ")}`
1137
+ `WRITE to ${c.name}.${op.id} from ${clientId} touched fields the owner does not write: ${refused2.join(", ")}`
990
1138
  );
991
1139
  }
992
1140
  }
@@ -2943,7 +3091,7 @@ import {
2943
3091
  cloneValue as cloneValue2,
2944
3092
  createDirtySet as createDirtySet2,
2945
3093
  createState,
2946
- decodeSnapshot as decodeSnapshot2,
3094
+ decodeSnapshot as decodeSnapshot3,
2947
3095
  encodeDelta as encodeDelta5,
2948
3096
  encodeSnapshot as encodeSnapshot4,
2949
3097
  isDirtyEmpty as isDirtyEmpty4,
@@ -3051,7 +3199,7 @@ var PoseHistory = class {
3051
3199
  const scratch = spec.scratch;
3052
3200
  scratch.fill(0);
3053
3201
  try {
3054
- spec.read(instance, scratch);
3202
+ spec.read(instance, scratch, id);
3055
3203
  entry.channels.set(scratch, o2);
3056
3204
  } catch (err) {
3057
3205
  if (!this.hookFailed.has(collection)) {
@@ -3826,7 +3974,15 @@ function setLobbyPublic(core, value) {
3826
3974
  }
3827
3975
 
3828
3976
  // src/core/messages.ts
3829
- import { FrameType as FrameType4, decodeMsg, encodeFrame as encodeFrame3, encodeMsg } from "@irtio/protocol";
3977
+ import {
3978
+ FrameType as FrameType4,
3979
+ decodeChatMsg,
3980
+ decodeMsg,
3981
+ encodeChatMsg,
3982
+ encodeFrame as encodeFrame3,
3983
+ encodeMsg,
3984
+ isChatMsg
3985
+ } from "@irtio/protocol";
3830
3986
  import { decodeFields as decodeFields2 } from "@irtio/schema";
3831
3987
  function toRoomTarget(target) {
3832
3988
  switch (target.kind) {
@@ -3874,6 +4030,7 @@ function decodeTyped(core, clientId, index, payload) {
3874
4030
  }
3875
4031
  function handleMsg(core, clientId, payload) {
3876
4032
  core.recordEvent("msg", clientId);
4033
+ if (isChatMsg(payload)) return handleChat(core, clientId, payload);
3877
4034
  let msg;
3878
4035
  try {
3879
4036
  msg = decodeMsg(payload);
@@ -3917,6 +4074,35 @@ function handleMsg(core, clientId, payload) {
3917
4074
  deliver(core, msg.target, frame, clientId);
3918
4075
  return true;
3919
4076
  }
4077
+ function handleChat(core, clientId, payload) {
4078
+ let chat;
4079
+ try {
4080
+ chat = decodeChatMsg(payload);
4081
+ } catch (err) {
4082
+ core.log("warn", `chat from ${clientId} failed to decode:`, err);
4083
+ return false;
4084
+ }
4085
+ if (chat.target.kind !== "all" && chat.target.kind !== "client") {
4086
+ core.stats.messagesDropped++;
4087
+ return true;
4088
+ }
4089
+ const onChat = core.definition.config.onChat;
4090
+ if (onChat) {
4091
+ const line = {
4092
+ from: clientId,
4093
+ text: chat.text,
4094
+ target: chat.target.kind === "all" ? "all" : chat.target.clientId
4095
+ };
4096
+ const ran = core.tryRun("onChat", () => onChat(core.anyState, line, core.ctxFor(clientId)));
4097
+ if (!ran.ok || ran.value === false) return true;
4098
+ }
4099
+ const frame = encodeFrame3(
4100
+ FrameType4.MSG,
4101
+ encodeChatMsg({ target: { kind: "client", clientId }, text: chat.text })
4102
+ );
4103
+ deliver(core, chat.target, frame, clientId);
4104
+ return true;
4105
+ }
3920
4106
  function wireTargetOf(target) {
3921
4107
  return target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
3922
4108
  }
@@ -3938,23 +4124,175 @@ function sendTypedMessage(core, index, target, payload) {
3938
4124
  // src/core/replay.ts
3939
4125
  import {
3940
4126
  CLIP_MAX_BYTES,
3941
- encodeClip
4127
+ CLIP_TTL_DEFAULT_MS,
4128
+ RECORDING_MAX_BYTES,
4129
+ clipExpiryEpochS,
4130
+ encodeClip,
4131
+ encodeRecordingChunk,
4132
+ parseRetention
3942
4133
  } from "@irtio/protocol";
3943
4134
  import {
3944
4135
  encodeDelta as encodeDelta3,
3945
4136
  encodeSnapshot as encodeSnapshot3,
3946
4137
  isDirtyEmpty as isDirtyEmpty2
3947
4138
  } from "@irtio/schema";
4139
+
4140
+ // src/core/replay-excerpt.ts
4141
+ import {
4142
+ applyDelta,
4143
+ decodeDelta as decodeDelta2,
4144
+ decodeSnapshot as decodeSnapshot2
4145
+ } from "@irtio/schema";
4146
+ var EXCERPT_MAX_SECONDS = 30;
4147
+ var EXCERPT_BURST = 4;
4148
+ var EXCERPT_COOLDOWN_MS = 1e3;
4149
+ var EXCERPT_MAX_BYTES = 4 * 1024 * 1024;
4150
+ var ExcerptTooLargeError = class extends Error {
4151
+ constructor(bytes) {
4152
+ super(`excerpt output passed ${EXCERPT_MAX_BYTES} bytes`);
4153
+ this.bytes = bytes;
4154
+ this.name = "ExcerptTooLargeError";
4155
+ }
4156
+ bytes;
4157
+ };
4158
+ function estimateValue(value) {
4159
+ if (typeof value === "string") return value.length + 2;
4160
+ if (Array.isArray(value)) {
4161
+ let n = 2;
4162
+ for (const v of value) n += estimateValue(v) + 1;
4163
+ return n;
4164
+ }
4165
+ if (typeof value === "object" && value !== null) {
4166
+ let n = 2;
4167
+ for (const [k, v] of Object.entries(value)) {
4168
+ n += k.length + 3 + estimateValue(v);
4169
+ }
4170
+ return n;
4171
+ }
4172
+ return 8;
4173
+ }
4174
+ function copyValue(value) {
4175
+ if (Array.isArray(value)) return value.map(copyValue);
4176
+ if (typeof value === "object" && value !== null) {
4177
+ const out = {};
4178
+ for (const [k, v] of Object.entries(value)) out[k] = copyValue(v);
4179
+ return out;
4180
+ }
4181
+ return value;
4182
+ }
4183
+ function projectTick(state, wanted, ids) {
4184
+ const entities = {};
4185
+ let bytes = 0;
4186
+ for (const desc of wanted) {
4187
+ const coll = state[desc.name];
4188
+ if (coll === void 0) continue;
4189
+ const rows = {};
4190
+ let any = false;
4191
+ for (const id of [...coll.ids()]) {
4192
+ if (ids !== void 0 && !ids.has(id)) continue;
4193
+ const record = coll.get(id);
4194
+ if (record === void 0) continue;
4195
+ const row = {};
4196
+ for (const field of desc.fields) {
4197
+ const v = record[field.name];
4198
+ if (v === void 0) continue;
4199
+ row[field.name] = copyValue(v);
4200
+ bytes += field.name.length + 3 + estimateValue(v);
4201
+ }
4202
+ bytes += id.length + 4;
4203
+ rows[id] = row;
4204
+ any = true;
4205
+ }
4206
+ if (any) {
4207
+ bytes += desc.name.length + 4;
4208
+ entities[desc.name] = rows;
4209
+ }
4210
+ }
4211
+ return { entities, bytes };
4212
+ }
4213
+ function wantedCollections(ext, names) {
4214
+ const all = ext.collections.filter((c) => c.kind === "entity");
4215
+ if (names === void 0) return [...all];
4216
+ const wanted = new Set(names);
4217
+ return all.filter((c) => wanted.has(c.name));
4218
+ }
4219
+ function unknownCollection(ext, options) {
4220
+ const names = options?.collections;
4221
+ if (names === void 0) return void 0;
4222
+ const known = new Set(
4223
+ ext.collections.filter((c) => c.kind === "entity").map((c) => c.name)
4224
+ );
4225
+ for (const name of names) {
4226
+ if (!known.has(name)) return String(name);
4227
+ }
4228
+ return void 0;
4229
+ }
4230
+ function walkExcerpt(ext, ring, tickRate, seconds, options) {
4231
+ const ringFrames = ring.frames;
4232
+ const last = ringFrames[ringFrames.length - 1];
4233
+ if (last === void 0) return { startTick: 0, endTick: 0, tickRate, frames: [] };
4234
+ const from = last.tick - Math.ceil(seconds * tickRate);
4235
+ const window = ring.tail(from);
4236
+ const head = window[0];
4237
+ if (head === void 0 || !head.keyframe) {
4238
+ return { startTick: 0, endTick: 0, tickRate, frames: [] };
4239
+ }
4240
+ const wanted = wantedCollections(ext, options?.collections);
4241
+ const ids = options?.ids === void 0 ? void 0 : new Set(options.ids);
4242
+ const state = decodeSnapshot2(ext, head.bytes).state;
4243
+ const frames = [];
4244
+ let bytes = 0;
4245
+ const emit = (tick) => {
4246
+ if (tick < from) return;
4247
+ const { entities, bytes: n } = projectTick(state, wanted, ids);
4248
+ bytes += n;
4249
+ if (bytes > EXCERPT_MAX_BYTES) throw new ExcerptTooLargeError(bytes);
4250
+ frames.push({ tick, entities });
4251
+ };
4252
+ if (window[1]?.tick !== head.tick) emit(head.tick);
4253
+ for (let i = 1; i < window.length; i++) {
4254
+ const f = window[i];
4255
+ if (f.keyframe) continue;
4256
+ applyDelta(ext, state, decodeDelta2(ext, f.bytes));
4257
+ emit(f.tick);
4258
+ }
4259
+ const first = frames[0];
4260
+ return {
4261
+ startTick: first?.tick ?? head.tick,
4262
+ endTick: frames[frames.length - 1]?.tick ?? head.tick,
4263
+ tickRate,
4264
+ frames
4265
+ };
4266
+ }
4267
+
4268
+ // src/core/replay.ts
3948
4269
  var REPLAY_DEFAULT_SECONDS = 60;
3949
4270
  var REPLAY_MAX_SECONDS = 300;
3950
4271
  var REPLAY_RING_MAX_BYTES = 8 * 1024 * 1024;
3951
4272
  var REPLAY_KEYFRAME_SECONDS = 2;
3952
4273
  var CLIP_COOLDOWN_MS = 1e3;
4274
+ var CLIP_BURST = 8;
3953
4275
  var ReplayRing = class {
3954
- constructor(bounds) {
4276
+ /**
4277
+ * M6 record packet: `onEvict` is handed every segment the ring drops, oldest first, at the
4278
+ * moment it drops it — which is the whole of what a recording is. It is optional and unset in
4279
+ * every room that did not ask to record, so the ring a clip-only room runs is the ring it ran
4280
+ * before this packet, allocation for allocation.
4281
+ *
4282
+ * A *wipe* deliberately does not call it. A wipe is one segment that outgrew the entire ring, so
4283
+ * emitting it would mean writing a chunk bigger than the bound the ring exists to keep. For a
4284
+ * clip that is "holds less footage than it asked for"; for a recording it is not, because the
4285
+ * frames after the wipe are deltas whose keyframe was never written, and playback skips
4286
+ * mid-stream keyframes rather than re-basing on them. So a wipe is not silently less footage: it
4287
+ * ENDS the recording (`append` returns `true` and the flush tap stops it), which turns wrong
4288
+ * footage into short footage.
4289
+ */
4290
+ constructor(bounds, onEvict) {
3955
4291
  this.bounds = bounds;
4292
+ this.onEvict = onEvict;
3956
4293
  }
3957
4294
  bounds;
4295
+ onEvict;
3958
4296
  items = [];
3959
4297
  total = 0;
3960
4298
  /** How many times an oversized segment has emptied the ring. Read through `append`'s return. */
@@ -4025,10 +4363,13 @@ var ReplayRing = class {
4025
4363
  return;
4026
4364
  }
4027
4365
  for (let i = 0; i < next; i++) this.total -= this.items[i].bytes.length;
4366
+ const evicted = this.items.slice(0, next);
4028
4367
  this.items = this.items.slice(next);
4368
+ this.onEvict?.(evicted);
4029
4369
  }
4030
4370
  }
4031
4371
  };
4372
+ var RECORD_QUEUE_MAX = 2;
4032
4373
  var states5 = /* @__PURE__ */ new WeakMap();
4033
4374
  function configOf(core) {
4034
4375
  const config = core.definition.config;
@@ -4047,17 +4388,43 @@ function stateOf6(core) {
4047
4388
  REPLAY_MAX_SECONDS,
4048
4389
  Math.max(1, Math.floor(declared.seconds ?? REPLAY_DEFAULT_SECONDS))
4049
4390
  );
4391
+ const record = declared.record === true;
4392
+ const outbox = [];
4393
+ const ttlMs = parseRetention(declared.ttl) ?? CLIP_TTL_DEFAULT_MS;
4050
4394
  s = {
4051
4395
  seconds,
4396
+ ttlMs,
4397
+ recExpiresAtEpochS: void 0,
4052
4398
  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,
4399
+ ring: new ReplayRing(
4400
+ {
4401
+ windowTicks: Math.max(1, Math.ceil(seconds * tickRate)),
4402
+ keyframeEvery: Math.max(1, Math.round(REPLAY_KEYFRAME_SECONDS * tickRate)),
4403
+ maxBytes: REPLAY_RING_MAX_BYTES
4404
+ },
4405
+ record ? (frames) => outbox.push(...frames) : void 0
4406
+ ),
4407
+ clipTokens: CLIP_BURST,
4408
+ lastRefillAt: Number.NEGATIVE_INFINITY,
4059
4409
  clipsTaken: 0,
4060
- disarmed: false
4410
+ excerptTokens: EXCERPT_BURST,
4411
+ lastExcerptRefillAt: Number.NEGATIVE_INFINITY,
4412
+ excerptsTaken: 0,
4413
+ disarmed: false,
4414
+ record,
4415
+ outbox,
4416
+ queued: [],
4417
+ recId: void 0,
4418
+ nextSeq: 0,
4419
+ encoded: 0,
4420
+ recBytes: 0,
4421
+ inFlight: false,
4422
+ recStopped: false,
4423
+ recWarned: false,
4424
+ recExpiryWarned: false,
4425
+ finalDrained: false,
4426
+ dropInFlight: false,
4427
+ chunksOut: 0
4061
4428
  };
4062
4429
  states5.set(core, s);
4063
4430
  }
@@ -4106,27 +4473,39 @@ function cacheKeyFor(core, state) {
4106
4473
  function recordFlush(core, dirty, byView) {
4107
4474
  const state = stateOf6(core);
4108
4475
  if (!state || state.disarmed) return;
4476
+ appendFlush(core, state, dirty, byView);
4477
+ if (state.record) pumpDrain(core, state);
4478
+ }
4479
+ function appendFlush(core, state, dirty, byView) {
4109
4480
  const ring = state.ring;
4110
4481
  if (!ring.empty && isDirtyEmpty2(dirty)) return;
4111
4482
  if (ring.keyframeDue(core.tick)) {
4112
- const wiped = ring.append({
4483
+ const wiped2 = ring.append({
4113
4484
  tick: core.tick,
4114
4485
  keyframe: true,
4115
4486
  bytes: recorderSnapshot(core, state)
4116
4487
  });
4117
- if (wiped) {
4488
+ if (wiped2) {
4118
4489
  disarm(core, state);
4119
4490
  return;
4120
4491
  }
4121
4492
  }
4122
4493
  const key = cacheKeyFor(core, state);
4123
4494
  const cached = key === void 0 ? void 0 : byView.get(key);
4124
- const delta = cached !== void 0 ? cached : recorderDelta(core, state, dirty);
4495
+ const delta = cached ?? recorderDelta(core, state, dirty);
4125
4496
  if (!delta) return;
4126
- ring.append({ tick: core.tick, keyframe: false, bytes: delta });
4497
+ const wiped = ring.append({ tick: core.tick, keyframe: false, bytes: delta });
4498
+ if (wiped && state.record) {
4499
+ stopRecording(
4500
+ core,
4501
+ state,
4502
+ `one replay segment was larger than the whole ${REPLAY_RING_MAX_BYTES}-byte ring, so the ring was emptied and the footage after it could not be re-based`
4503
+ );
4504
+ }
4127
4505
  }
4128
4506
  function disarm(core, state) {
4129
4507
  state.disarmed = true;
4508
+ if (state.record) stopRecording(core, state, "the replay ring disarmed");
4130
4509
  core.host.log("warn", [
4131
4510
  `irtio: room ${core.roomId}: a single replay keyframe is larger than the replay ring's ${REPLAY_RING_MAX_BYTES}-byte bound, so nothing could ever be held. Recording is off for the life of this room and room.replay.clip() will refuse. Record a narrower view (\`replay: { view: <role> }\`) or hold less state in the recorded collections.`
4132
4511
  ]);
@@ -4169,11 +4548,17 @@ function takeClip(core, seconds) {
4169
4548
  }
4170
4549
  const state = stateOf6(core);
4171
4550
  const now = core.host.now();
4172
- if (now - state.lastClipAt < CLIP_COOLDOWN_MS) {
4551
+ if (state.lastRefillAt === Number.NEGATIVE_INFINITY) state.lastRefillAt = now;
4552
+ const refilled = Math.floor((now - state.lastRefillAt) / CLIP_COOLDOWN_MS);
4553
+ if (refilled > 0) {
4554
+ state.clipTokens = Math.min(CLIP_BURST, state.clipTokens + refilled);
4555
+ state.lastRefillAt += refilled * CLIP_COOLDOWN_MS;
4556
+ }
4557
+ if (state.clipTokens <= 0) {
4173
4558
  return rejectAsEvent(
4174
4559
  core,
4175
4560
  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`
4561
+ `E_CLIP_COOLDOWN: room.replay.clip allows bursts of ${CLIP_BURST}, refilled one every ${CLIP_COOLDOWN_MS}ms per room; this room is out of clip tokens right now`
4177
4562
  )
4178
4563
  );
4179
4564
  }
@@ -4208,9 +4593,256 @@ function takeClip(core, seconds) {
4208
4593
  )
4209
4594
  );
4210
4595
  }
4211
- state.lastClipAt = now;
4596
+ state.clipTokens--;
4212
4597
  state.clipsTaken++;
4213
- return startHostCall(core, { kind: "clip", bytes }, (value) => ({ id: value ?? "" }));
4598
+ return startHostCall(
4599
+ core,
4600
+ { kind: "clip", bytes, expiresAtEpochS: clipExpiryEpochS(core.wallNow(), state.ttlMs) },
4601
+ (value) => ({ id: value ?? "" })
4602
+ );
4603
+ }
4604
+ function takeExcerpt(core, options) {
4605
+ if (!replayArmed(core)) {
4606
+ return rejectAsEvent(
4607
+ core,
4608
+ new Error(
4609
+ "E_REPLAY_UNARMED: room.replay.excerpt: this room type did not declare `replay:`, so nothing is being recorded. Add `replay: { seconds: 60 }` to the room config to arm the ring."
4610
+ )
4611
+ );
4612
+ }
4613
+ const seconds = options?.seconds;
4614
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) {
4615
+ return rejectAsEvent(
4616
+ core,
4617
+ new Error(
4618
+ "E_EXCERPT_BAD_ARGS: room.replay.excerpt needs { seconds } as a positive number, got " + JSON.stringify(seconds)
4619
+ )
4620
+ );
4621
+ }
4622
+ if (seconds > EXCERPT_MAX_SECONDS) {
4623
+ return rejectAsEvent(
4624
+ core,
4625
+ new Error(
4626
+ `E_EXCERPT_TOO_LONG: room.replay.excerpt decodes at most ${EXCERPT_MAX_SECONDS} seconds at a time and was asked for ${seconds}. An excerpt is a killcam, not a match; for a whole match use \`replay: { record: true }\`.`
4627
+ )
4628
+ );
4629
+ }
4630
+ const badName = unknownCollection(core.ext, options);
4631
+ if (badName !== void 0) {
4632
+ return rejectAsEvent(
4633
+ core,
4634
+ new Error(
4635
+ `E_EXCERPT_BAD_ARGS: room.replay.excerpt was asked for the collection ${JSON.stringify(
4636
+ badName
4637
+ )}, which is not an entity collection in this room's schema. Singleton collections are not excerptable.`
4638
+ )
4639
+ );
4640
+ }
4641
+ const state = stateOf6(core);
4642
+ if (state.disarmed) {
4643
+ return rejectAsEvent(
4644
+ core,
4645
+ new Error(
4646
+ `E_EXCERPT_DISARMED: recording stopped in this room because a single replay keyframe was larger than the ${REPLAY_RING_MAX_BYTES}-byte replay ring; see the warning on this room's log`
4647
+ )
4648
+ );
4649
+ }
4650
+ const now = core.host.now();
4651
+ if (state.lastExcerptRefillAt === Number.NEGATIVE_INFINITY) state.lastExcerptRefillAt = now;
4652
+ const refilled = Math.floor((now - state.lastExcerptRefillAt) / EXCERPT_COOLDOWN_MS);
4653
+ if (refilled > 0) {
4654
+ state.excerptTokens = Math.min(EXCERPT_BURST, state.excerptTokens + refilled);
4655
+ state.lastExcerptRefillAt += refilled * EXCERPT_COOLDOWN_MS;
4656
+ }
4657
+ if (state.excerptTokens <= 0) {
4658
+ return rejectAsEvent(
4659
+ core,
4660
+ new Error(
4661
+ `E_EXCERPT_COOLDOWN: room.replay.excerpt allows bursts of ${EXCERPT_BURST}, refilled one every ${EXCERPT_COOLDOWN_MS}ms per room; this room is out of excerpt tokens right now`
4662
+ )
4663
+ );
4664
+ }
4665
+ state.excerptTokens--;
4666
+ const tickRate = core.definition.config.tickRate ?? 20;
4667
+ let out;
4668
+ try {
4669
+ out = walkExcerpt(core.ext, state.ring, tickRate, seconds, options);
4670
+ } catch (err) {
4671
+ if (err instanceof ExcerptTooLargeError) {
4672
+ return rejectAsEvent(
4673
+ core,
4674
+ new Error(
4675
+ `E_EXCERPT_TOO_LARGE: this excerpt would materialize about ${err.bytes} bytes of frames and the limit is ${EXCERPT_MAX_BYTES}. Ask for fewer seconds, or name the collections (and ids) you actually need in \`collections\`/\`ids\`.`
4676
+ )
4677
+ );
4678
+ }
4679
+ return rejectAsEvent(
4680
+ core,
4681
+ new Error(`E_EXCERPT_DECODE: the replay ring could not be decoded: ${String(err)}`)
4682
+ );
4683
+ }
4684
+ state.excerptsTaken++;
4685
+ return resolveAsEvent(core, out);
4686
+ }
4687
+ var recordingCap = RECORDING_MAX_BYTES;
4688
+ function stopRecording(core, state, why) {
4689
+ if (state.recStopped) return;
4690
+ state.recStopped = true;
4691
+ state.outbox.length = 0;
4692
+ state.queued.length = 0;
4693
+ if (state.recWarned) return;
4694
+ state.recWarned = true;
4695
+ core.host.log("warn", [
4696
+ `irtio: room ${core.roomId}: full-match recording stopped (${why}). The chunks already written stay playable and room.replay.clip() still works; nothing further is recorded for the life of this room.`
4697
+ ]);
4698
+ }
4699
+ function pumpDrain(core, state) {
4700
+ if (state.recStopped) {
4701
+ state.outbox.length = 0;
4702
+ return;
4703
+ }
4704
+ if (state.outbox.length > 0) {
4705
+ const frames = state.outbox.splice(0, state.outbox.length);
4706
+ encodeAndQueue(core, state, frames);
4707
+ }
4708
+ sendNext(core, state);
4709
+ }
4710
+ function encodeAndQueue(core, state, frames) {
4711
+ if (frames.length === 0) return;
4712
+ if (state.recExpiresAtEpochS === void 0) {
4713
+ state.recExpiresAtEpochS = clipExpiryEpochS(core.wallNow(), state.ttlMs);
4714
+ }
4715
+ const first = frames[0];
4716
+ const header = state.encoded === 0 ? {
4717
+ // The GAME schema's hash, for `assembleClip`'s reason: it is what a client's HELLO
4718
+ // carries and what a deploy changes.
4719
+ schemaHash8: core.definition.schema.hash8,
4720
+ tickRate: core.definition.config.tickRate ?? 20,
4721
+ startTick: first.tick,
4722
+ view: state.view
4723
+ } : void 0;
4724
+ if (header !== void 0 && first.keyframe !== true) {
4725
+ stopRecording(core, state, "the first chunk would not have begun with a keyframe");
4726
+ return;
4727
+ }
4728
+ let bytes;
4729
+ try {
4730
+ bytes = encodeRecordingChunk(
4731
+ frames.map((f) => ({
4732
+ tick: f.tick,
4733
+ kind: f.keyframe ? "keyframe" : "delta",
4734
+ bytes: f.bytes
4735
+ })),
4736
+ header
4737
+ );
4738
+ } catch (err) {
4739
+ stopRecording(core, state, `the chunk could not be encoded: ${String(err)}`);
4740
+ return;
4741
+ }
4742
+ if (bytes.length === 0) return;
4743
+ if (state.recBytes + bytes.length > recordingCap) {
4744
+ stopRecording(
4745
+ core,
4746
+ state,
4747
+ `it reached the ${recordingCap}-byte per-recording limit after ${state.chunksOut} chunk(s)`
4748
+ );
4749
+ return;
4750
+ }
4751
+ state.encoded++;
4752
+ state.queued.push(bytes);
4753
+ if (state.queued.length > RECORD_QUEUE_MAX) {
4754
+ stopRecording(
4755
+ core,
4756
+ state,
4757
+ `the store is answering slower than the ring evicts: ${RECORD_QUEUE_MAX} chunk(s) were already waiting behind the one in flight, and dropping one would have left a gap nothing downstream could re-base on`
4758
+ );
4759
+ }
4760
+ }
4761
+ function sendNext(core, state, all = false) {
4762
+ if (state.recStopped && !state.finalDrained) return;
4763
+ const parallel = all && state.recId !== void 0;
4764
+ for (; ; ) {
4765
+ if (state.inFlight && !parallel) return;
4766
+ const bytes = state.queued.shift();
4767
+ if (bytes === void 0) return;
4768
+ sendChunk(core, state, bytes);
4769
+ if (!parallel) return;
4770
+ }
4771
+ }
4772
+ function warnRecordingOutlivedTtl(core, state) {
4773
+ if (state.recExpiryWarned) return;
4774
+ const expiresAtEpochS = state.recExpiresAtEpochS;
4775
+ if (expiresAtEpochS === void 0 || expiresAtEpochS * 1e3 > core.wallNow()) return;
4776
+ state.recExpiryWarned = true;
4777
+ core.host.log("warn", [
4778
+ `irtio: room ${core.roomId}: this full-match recording expired at ${expiresAtEpochS} (unix seconds) while it was still being written. A recording is kept for replay.ttl from its first stored chunk, so a ttl shorter than the match deletes the recording while the match is still running and its link answers 404. Set replay.ttl to at least the length of a match.`
4779
+ ]);
4780
+ }
4781
+ function sendChunk(core, state, bytes) {
4782
+ warnRecordingOutlivedTtl(core, state);
4783
+ const seq = state.nextSeq;
4784
+ state.nextSeq++;
4785
+ state.chunksOut++;
4786
+ state.recBytes += bytes.length;
4787
+ state.inFlight = true;
4788
+ startHostCall(
4789
+ core,
4790
+ {
4791
+ kind: "record-chunk",
4792
+ recId: state.recId,
4793
+ seq,
4794
+ bytes,
4795
+ // The first chunk's expiry, on every chunk. `encodeAndQueue` set it before anything could
4796
+ // reach here; the fallback is a value, not a second policy.
4797
+ expiresAtEpochS: state.recExpiresAtEpochS ?? clipExpiryEpochS(core.wallNow(), state.ttlMs)
4798
+ },
4799
+ (value) => value ?? ""
4800
+ ).then((id) => {
4801
+ state.inFlight = false;
4802
+ state.dropInFlight = false;
4803
+ if (state.recId === void 0 && id !== "") state.recId = id;
4804
+ sendNext(core, state, state.finalDrained);
4805
+ }).catch((err) => {
4806
+ state.inFlight = false;
4807
+ if (state.dropInFlight) {
4808
+ state.dropInFlight = false;
4809
+ if (state.recId === void 0) {
4810
+ stopRecording(
4811
+ core,
4812
+ state,
4813
+ "the reply carrying the recording's id was dropped before it arrived, and a second chunk would have been stored as a second recording"
4814
+ );
4815
+ return;
4816
+ }
4817
+ sendNext(core, state, state.finalDrained);
4818
+ return;
4819
+ }
4820
+ if (state.queued.length > 0) {
4821
+ const bytes2 = state.queued.reduce((n, b) => n + b.length, 0);
4822
+ core.host.log("warn", [
4823
+ `irtio: room ${core.roomId}: the full-match recording lost its last ${state.queued.length} chunk(s) (${bytes2} bytes). The room ended before the store answered the first chunk, so the recording's id was never known here and the tail had nowhere to go. Everything already stored plays.`
4824
+ ]);
4825
+ state.queued.length = 0;
4826
+ }
4827
+ stopRecording(core, state, `a chunk was not stored: ${String(err)}`);
4828
+ });
4829
+ }
4830
+ function drainRecordingFinal(core) {
4831
+ const state = states5.get(core);
4832
+ if (!state || !state.record || state.recStopped || state.finalDrained) return;
4833
+ state.finalDrained = true;
4834
+ const remaining = state.ring.frames;
4835
+ const frames = [...state.outbox.splice(0, state.outbox.length), ...remaining];
4836
+ if (frames.length > 0) encodeAndQueue(core, state, frames);
4837
+ sendNext(core, state, true);
4838
+ state.recStopped = true;
4839
+ }
4840
+ function recordingId(core) {
4841
+ return states5.get(core)?.recId;
4842
+ }
4843
+ function noteRecordingRepliesDropped(core) {
4844
+ const state = states5.get(core);
4845
+ if (state?.inFlight === true) state.dropInFlight = true;
4214
4846
  }
4215
4847
 
4216
4848
  // src/core/room-api.ts
@@ -4580,7 +5212,17 @@ function createRoomApi(core, publicUrl) {
4580
5212
  get armed() {
4581
5213
  return replayArmed(core);
4582
5214
  },
4583
- clip: (seconds) => takeClip(core, seconds)
5215
+ clip: (seconds) => takeClip(core, seconds),
5216
+ // M7 planned feature 5: the ring read back as plain frames. Same read-through shape as
5217
+ // `clip` — the walk, the projection and the whole budget live in `core/replay-excerpt.ts`
5218
+ // and `core/replay.ts`.
5219
+ excerpt: (options) => takeExcerpt(core, options),
5220
+ // M6 record packet: the id of the recording being written, once the first chunk has been
5221
+ // stored. A getter, like `armed`, so a game reading it late in a match sees the id rather
5222
+ // than the `undefined` it would have captured at the start of one.
5223
+ get recording() {
5224
+ return recordingId(core);
5225
+ }
4584
5226
  };
4585
5227
  },
4586
5228
  // ---- end M6 lane N ----
@@ -4827,7 +5469,7 @@ var RoomCore = class _RoomCore {
4827
5469
  restored = candidate;
4828
5470
  }
4829
5471
  if (restored) {
4830
- plain = decodeSnapshot2(this.ext, restored.snapshot).state;
5472
+ plain = decodeSnapshot3(this.ext, restored.snapshot).state;
4831
5473
  const presence = plainEntity(plain, PRESENCE_COLLECTION);
4832
5474
  for (const id of [...presence.ids()]) presence.remove(id);
4833
5475
  if (restored.mode !== this.mode) {
@@ -4878,6 +5520,37 @@ var RoomCore = class _RoomCore {
4878
5520
  get busConfig() {
4879
5521
  return this.definition.config.bus;
4880
5522
  }
5523
+ /**
5524
+ * M7 D-P2: the platform notice, and the room's veto.
5525
+ *
5526
+ * Answers whether the supervisor should broadcast. `false` — the veto — comes back for exactly
5527
+ * one input: a handler that ran and returned `false`. Every other path answers `true`, and each
5528
+ * one is a different way of the room having said nothing:
5529
+ *
5530
+ * - **No handler.** A room that never wrote this hook. The design's whole point is that its
5531
+ * players still hear the warning; a code-less room does not even reach this file.
5532
+ * - **A handler that threw.** `tryRun` and not `guard`, so the throw counts toward the crash
5533
+ * threshold like any other, and it still does not become a veto. This is the one place the
5534
+ * convention differs from `onChat`, where a throw refuses, and the reason is the asymmetry of
5535
+ * the two losses: a chat line nobody sees is small; a save-loss warning nobody sees is the
5536
+ * feature not existing.
5537
+ * - **A stopped room.** Nobody to ask, and for `closing` nobody left to veto for.
5538
+ *
5539
+ * The handler may also have written state, posted chat or started an end sequence. All of that
5540
+ * is ordinary handler work and is flushed here the way an alarm's is.
5541
+ */
5542
+ deliverPlatformNotice(notice) {
5543
+ if (this.stopped) return true;
5544
+ const handler = this.definition.config.onPlatformNotice;
5545
+ if (!handler) return true;
5546
+ this.recordEvent("notice", void 0, notice.phase);
5547
+ const ran = this.tryRun(
5548
+ "onPlatformNotice",
5549
+ () => handler(this.anyState, notice, this.room)
5550
+ );
5551
+ this.eventFlush();
5552
+ return !(ran.ok && ran.value === false);
5553
+ }
4881
5554
  /**
4882
5555
  * D59: one published message arriving on a channel this room is subscribed to.
4883
5556
  *
@@ -5166,6 +5839,8 @@ var RoomCore = class _RoomCore {
5166
5839
  }
5167
5840
  stop() {
5168
5841
  if (this.stopped) return;
5842
+ drainRecordingFinal(this.internals);
5843
+ cancelAllAsks(this.internals);
5169
5844
  this.stopped = true;
5170
5845
  this.started = false;
5171
5846
  this.loop.stop();
@@ -5208,7 +5883,9 @@ var RoomCore = class _RoomCore {
5208
5883
  const onSleep = this.definition.config.onSleep;
5209
5884
  if (onSleep) this.guard("onSleep", () => onSleep(this.state, this.room));
5210
5885
  this.loop.clearAllTimers();
5886
+ cancelAllAsks(this.internals);
5211
5887
  rejectAllPending(this.internals, "room is hibernating");
5888
+ noteRecordingRepliesDropped(this.internals);
5212
5889
  rejectAllHostCalls(this.internals, "room is hibernating");
5213
5890
  return this.snapshot();
5214
5891
  }
@@ -5356,6 +6033,7 @@ var RoomCore = class _RoomCore {
5356
6033
  const ctx = this.ctxFor(clientId);
5357
6034
  this.guard("onLeave", () => onLeave(this.state, ctx, reason));
5358
6035
  }
6036
+ cancelAsksFor(this.internals, clientId);
5359
6037
  placeHolds(this.internals, clientId, entry.playerId);
5360
6038
  forgetWriter(this.internals, clientId);
5361
6039
  this.presence.remove(clientId);
@@ -5375,6 +6053,7 @@ var RoomCore = class _RoomCore {
5375
6053
  }
5376
6054
  entry.connected = false;
5377
6055
  entry.correction = void 0;
6056
+ cancelAsksFor(this.internals, clientId);
5378
6057
  const record = this.presence.get(clientId);
5379
6058
  if (record && record.connected !== false) record.connected = false;
5380
6059
  this.invalidateClients();
@@ -5501,6 +6180,12 @@ var RoomCore = class _RoomCore {
5501
6180
  return holdCount(this.internals);
5502
6181
  }
5503
6182
  // ---- end M6 lane K ----
6183
+ // ---- M6 ask packet ----
6184
+ /** How many `transfer: 'ask'` requests are waiting on an owner's answer right now. */
6185
+ askCount() {
6186
+ return askCount(this.internals);
6187
+ }
6188
+ // ---- end M6 ask packet ----
5504
6189
  profile() {
5505
6190
  return this.ledger?.snapshot();
5506
6191
  }