@irtio/runtime 0.1.0 → 0.3.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-X5S364FY.js → chunk-5ZDQAAFJ.js} +952 -44
- package/dist/{chunk-3DQHT4CM.js → chunk-HFOMXKSO.js} +1 -1
- package/dist/{contract-BhD88PGb.d.ts → contract-B8QSO0MH.d.ts} +86 -2
- package/dist/index.d.ts +39 -8
- package/dist/index.js +18 -2
- package/dist/room-9ZQoy9yi.d.ts +458 -0
- package/dist/test/index.d.ts +59 -2
- package/dist/test/index.js +142 -7
- package/dist/worker/index.d.ts +40 -2
- package/dist/worker/index.js +35 -4
- package/package.json +5 -4
- package/dist/room-CBsSCueH.d.ts +0 -246
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/contract.ts
|
|
2
|
+
var HOST_CALL_TIMEOUT_MS = 1e4;
|
|
2
3
|
var RoomFullError = class extends Error {
|
|
3
4
|
constructor(maxClients) {
|
|
4
5
|
super(`room is full (maxClients=${maxClients})`);
|
|
@@ -420,7 +421,10 @@ function applyWrite(core, clientId, payload) {
|
|
|
420
421
|
return { ok: false };
|
|
421
422
|
}
|
|
422
423
|
const judged = core.clients.get(clientId);
|
|
423
|
-
if (judged && delta.tick > judged.lastClientTick)
|
|
424
|
+
if (judged && delta.tick > judged.lastClientTick) {
|
|
425
|
+
judged.lastClientTick = delta.tick;
|
|
426
|
+
judged.lastAppliedTick = core.tick;
|
|
427
|
+
}
|
|
424
428
|
for (const dc of delta.collections) {
|
|
425
429
|
const c = collectionDescOf(core.ext, dc.name);
|
|
426
430
|
if (!c) continue;
|
|
@@ -463,6 +467,26 @@ function applyOp(core, clientId, c, op) {
|
|
|
463
467
|
const prev = cloneValue(current);
|
|
464
468
|
const next = cloneValue(current);
|
|
465
469
|
applyMaskedPatch(fields, next, op.patch, op.mask);
|
|
470
|
+
const requested = cloneValue(next);
|
|
471
|
+
const simulated = c.physics;
|
|
472
|
+
if (simulated) {
|
|
473
|
+
const refused = [];
|
|
474
|
+
for (const leaf of leafPaths(fields, op.mask)) {
|
|
475
|
+
const field = leaf.names[0];
|
|
476
|
+
if (simulated.intents.includes(field)) continue;
|
|
477
|
+
next[field] = cloneValue(prev[field]);
|
|
478
|
+
refused.push(
|
|
479
|
+
simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
if (refused.length > 0) {
|
|
483
|
+
core.recordEvent("write-rejected", clientId, `${c.name}: ${refused.join(", ")}`);
|
|
484
|
+
core.log(
|
|
485
|
+
"warn",
|
|
486
|
+
`WRITE to ${c.name}.${op.id} from ${clientId} touched fields the owner does not write: ${refused.join(", ")}`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
466
490
|
const validator = core.definition.config.validate?.[c.name];
|
|
467
491
|
let result = next;
|
|
468
492
|
if (validator) {
|
|
@@ -493,7 +517,7 @@ function applyOp(core, clientId, c, op) {
|
|
|
493
517
|
for (const leaf of written) map.set(leafKey(leaf), cloneValue(valueAt(result, leaf.names)));
|
|
494
518
|
}
|
|
495
519
|
const corrected = written.filter(
|
|
496
|
-
(leaf) => !deepEqual(valueAt(result, leaf.names), valueAt(
|
|
520
|
+
(leaf) => !deepEqual(valueAt(result, leaf.names), valueAt(requested, leaf.names))
|
|
497
521
|
);
|
|
498
522
|
correctLeaves(core, clientId, c.name, op.id, corrected);
|
|
499
523
|
}
|
|
@@ -567,7 +591,7 @@ var Loop = class {
|
|
|
567
591
|
this.lastActivity = this.lastWake;
|
|
568
592
|
this.accumulator = 0;
|
|
569
593
|
if (this.core.mode === "tick") this.scheduleTick();
|
|
570
|
-
|
|
594
|
+
this.armIdle(this.core.definition.config.idleMs);
|
|
571
595
|
}
|
|
572
596
|
stop() {
|
|
573
597
|
this.running = false;
|
|
@@ -642,7 +666,7 @@ var Loop = class {
|
|
|
642
666
|
}
|
|
643
667
|
if (this.running && !this.core.stopped) this.scheduleTick();
|
|
644
668
|
}
|
|
645
|
-
/** One tick: inbound → room timers → `tick(state, dt, room)` → flush. */
|
|
669
|
+
/** One tick: inbound → room timers → `tick(state, dt, room)` → physics step → flush. */
|
|
646
670
|
runTick() {
|
|
647
671
|
const host = this.core.host;
|
|
648
672
|
const started = host.now();
|
|
@@ -651,6 +675,7 @@ var Loop = class {
|
|
|
651
675
|
this.fireDueTimers();
|
|
652
676
|
checkTimeouts(this.core);
|
|
653
677
|
const config = this.core.definition.config;
|
|
678
|
+
let failed;
|
|
654
679
|
if (config.tick) {
|
|
655
680
|
const fn = config.tick;
|
|
656
681
|
const dt = 1 / config.tickRate;
|
|
@@ -658,17 +683,29 @@ var Loop = class {
|
|
|
658
683
|
"tick",
|
|
659
684
|
() => fn(this.core.anyState, dt, this.core.room)
|
|
660
685
|
);
|
|
661
|
-
if (ran.ok)
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
686
|
+
if (!ran.ok) failed = "tick()";
|
|
687
|
+
}
|
|
688
|
+
const physics = this.core.physics;
|
|
689
|
+
if (physics) {
|
|
690
|
+
const ran = this.core.tryRun("physics", () => {
|
|
691
|
+
physics.reconcile();
|
|
692
|
+
physics.step();
|
|
693
|
+
physics.sync();
|
|
694
|
+
});
|
|
695
|
+
if (!ran.ok) {
|
|
696
|
+
failed = failed === void 0 ? "the physics step" : `${failed} and the physics step`;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (failed === void 0) this.consecutiveThrows = 0;
|
|
700
|
+
else {
|
|
701
|
+
this.consecutiveThrows++;
|
|
702
|
+
if (this.consecutiveThrows >= CRASH_AFTER_THROWS) {
|
|
703
|
+
this.core.flush();
|
|
704
|
+
const reason = `${failed} threw ${this.consecutiveThrows} times in a row`;
|
|
705
|
+
this.core.stopped = true;
|
|
706
|
+
this.stop();
|
|
707
|
+
host.crashed(reason);
|
|
708
|
+
return;
|
|
672
709
|
}
|
|
673
710
|
}
|
|
674
711
|
this.core.flush();
|
|
@@ -697,7 +734,6 @@ var Loop = class {
|
|
|
697
734
|
return ok;
|
|
698
735
|
}
|
|
699
736
|
noteActivity() {
|
|
700
|
-
if (this.core.mode !== "event") return;
|
|
701
737
|
this.lastActivity = this.core.host.now();
|
|
702
738
|
this.slept = false;
|
|
703
739
|
if (this.running && this.idleHandle === void 0) {
|
|
@@ -711,6 +747,12 @@ var Loop = class {
|
|
|
711
747
|
this.idleHandle = void 0;
|
|
712
748
|
if (!this.running || this.core.stopped) return;
|
|
713
749
|
const idleMs = this.core.definition.config.idleMs;
|
|
750
|
+
if (idleMs <= 0) return;
|
|
751
|
+
if (this.core.mode === "tick" && this.core.clients.size > 0) {
|
|
752
|
+
this.lastActivity = this.core.host.now();
|
|
753
|
+
this.armIdle(idleMs);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
714
756
|
const waited = this.core.host.now() - this.lastActivity;
|
|
715
757
|
if (waited >= idleMs) {
|
|
716
758
|
if (!this.slept) {
|
|
@@ -774,6 +816,351 @@ var Loop = class {
|
|
|
774
816
|
}
|
|
775
817
|
};
|
|
776
818
|
|
|
819
|
+
// src/core/physics.ts
|
|
820
|
+
import { ByteReader, ByteWriter } from "@irtio/schema";
|
|
821
|
+
var engine;
|
|
822
|
+
var loading;
|
|
823
|
+
async function initPhysics() {
|
|
824
|
+
if (engine) return engine;
|
|
825
|
+
loading ??= (async () => {
|
|
826
|
+
const mod = await import("@dimforge/rapier3d-compat");
|
|
827
|
+
const ns = mod.default ?? mod;
|
|
828
|
+
await ns.init();
|
|
829
|
+
engine = ns;
|
|
830
|
+
return ns;
|
|
831
|
+
})();
|
|
832
|
+
return loading;
|
|
833
|
+
}
|
|
834
|
+
function loadedPhysics() {
|
|
835
|
+
return engine;
|
|
836
|
+
}
|
|
837
|
+
function resetPhysicsForTests() {
|
|
838
|
+
engine = void 0;
|
|
839
|
+
loading = void 0;
|
|
840
|
+
}
|
|
841
|
+
function encodePhysicsSection(section) {
|
|
842
|
+
const w = new ByteWriter(section.world.length + section.bodies.length * 24 + 8);
|
|
843
|
+
w.blob(section.world);
|
|
844
|
+
w.varint(section.bodies.length);
|
|
845
|
+
for (const [name, id, handle] of section.bodies) {
|
|
846
|
+
w.str(name);
|
|
847
|
+
w.str(id);
|
|
848
|
+
w.f64(handle);
|
|
849
|
+
}
|
|
850
|
+
return w.finish();
|
|
851
|
+
}
|
|
852
|
+
function decodePhysicsSection(bytes) {
|
|
853
|
+
const r = new ByteReader(bytes);
|
|
854
|
+
const world = r.blob().slice();
|
|
855
|
+
const count = r.varint();
|
|
856
|
+
const bodies = [];
|
|
857
|
+
for (let i = 0; i < count; i++) bodies.push([r.str(), r.str(), r.f64()]);
|
|
858
|
+
return { world, bodies };
|
|
859
|
+
}
|
|
860
|
+
function bodyKey(collection, id) {
|
|
861
|
+
return `${collection}\0${id}`;
|
|
862
|
+
}
|
|
863
|
+
function planarLockWarning(spec) {
|
|
864
|
+
const boxed = (spec.colliders ?? []).some(
|
|
865
|
+
(c) => c.shape.type === CUBOID_SHAPE || c.shape.type === ROUND_CUBOID_SHAPE
|
|
866
|
+
);
|
|
867
|
+
if (!boxed) return void 0;
|
|
868
|
+
const b = spec.body;
|
|
869
|
+
const t = [b.translationsEnabledX, b.translationsEnabledY, b.translationsEnabledZ];
|
|
870
|
+
const r = [b.rotationsEnabledX, b.rotationsEnabledY, b.rotationsEnabledZ];
|
|
871
|
+
const axes = ["x", "y", "z"];
|
|
872
|
+
for (let i = 0; i < 3; i++) {
|
|
873
|
+
if (t[i]) continue;
|
|
874
|
+
if (r[(i + 1) % 3] || r[(i + 2) % 3]) continue;
|
|
875
|
+
const free = axes[i] === "z" ? "enabledRotations(true, false, true)" : "one of the other two rotations";
|
|
876
|
+
return `locks translation on ${axes[i]} and both rotations across that plane on a box-shaped body. That removes friction entirely: the box slides at a constant speed until it hits something. Free one out-of-plane rotation \u2014 in the xy plane that is ${free}, and it costs nothing, because geometry symmetric about the plane generates no torque about it.`;
|
|
877
|
+
}
|
|
878
|
+
return void 0;
|
|
879
|
+
}
|
|
880
|
+
var CUBOID_SHAPE = 1;
|
|
881
|
+
var ROUND_CUBOID_SHAPE = 12;
|
|
882
|
+
var PhysicsRuntime = class {
|
|
883
|
+
rapier;
|
|
884
|
+
world;
|
|
885
|
+
/** `true` when the world was built from scratch and `setup` has to run. */
|
|
886
|
+
rebuilt;
|
|
887
|
+
core;
|
|
888
|
+
config;
|
|
889
|
+
/** Physics-backed collections, in schema (name-sorted) order. */
|
|
890
|
+
collections;
|
|
891
|
+
bodies = /* @__PURE__ */ new Map();
|
|
892
|
+
/** Collections already warned about the friction-killing 2D lock recipe (bug 6). */
|
|
893
|
+
warnedPlanar = /* @__PURE__ */ new Set();
|
|
894
|
+
/**
|
|
895
|
+
* Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
|
|
896
|
+
* to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
|
|
897
|
+
* tick it falls asleep, or the schema keeps a phantom residual velocity forever (and a
|
|
898
|
+
* world rebuilt from schema state would wake it with a kick). Found by the drift check.
|
|
899
|
+
*/
|
|
900
|
+
sleepSynced = /* @__PURE__ */ new Set();
|
|
901
|
+
constructor(core, rapier, options) {
|
|
902
|
+
const config = core.definition.config.physics;
|
|
903
|
+
if (!config) throw new Error("PhysicsRuntime: the room config declares no physics");
|
|
904
|
+
this.core = core;
|
|
905
|
+
this.config = config;
|
|
906
|
+
this.rapier = rapier;
|
|
907
|
+
this.collections = core.ext.collections.filter(
|
|
908
|
+
(c) => c.physics !== void 0
|
|
909
|
+
);
|
|
910
|
+
if (options.restore) {
|
|
911
|
+
this.world = rapier.World.restoreSnapshot(options.restore.world);
|
|
912
|
+
this.rebuilt = false;
|
|
913
|
+
for (const [name, id, handle] of options.restore.bodies) {
|
|
914
|
+
const body = this.world.getRigidBody(handle);
|
|
915
|
+
if (body) this.bodies.set(bodyKey(name, id), body);
|
|
916
|
+
}
|
|
917
|
+
} else {
|
|
918
|
+
const g = config.gravity;
|
|
919
|
+
this.world = new rapier.World({ x: g.x, y: g.y, z: g.z });
|
|
920
|
+
this.rebuilt = true;
|
|
921
|
+
}
|
|
922
|
+
this.world.timestep = config.timestep ?? options.defaultTimestep;
|
|
923
|
+
}
|
|
924
|
+
get timestep() {
|
|
925
|
+
return this.world.timestep;
|
|
926
|
+
}
|
|
927
|
+
/** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
|
|
928
|
+
runSetup(room) {
|
|
929
|
+
const setup = this.config.setup;
|
|
930
|
+
if (!setup) return;
|
|
931
|
+
this.core.guard("physics.setup", () => setup(this.world, this.rapier, room));
|
|
932
|
+
}
|
|
933
|
+
free() {
|
|
934
|
+
this.bodies.clear();
|
|
935
|
+
this.world.free();
|
|
936
|
+
}
|
|
937
|
+
// -------------------------------------------------------------------------
|
|
938
|
+
// Bodies
|
|
939
|
+
// -------------------------------------------------------------------------
|
|
940
|
+
/** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
|
|
941
|
+
bodyFor(collection, id) {
|
|
942
|
+
const existing = this.bodies.get(bodyKey(collection, id));
|
|
943
|
+
if (existing) return existing;
|
|
944
|
+
const desc = this.collections.find((c) => c.name === collection);
|
|
945
|
+
if (!desc) return void 0;
|
|
946
|
+
const coll = plainEntity(this.core.plain, collection);
|
|
947
|
+
const record = coll.get(id);
|
|
948
|
+
if (record === void 0) return void 0;
|
|
949
|
+
return this.create(desc, id, record);
|
|
950
|
+
}
|
|
951
|
+
create(desc, id, record) {
|
|
952
|
+
const factory = this.config.bodies?.[desc.name];
|
|
953
|
+
if (!factory) {
|
|
954
|
+
this.core.log("error", `irtio: physics.bodies.${desc.name} is missing; no body created`);
|
|
955
|
+
return void 0;
|
|
956
|
+
}
|
|
957
|
+
const spec = this.core.guard(
|
|
958
|
+
`physics.bodies.${desc.name}`,
|
|
959
|
+
() => factory(this.rapier, record, id)
|
|
960
|
+
);
|
|
961
|
+
if (!spec || !spec.body) {
|
|
962
|
+
this.core.log(
|
|
963
|
+
"error",
|
|
964
|
+
`irtio: physics.bodies.${desc.name} returned no { body } for ${JSON.stringify(id)}`
|
|
965
|
+
);
|
|
966
|
+
return void 0;
|
|
967
|
+
}
|
|
968
|
+
const planar = planarLockWarning(spec);
|
|
969
|
+
if (planar !== void 0 && !this.warnedPlanar.has(desc.name)) {
|
|
970
|
+
this.warnedPlanar.add(desc.name);
|
|
971
|
+
this.core.log("warn", `irtio: physics.bodies.${desc.name} ${planar}`);
|
|
972
|
+
}
|
|
973
|
+
const body = this.world.createRigidBody(spec.body);
|
|
974
|
+
for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
|
|
975
|
+
this.applyRecordToBody(desc, body, record);
|
|
976
|
+
this.bodies.set(bodyKey(desc.name, id), body);
|
|
977
|
+
return body;
|
|
978
|
+
}
|
|
979
|
+
applyRecordToBody(desc, body, record) {
|
|
980
|
+
const physics = desc.physics;
|
|
981
|
+
if (!physics) return;
|
|
982
|
+
const t = { ...body.translation() };
|
|
983
|
+
const r = { ...body.rotation() };
|
|
984
|
+
const v = { ...body.linvel() };
|
|
985
|
+
const w = { ...body.angvel() };
|
|
986
|
+
let setT = false;
|
|
987
|
+
let setR = false;
|
|
988
|
+
let setV = false;
|
|
989
|
+
let setW = false;
|
|
990
|
+
for (const [channel, field] of physics.channels) {
|
|
991
|
+
const raw = record[field];
|
|
992
|
+
if (typeof raw !== "number") continue;
|
|
993
|
+
switch (channel) {
|
|
994
|
+
case "x":
|
|
995
|
+
case "y":
|
|
996
|
+
case "z":
|
|
997
|
+
t[channel] = raw;
|
|
998
|
+
setT = true;
|
|
999
|
+
break;
|
|
1000
|
+
case "qx":
|
|
1001
|
+
r.x = raw;
|
|
1002
|
+
setR = true;
|
|
1003
|
+
break;
|
|
1004
|
+
case "qy":
|
|
1005
|
+
r.y = raw;
|
|
1006
|
+
setR = true;
|
|
1007
|
+
break;
|
|
1008
|
+
case "qz":
|
|
1009
|
+
r.z = raw;
|
|
1010
|
+
setR = true;
|
|
1011
|
+
break;
|
|
1012
|
+
case "qw":
|
|
1013
|
+
r.w = raw;
|
|
1014
|
+
setR = true;
|
|
1015
|
+
break;
|
|
1016
|
+
case "vx":
|
|
1017
|
+
v.x = raw;
|
|
1018
|
+
setV = true;
|
|
1019
|
+
break;
|
|
1020
|
+
case "vy":
|
|
1021
|
+
v.y = raw;
|
|
1022
|
+
setV = true;
|
|
1023
|
+
break;
|
|
1024
|
+
case "vz":
|
|
1025
|
+
v.z = raw;
|
|
1026
|
+
setV = true;
|
|
1027
|
+
break;
|
|
1028
|
+
case "wx":
|
|
1029
|
+
w.x = raw;
|
|
1030
|
+
setW = true;
|
|
1031
|
+
break;
|
|
1032
|
+
case "wy":
|
|
1033
|
+
w.y = raw;
|
|
1034
|
+
setW = true;
|
|
1035
|
+
break;
|
|
1036
|
+
case "wz":
|
|
1037
|
+
w.z = raw;
|
|
1038
|
+
setW = true;
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (setT) body.setTranslation(t, true);
|
|
1043
|
+
if (setR) body.setRotation(r, true);
|
|
1044
|
+
if (setV) body.setLinvel(v, true);
|
|
1045
|
+
if (setW) body.setAngvel(w, true);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
|
|
1049
|
+
* tick, right before the step, in collection order then instance order.
|
|
1050
|
+
*/
|
|
1051
|
+
reconcile() {
|
|
1052
|
+
const live = /* @__PURE__ */ new Set();
|
|
1053
|
+
for (const desc of this.collections) {
|
|
1054
|
+
const coll = plainEntity(this.core.plain, desc.name);
|
|
1055
|
+
for (const id of coll.ids()) {
|
|
1056
|
+
const key = bodyKey(desc.name, id);
|
|
1057
|
+
live.add(key);
|
|
1058
|
+
if (this.bodies.has(key)) continue;
|
|
1059
|
+
const record = coll.get(id);
|
|
1060
|
+
if (record !== void 0) this.create(desc, id, record);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
for (const [key, body] of [...this.bodies]) {
|
|
1064
|
+
if (live.has(key)) continue;
|
|
1065
|
+
this.bodies.delete(key);
|
|
1066
|
+
this.sleepSynced.delete(key);
|
|
1067
|
+
this.world.removeRigidBody(body);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
// -------------------------------------------------------------------------
|
|
1071
|
+
// Step and sync
|
|
1072
|
+
// -------------------------------------------------------------------------
|
|
1073
|
+
step() {
|
|
1074
|
+
this.world.step();
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
|
|
1078
|
+
* Values are `Math.fround`ed for f32 fields, so what room code reads is exactly what the wire
|
|
1079
|
+
* carries — and so a field that has not really moved does not re-dirty every tick.
|
|
1080
|
+
*/
|
|
1081
|
+
sync() {
|
|
1082
|
+
for (const desc of this.collections) {
|
|
1083
|
+
const physics = desc.physics;
|
|
1084
|
+
if (!physics) continue;
|
|
1085
|
+
const tracked = this.core.anyState[desc.name];
|
|
1086
|
+
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
1087
|
+
const rounders = roundersFor(desc);
|
|
1088
|
+
for (const id of plainColl.ids()) {
|
|
1089
|
+
const key = bodyKey(desc.name, id);
|
|
1090
|
+
const body = this.bodies.get(key);
|
|
1091
|
+
if (!body) continue;
|
|
1092
|
+
if (body.isSleeping()) {
|
|
1093
|
+
if (this.sleepSynced.has(key)) continue;
|
|
1094
|
+
this.sleepSynced.add(key);
|
|
1095
|
+
} else {
|
|
1096
|
+
this.sleepSynced.delete(key);
|
|
1097
|
+
}
|
|
1098
|
+
const record = tracked.get(id);
|
|
1099
|
+
if (!record) continue;
|
|
1100
|
+
const t = body.translation();
|
|
1101
|
+
const r = body.rotation();
|
|
1102
|
+
const v = body.linvel();
|
|
1103
|
+
const w = body.angvel();
|
|
1104
|
+
for (const [channel, field] of physics.channels) {
|
|
1105
|
+
const next = (rounders[field] ?? identity)(channelValue(channel, t, r, v, w));
|
|
1106
|
+
if (record[field] !== next) record[field] = next;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
// -------------------------------------------------------------------------
|
|
1112
|
+
// Hibernation
|
|
1113
|
+
// -------------------------------------------------------------------------
|
|
1114
|
+
serialize() {
|
|
1115
|
+
const bodies = [];
|
|
1116
|
+
for (const [key, body] of this.bodies) {
|
|
1117
|
+
const sep = key.indexOf("\0");
|
|
1118
|
+
bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
|
|
1119
|
+
}
|
|
1120
|
+
return { world: this.world.takeSnapshot(), bodies };
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
function identity(v) {
|
|
1124
|
+
return v;
|
|
1125
|
+
}
|
|
1126
|
+
function roundersFor(desc) {
|
|
1127
|
+
const out = {};
|
|
1128
|
+
for (const f of desc.fields) {
|
|
1129
|
+
if (f.type.kind === "f32") out[f.name] = Math.fround;
|
|
1130
|
+
}
|
|
1131
|
+
return out;
|
|
1132
|
+
}
|
|
1133
|
+
function channelValue(channel, t, r, v, w) {
|
|
1134
|
+
switch (channel) {
|
|
1135
|
+
case "x":
|
|
1136
|
+
return t.x;
|
|
1137
|
+
case "y":
|
|
1138
|
+
return t.y;
|
|
1139
|
+
case "z":
|
|
1140
|
+
return t.z;
|
|
1141
|
+
case "qx":
|
|
1142
|
+
return r.x;
|
|
1143
|
+
case "qy":
|
|
1144
|
+
return r.y;
|
|
1145
|
+
case "qz":
|
|
1146
|
+
return r.z;
|
|
1147
|
+
case "qw":
|
|
1148
|
+
return r.w;
|
|
1149
|
+
case "vx":
|
|
1150
|
+
return v.x;
|
|
1151
|
+
case "vy":
|
|
1152
|
+
return v.y;
|
|
1153
|
+
case "vz":
|
|
1154
|
+
return v.z;
|
|
1155
|
+
case "wx":
|
|
1156
|
+
return w.x;
|
|
1157
|
+
case "wy":
|
|
1158
|
+
return w.y;
|
|
1159
|
+
default:
|
|
1160
|
+
return w.z;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
777
1164
|
// src/core/random.ts
|
|
778
1165
|
var Mulberry32 = class {
|
|
779
1166
|
/** Current internal state (u32). Survives hibernation. */
|
|
@@ -802,8 +1189,74 @@ import {
|
|
|
802
1189
|
isDirtyEmpty,
|
|
803
1190
|
markAdd,
|
|
804
1191
|
markPath,
|
|
805
|
-
markRemove
|
|
1192
|
+
markRemove,
|
|
1193
|
+
mergeDirty
|
|
806
1194
|
} from "@irtio/schema";
|
|
1195
|
+
|
|
1196
|
+
// src/core/spatial.ts
|
|
1197
|
+
function cellFor(grid, value) {
|
|
1198
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
1199
|
+
const record = value;
|
|
1200
|
+
const x = record[grid.x];
|
|
1201
|
+
const y = record[grid.y];
|
|
1202
|
+
if (typeof x !== "number" || typeof y !== "number") return void 0;
|
|
1203
|
+
return { x: Math.floor(x / grid.cell), y: Math.floor(y / grid.cell) };
|
|
1204
|
+
}
|
|
1205
|
+
function cellKey(cell) {
|
|
1206
|
+
return `${cell.x},${cell.y}`;
|
|
1207
|
+
}
|
|
1208
|
+
function buildSpatialIndex(plain, desc) {
|
|
1209
|
+
if (!desc.grid || desc.kind !== "entity") {
|
|
1210
|
+
throw new Error(`${desc.name}: cannot build an index without an entity grid`);
|
|
1211
|
+
}
|
|
1212
|
+
const collection = plainEntity(plain, desc.name);
|
|
1213
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
1214
|
+
const order = /* @__PURE__ */ new Map();
|
|
1215
|
+
let n = 0;
|
|
1216
|
+
for (const id of collection.ids()) {
|
|
1217
|
+
order.set(id, n++);
|
|
1218
|
+
const cell = cellFor(desc.grid, collection.get(id));
|
|
1219
|
+
if (!cell) continue;
|
|
1220
|
+
const key = cellKey(cell);
|
|
1221
|
+
let ids = buckets.get(key);
|
|
1222
|
+
if (!ids) {
|
|
1223
|
+
ids = /* @__PURE__ */ new Set();
|
|
1224
|
+
buckets.set(key, ids);
|
|
1225
|
+
}
|
|
1226
|
+
ids.add(id);
|
|
1227
|
+
}
|
|
1228
|
+
return { desc, buckets, collection, order };
|
|
1229
|
+
}
|
|
1230
|
+
function querySpatialIndex(index, clientId, role) {
|
|
1231
|
+
const grid = index.desc.grid;
|
|
1232
|
+
if ((grid.wideRoles ?? []).includes(role)) return new Set(index.collection.ids());
|
|
1233
|
+
const anchor = index.collection.get(clientId);
|
|
1234
|
+
const anchorCell = cellFor(grid, anchor);
|
|
1235
|
+
if (!anchor || !anchorCell) return /* @__PURE__ */ new Set();
|
|
1236
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1237
|
+
for (let x = anchorCell.x - grid.radius; x <= anchorCell.x + grid.radius; x++) {
|
|
1238
|
+
for (let y = anchorCell.y - grid.radius; y <= anchorCell.y + grid.radius; y++) {
|
|
1239
|
+
const ids = index.buckets.get(cellKey({ x, y }));
|
|
1240
|
+
if (ids) for (const id of ids) candidates.add(id);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
candidates.add(clientId);
|
|
1244
|
+
const ranked = [];
|
|
1245
|
+
for (const id of candidates) if (index.order.has(id)) ranked.push(id);
|
|
1246
|
+
ranked.sort((a, b) => index.order.get(a) - index.order.get(b));
|
|
1247
|
+
return new Set(ranked);
|
|
1248
|
+
}
|
|
1249
|
+
function buildSpatialIndexes(collections, plain) {
|
|
1250
|
+
const indexes = /* @__PURE__ */ new Map();
|
|
1251
|
+
for (const desc of collections) {
|
|
1252
|
+
if (desc.visibility === "spatial-grid" && desc.grid) {
|
|
1253
|
+
indexes.set(desc.name, buildSpatialIndex(plain, desc));
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return indexes;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// src/core/views.ts
|
|
807
1260
|
var cache = /* @__PURE__ */ new WeakMap();
|
|
808
1261
|
function infoOf(ext) {
|
|
809
1262
|
let i = cache.get(ext);
|
|
@@ -837,8 +1290,82 @@ function visibleNames(ext, role) {
|
|
|
837
1290
|
function viewKeyFor(ext, role) {
|
|
838
1291
|
return infoOf(ext).scoped.has(role) ? role : "all";
|
|
839
1292
|
}
|
|
840
|
-
function encodeViewSnapshot(ext, plain, role, tick) {
|
|
841
|
-
return encodeSnapshot(
|
|
1293
|
+
function encodeViewSnapshot(ext, plain, role, tick, memberships) {
|
|
1294
|
+
return encodeSnapshot(
|
|
1295
|
+
ext,
|
|
1296
|
+
plain,
|
|
1297
|
+
{ tick },
|
|
1298
|
+
{
|
|
1299
|
+
collections: visibleTo(role),
|
|
1300
|
+
entities: (c, id) => c.visibility !== "spatial-grid" || (memberships?.get(c.name)?.has(id) ?? false)
|
|
1301
|
+
}
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
function spatialMemberships(ext, plain, clientId, role, indexes = buildSpatialIndexes(ext.collections, plain)) {
|
|
1305
|
+
const out = /* @__PURE__ */ new Map();
|
|
1306
|
+
for (const [name, index] of indexes) out.set(name, querySpatialIndex(index, clientId, role));
|
|
1307
|
+
return out;
|
|
1308
|
+
}
|
|
1309
|
+
function createVisibilityPolicy(ext, plain, clientId, role) {
|
|
1310
|
+
const memberships = spatialMemberships(ext, plain, clientId, role);
|
|
1311
|
+
return {
|
|
1312
|
+
memberships,
|
|
1313
|
+
maySeeCollection: (desc) => isVisible(desc, role),
|
|
1314
|
+
maySeeEntity: (desc, id) => isVisible(desc, role) && (desc.visibility !== "spatial-grid" || (memberships.get(desc.name)?.has(id) ?? false)),
|
|
1315
|
+
describeSpatial: (desc, ids) => {
|
|
1316
|
+
const grid = desc.grid;
|
|
1317
|
+
if (desc.visibility !== "spatial-grid" || !grid) return void 0;
|
|
1318
|
+
const coll = plainEntity(plain, desc.name);
|
|
1319
|
+
const at = (id) => {
|
|
1320
|
+
const cell = cellFor(grid, coll.get(id));
|
|
1321
|
+
return cell ? `${id}@(${cell.x},${cell.y})` : `${id}@(no position)`;
|
|
1322
|
+
};
|
|
1323
|
+
return `viewer ${at(clientId)}, ${desc.name} ${ids.map(at).join(" ")}, radius ${grid.radius}`;
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
function sharedViewDirty(ext, dirty, role) {
|
|
1328
|
+
return filterDirty(dirty, (name) => {
|
|
1329
|
+
const desc = ext.collection(name);
|
|
1330
|
+
return desc.visibility !== "spatial-grid" && isVisible(desc, role);
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
function aoiViewDirty(ext, dirty, previous, current) {
|
|
1334
|
+
const out = createDirtySet();
|
|
1335
|
+
for (const desc of ext.collections) {
|
|
1336
|
+
if (desc.visibility !== "spatial-grid") continue;
|
|
1337
|
+
const before = previous.get(desc.name) ?? /* @__PURE__ */ new Set();
|
|
1338
|
+
const now = current.get(desc.name) ?? /* @__PURE__ */ new Set();
|
|
1339
|
+
for (const id of before) if (!now.has(id)) markRemove(out, desc.name, id);
|
|
1340
|
+
for (const id of now) if (!before.has(id)) markAdd(out, desc.name, id);
|
|
1341
|
+
const changed = dirty.get(desc.name);
|
|
1342
|
+
if (!changed) continue;
|
|
1343
|
+
const visibleChanges = createDirtySet();
|
|
1344
|
+
const collection = collectionDirty(visibleChanges, desc.name);
|
|
1345
|
+
for (const id of changed.added) if (now.has(id)) collection.added.add(id);
|
|
1346
|
+
for (const id of changed.removed) if (before.has(id)) collection.removed.add(id);
|
|
1347
|
+
for (const [id, record] of changed.updated) {
|
|
1348
|
+
if (before.has(id) && now.has(id)) collection.updated.set(id, record);
|
|
1349
|
+
}
|
|
1350
|
+
mergeDirty(out, visibleChanges);
|
|
1351
|
+
}
|
|
1352
|
+
return out;
|
|
1353
|
+
}
|
|
1354
|
+
function membershipSafeDirty(ext, dirty, memberships) {
|
|
1355
|
+
const out = createDirtySet();
|
|
1356
|
+
for (const [name, changed] of dirty) {
|
|
1357
|
+
const desc = ext.collection(name);
|
|
1358
|
+
if (desc.visibility !== "spatial-grid") {
|
|
1359
|
+
out.set(name, changed);
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1362
|
+
const visible = memberships.get(name) ?? /* @__PURE__ */ new Set();
|
|
1363
|
+
const kept = collectionDirty(out, name);
|
|
1364
|
+
for (const id of changed.added) if (visible.has(id)) kept.added.add(id);
|
|
1365
|
+
for (const id of changed.removed) if (visible.has(id)) kept.removed.add(id);
|
|
1366
|
+
for (const [id, record] of changed.updated) if (visible.has(id)) kept.updated.set(id, record);
|
|
1367
|
+
}
|
|
1368
|
+
return out;
|
|
842
1369
|
}
|
|
843
1370
|
function encodeViewDelta(ext, plain, dirty, role, tick) {
|
|
844
1371
|
const keep = visibleNames(ext, role);
|
|
@@ -891,27 +1418,35 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
|
|
|
891
1418
|
}
|
|
892
1419
|
|
|
893
1420
|
// src/core/snapshot.ts
|
|
894
|
-
import { ByteReader, ByteWriter } from "@irtio/schema";
|
|
895
|
-
var SNAPSHOT_FORMAT_VERSION =
|
|
1421
|
+
import { ByteReader as ByteReader2, ByteWriter as ByteWriter2 } from "@irtio/schema";
|
|
1422
|
+
var SNAPSHOT_FORMAT_VERSION = 2;
|
|
1423
|
+
var READABLE_SNAPSHOT_VERSIONS = [1, 2];
|
|
896
1424
|
function parseHibernationBlob(bytes) {
|
|
897
|
-
const r = new
|
|
1425
|
+
const r = new ByteReader2(bytes);
|
|
898
1426
|
const version = r.u8();
|
|
899
|
-
if (version
|
|
1427
|
+
if (!READABLE_SNAPSHOT_VERSIONS.includes(version)) {
|
|
900
1428
|
throw new Error(`RoomCore.restore: unsupported snapshot format version ${version}`);
|
|
901
1429
|
}
|
|
902
1430
|
const seed = r.u32();
|
|
903
1431
|
const rngState = r.u32();
|
|
904
1432
|
const tick = r.u32();
|
|
905
1433
|
const mode = r.u8() === 1 ? "event" : "tick";
|
|
906
|
-
|
|
1434
|
+
let physics;
|
|
1435
|
+
if (version >= 2) {
|
|
1436
|
+
const section = r.blob();
|
|
1437
|
+
if (section.length > 0) physics = section;
|
|
1438
|
+
}
|
|
1439
|
+
return { version, seed, rngState, tick, mode, physics, snapshot: r.rest() };
|
|
907
1440
|
}
|
|
908
|
-
function writeHibernationBlob(header, snapshot) {
|
|
909
|
-
const
|
|
910
|
-
w.
|
|
1441
|
+
function writeHibernationBlob(header, snapshot, physics) {
|
|
1442
|
+
const version = physics ? 2 : 1;
|
|
1443
|
+
const w = new ByteWriter2(snapshot.length + (physics?.length ?? 0) + 24);
|
|
1444
|
+
w.u8(version);
|
|
911
1445
|
w.u32(header.seed >>> 0);
|
|
912
1446
|
w.u32(header.rngState >>> 0);
|
|
913
1447
|
w.u32(header.tick >>> 0);
|
|
914
1448
|
w.u8(header.mode === "event" ? 1 : 0);
|
|
1449
|
+
if (physics) w.blob(physics);
|
|
915
1450
|
w.bytes(snapshot);
|
|
916
1451
|
return w.finish();
|
|
917
1452
|
}
|
|
@@ -930,12 +1465,78 @@ import {
|
|
|
930
1465
|
createDirtySet as createDirtySet2,
|
|
931
1466
|
createState,
|
|
932
1467
|
decodeSnapshot,
|
|
1468
|
+
encodeDelta as encodeDelta4,
|
|
933
1469
|
encodeSnapshot as encodeSnapshot2,
|
|
934
1470
|
isDirtyEmpty as isDirtyEmpty3,
|
|
935
1471
|
track,
|
|
936
1472
|
validateForDeploy
|
|
937
1473
|
} from "@irtio/schema";
|
|
938
1474
|
|
|
1475
|
+
// src/core/host-calls.ts
|
|
1476
|
+
var states2 = /* @__PURE__ */ new WeakMap();
|
|
1477
|
+
function stateOf2(core) {
|
|
1478
|
+
let s = states2.get(core);
|
|
1479
|
+
if (!s) {
|
|
1480
|
+
s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
|
|
1481
|
+
states2.set(core, s);
|
|
1482
|
+
}
|
|
1483
|
+
return s;
|
|
1484
|
+
}
|
|
1485
|
+
function startHostCall(core, call, map) {
|
|
1486
|
+
return new Promise((resolve, reject2) => {
|
|
1487
|
+
if (core.stopped) {
|
|
1488
|
+
reject2(new Error(`room.${call.kind}: the room is stopped`));
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
const s = stateOf2(core);
|
|
1492
|
+
const reqId = s.nextReqId;
|
|
1493
|
+
s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
|
|
1494
|
+
s.pending.set(reqId, {
|
|
1495
|
+
kind: call.kind,
|
|
1496
|
+
deadline: core.host.now() + HOST_CALL_TIMEOUT_MS,
|
|
1497
|
+
map,
|
|
1498
|
+
resolve,
|
|
1499
|
+
reject: reject2
|
|
1500
|
+
});
|
|
1501
|
+
try {
|
|
1502
|
+
core.host.hostCall(reqId, call);
|
|
1503
|
+
} catch (err) {
|
|
1504
|
+
s.pending.delete(reqId);
|
|
1505
|
+
reject2(err instanceof Error ? err : new Error(String(err)));
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if (core.mode === "event") {
|
|
1509
|
+
core.loop.after(HOST_CALL_TIMEOUT_MS, () => checkHostCallTimeouts(core));
|
|
1510
|
+
}
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
function completeHostCall(core, reqId, result) {
|
|
1514
|
+
const s = stateOf2(core);
|
|
1515
|
+
const pending = s.pending.get(reqId);
|
|
1516
|
+
if (!pending) return false;
|
|
1517
|
+
s.pending.delete(reqId);
|
|
1518
|
+
if (result.ok) pending.resolve(pending.map(result.value));
|
|
1519
|
+
else pending.reject(new Error(`${result.code}: ${result.message}`));
|
|
1520
|
+
return true;
|
|
1521
|
+
}
|
|
1522
|
+
function checkHostCallTimeouts(core) {
|
|
1523
|
+
const s = stateOf2(core);
|
|
1524
|
+
if (s.pending.size === 0) return;
|
|
1525
|
+
const now = core.host.now();
|
|
1526
|
+
for (const [reqId, p] of [...s.pending]) {
|
|
1527
|
+
if (p.deadline > now) continue;
|
|
1528
|
+
s.pending.delete(reqId);
|
|
1529
|
+
p.reject(new Error(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
function rejectAllHostCalls(core, reason) {
|
|
1533
|
+
const s = stateOf2(core);
|
|
1534
|
+
for (const [reqId, p] of [...s.pending]) {
|
|
1535
|
+
s.pending.delete(reqId);
|
|
1536
|
+
p.reject(new Error(reason));
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
939
1540
|
// src/core/messages.ts
|
|
940
1541
|
import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
|
|
941
1542
|
function toRoomTarget(target) {
|
|
@@ -1002,9 +1603,40 @@ function sendMessage(core, target, bytes) {
|
|
|
1002
1603
|
// src/core/room-api.ts
|
|
1003
1604
|
import { FrameType as FrameType4, encodeFrame as encodeFrame3 } from "@irtio/protocol";
|
|
1004
1605
|
import { encodeDelta as encodeDelta3, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
|
|
1606
|
+
function makePhysicsApi(p) {
|
|
1607
|
+
return {
|
|
1608
|
+
get rapier() {
|
|
1609
|
+
return p.rapier;
|
|
1610
|
+
},
|
|
1611
|
+
get world() {
|
|
1612
|
+
return p.world;
|
|
1613
|
+
},
|
|
1614
|
+
get timestep() {
|
|
1615
|
+
return p.timestep;
|
|
1616
|
+
},
|
|
1617
|
+
body(collection, id) {
|
|
1618
|
+
return p.bodyFor(collection, id);
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
function makeKv(core) {
|
|
1623
|
+
return {
|
|
1624
|
+
get(playerId, key) {
|
|
1625
|
+
return startHostCall(core, { kind: "kvGet", playerId, key }, (v) => v);
|
|
1626
|
+
},
|
|
1627
|
+
set(playerId, key, value) {
|
|
1628
|
+
return startHostCall(core, { kind: "kvSet", playerId, key, value }, () => void 0);
|
|
1629
|
+
},
|
|
1630
|
+
delete(playerId, key) {
|
|
1631
|
+
return startHostCall(core, { kind: "kvDelete", playerId, key }, () => void 0);
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1005
1635
|
function createRoomApi(core, publicUrl) {
|
|
1006
1636
|
let cached;
|
|
1007
1637
|
let lastNow = Number.NEGATIVE_INFINITY;
|
|
1638
|
+
let physicsApi;
|
|
1639
|
+
let kvApi;
|
|
1008
1640
|
const link = () => {
|
|
1009
1641
|
const sep = publicUrl.includes("?") ? "&" : "?";
|
|
1010
1642
|
return `${publicUrl}${sep}room=${encodeURIComponent(core.roomId)}`;
|
|
@@ -1038,6 +1670,19 @@ function createRoomApi(core, publicUrl) {
|
|
|
1038
1670
|
random() {
|
|
1039
1671
|
return core.rng.next();
|
|
1040
1672
|
},
|
|
1673
|
+
// D22. A single object, built once: `room.physics` is read on every tick of a physics room.
|
|
1674
|
+
// In a room with no `physics:` config, touching it is a mistake worth naming loudly rather
|
|
1675
|
+
// than a silent `undefined` that shows up three frames later as "cannot read world of
|
|
1676
|
+
// undefined". The property itself is safe to *hold*; only reading through it throws.
|
|
1677
|
+
get physics() {
|
|
1678
|
+
const p = core.physics;
|
|
1679
|
+
if (!p) {
|
|
1680
|
+
throw new Error(
|
|
1681
|
+
"room.physics: this room has no physics \u2014 add physics: { engine: 'rapier3d', gravity, bodies } to defineRoom(...)"
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
return physicsApi ?? (physicsApi = makePhysicsApi(p));
|
|
1685
|
+
},
|
|
1041
1686
|
send(target, bytes) {
|
|
1042
1687
|
sendMessage(core, target, bytes);
|
|
1043
1688
|
},
|
|
@@ -1073,6 +1718,27 @@ function createRoomApi(core, publicUrl) {
|
|
|
1073
1718
|
clearInterval(handle) {
|
|
1074
1719
|
core.loop.clearTimer(handle);
|
|
1075
1720
|
},
|
|
1721
|
+
save() {
|
|
1722
|
+
return startHostCall(core, { kind: "save" }, (id) => id ?? "");
|
|
1723
|
+
},
|
|
1724
|
+
get kv() {
|
|
1725
|
+
return kvApi ?? (kvApi = makeKv(core));
|
|
1726
|
+
},
|
|
1727
|
+
alarm(name, atMs) {
|
|
1728
|
+
if (!isAlarmName(core, name, "room.alarm")) return;
|
|
1729
|
+
if (!Number.isFinite(atMs)) {
|
|
1730
|
+
core.log("warn", `room.alarm: ${JSON.stringify(name)} needs a finite time; ignored`);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
core.host.setAlarm(name, atMs);
|
|
1734
|
+
},
|
|
1735
|
+
cancelAlarm(name) {
|
|
1736
|
+
if (typeof name !== "string" || name === "") {
|
|
1737
|
+
core.log("warn", "room.cancelAlarm: a name is required; ignored");
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
core.host.setAlarm(name, void 0);
|
|
1741
|
+
},
|
|
1076
1742
|
call(clientId) {
|
|
1077
1743
|
return createCallProxy(core, clientId);
|
|
1078
1744
|
},
|
|
@@ -1088,6 +1754,21 @@ function createRoomApi(core, publicUrl) {
|
|
|
1088
1754
|
}
|
|
1089
1755
|
};
|
|
1090
1756
|
}
|
|
1757
|
+
function isAlarmName(core, name, where) {
|
|
1758
|
+
if (typeof name !== "string" || name === "") {
|
|
1759
|
+
core.log("warn", `${where}: a name is required; ignored`);
|
|
1760
|
+
return false;
|
|
1761
|
+
}
|
|
1762
|
+
const alarms = core.definition.config.alarms;
|
|
1763
|
+
if (!alarms || typeof alarms[name] !== "function") {
|
|
1764
|
+
core.log(
|
|
1765
|
+
"warn",
|
|
1766
|
+
`${where}: no handler named ${JSON.stringify(name)} \u2014 add it to the room's \`alarms: { \u2026 }\` config; the alarm was not armed`
|
|
1767
|
+
);
|
|
1768
|
+
return false;
|
|
1769
|
+
}
|
|
1770
|
+
return true;
|
|
1771
|
+
}
|
|
1091
1772
|
function setRole(core, clientId, role) {
|
|
1092
1773
|
const entry = core.clients.get(clientId);
|
|
1093
1774
|
if (!entry) {
|
|
@@ -1115,6 +1796,7 @@ function setRole(core, clientId, role) {
|
|
|
1115
1796
|
|
|
1116
1797
|
// src/core/room.ts
|
|
1117
1798
|
var DEFAULT_PUBLIC_URL = "http://localhost/";
|
|
1799
|
+
var CONTINUATION_FLUSH_TURNS = 8;
|
|
1118
1800
|
function ownJoinCapture(plain, name, added, clientId) {
|
|
1119
1801
|
const coll = plainEntity(plain, name);
|
|
1120
1802
|
let mine;
|
|
@@ -1137,6 +1819,8 @@ var RoomCore = class _RoomCore {
|
|
|
1137
1819
|
rng;
|
|
1138
1820
|
clients = /* @__PURE__ */ new Map();
|
|
1139
1821
|
loop;
|
|
1822
|
+
/** D22: the Rapier world, or `undefined` in a room whose config declares no physics. */
|
|
1823
|
+
physics;
|
|
1140
1824
|
stats = {
|
|
1141
1825
|
ticks: 0,
|
|
1142
1826
|
lastTickMs: 0,
|
|
@@ -1148,14 +1832,30 @@ var RoomCore = class _RoomCore {
|
|
|
1148
1832
|
bytesOutByClient: /* @__PURE__ */ new Map(),
|
|
1149
1833
|
handlerErrors: 0,
|
|
1150
1834
|
encodesLastFlush: 0,
|
|
1835
|
+
aoiEncodesLastFlush: 0,
|
|
1836
|
+
gridBuildMs: 0,
|
|
1837
|
+
gridQueryMs: 0,
|
|
1838
|
+
aoiEncodeMs: 0,
|
|
1839
|
+
visibleIdsTotal: 0,
|
|
1840
|
+
membershipEnters: 0,
|
|
1841
|
+
membershipLeaves: 0,
|
|
1151
1842
|
corrections: 0
|
|
1152
1843
|
};
|
|
1153
1844
|
tick = 0;
|
|
1154
1845
|
stopped = false;
|
|
1846
|
+
/**
|
|
1847
|
+
* Called for every handler throw `tryRun` swallows, before it is logged. A test harness sets
|
|
1848
|
+
* this so a room that breaks fails the test that broke it (bug 2): the runtime's job is to
|
|
1849
|
+
* keep the room up in production, but under test that same guarding turns a broken handler
|
|
1850
|
+
* into a timeout in an unrelated assertion ten seconds later. Unset in production.
|
|
1851
|
+
*/
|
|
1852
|
+
onHandlerError;
|
|
1155
1853
|
seed;
|
|
1156
1854
|
api;
|
|
1157
1855
|
internals;
|
|
1158
1856
|
started = false;
|
|
1857
|
+
/** One pending continuation flush at a time; concurrent completions coalesce into it. */
|
|
1858
|
+
continuationFlushPending = false;
|
|
1159
1859
|
constructor(definition, host, options) {
|
|
1160
1860
|
this.definition = definition;
|
|
1161
1861
|
this.host = host;
|
|
@@ -1191,6 +1891,7 @@ var RoomCore = class _RoomCore {
|
|
|
1191
1891
|
this.internals = self;
|
|
1192
1892
|
this.loop = new Loop(self);
|
|
1193
1893
|
this.api = createRoomApi(self, options.publicUrl ?? DEFAULT_PUBLIC_URL);
|
|
1894
|
+
this.physics = this.buildPhysics(restored);
|
|
1194
1895
|
if (restored) {
|
|
1195
1896
|
const onWake = definition.config.onWake;
|
|
1196
1897
|
if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
|
|
@@ -1199,6 +1900,42 @@ var RoomCore = class _RoomCore {
|
|
|
1199
1900
|
if (onCreate) this.guard("onCreate", () => onCreate(this.state, this.room));
|
|
1200
1901
|
}
|
|
1201
1902
|
}
|
|
1903
|
+
/**
|
|
1904
|
+
* D22: builds the world, or returns `undefined` for a room with no `physics:` config.
|
|
1905
|
+
*
|
|
1906
|
+
* A v2 blob restores the world from its own bytes and `setup` does **not** run — the static
|
|
1907
|
+
* geometry is already in there. Anything else (a fresh room, a v1 blob written before this
|
|
1908
|
+
* room had physics, a migrated snapshot whose world was deliberately dropped) builds a world,
|
|
1909
|
+
* runs `setup`, and lets the first tick's `reconcile` rebuild the bodies from schema state:
|
|
1910
|
+
* positions and velocities live in schema fields, so the rebuild is faithful to what the state
|
|
1911
|
+
* says. Transient contact state — resting contacts, accumulated impulses — is not in the schema
|
|
1912
|
+
* and is lost; a stack of boxes may settle again with a small visible jolt.
|
|
1913
|
+
*/
|
|
1914
|
+
buildPhysics(restored) {
|
|
1915
|
+
const config = this.definition.config.physics;
|
|
1916
|
+
if (!config) return void 0;
|
|
1917
|
+
const engine2 = loadedPhysics();
|
|
1918
|
+
if (!engine2) {
|
|
1919
|
+
throw new Error(
|
|
1920
|
+
"RoomCore: this room declares physics but the engine is not initialized \u2014 the host must `await initPhysics()` (from '@irtio/runtime') before constructing the room; handlers are synchronous, so the WASM cannot be loaded later"
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1923
|
+
const section = restored?.physics ? decodePhysicsSection(restored.physics) : void 0;
|
|
1924
|
+
const physics = new PhysicsRuntime(this.internals, engine2, {
|
|
1925
|
+
...section ? { restore: section } : {},
|
|
1926
|
+
defaultTimestep: 1 / this.definition.config.tickRate
|
|
1927
|
+
});
|
|
1928
|
+
if (physics.rebuilt) {
|
|
1929
|
+
if (restored) {
|
|
1930
|
+
this.host.log("info", [
|
|
1931
|
+
`irtio: no physics world in this snapshot (format v${restored.version}) \u2014 rebuilding it from schema state and re-running physics.setup; contact state is not restored`
|
|
1932
|
+
]);
|
|
1933
|
+
}
|
|
1934
|
+
physics.runSetup(this.room);
|
|
1935
|
+
physics.reconcile();
|
|
1936
|
+
}
|
|
1937
|
+
return physics;
|
|
1938
|
+
}
|
|
1202
1939
|
/** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
|
|
1203
1940
|
static restore(definition, bytes, host, options) {
|
|
1204
1941
|
return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
|
|
@@ -1229,6 +1966,10 @@ var RoomCore = class _RoomCore {
|
|
|
1229
1966
|
`${name}: ${err instanceof Error ? err.message : String(err)}`
|
|
1230
1967
|
);
|
|
1231
1968
|
this.host.log("error", [`irtio: ${name} threw`, err]);
|
|
1969
|
+
try {
|
|
1970
|
+
this.onHandlerError?.(name, err);
|
|
1971
|
+
} catch {
|
|
1972
|
+
}
|
|
1232
1973
|
return { ok: false };
|
|
1233
1974
|
}
|
|
1234
1975
|
}
|
|
@@ -1275,16 +2016,35 @@ var RoomCore = class _RoomCore {
|
|
|
1275
2016
|
this.started = false;
|
|
1276
2017
|
this.loop.stop();
|
|
1277
2018
|
rejectAllPending(this.internals, "room stopped");
|
|
2019
|
+
rejectAllHostCalls(this.internals, "room stopped");
|
|
2020
|
+
this.physics?.free();
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* The hibernation blob's bytes, and **nothing else** — no `onSleep`, no timers cleared, no
|
|
2024
|
+
* pending work rejected. A save (D24) is a *copy* of the room; hibernation is the room
|
|
2025
|
+
* *leaving*. They want identical bytes and opposite side effects, so the bytes live here and
|
|
2026
|
+
* the departure lives in `serialize()`.
|
|
2027
|
+
*
|
|
2028
|
+
* Conflating the two is not hypothetical: the first cut of `room.save()` routed through
|
|
2029
|
+
* `serialize()`, which rejected every pending host call — including the `save()` that had just
|
|
2030
|
+
* asked for it. The room waited out its own 10 s deadline for a save that had already been
|
|
2031
|
+
* written.
|
|
2032
|
+
*/
|
|
2033
|
+
snapshot() {
|
|
2034
|
+
const physics = this.physics ? encodePhysicsSection(this.physics.serialize()) : void 0;
|
|
2035
|
+
return writeHibernationBlob(
|
|
2036
|
+
{ seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
|
|
2037
|
+
encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
|
|
2038
|
+
physics
|
|
2039
|
+
);
|
|
1278
2040
|
}
|
|
1279
2041
|
serialize() {
|
|
1280
2042
|
const onSleep = this.definition.config.onSleep;
|
|
1281
2043
|
if (onSleep) this.guard("onSleep", () => onSleep(this.state, this.room));
|
|
1282
2044
|
this.loop.clearAllTimers();
|
|
1283
2045
|
rejectAllPending(this.internals, "room is hibernating");
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
encodeSnapshot2(this.ext, this.plain, { tick: this.tick })
|
|
1287
|
-
);
|
|
2046
|
+
rejectAllHostCalls(this.internals, "room is hibernating");
|
|
2047
|
+
return this.snapshot();
|
|
1288
2048
|
}
|
|
1289
2049
|
// -------------------------------------------------------------------------
|
|
1290
2050
|
// Clients
|
|
@@ -1319,7 +2079,13 @@ var RoomCore = class _RoomCore {
|
|
|
1319
2079
|
return {
|
|
1320
2080
|
tick: this.tick,
|
|
1321
2081
|
role: role2,
|
|
1322
|
-
snapshot: encodeViewSnapshot(
|
|
2082
|
+
snapshot: encodeViewSnapshot(
|
|
2083
|
+
this.ext,
|
|
2084
|
+
this.plain,
|
|
2085
|
+
role2,
|
|
2086
|
+
this.tick,
|
|
2087
|
+
spatialMemberships(this.ext, this.plain, clientId, role2)
|
|
2088
|
+
)
|
|
1323
2089
|
};
|
|
1324
2090
|
}
|
|
1325
2091
|
const presence = this.presence;
|
|
@@ -1340,13 +2106,20 @@ var RoomCore = class _RoomCore {
|
|
|
1340
2106
|
}
|
|
1341
2107
|
const entry = existing ?? {
|
|
1342
2108
|
clientId,
|
|
2109
|
+
// D25: defaults to the client id, which a resume token carries across a reconnect — so for
|
|
2110
|
+
// a key join `ctx.playerId` is exactly as durable as the resume token and no more. A JWT
|
|
2111
|
+
// join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
|
|
2112
|
+
// and nothing else here changes.
|
|
2113
|
+
playerId: options.playerId ?? clientId,
|
|
1343
2114
|
role,
|
|
1344
2115
|
name,
|
|
1345
2116
|
connected: true,
|
|
1346
2117
|
correction: void 0,
|
|
1347
2118
|
accepted: /* @__PURE__ */ new Map(),
|
|
1348
2119
|
lastClientTick: 0,
|
|
1349
|
-
|
|
2120
|
+
lastAppliedTick: 0,
|
|
2121
|
+
pendingJoinAdds: void 0,
|
|
2122
|
+
spatialMembership: /* @__PURE__ */ new Map()
|
|
1350
2123
|
};
|
|
1351
2124
|
entry.role = role;
|
|
1352
2125
|
entry.name = name;
|
|
@@ -1360,12 +2133,23 @@ var RoomCore = class _RoomCore {
|
|
|
1360
2133
|
}
|
|
1361
2134
|
this.loop.noteActivity();
|
|
1362
2135
|
this.recordEvent("join", clientId, reconnecting ? "reconnect" : void 0);
|
|
1363
|
-
const
|
|
2136
|
+
const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
|
|
2137
|
+
entry.spatialMembership = memberships;
|
|
2138
|
+
const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
|
|
1364
2139
|
const pending = /* @__PURE__ */ new Map();
|
|
1365
2140
|
for (const [name2, cd] of this.tracked.dirty) {
|
|
1366
2141
|
if (cd.added.size === 0) continue;
|
|
1367
2142
|
const mine = ownJoinCapture(this.plain, name2, cd.added, clientId);
|
|
1368
|
-
|
|
2143
|
+
const visible = memberships.get(name2);
|
|
2144
|
+
if (visible) {
|
|
2145
|
+
const coll = plainEntity(this.plain, name2);
|
|
2146
|
+
const captured = mine ?? /* @__PURE__ */ new Map();
|
|
2147
|
+
for (const id of cd.added) {
|
|
2148
|
+
if (!visible.has(id) || captured.has(id)) continue;
|
|
2149
|
+
captured.set(id, { value: cloneValue2(coll.get(id)), owner: coll.ownerOf(id) });
|
|
2150
|
+
}
|
|
2151
|
+
if (captured.size > 0) pending.set(name2, captured);
|
|
2152
|
+
} else if (mine) pending.set(name2, mine);
|
|
1369
2153
|
}
|
|
1370
2154
|
entry.pendingJoinAdds = pending.size > 0 ? pending : void 0;
|
|
1371
2155
|
this.eventFlush();
|
|
@@ -1415,6 +2199,7 @@ var RoomCore = class _RoomCore {
|
|
|
1415
2199
|
const entry = this.clients.get(clientId);
|
|
1416
2200
|
return {
|
|
1417
2201
|
clientId,
|
|
2202
|
+
playerId: entry?.playerId ?? clientId,
|
|
1418
2203
|
role: entry?.role ?? "",
|
|
1419
2204
|
name: entry?.name ?? "",
|
|
1420
2205
|
tick: this.tick,
|
|
@@ -1422,6 +2207,63 @@ var RoomCore = class _RoomCore {
|
|
|
1422
2207
|
room: this.room
|
|
1423
2208
|
};
|
|
1424
2209
|
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Week 12: the host answering a `room.save()` / `room.kv.*`. The continuation runs here — its
|
|
2212
|
+
* own event, between ticks, off the back of a host turn — and the flush afterwards is what
|
|
2213
|
+
* makes "state mutated in a continuation is tracked normally" true rather than aspirational.
|
|
2214
|
+
*/
|
|
2215
|
+
completeHostCall(reqId, result) {
|
|
2216
|
+
if (this.stopped) return;
|
|
2217
|
+
if (!completeHostCall(this.internals, reqId, result)) {
|
|
2218
|
+
this.log("warn", `completeHostCall: nothing is waiting on reqId ${reqId}`);
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
checkHostCallTimeouts(this.internals);
|
|
2222
|
+
this.scheduleContinuationFlush();
|
|
2223
|
+
}
|
|
2224
|
+
/**
|
|
2225
|
+
* Flushes whatever a promise continuation wrote, as its own event, once the microtask queue
|
|
2226
|
+
* that continuation lives on has drained.
|
|
2227
|
+
*
|
|
2228
|
+
* The subtlety this exists for: `resolve()` does not run the room's `.then` — it *queues* it,
|
|
2229
|
+
* and every promise link between the resolve and the room's callback costs another microtask
|
|
2230
|
+
* turn. A single `queueMicrotask(flush)` therefore only ever catches a continuation exactly one
|
|
2231
|
+
* link deep, and silently drops the state written by anything the room chained further out.
|
|
2232
|
+
* Draining a bounded number of turns first covers the chains rooms actually write, coalesces
|
|
2233
|
+
* concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
|
|
2234
|
+
* the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
|
|
2235
|
+
*/
|
|
2236
|
+
scheduleContinuationFlush() {
|
|
2237
|
+
if (this.mode !== "event" || this.continuationFlushPending) return;
|
|
2238
|
+
this.continuationFlushPending = true;
|
|
2239
|
+
let turns = 0;
|
|
2240
|
+
const drain = () => {
|
|
2241
|
+
if (++turns < CONTINUATION_FLUSH_TURNS) {
|
|
2242
|
+
queueMicrotask(drain);
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
this.continuationFlushPending = false;
|
|
2246
|
+
if (!this.stopped) this.eventFlush();
|
|
2247
|
+
};
|
|
2248
|
+
queueMicrotask(drain);
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* D26: one durable alarm firing. Same scheduling class as an RPC — a discrete event between
|
|
2252
|
+
* ticks — so a tick-mode room never sees a `tick` run half-alarmed, and a handler that re-arms
|
|
2253
|
+
* its own name is the supported way to build a repeating timer.
|
|
2254
|
+
*/
|
|
2255
|
+
fireAlarm(name) {
|
|
2256
|
+
if (this.stopped) return;
|
|
2257
|
+
const alarms = this.definition.config.alarms;
|
|
2258
|
+
const handler = alarms?.[name];
|
|
2259
|
+
if (!handler) {
|
|
2260
|
+
this.log("warn", `alarm ${JSON.stringify(name)} fired but the room has no handler for it`);
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
this.recordEvent("alarm", void 0, name);
|
|
2264
|
+
this.guard(`alarms.${name}`, () => handler(this.state, this.room));
|
|
2265
|
+
this.eventFlush();
|
|
2266
|
+
}
|
|
1425
2267
|
correctionFor(clientId) {
|
|
1426
2268
|
const entry = this.clients.get(clientId);
|
|
1427
2269
|
if (!entry) return void 0;
|
|
@@ -1486,12 +2328,8 @@ var RoomCore = class _RoomCore {
|
|
|
1486
2328
|
case FrameType5.REPLY: {
|
|
1487
2329
|
if (!handleReply(this.internals, clientId, payload)) {
|
|
1488
2330
|
this.badFrame(clientId, "malformed REPLY payload");
|
|
1489
|
-
} else
|
|
1490
|
-
|
|
1491
|
-
if (this.stopped || isDirtyEmpty3(this.tracked.dirty)) return;
|
|
1492
|
-
this.tick++;
|
|
1493
|
-
this.flush();
|
|
1494
|
-
});
|
|
2331
|
+
} else {
|
|
2332
|
+
this.scheduleContinuationFlush();
|
|
1495
2333
|
}
|
|
1496
2334
|
return;
|
|
1497
2335
|
}
|
|
@@ -1515,15 +2353,77 @@ var RoomCore = class _RoomCore {
|
|
|
1515
2353
|
const dirty = this.tracked.flush();
|
|
1516
2354
|
serverWinsCorrections(this.internals, dirty);
|
|
1517
2355
|
this.stats.encodesLastFlush = 0;
|
|
2356
|
+
this.stats.aoiEncodesLastFlush = 0;
|
|
2357
|
+
const spatialDescs = this.ext.collections.filter((desc) => desc.visibility === "spatial-grid");
|
|
2358
|
+
const buildStarted = performance.now();
|
|
2359
|
+
const indexes = buildSpatialIndexes(spatialDescs, this.plain);
|
|
2360
|
+
this.stats.gridBuildMs += performance.now() - buildStarted;
|
|
1518
2361
|
const byView = /* @__PURE__ */ new Map();
|
|
1519
2362
|
for (const entry of this.clients.values()) {
|
|
1520
2363
|
if (entry.connected) {
|
|
2364
|
+
const queryStarted = performance.now();
|
|
2365
|
+
const memberships = spatialMemberships(
|
|
2366
|
+
this.ext,
|
|
2367
|
+
this.plain,
|
|
2368
|
+
entry.clientId,
|
|
2369
|
+
entry.role,
|
|
2370
|
+
indexes
|
|
2371
|
+
);
|
|
2372
|
+
this.stats.gridQueryMs += performance.now() - queryStarted;
|
|
2373
|
+
for (const [name, ids] of memberships) {
|
|
2374
|
+
const before = entry.spatialMembership.get(name) ?? /* @__PURE__ */ new Set();
|
|
2375
|
+
this.stats.visibleIdsTotal += ids.size;
|
|
2376
|
+
for (const id of ids) if (!before.has(id)) this.stats.membershipEnters++;
|
|
2377
|
+
for (const id of before) if (!ids.has(id)) this.stats.membershipLeaves++;
|
|
2378
|
+
}
|
|
1521
2379
|
if (entry.correction) {
|
|
1522
|
-
const payload = encodeCorrection(
|
|
1523
|
-
|
|
2380
|
+
const payload = encodeCorrection(
|
|
2381
|
+
this.internals,
|
|
2382
|
+
membershipSafeDirty(this.ext, entry.correction, memberships)
|
|
2383
|
+
);
|
|
2384
|
+
if (payload) {
|
|
2385
|
+
this.send(
|
|
2386
|
+
entry.clientId,
|
|
2387
|
+
encodeCorrectFrame(payload, entry.lastClientTick, entry.lastAppliedTick)
|
|
2388
|
+
);
|
|
2389
|
+
}
|
|
1524
2390
|
}
|
|
1525
2391
|
let delta;
|
|
1526
|
-
if (
|
|
2392
|
+
if (spatialDescs.length > 0) {
|
|
2393
|
+
const sourceDirty = entry.pendingJoinAdds ? stripAdds(this.plain, dirty, entry.pendingJoinAdds) : dirty;
|
|
2394
|
+
let shared;
|
|
2395
|
+
if (entry.pendingJoinAdds) {
|
|
2396
|
+
const sharedDirty = sharedViewDirty(this.ext, sourceDirty, entry.role);
|
|
2397
|
+
shared = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
|
|
2398
|
+
if (shared) this.stats.encodesLastFlush++;
|
|
2399
|
+
} else {
|
|
2400
|
+
const key = viewKeyFor(this.ext, entry.role);
|
|
2401
|
+
let cached = byView.get(key);
|
|
2402
|
+
if (cached === void 0) {
|
|
2403
|
+
const sharedDirty = sharedViewDirty(this.ext, dirty, entry.role);
|
|
2404
|
+
cached = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
|
|
2405
|
+
if (cached) this.stats.encodesLastFlush++;
|
|
2406
|
+
byView.set(key, cached);
|
|
2407
|
+
}
|
|
2408
|
+
shared = cached;
|
|
2409
|
+
}
|
|
2410
|
+
if (shared) this.send(entry.clientId, encodeFrame4(FrameType5.DELTA, shared));
|
|
2411
|
+
const aoiDirty = aoiViewDirty(
|
|
2412
|
+
this.ext,
|
|
2413
|
+
sourceDirty,
|
|
2414
|
+
entry.spatialMembership,
|
|
2415
|
+
memberships
|
|
2416
|
+
);
|
|
2417
|
+
const encodeStarted = performance.now();
|
|
2418
|
+
delta = isDirtyEmpty3(aoiDirty) ? null : encodeDelta4(this.ext, this.plain, aoiDirty, { tick: this.tick });
|
|
2419
|
+
this.stats.aoiEncodeMs += performance.now() - encodeStarted;
|
|
2420
|
+
if (delta) {
|
|
2421
|
+
this.stats.encodesLastFlush++;
|
|
2422
|
+
this.stats.aoiEncodesLastFlush++;
|
|
2423
|
+
}
|
|
2424
|
+
entry.pendingJoinAdds = void 0;
|
|
2425
|
+
entry.spatialMembership = memberships;
|
|
2426
|
+
} else if (entry.pendingJoinAdds) {
|
|
1527
2427
|
delta = encodeViewDelta(
|
|
1528
2428
|
this.ext,
|
|
1529
2429
|
this.plain,
|
|
@@ -1552,21 +2452,29 @@ var RoomCore = class _RoomCore {
|
|
|
1552
2452
|
};
|
|
1553
2453
|
|
|
1554
2454
|
export {
|
|
2455
|
+
HOST_CALL_TIMEOUT_MS,
|
|
1555
2456
|
RoomFullError,
|
|
1556
2457
|
EVENT_RING_SIZE,
|
|
1557
2458
|
inspectState,
|
|
1558
2459
|
RPC_TIMEOUT_MS,
|
|
1559
2460
|
MAX_CATCHUP,
|
|
1560
2461
|
CRASH_AFTER_THROWS,
|
|
2462
|
+
initPhysics,
|
|
2463
|
+
loadedPhysics,
|
|
2464
|
+
resetPhysicsForTests,
|
|
2465
|
+
encodePhysicsSection,
|
|
2466
|
+
decodePhysicsSection,
|
|
1561
2467
|
Mulberry32,
|
|
1562
2468
|
isVisible,
|
|
1563
2469
|
visibleTo,
|
|
1564
2470
|
visibleNames,
|
|
1565
2471
|
viewKeyFor,
|
|
1566
2472
|
encodeViewSnapshot,
|
|
2473
|
+
createVisibilityPolicy,
|
|
1567
2474
|
encodeViewDelta,
|
|
1568
2475
|
catchUpDirty,
|
|
1569
2476
|
SNAPSHOT_FORMAT_VERSION,
|
|
2477
|
+
READABLE_SNAPSHOT_VERSIONS,
|
|
1570
2478
|
parseHibernationBlob,
|
|
1571
2479
|
writeHibernationBlob,
|
|
1572
2480
|
RoomCore
|