@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.
- package/dist/{chunk-GYPUNTQN.js → chunk-LT46QFLW.js} +751 -66
- package/dist/{chunk-ACC3SDIO.js → chunk-YWDRE77H.js} +1 -1
- package/dist/{contract-Hk4SkDtW.d.ts → contract-UEKTud1Z.d.ts} +44 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/{room-0FZBGyke.d.ts → room-uhj3b-8E.d.ts} +24 -2
- package/dist/test/index.d.ts +9 -3
- package/dist/test/index.js +18 -1
- package/dist/worker/index.d.ts +37 -3
- package/dist/worker/index.js +34 -3
- package/package.json +4 -4
|
@@ -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(),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
483
|
+
grantOwnership(core, c.name, id, clientId);
|
|
455
484
|
return "granted";
|
|
456
485
|
}
|
|
486
|
+
// ---- M6 ask packet ----
|
|
457
487
|
case "ask": {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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 "
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
652
|
+
return plain.ownerOf(id) === clientId ? granted() : refused("");
|
|
607
653
|
}
|
|
608
654
|
const owner = plain.ownerOf(id);
|
|
609
|
-
if (owner === clientId) return
|
|
610
|
-
if (owner !== SERVER_OWNER2) return
|
|
655
|
+
if (owner === clientId) return granted();
|
|
656
|
+
if (owner !== SERVER_OWNER2) return refused("");
|
|
611
657
|
tracked.setOwner(id, clientId);
|
|
612
|
-
return
|
|
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
|
-
|
|
643
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1129
|
+
refused2.push(
|
|
982
1130
|
simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`
|
|
983
1131
|
);
|
|
984
1132
|
}
|
|
985
|
-
if (
|
|
986
|
-
core.recordEvent("write-rejected", clientId, `${c.name}: ${
|
|
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: ${
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
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
|
-
|
|
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
|
|
4483
|
+
const wiped2 = ring.append({
|
|
4113
4484
|
tick: core.tick,
|
|
4114
4485
|
keyframe: true,
|
|
4115
4486
|
bytes: recorderSnapshot(core, state)
|
|
4116
4487
|
});
|
|
4117
|
-
if (
|
|
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
|
|
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 (
|
|
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
|
|
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.
|
|
4596
|
+
state.clipTokens--;
|
|
4212
4597
|
state.clipsTaken++;
|
|
4213
|
-
return startHostCall(
|
|
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 =
|
|
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
|
}
|