@irtio/runtime 0.1.0 → 0.2.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-3DQHT4CM.js → chunk-FA4IOC2Z.js} +1 -1
- package/dist/{chunk-X5S364FY.js → chunk-GKMD3ICD.js} +888 -31
- 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-BfALTh7M.d.ts +431 -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})`);
|
|
@@ -463,6 +464,26 @@ function applyOp(core, clientId, c, op) {
|
|
|
463
464
|
const prev = cloneValue(current);
|
|
464
465
|
const next = cloneValue(current);
|
|
465
466
|
applyMaskedPatch(fields, next, op.patch, op.mask);
|
|
467
|
+
const requested = cloneValue(next);
|
|
468
|
+
const simulated = c.physics;
|
|
469
|
+
if (simulated) {
|
|
470
|
+
const refused = [];
|
|
471
|
+
for (const leaf of leafPaths(fields, op.mask)) {
|
|
472
|
+
const field = leaf.names[0];
|
|
473
|
+
if (simulated.intents.includes(field)) continue;
|
|
474
|
+
next[field] = cloneValue(prev[field]);
|
|
475
|
+
refused.push(
|
|
476
|
+
simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
if (refused.length > 0) {
|
|
480
|
+
core.recordEvent("write-rejected", clientId, `${c.name}: ${refused.join(", ")}`);
|
|
481
|
+
core.log(
|
|
482
|
+
"warn",
|
|
483
|
+
`WRITE to ${c.name}.${op.id} from ${clientId} touched fields the owner does not write: ${refused.join(", ")}`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
466
487
|
const validator = core.definition.config.validate?.[c.name];
|
|
467
488
|
let result = next;
|
|
468
489
|
if (validator) {
|
|
@@ -493,7 +514,7 @@ function applyOp(core, clientId, c, op) {
|
|
|
493
514
|
for (const leaf of written) map.set(leafKey(leaf), cloneValue(valueAt(result, leaf.names)));
|
|
494
515
|
}
|
|
495
516
|
const corrected = written.filter(
|
|
496
|
-
(leaf) => !deepEqual(valueAt(result, leaf.names), valueAt(
|
|
517
|
+
(leaf) => !deepEqual(valueAt(result, leaf.names), valueAt(requested, leaf.names))
|
|
497
518
|
);
|
|
498
519
|
correctLeaves(core, clientId, c.name, op.id, corrected);
|
|
499
520
|
}
|
|
@@ -567,7 +588,7 @@ var Loop = class {
|
|
|
567
588
|
this.lastActivity = this.lastWake;
|
|
568
589
|
this.accumulator = 0;
|
|
569
590
|
if (this.core.mode === "tick") this.scheduleTick();
|
|
570
|
-
|
|
591
|
+
this.armIdle(this.core.definition.config.idleMs);
|
|
571
592
|
}
|
|
572
593
|
stop() {
|
|
573
594
|
this.running = false;
|
|
@@ -642,7 +663,7 @@ var Loop = class {
|
|
|
642
663
|
}
|
|
643
664
|
if (this.running && !this.core.stopped) this.scheduleTick();
|
|
644
665
|
}
|
|
645
|
-
/** One tick: inbound → room timers → `tick(state, dt, room)` → flush. */
|
|
666
|
+
/** One tick: inbound → room timers → `tick(state, dt, room)` → physics step → flush. */
|
|
646
667
|
runTick() {
|
|
647
668
|
const host = this.core.host;
|
|
648
669
|
const started = host.now();
|
|
@@ -671,6 +692,14 @@ var Loop = class {
|
|
|
671
692
|
}
|
|
672
693
|
}
|
|
673
694
|
}
|
|
695
|
+
const physics = this.core.physics;
|
|
696
|
+
if (physics) {
|
|
697
|
+
this.core.tryRun("physics", () => {
|
|
698
|
+
physics.reconcile();
|
|
699
|
+
physics.step();
|
|
700
|
+
physics.sync();
|
|
701
|
+
});
|
|
702
|
+
}
|
|
674
703
|
this.core.flush();
|
|
675
704
|
const elapsed = Math.max(0, host.now() - started);
|
|
676
705
|
const stats = this.core.stats;
|
|
@@ -697,7 +726,6 @@ var Loop = class {
|
|
|
697
726
|
return ok;
|
|
698
727
|
}
|
|
699
728
|
noteActivity() {
|
|
700
|
-
if (this.core.mode !== "event") return;
|
|
701
729
|
this.lastActivity = this.core.host.now();
|
|
702
730
|
this.slept = false;
|
|
703
731
|
if (this.running && this.idleHandle === void 0) {
|
|
@@ -711,6 +739,12 @@ var Loop = class {
|
|
|
711
739
|
this.idleHandle = void 0;
|
|
712
740
|
if (!this.running || this.core.stopped) return;
|
|
713
741
|
const idleMs = this.core.definition.config.idleMs;
|
|
742
|
+
if (idleMs <= 0) return;
|
|
743
|
+
if (this.core.mode === "tick" && this.core.clients.size > 0) {
|
|
744
|
+
this.lastActivity = this.core.host.now();
|
|
745
|
+
this.armIdle(idleMs);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
714
748
|
const waited = this.core.host.now() - this.lastActivity;
|
|
715
749
|
if (waited >= idleMs) {
|
|
716
750
|
if (!this.slept) {
|
|
@@ -774,6 +808,325 @@ var Loop = class {
|
|
|
774
808
|
}
|
|
775
809
|
};
|
|
776
810
|
|
|
811
|
+
// src/core/physics.ts
|
|
812
|
+
import { ByteReader, ByteWriter } from "@irtio/schema";
|
|
813
|
+
var engine;
|
|
814
|
+
var loading;
|
|
815
|
+
async function initPhysics() {
|
|
816
|
+
if (engine) return engine;
|
|
817
|
+
loading ??= (async () => {
|
|
818
|
+
const mod = await import("@dimforge/rapier3d-compat");
|
|
819
|
+
const ns = mod.default ?? mod;
|
|
820
|
+
await ns.init();
|
|
821
|
+
engine = ns;
|
|
822
|
+
return ns;
|
|
823
|
+
})();
|
|
824
|
+
return loading;
|
|
825
|
+
}
|
|
826
|
+
function loadedPhysics() {
|
|
827
|
+
return engine;
|
|
828
|
+
}
|
|
829
|
+
function resetPhysicsForTests() {
|
|
830
|
+
engine = void 0;
|
|
831
|
+
loading = void 0;
|
|
832
|
+
}
|
|
833
|
+
function encodePhysicsSection(section) {
|
|
834
|
+
const w = new ByteWriter(section.world.length + section.bodies.length * 24 + 8);
|
|
835
|
+
w.blob(section.world);
|
|
836
|
+
w.varint(section.bodies.length);
|
|
837
|
+
for (const [name, id, handle] of section.bodies) {
|
|
838
|
+
w.str(name);
|
|
839
|
+
w.str(id);
|
|
840
|
+
w.f64(handle);
|
|
841
|
+
}
|
|
842
|
+
return w.finish();
|
|
843
|
+
}
|
|
844
|
+
function decodePhysicsSection(bytes) {
|
|
845
|
+
const r = new ByteReader(bytes);
|
|
846
|
+
const world = r.blob().slice();
|
|
847
|
+
const count = r.varint();
|
|
848
|
+
const bodies = [];
|
|
849
|
+
for (let i = 0; i < count; i++) bodies.push([r.str(), r.str(), r.f64()]);
|
|
850
|
+
return { world, bodies };
|
|
851
|
+
}
|
|
852
|
+
function bodyKey(collection, id) {
|
|
853
|
+
return `${collection}\0${id}`;
|
|
854
|
+
}
|
|
855
|
+
var PhysicsRuntime = class {
|
|
856
|
+
rapier;
|
|
857
|
+
world;
|
|
858
|
+
/** `true` when the world was built from scratch and `setup` has to run. */
|
|
859
|
+
rebuilt;
|
|
860
|
+
core;
|
|
861
|
+
config;
|
|
862
|
+
/** Physics-backed collections, in schema (name-sorted) order. */
|
|
863
|
+
collections;
|
|
864
|
+
bodies = /* @__PURE__ */ new Map();
|
|
865
|
+
/**
|
|
866
|
+
* Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
|
|
867
|
+
* to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
|
|
868
|
+
* tick it falls asleep, or the schema keeps a phantom residual velocity forever (and a
|
|
869
|
+
* world rebuilt from schema state would wake it with a kick). Found by the drift check.
|
|
870
|
+
*/
|
|
871
|
+
sleepSynced = /* @__PURE__ */ new Set();
|
|
872
|
+
constructor(core, rapier, options) {
|
|
873
|
+
const config = core.definition.config.physics;
|
|
874
|
+
if (!config) throw new Error("PhysicsRuntime: the room config declares no physics");
|
|
875
|
+
this.core = core;
|
|
876
|
+
this.config = config;
|
|
877
|
+
this.rapier = rapier;
|
|
878
|
+
this.collections = core.ext.collections.filter(
|
|
879
|
+
(c) => c.physics !== void 0
|
|
880
|
+
);
|
|
881
|
+
if (options.restore) {
|
|
882
|
+
this.world = rapier.World.restoreSnapshot(options.restore.world);
|
|
883
|
+
this.rebuilt = false;
|
|
884
|
+
for (const [name, id, handle] of options.restore.bodies) {
|
|
885
|
+
const body = this.world.getRigidBody(handle);
|
|
886
|
+
if (body) this.bodies.set(bodyKey(name, id), body);
|
|
887
|
+
}
|
|
888
|
+
} else {
|
|
889
|
+
const g = config.gravity;
|
|
890
|
+
this.world = new rapier.World({ x: g.x, y: g.y, z: g.z });
|
|
891
|
+
this.rebuilt = true;
|
|
892
|
+
}
|
|
893
|
+
this.world.timestep = config.timestep ?? options.defaultTimestep;
|
|
894
|
+
}
|
|
895
|
+
get timestep() {
|
|
896
|
+
return this.world.timestep;
|
|
897
|
+
}
|
|
898
|
+
/** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
|
|
899
|
+
runSetup(room) {
|
|
900
|
+
const setup = this.config.setup;
|
|
901
|
+
if (!setup) return;
|
|
902
|
+
this.core.guard("physics.setup", () => setup(this.world, this.rapier, room));
|
|
903
|
+
}
|
|
904
|
+
free() {
|
|
905
|
+
this.bodies.clear();
|
|
906
|
+
this.world.free();
|
|
907
|
+
}
|
|
908
|
+
// -------------------------------------------------------------------------
|
|
909
|
+
// Bodies
|
|
910
|
+
// -------------------------------------------------------------------------
|
|
911
|
+
/** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
|
|
912
|
+
bodyFor(collection, id) {
|
|
913
|
+
const existing = this.bodies.get(bodyKey(collection, id));
|
|
914
|
+
if (existing) return existing;
|
|
915
|
+
const desc = this.collections.find((c) => c.name === collection);
|
|
916
|
+
if (!desc) return void 0;
|
|
917
|
+
const coll = plainEntity(this.core.plain, collection);
|
|
918
|
+
const record = coll.get(id);
|
|
919
|
+
if (record === void 0) return void 0;
|
|
920
|
+
return this.create(desc, id, record);
|
|
921
|
+
}
|
|
922
|
+
create(desc, id, record) {
|
|
923
|
+
const factory = this.config.bodies?.[desc.name];
|
|
924
|
+
if (!factory) {
|
|
925
|
+
this.core.log("error", `irtio: physics.bodies.${desc.name} is missing; no body created`);
|
|
926
|
+
return void 0;
|
|
927
|
+
}
|
|
928
|
+
const spec = this.core.guard(
|
|
929
|
+
`physics.bodies.${desc.name}`,
|
|
930
|
+
() => factory(this.rapier, record, id)
|
|
931
|
+
);
|
|
932
|
+
if (!spec || !spec.body) {
|
|
933
|
+
this.core.log(
|
|
934
|
+
"error",
|
|
935
|
+
`irtio: physics.bodies.${desc.name} returned no { body } for ${JSON.stringify(id)}`
|
|
936
|
+
);
|
|
937
|
+
return void 0;
|
|
938
|
+
}
|
|
939
|
+
const body = this.world.createRigidBody(spec.body);
|
|
940
|
+
for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
|
|
941
|
+
this.applyRecordToBody(desc, body, record);
|
|
942
|
+
this.bodies.set(bodyKey(desc.name, id), body);
|
|
943
|
+
return body;
|
|
944
|
+
}
|
|
945
|
+
applyRecordToBody(desc, body, record) {
|
|
946
|
+
const physics = desc.physics;
|
|
947
|
+
if (!physics) return;
|
|
948
|
+
const t = { ...body.translation() };
|
|
949
|
+
const r = { ...body.rotation() };
|
|
950
|
+
const v = { ...body.linvel() };
|
|
951
|
+
const w = { ...body.angvel() };
|
|
952
|
+
let setT = false;
|
|
953
|
+
let setR = false;
|
|
954
|
+
let setV = false;
|
|
955
|
+
let setW = false;
|
|
956
|
+
for (const [channel, field] of physics.channels) {
|
|
957
|
+
const raw = record[field];
|
|
958
|
+
if (typeof raw !== "number") continue;
|
|
959
|
+
switch (channel) {
|
|
960
|
+
case "x":
|
|
961
|
+
case "y":
|
|
962
|
+
case "z":
|
|
963
|
+
t[channel] = raw;
|
|
964
|
+
setT = true;
|
|
965
|
+
break;
|
|
966
|
+
case "qx":
|
|
967
|
+
r.x = raw;
|
|
968
|
+
setR = true;
|
|
969
|
+
break;
|
|
970
|
+
case "qy":
|
|
971
|
+
r.y = raw;
|
|
972
|
+
setR = true;
|
|
973
|
+
break;
|
|
974
|
+
case "qz":
|
|
975
|
+
r.z = raw;
|
|
976
|
+
setR = true;
|
|
977
|
+
break;
|
|
978
|
+
case "qw":
|
|
979
|
+
r.w = raw;
|
|
980
|
+
setR = true;
|
|
981
|
+
break;
|
|
982
|
+
case "vx":
|
|
983
|
+
v.x = raw;
|
|
984
|
+
setV = true;
|
|
985
|
+
break;
|
|
986
|
+
case "vy":
|
|
987
|
+
v.y = raw;
|
|
988
|
+
setV = true;
|
|
989
|
+
break;
|
|
990
|
+
case "vz":
|
|
991
|
+
v.z = raw;
|
|
992
|
+
setV = true;
|
|
993
|
+
break;
|
|
994
|
+
case "wx":
|
|
995
|
+
w.x = raw;
|
|
996
|
+
setW = true;
|
|
997
|
+
break;
|
|
998
|
+
case "wy":
|
|
999
|
+
w.y = raw;
|
|
1000
|
+
setW = true;
|
|
1001
|
+
break;
|
|
1002
|
+
case "wz":
|
|
1003
|
+
w.z = raw;
|
|
1004
|
+
setW = true;
|
|
1005
|
+
break;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (setT) body.setTranslation(t, true);
|
|
1009
|
+
if (setR) body.setRotation(r, true);
|
|
1010
|
+
if (setV) body.setLinvel(v, true);
|
|
1011
|
+
if (setW) body.setAngvel(w, true);
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
|
|
1015
|
+
* tick, right before the step, in collection order then instance order.
|
|
1016
|
+
*/
|
|
1017
|
+
reconcile() {
|
|
1018
|
+
const live = /* @__PURE__ */ new Set();
|
|
1019
|
+
for (const desc of this.collections) {
|
|
1020
|
+
const coll = plainEntity(this.core.plain, desc.name);
|
|
1021
|
+
for (const id of coll.ids()) {
|
|
1022
|
+
const key = bodyKey(desc.name, id);
|
|
1023
|
+
live.add(key);
|
|
1024
|
+
if (this.bodies.has(key)) continue;
|
|
1025
|
+
const record = coll.get(id);
|
|
1026
|
+
if (record !== void 0) this.create(desc, id, record);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
for (const [key, body] of [...this.bodies]) {
|
|
1030
|
+
if (live.has(key)) continue;
|
|
1031
|
+
this.bodies.delete(key);
|
|
1032
|
+
this.sleepSynced.delete(key);
|
|
1033
|
+
this.world.removeRigidBody(body);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// -------------------------------------------------------------------------
|
|
1037
|
+
// Step and sync
|
|
1038
|
+
// -------------------------------------------------------------------------
|
|
1039
|
+
step() {
|
|
1040
|
+
this.world.step();
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
|
|
1044
|
+
* Values are `Math.fround`ed for f32 fields, so what room code reads is exactly what the wire
|
|
1045
|
+
* carries — and so a field that has not really moved does not re-dirty every tick.
|
|
1046
|
+
*/
|
|
1047
|
+
sync() {
|
|
1048
|
+
for (const desc of this.collections) {
|
|
1049
|
+
const physics = desc.physics;
|
|
1050
|
+
if (!physics) continue;
|
|
1051
|
+
const tracked = this.core.anyState[desc.name];
|
|
1052
|
+
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
1053
|
+
const rounders = roundersFor(desc);
|
|
1054
|
+
for (const id of plainColl.ids()) {
|
|
1055
|
+
const key = bodyKey(desc.name, id);
|
|
1056
|
+
const body = this.bodies.get(key);
|
|
1057
|
+
if (!body) continue;
|
|
1058
|
+
if (body.isSleeping()) {
|
|
1059
|
+
if (this.sleepSynced.has(key)) continue;
|
|
1060
|
+
this.sleepSynced.add(key);
|
|
1061
|
+
} else {
|
|
1062
|
+
this.sleepSynced.delete(key);
|
|
1063
|
+
}
|
|
1064
|
+
const record = tracked.get(id);
|
|
1065
|
+
if (!record) continue;
|
|
1066
|
+
const t = body.translation();
|
|
1067
|
+
const r = body.rotation();
|
|
1068
|
+
const v = body.linvel();
|
|
1069
|
+
const w = body.angvel();
|
|
1070
|
+
for (const [channel, field] of physics.channels) {
|
|
1071
|
+
const next = (rounders[field] ?? identity)(channelValue(channel, t, r, v, w));
|
|
1072
|
+
if (record[field] !== next) record[field] = next;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
// -------------------------------------------------------------------------
|
|
1078
|
+
// Hibernation
|
|
1079
|
+
// -------------------------------------------------------------------------
|
|
1080
|
+
serialize() {
|
|
1081
|
+
const bodies = [];
|
|
1082
|
+
for (const [key, body] of this.bodies) {
|
|
1083
|
+
const sep = key.indexOf("\0");
|
|
1084
|
+
bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
|
|
1085
|
+
}
|
|
1086
|
+
return { world: this.world.takeSnapshot(), bodies };
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
function identity(v) {
|
|
1090
|
+
return v;
|
|
1091
|
+
}
|
|
1092
|
+
function roundersFor(desc) {
|
|
1093
|
+
const out = {};
|
|
1094
|
+
for (const f of desc.fields) {
|
|
1095
|
+
if (f.type.kind === "f32") out[f.name] = Math.fround;
|
|
1096
|
+
}
|
|
1097
|
+
return out;
|
|
1098
|
+
}
|
|
1099
|
+
function channelValue(channel, t, r, v, w) {
|
|
1100
|
+
switch (channel) {
|
|
1101
|
+
case "x":
|
|
1102
|
+
return t.x;
|
|
1103
|
+
case "y":
|
|
1104
|
+
return t.y;
|
|
1105
|
+
case "z":
|
|
1106
|
+
return t.z;
|
|
1107
|
+
case "qx":
|
|
1108
|
+
return r.x;
|
|
1109
|
+
case "qy":
|
|
1110
|
+
return r.y;
|
|
1111
|
+
case "qz":
|
|
1112
|
+
return r.z;
|
|
1113
|
+
case "qw":
|
|
1114
|
+
return r.w;
|
|
1115
|
+
case "vx":
|
|
1116
|
+
return v.x;
|
|
1117
|
+
case "vy":
|
|
1118
|
+
return v.y;
|
|
1119
|
+
case "vz":
|
|
1120
|
+
return v.z;
|
|
1121
|
+
case "wx":
|
|
1122
|
+
return w.x;
|
|
1123
|
+
case "wy":
|
|
1124
|
+
return w.y;
|
|
1125
|
+
default:
|
|
1126
|
+
return w.z;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
777
1130
|
// src/core/random.ts
|
|
778
1131
|
var Mulberry32 = class {
|
|
779
1132
|
/** Current internal state (u32). Survives hibernation. */
|
|
@@ -802,8 +1155,74 @@ import {
|
|
|
802
1155
|
isDirtyEmpty,
|
|
803
1156
|
markAdd,
|
|
804
1157
|
markPath,
|
|
805
|
-
markRemove
|
|
1158
|
+
markRemove,
|
|
1159
|
+
mergeDirty
|
|
806
1160
|
} from "@irtio/schema";
|
|
1161
|
+
|
|
1162
|
+
// src/core/spatial.ts
|
|
1163
|
+
function cellFor(grid, value) {
|
|
1164
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
1165
|
+
const record = value;
|
|
1166
|
+
const x = record[grid.x];
|
|
1167
|
+
const y = record[grid.y];
|
|
1168
|
+
if (typeof x !== "number" || typeof y !== "number") return void 0;
|
|
1169
|
+
return { x: Math.floor(x / grid.cell), y: Math.floor(y / grid.cell) };
|
|
1170
|
+
}
|
|
1171
|
+
function cellKey(cell) {
|
|
1172
|
+
return `${cell.x},${cell.y}`;
|
|
1173
|
+
}
|
|
1174
|
+
function buildSpatialIndex(plain, desc) {
|
|
1175
|
+
if (!desc.grid || desc.kind !== "entity") {
|
|
1176
|
+
throw new Error(`${desc.name}: cannot build an index without an entity grid`);
|
|
1177
|
+
}
|
|
1178
|
+
const collection = plainEntity(plain, desc.name);
|
|
1179
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
1180
|
+
const order = /* @__PURE__ */ new Map();
|
|
1181
|
+
let n = 0;
|
|
1182
|
+
for (const id of collection.ids()) {
|
|
1183
|
+
order.set(id, n++);
|
|
1184
|
+
const cell = cellFor(desc.grid, collection.get(id));
|
|
1185
|
+
if (!cell) continue;
|
|
1186
|
+
const key = cellKey(cell);
|
|
1187
|
+
let ids = buckets.get(key);
|
|
1188
|
+
if (!ids) {
|
|
1189
|
+
ids = /* @__PURE__ */ new Set();
|
|
1190
|
+
buckets.set(key, ids);
|
|
1191
|
+
}
|
|
1192
|
+
ids.add(id);
|
|
1193
|
+
}
|
|
1194
|
+
return { desc, buckets, collection, order };
|
|
1195
|
+
}
|
|
1196
|
+
function querySpatialIndex(index, clientId, role) {
|
|
1197
|
+
const grid = index.desc.grid;
|
|
1198
|
+
if ((grid.wideRoles ?? []).includes(role)) return new Set(index.collection.ids());
|
|
1199
|
+
const anchor = index.collection.get(clientId);
|
|
1200
|
+
const anchorCell = cellFor(grid, anchor);
|
|
1201
|
+
if (!anchor || !anchorCell) return /* @__PURE__ */ new Set();
|
|
1202
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1203
|
+
for (let x = anchorCell.x - grid.radius; x <= anchorCell.x + grid.radius; x++) {
|
|
1204
|
+
for (let y = anchorCell.y - grid.radius; y <= anchorCell.y + grid.radius; y++) {
|
|
1205
|
+
const ids = index.buckets.get(cellKey({ x, y }));
|
|
1206
|
+
if (ids) for (const id of ids) candidates.add(id);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
candidates.add(clientId);
|
|
1210
|
+
const ranked = [];
|
|
1211
|
+
for (const id of candidates) if (index.order.has(id)) ranked.push(id);
|
|
1212
|
+
ranked.sort((a, b) => index.order.get(a) - index.order.get(b));
|
|
1213
|
+
return new Set(ranked);
|
|
1214
|
+
}
|
|
1215
|
+
function buildSpatialIndexes(collections, plain) {
|
|
1216
|
+
const indexes = /* @__PURE__ */ new Map();
|
|
1217
|
+
for (const desc of collections) {
|
|
1218
|
+
if (desc.visibility === "spatial-grid" && desc.grid) {
|
|
1219
|
+
indexes.set(desc.name, buildSpatialIndex(plain, desc));
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return indexes;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// src/core/views.ts
|
|
807
1226
|
var cache = /* @__PURE__ */ new WeakMap();
|
|
808
1227
|
function infoOf(ext) {
|
|
809
1228
|
let i = cache.get(ext);
|
|
@@ -837,8 +1256,82 @@ function visibleNames(ext, role) {
|
|
|
837
1256
|
function viewKeyFor(ext, role) {
|
|
838
1257
|
return infoOf(ext).scoped.has(role) ? role : "all";
|
|
839
1258
|
}
|
|
840
|
-
function encodeViewSnapshot(ext, plain, role, tick) {
|
|
841
|
-
return encodeSnapshot(
|
|
1259
|
+
function encodeViewSnapshot(ext, plain, role, tick, memberships) {
|
|
1260
|
+
return encodeSnapshot(
|
|
1261
|
+
ext,
|
|
1262
|
+
plain,
|
|
1263
|
+
{ tick },
|
|
1264
|
+
{
|
|
1265
|
+
collections: visibleTo(role),
|
|
1266
|
+
entities: (c, id) => c.visibility !== "spatial-grid" || (memberships?.get(c.name)?.has(id) ?? false)
|
|
1267
|
+
}
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
function spatialMemberships(ext, plain, clientId, role, indexes = buildSpatialIndexes(ext.collections, plain)) {
|
|
1271
|
+
const out = /* @__PURE__ */ new Map();
|
|
1272
|
+
for (const [name, index] of indexes) out.set(name, querySpatialIndex(index, clientId, role));
|
|
1273
|
+
return out;
|
|
1274
|
+
}
|
|
1275
|
+
function createVisibilityPolicy(ext, plain, clientId, role) {
|
|
1276
|
+
const memberships = spatialMemberships(ext, plain, clientId, role);
|
|
1277
|
+
return {
|
|
1278
|
+
memberships,
|
|
1279
|
+
maySeeCollection: (desc) => isVisible(desc, role),
|
|
1280
|
+
maySeeEntity: (desc, id) => isVisible(desc, role) && (desc.visibility !== "spatial-grid" || (memberships.get(desc.name)?.has(id) ?? false)),
|
|
1281
|
+
describeSpatial: (desc, ids) => {
|
|
1282
|
+
const grid = desc.grid;
|
|
1283
|
+
if (desc.visibility !== "spatial-grid" || !grid) return void 0;
|
|
1284
|
+
const coll = plainEntity(plain, desc.name);
|
|
1285
|
+
const at = (id) => {
|
|
1286
|
+
const cell = cellFor(grid, coll.get(id));
|
|
1287
|
+
return cell ? `${id}@(${cell.x},${cell.y})` : `${id}@(no position)`;
|
|
1288
|
+
};
|
|
1289
|
+
return `viewer ${at(clientId)}, ${desc.name} ${ids.map(at).join(" ")}, radius ${grid.radius}`;
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
function sharedViewDirty(ext, dirty, role) {
|
|
1294
|
+
return filterDirty(dirty, (name) => {
|
|
1295
|
+
const desc = ext.collection(name);
|
|
1296
|
+
return desc.visibility !== "spatial-grid" && isVisible(desc, role);
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
function aoiViewDirty(ext, dirty, previous, current) {
|
|
1300
|
+
const out = createDirtySet();
|
|
1301
|
+
for (const desc of ext.collections) {
|
|
1302
|
+
if (desc.visibility !== "spatial-grid") continue;
|
|
1303
|
+
const before = previous.get(desc.name) ?? /* @__PURE__ */ new Set();
|
|
1304
|
+
const now = current.get(desc.name) ?? /* @__PURE__ */ new Set();
|
|
1305
|
+
for (const id of before) if (!now.has(id)) markRemove(out, desc.name, id);
|
|
1306
|
+
for (const id of now) if (!before.has(id)) markAdd(out, desc.name, id);
|
|
1307
|
+
const changed = dirty.get(desc.name);
|
|
1308
|
+
if (!changed) continue;
|
|
1309
|
+
const visibleChanges = createDirtySet();
|
|
1310
|
+
const collection = collectionDirty(visibleChanges, desc.name);
|
|
1311
|
+
for (const id of changed.added) if (now.has(id)) collection.added.add(id);
|
|
1312
|
+
for (const id of changed.removed) if (before.has(id)) collection.removed.add(id);
|
|
1313
|
+
for (const [id, record] of changed.updated) {
|
|
1314
|
+
if (before.has(id) && now.has(id)) collection.updated.set(id, record);
|
|
1315
|
+
}
|
|
1316
|
+
mergeDirty(out, visibleChanges);
|
|
1317
|
+
}
|
|
1318
|
+
return out;
|
|
1319
|
+
}
|
|
1320
|
+
function membershipSafeDirty(ext, dirty, memberships) {
|
|
1321
|
+
const out = createDirtySet();
|
|
1322
|
+
for (const [name, changed] of dirty) {
|
|
1323
|
+
const desc = ext.collection(name);
|
|
1324
|
+
if (desc.visibility !== "spatial-grid") {
|
|
1325
|
+
out.set(name, changed);
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
const visible = memberships.get(name) ?? /* @__PURE__ */ new Set();
|
|
1329
|
+
const kept = collectionDirty(out, name);
|
|
1330
|
+
for (const id of changed.added) if (visible.has(id)) kept.added.add(id);
|
|
1331
|
+
for (const id of changed.removed) if (visible.has(id)) kept.removed.add(id);
|
|
1332
|
+
for (const [id, record] of changed.updated) if (visible.has(id)) kept.updated.set(id, record);
|
|
1333
|
+
}
|
|
1334
|
+
return out;
|
|
842
1335
|
}
|
|
843
1336
|
function encodeViewDelta(ext, plain, dirty, role, tick) {
|
|
844
1337
|
const keep = visibleNames(ext, role);
|
|
@@ -891,27 +1384,35 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
|
|
|
891
1384
|
}
|
|
892
1385
|
|
|
893
1386
|
// src/core/snapshot.ts
|
|
894
|
-
import { ByteReader, ByteWriter } from "@irtio/schema";
|
|
895
|
-
var SNAPSHOT_FORMAT_VERSION =
|
|
1387
|
+
import { ByteReader as ByteReader2, ByteWriter as ByteWriter2 } from "@irtio/schema";
|
|
1388
|
+
var SNAPSHOT_FORMAT_VERSION = 2;
|
|
1389
|
+
var READABLE_SNAPSHOT_VERSIONS = [1, 2];
|
|
896
1390
|
function parseHibernationBlob(bytes) {
|
|
897
|
-
const r = new
|
|
1391
|
+
const r = new ByteReader2(bytes);
|
|
898
1392
|
const version = r.u8();
|
|
899
|
-
if (version
|
|
1393
|
+
if (!READABLE_SNAPSHOT_VERSIONS.includes(version)) {
|
|
900
1394
|
throw new Error(`RoomCore.restore: unsupported snapshot format version ${version}`);
|
|
901
1395
|
}
|
|
902
1396
|
const seed = r.u32();
|
|
903
1397
|
const rngState = r.u32();
|
|
904
1398
|
const tick = r.u32();
|
|
905
1399
|
const mode = r.u8() === 1 ? "event" : "tick";
|
|
906
|
-
|
|
1400
|
+
let physics;
|
|
1401
|
+
if (version >= 2) {
|
|
1402
|
+
const section = r.blob();
|
|
1403
|
+
if (section.length > 0) physics = section;
|
|
1404
|
+
}
|
|
1405
|
+
return { version, seed, rngState, tick, mode, physics, snapshot: r.rest() };
|
|
907
1406
|
}
|
|
908
|
-
function writeHibernationBlob(header, snapshot) {
|
|
909
|
-
const
|
|
910
|
-
w.
|
|
1407
|
+
function writeHibernationBlob(header, snapshot, physics) {
|
|
1408
|
+
const version = physics ? 2 : 1;
|
|
1409
|
+
const w = new ByteWriter2(snapshot.length + (physics?.length ?? 0) + 24);
|
|
1410
|
+
w.u8(version);
|
|
911
1411
|
w.u32(header.seed >>> 0);
|
|
912
1412
|
w.u32(header.rngState >>> 0);
|
|
913
1413
|
w.u32(header.tick >>> 0);
|
|
914
1414
|
w.u8(header.mode === "event" ? 1 : 0);
|
|
1415
|
+
if (physics) w.blob(physics);
|
|
915
1416
|
w.bytes(snapshot);
|
|
916
1417
|
return w.finish();
|
|
917
1418
|
}
|
|
@@ -930,12 +1431,78 @@ import {
|
|
|
930
1431
|
createDirtySet as createDirtySet2,
|
|
931
1432
|
createState,
|
|
932
1433
|
decodeSnapshot,
|
|
1434
|
+
encodeDelta as encodeDelta4,
|
|
933
1435
|
encodeSnapshot as encodeSnapshot2,
|
|
934
1436
|
isDirtyEmpty as isDirtyEmpty3,
|
|
935
1437
|
track,
|
|
936
1438
|
validateForDeploy
|
|
937
1439
|
} from "@irtio/schema";
|
|
938
1440
|
|
|
1441
|
+
// src/core/host-calls.ts
|
|
1442
|
+
var states2 = /* @__PURE__ */ new WeakMap();
|
|
1443
|
+
function stateOf2(core) {
|
|
1444
|
+
let s = states2.get(core);
|
|
1445
|
+
if (!s) {
|
|
1446
|
+
s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
|
|
1447
|
+
states2.set(core, s);
|
|
1448
|
+
}
|
|
1449
|
+
return s;
|
|
1450
|
+
}
|
|
1451
|
+
function startHostCall(core, call, map) {
|
|
1452
|
+
return new Promise((resolve, reject2) => {
|
|
1453
|
+
if (core.stopped) {
|
|
1454
|
+
reject2(new Error(`room.${call.kind}: the room is stopped`));
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const s = stateOf2(core);
|
|
1458
|
+
const reqId = s.nextReqId;
|
|
1459
|
+
s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
|
|
1460
|
+
s.pending.set(reqId, {
|
|
1461
|
+
kind: call.kind,
|
|
1462
|
+
deadline: core.host.now() + HOST_CALL_TIMEOUT_MS,
|
|
1463
|
+
map,
|
|
1464
|
+
resolve,
|
|
1465
|
+
reject: reject2
|
|
1466
|
+
});
|
|
1467
|
+
try {
|
|
1468
|
+
core.host.hostCall(reqId, call);
|
|
1469
|
+
} catch (err) {
|
|
1470
|
+
s.pending.delete(reqId);
|
|
1471
|
+
reject2(err instanceof Error ? err : new Error(String(err)));
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
if (core.mode === "event") {
|
|
1475
|
+
core.loop.after(HOST_CALL_TIMEOUT_MS, () => checkHostCallTimeouts(core));
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
function completeHostCall(core, reqId, result) {
|
|
1480
|
+
const s = stateOf2(core);
|
|
1481
|
+
const pending = s.pending.get(reqId);
|
|
1482
|
+
if (!pending) return false;
|
|
1483
|
+
s.pending.delete(reqId);
|
|
1484
|
+
if (result.ok) pending.resolve(pending.map(result.value));
|
|
1485
|
+
else pending.reject(new Error(`${result.code}: ${result.message}`));
|
|
1486
|
+
return true;
|
|
1487
|
+
}
|
|
1488
|
+
function checkHostCallTimeouts(core) {
|
|
1489
|
+
const s = stateOf2(core);
|
|
1490
|
+
if (s.pending.size === 0) return;
|
|
1491
|
+
const now = core.host.now();
|
|
1492
|
+
for (const [reqId, p] of [...s.pending]) {
|
|
1493
|
+
if (p.deadline > now) continue;
|
|
1494
|
+
s.pending.delete(reqId);
|
|
1495
|
+
p.reject(new Error(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
function rejectAllHostCalls(core, reason) {
|
|
1499
|
+
const s = stateOf2(core);
|
|
1500
|
+
for (const [reqId, p] of [...s.pending]) {
|
|
1501
|
+
s.pending.delete(reqId);
|
|
1502
|
+
p.reject(new Error(reason));
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
939
1506
|
// src/core/messages.ts
|
|
940
1507
|
import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
|
|
941
1508
|
function toRoomTarget(target) {
|
|
@@ -1002,9 +1569,40 @@ function sendMessage(core, target, bytes) {
|
|
|
1002
1569
|
// src/core/room-api.ts
|
|
1003
1570
|
import { FrameType as FrameType4, encodeFrame as encodeFrame3 } from "@irtio/protocol";
|
|
1004
1571
|
import { encodeDelta as encodeDelta3, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
|
|
1572
|
+
function makePhysicsApi(p) {
|
|
1573
|
+
return {
|
|
1574
|
+
get rapier() {
|
|
1575
|
+
return p.rapier;
|
|
1576
|
+
},
|
|
1577
|
+
get world() {
|
|
1578
|
+
return p.world;
|
|
1579
|
+
},
|
|
1580
|
+
get timestep() {
|
|
1581
|
+
return p.timestep;
|
|
1582
|
+
},
|
|
1583
|
+
body(collection, id) {
|
|
1584
|
+
return p.bodyFor(collection, id);
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
function makeKv(core) {
|
|
1589
|
+
return {
|
|
1590
|
+
get(playerId, key) {
|
|
1591
|
+
return startHostCall(core, { kind: "kvGet", playerId, key }, (v) => v);
|
|
1592
|
+
},
|
|
1593
|
+
set(playerId, key, value) {
|
|
1594
|
+
return startHostCall(core, { kind: "kvSet", playerId, key, value }, () => void 0);
|
|
1595
|
+
},
|
|
1596
|
+
delete(playerId, key) {
|
|
1597
|
+
return startHostCall(core, { kind: "kvDelete", playerId, key }, () => void 0);
|
|
1598
|
+
}
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1005
1601
|
function createRoomApi(core, publicUrl) {
|
|
1006
1602
|
let cached;
|
|
1007
1603
|
let lastNow = Number.NEGATIVE_INFINITY;
|
|
1604
|
+
let physicsApi;
|
|
1605
|
+
let kvApi;
|
|
1008
1606
|
const link = () => {
|
|
1009
1607
|
const sep = publicUrl.includes("?") ? "&" : "?";
|
|
1010
1608
|
return `${publicUrl}${sep}room=${encodeURIComponent(core.roomId)}`;
|
|
@@ -1038,6 +1636,19 @@ function createRoomApi(core, publicUrl) {
|
|
|
1038
1636
|
random() {
|
|
1039
1637
|
return core.rng.next();
|
|
1040
1638
|
},
|
|
1639
|
+
// D22. A single object, built once: `room.physics` is read on every tick of a physics room.
|
|
1640
|
+
// In a room with no `physics:` config, touching it is a mistake worth naming loudly rather
|
|
1641
|
+
// than a silent `undefined` that shows up three frames later as "cannot read world of
|
|
1642
|
+
// undefined". The property itself is safe to *hold*; only reading through it throws.
|
|
1643
|
+
get physics() {
|
|
1644
|
+
const p = core.physics;
|
|
1645
|
+
if (!p) {
|
|
1646
|
+
throw new Error(
|
|
1647
|
+
"room.physics: this room has no physics \u2014 add physics: { engine: 'rapier3d', gravity, bodies } to defineRoom(...)"
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
return physicsApi ?? (physicsApi = makePhysicsApi(p));
|
|
1651
|
+
},
|
|
1041
1652
|
send(target, bytes) {
|
|
1042
1653
|
sendMessage(core, target, bytes);
|
|
1043
1654
|
},
|
|
@@ -1073,6 +1684,27 @@ function createRoomApi(core, publicUrl) {
|
|
|
1073
1684
|
clearInterval(handle) {
|
|
1074
1685
|
core.loop.clearTimer(handle);
|
|
1075
1686
|
},
|
|
1687
|
+
save() {
|
|
1688
|
+
return startHostCall(core, { kind: "save" }, (id) => id ?? "");
|
|
1689
|
+
},
|
|
1690
|
+
get kv() {
|
|
1691
|
+
return kvApi ?? (kvApi = makeKv(core));
|
|
1692
|
+
},
|
|
1693
|
+
alarm(name, atMs) {
|
|
1694
|
+
if (!isAlarmName(core, name, "room.alarm")) return;
|
|
1695
|
+
if (!Number.isFinite(atMs)) {
|
|
1696
|
+
core.log("warn", `room.alarm: ${JSON.stringify(name)} needs a finite time; ignored`);
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
core.host.setAlarm(name, atMs);
|
|
1700
|
+
},
|
|
1701
|
+
cancelAlarm(name) {
|
|
1702
|
+
if (typeof name !== "string" || name === "") {
|
|
1703
|
+
core.log("warn", "room.cancelAlarm: a name is required; ignored");
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
core.host.setAlarm(name, void 0);
|
|
1707
|
+
},
|
|
1076
1708
|
call(clientId) {
|
|
1077
1709
|
return createCallProxy(core, clientId);
|
|
1078
1710
|
},
|
|
@@ -1088,6 +1720,21 @@ function createRoomApi(core, publicUrl) {
|
|
|
1088
1720
|
}
|
|
1089
1721
|
};
|
|
1090
1722
|
}
|
|
1723
|
+
function isAlarmName(core, name, where) {
|
|
1724
|
+
if (typeof name !== "string" || name === "") {
|
|
1725
|
+
core.log("warn", `${where}: a name is required; ignored`);
|
|
1726
|
+
return false;
|
|
1727
|
+
}
|
|
1728
|
+
const alarms = core.definition.config.alarms;
|
|
1729
|
+
if (!alarms || typeof alarms[name] !== "function") {
|
|
1730
|
+
core.log(
|
|
1731
|
+
"warn",
|
|
1732
|
+
`${where}: no handler named ${JSON.stringify(name)} \u2014 add it to the room's \`alarms: { \u2026 }\` config; the alarm was not armed`
|
|
1733
|
+
);
|
|
1734
|
+
return false;
|
|
1735
|
+
}
|
|
1736
|
+
return true;
|
|
1737
|
+
}
|
|
1091
1738
|
function setRole(core, clientId, role) {
|
|
1092
1739
|
const entry = core.clients.get(clientId);
|
|
1093
1740
|
if (!entry) {
|
|
@@ -1115,6 +1762,7 @@ function setRole(core, clientId, role) {
|
|
|
1115
1762
|
|
|
1116
1763
|
// src/core/room.ts
|
|
1117
1764
|
var DEFAULT_PUBLIC_URL = "http://localhost/";
|
|
1765
|
+
var CONTINUATION_FLUSH_TURNS = 8;
|
|
1118
1766
|
function ownJoinCapture(plain, name, added, clientId) {
|
|
1119
1767
|
const coll = plainEntity(plain, name);
|
|
1120
1768
|
let mine;
|
|
@@ -1137,6 +1785,8 @@ var RoomCore = class _RoomCore {
|
|
|
1137
1785
|
rng;
|
|
1138
1786
|
clients = /* @__PURE__ */ new Map();
|
|
1139
1787
|
loop;
|
|
1788
|
+
/** D22: the Rapier world, or `undefined` in a room whose config declares no physics. */
|
|
1789
|
+
physics;
|
|
1140
1790
|
stats = {
|
|
1141
1791
|
ticks: 0,
|
|
1142
1792
|
lastTickMs: 0,
|
|
@@ -1148,6 +1798,13 @@ var RoomCore = class _RoomCore {
|
|
|
1148
1798
|
bytesOutByClient: /* @__PURE__ */ new Map(),
|
|
1149
1799
|
handlerErrors: 0,
|
|
1150
1800
|
encodesLastFlush: 0,
|
|
1801
|
+
aoiEncodesLastFlush: 0,
|
|
1802
|
+
gridBuildMs: 0,
|
|
1803
|
+
gridQueryMs: 0,
|
|
1804
|
+
aoiEncodeMs: 0,
|
|
1805
|
+
visibleIdsTotal: 0,
|
|
1806
|
+
membershipEnters: 0,
|
|
1807
|
+
membershipLeaves: 0,
|
|
1151
1808
|
corrections: 0
|
|
1152
1809
|
};
|
|
1153
1810
|
tick = 0;
|
|
@@ -1156,6 +1813,8 @@ var RoomCore = class _RoomCore {
|
|
|
1156
1813
|
api;
|
|
1157
1814
|
internals;
|
|
1158
1815
|
started = false;
|
|
1816
|
+
/** One pending continuation flush at a time; concurrent completions coalesce into it. */
|
|
1817
|
+
continuationFlushPending = false;
|
|
1159
1818
|
constructor(definition, host, options) {
|
|
1160
1819
|
this.definition = definition;
|
|
1161
1820
|
this.host = host;
|
|
@@ -1191,6 +1850,7 @@ var RoomCore = class _RoomCore {
|
|
|
1191
1850
|
this.internals = self;
|
|
1192
1851
|
this.loop = new Loop(self);
|
|
1193
1852
|
this.api = createRoomApi(self, options.publicUrl ?? DEFAULT_PUBLIC_URL);
|
|
1853
|
+
this.physics = this.buildPhysics(restored);
|
|
1194
1854
|
if (restored) {
|
|
1195
1855
|
const onWake = definition.config.onWake;
|
|
1196
1856
|
if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
|
|
@@ -1199,6 +1859,42 @@ var RoomCore = class _RoomCore {
|
|
|
1199
1859
|
if (onCreate) this.guard("onCreate", () => onCreate(this.state, this.room));
|
|
1200
1860
|
}
|
|
1201
1861
|
}
|
|
1862
|
+
/**
|
|
1863
|
+
* D22: builds the world, or returns `undefined` for a room with no `physics:` config.
|
|
1864
|
+
*
|
|
1865
|
+
* A v2 blob restores the world from its own bytes and `setup` does **not** run — the static
|
|
1866
|
+
* geometry is already in there. Anything else (a fresh room, a v1 blob written before this
|
|
1867
|
+
* room had physics, a migrated snapshot whose world was deliberately dropped) builds a world,
|
|
1868
|
+
* runs `setup`, and lets the first tick's `reconcile` rebuild the bodies from schema state:
|
|
1869
|
+
* positions and velocities live in schema fields, so the rebuild is faithful to what the state
|
|
1870
|
+
* says. Transient contact state — resting contacts, accumulated impulses — is not in the schema
|
|
1871
|
+
* and is lost; a stack of boxes may settle again with a small visible jolt.
|
|
1872
|
+
*/
|
|
1873
|
+
buildPhysics(restored) {
|
|
1874
|
+
const config = this.definition.config.physics;
|
|
1875
|
+
if (!config) return void 0;
|
|
1876
|
+
const engine2 = loadedPhysics();
|
|
1877
|
+
if (!engine2) {
|
|
1878
|
+
throw new Error(
|
|
1879
|
+
"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"
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
const section = restored?.physics ? decodePhysicsSection(restored.physics) : void 0;
|
|
1883
|
+
const physics = new PhysicsRuntime(this.internals, engine2, {
|
|
1884
|
+
...section ? { restore: section } : {},
|
|
1885
|
+
defaultTimestep: 1 / this.definition.config.tickRate
|
|
1886
|
+
});
|
|
1887
|
+
if (physics.rebuilt) {
|
|
1888
|
+
if (restored) {
|
|
1889
|
+
this.host.log("info", [
|
|
1890
|
+
`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`
|
|
1891
|
+
]);
|
|
1892
|
+
}
|
|
1893
|
+
physics.runSetup(this.room);
|
|
1894
|
+
physics.reconcile();
|
|
1895
|
+
}
|
|
1896
|
+
return physics;
|
|
1897
|
+
}
|
|
1202
1898
|
/** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
|
|
1203
1899
|
static restore(definition, bytes, host, options) {
|
|
1204
1900
|
return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
|
|
@@ -1275,16 +1971,35 @@ var RoomCore = class _RoomCore {
|
|
|
1275
1971
|
this.started = false;
|
|
1276
1972
|
this.loop.stop();
|
|
1277
1973
|
rejectAllPending(this.internals, "room stopped");
|
|
1974
|
+
rejectAllHostCalls(this.internals, "room stopped");
|
|
1975
|
+
this.physics?.free();
|
|
1976
|
+
}
|
|
1977
|
+
/**
|
|
1978
|
+
* The hibernation blob's bytes, and **nothing else** — no `onSleep`, no timers cleared, no
|
|
1979
|
+
* pending work rejected. A save (D24) is a *copy* of the room; hibernation is the room
|
|
1980
|
+
* *leaving*. They want identical bytes and opposite side effects, so the bytes live here and
|
|
1981
|
+
* the departure lives in `serialize()`.
|
|
1982
|
+
*
|
|
1983
|
+
* Conflating the two is not hypothetical: the first cut of `room.save()` routed through
|
|
1984
|
+
* `serialize()`, which rejected every pending host call — including the `save()` that had just
|
|
1985
|
+
* asked for it. The room waited out its own 10 s deadline for a save that had already been
|
|
1986
|
+
* written.
|
|
1987
|
+
*/
|
|
1988
|
+
snapshot() {
|
|
1989
|
+
const physics = this.physics ? encodePhysicsSection(this.physics.serialize()) : void 0;
|
|
1990
|
+
return writeHibernationBlob(
|
|
1991
|
+
{ seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
|
|
1992
|
+
encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
|
|
1993
|
+
physics
|
|
1994
|
+
);
|
|
1278
1995
|
}
|
|
1279
1996
|
serialize() {
|
|
1280
1997
|
const onSleep = this.definition.config.onSleep;
|
|
1281
1998
|
if (onSleep) this.guard("onSleep", () => onSleep(this.state, this.room));
|
|
1282
1999
|
this.loop.clearAllTimers();
|
|
1283
2000
|
rejectAllPending(this.internals, "room is hibernating");
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
encodeSnapshot2(this.ext, this.plain, { tick: this.tick })
|
|
1287
|
-
);
|
|
2001
|
+
rejectAllHostCalls(this.internals, "room is hibernating");
|
|
2002
|
+
return this.snapshot();
|
|
1288
2003
|
}
|
|
1289
2004
|
// -------------------------------------------------------------------------
|
|
1290
2005
|
// Clients
|
|
@@ -1319,7 +2034,13 @@ var RoomCore = class _RoomCore {
|
|
|
1319
2034
|
return {
|
|
1320
2035
|
tick: this.tick,
|
|
1321
2036
|
role: role2,
|
|
1322
|
-
snapshot: encodeViewSnapshot(
|
|
2037
|
+
snapshot: encodeViewSnapshot(
|
|
2038
|
+
this.ext,
|
|
2039
|
+
this.plain,
|
|
2040
|
+
role2,
|
|
2041
|
+
this.tick,
|
|
2042
|
+
spatialMemberships(this.ext, this.plain, clientId, role2)
|
|
2043
|
+
)
|
|
1323
2044
|
};
|
|
1324
2045
|
}
|
|
1325
2046
|
const presence = this.presence;
|
|
@@ -1340,13 +2061,19 @@ var RoomCore = class _RoomCore {
|
|
|
1340
2061
|
}
|
|
1341
2062
|
const entry = existing ?? {
|
|
1342
2063
|
clientId,
|
|
2064
|
+
// D25: defaults to the client id, which a resume token carries across a reconnect — so for
|
|
2065
|
+
// a key join `ctx.playerId` is exactly as durable as the resume token and no more. A JWT
|
|
2066
|
+
// join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
|
|
2067
|
+
// and nothing else here changes.
|
|
2068
|
+
playerId: options.playerId ?? clientId,
|
|
1343
2069
|
role,
|
|
1344
2070
|
name,
|
|
1345
2071
|
connected: true,
|
|
1346
2072
|
correction: void 0,
|
|
1347
2073
|
accepted: /* @__PURE__ */ new Map(),
|
|
1348
2074
|
lastClientTick: 0,
|
|
1349
|
-
pendingJoinAdds: void 0
|
|
2075
|
+
pendingJoinAdds: void 0,
|
|
2076
|
+
spatialMembership: /* @__PURE__ */ new Map()
|
|
1350
2077
|
};
|
|
1351
2078
|
entry.role = role;
|
|
1352
2079
|
entry.name = name;
|
|
@@ -1360,12 +2087,23 @@ var RoomCore = class _RoomCore {
|
|
|
1360
2087
|
}
|
|
1361
2088
|
this.loop.noteActivity();
|
|
1362
2089
|
this.recordEvent("join", clientId, reconnecting ? "reconnect" : void 0);
|
|
1363
|
-
const
|
|
2090
|
+
const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
|
|
2091
|
+
entry.spatialMembership = memberships;
|
|
2092
|
+
const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
|
|
1364
2093
|
const pending = /* @__PURE__ */ new Map();
|
|
1365
2094
|
for (const [name2, cd] of this.tracked.dirty) {
|
|
1366
2095
|
if (cd.added.size === 0) continue;
|
|
1367
2096
|
const mine = ownJoinCapture(this.plain, name2, cd.added, clientId);
|
|
1368
|
-
|
|
2097
|
+
const visible = memberships.get(name2);
|
|
2098
|
+
if (visible) {
|
|
2099
|
+
const coll = plainEntity(this.plain, name2);
|
|
2100
|
+
const captured = mine ?? /* @__PURE__ */ new Map();
|
|
2101
|
+
for (const id of cd.added) {
|
|
2102
|
+
if (!visible.has(id) || captured.has(id)) continue;
|
|
2103
|
+
captured.set(id, { value: cloneValue2(coll.get(id)), owner: coll.ownerOf(id) });
|
|
2104
|
+
}
|
|
2105
|
+
if (captured.size > 0) pending.set(name2, captured);
|
|
2106
|
+
} else if (mine) pending.set(name2, mine);
|
|
1369
2107
|
}
|
|
1370
2108
|
entry.pendingJoinAdds = pending.size > 0 ? pending : void 0;
|
|
1371
2109
|
this.eventFlush();
|
|
@@ -1415,6 +2153,7 @@ var RoomCore = class _RoomCore {
|
|
|
1415
2153
|
const entry = this.clients.get(clientId);
|
|
1416
2154
|
return {
|
|
1417
2155
|
clientId,
|
|
2156
|
+
playerId: entry?.playerId ?? clientId,
|
|
1418
2157
|
role: entry?.role ?? "",
|
|
1419
2158
|
name: entry?.name ?? "",
|
|
1420
2159
|
tick: this.tick,
|
|
@@ -1422,6 +2161,63 @@ var RoomCore = class _RoomCore {
|
|
|
1422
2161
|
room: this.room
|
|
1423
2162
|
};
|
|
1424
2163
|
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Week 12: the host answering a `room.save()` / `room.kv.*`. The continuation runs here — its
|
|
2166
|
+
* own event, between ticks, off the back of a host turn — and the flush afterwards is what
|
|
2167
|
+
* makes "state mutated in a continuation is tracked normally" true rather than aspirational.
|
|
2168
|
+
*/
|
|
2169
|
+
completeHostCall(reqId, result) {
|
|
2170
|
+
if (this.stopped) return;
|
|
2171
|
+
if (!completeHostCall(this.internals, reqId, result)) {
|
|
2172
|
+
this.log("warn", `completeHostCall: nothing is waiting on reqId ${reqId}`);
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
checkHostCallTimeouts(this.internals);
|
|
2176
|
+
this.scheduleContinuationFlush();
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Flushes whatever a promise continuation wrote, as its own event, once the microtask queue
|
|
2180
|
+
* that continuation lives on has drained.
|
|
2181
|
+
*
|
|
2182
|
+
* The subtlety this exists for: `resolve()` does not run the room's `.then` — it *queues* it,
|
|
2183
|
+
* and every promise link between the resolve and the room's callback costs another microtask
|
|
2184
|
+
* turn. A single `queueMicrotask(flush)` therefore only ever catches a continuation exactly one
|
|
2185
|
+
* link deep, and silently drops the state written by anything the room chained further out.
|
|
2186
|
+
* Draining a bounded number of turns first covers the chains rooms actually write, coalesces
|
|
2187
|
+
* concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
|
|
2188
|
+
* the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
|
|
2189
|
+
*/
|
|
2190
|
+
scheduleContinuationFlush() {
|
|
2191
|
+
if (this.mode !== "event" || this.continuationFlushPending) return;
|
|
2192
|
+
this.continuationFlushPending = true;
|
|
2193
|
+
let turns = 0;
|
|
2194
|
+
const drain = () => {
|
|
2195
|
+
if (++turns < CONTINUATION_FLUSH_TURNS) {
|
|
2196
|
+
queueMicrotask(drain);
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
this.continuationFlushPending = false;
|
|
2200
|
+
if (!this.stopped) this.eventFlush();
|
|
2201
|
+
};
|
|
2202
|
+
queueMicrotask(drain);
|
|
2203
|
+
}
|
|
2204
|
+
/**
|
|
2205
|
+
* D26: one durable alarm firing. Same scheduling class as an RPC — a discrete event between
|
|
2206
|
+
* ticks — so a tick-mode room never sees a `tick` run half-alarmed, and a handler that re-arms
|
|
2207
|
+
* its own name is the supported way to build a repeating timer.
|
|
2208
|
+
*/
|
|
2209
|
+
fireAlarm(name) {
|
|
2210
|
+
if (this.stopped) return;
|
|
2211
|
+
const alarms = this.definition.config.alarms;
|
|
2212
|
+
const handler = alarms?.[name];
|
|
2213
|
+
if (!handler) {
|
|
2214
|
+
this.log("warn", `alarm ${JSON.stringify(name)} fired but the room has no handler for it`);
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
this.recordEvent("alarm", void 0, name);
|
|
2218
|
+
this.guard(`alarms.${name}`, () => handler(this.state, this.room));
|
|
2219
|
+
this.eventFlush();
|
|
2220
|
+
}
|
|
1425
2221
|
correctionFor(clientId) {
|
|
1426
2222
|
const entry = this.clients.get(clientId);
|
|
1427
2223
|
if (!entry) return void 0;
|
|
@@ -1486,12 +2282,8 @@ var RoomCore = class _RoomCore {
|
|
|
1486
2282
|
case FrameType5.REPLY: {
|
|
1487
2283
|
if (!handleReply(this.internals, clientId, payload)) {
|
|
1488
2284
|
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
|
-
});
|
|
2285
|
+
} else {
|
|
2286
|
+
this.scheduleContinuationFlush();
|
|
1495
2287
|
}
|
|
1496
2288
|
return;
|
|
1497
2289
|
}
|
|
@@ -1515,15 +2307,72 @@ var RoomCore = class _RoomCore {
|
|
|
1515
2307
|
const dirty = this.tracked.flush();
|
|
1516
2308
|
serverWinsCorrections(this.internals, dirty);
|
|
1517
2309
|
this.stats.encodesLastFlush = 0;
|
|
2310
|
+
this.stats.aoiEncodesLastFlush = 0;
|
|
2311
|
+
const spatialDescs = this.ext.collections.filter((desc) => desc.visibility === "spatial-grid");
|
|
2312
|
+
const buildStarted = performance.now();
|
|
2313
|
+
const indexes = buildSpatialIndexes(spatialDescs, this.plain);
|
|
2314
|
+
this.stats.gridBuildMs += performance.now() - buildStarted;
|
|
1518
2315
|
const byView = /* @__PURE__ */ new Map();
|
|
1519
2316
|
for (const entry of this.clients.values()) {
|
|
1520
2317
|
if (entry.connected) {
|
|
2318
|
+
const queryStarted = performance.now();
|
|
2319
|
+
const memberships = spatialMemberships(
|
|
2320
|
+
this.ext,
|
|
2321
|
+
this.plain,
|
|
2322
|
+
entry.clientId,
|
|
2323
|
+
entry.role,
|
|
2324
|
+
indexes
|
|
2325
|
+
);
|
|
2326
|
+
this.stats.gridQueryMs += performance.now() - queryStarted;
|
|
2327
|
+
for (const [name, ids] of memberships) {
|
|
2328
|
+
const before = entry.spatialMembership.get(name) ?? /* @__PURE__ */ new Set();
|
|
2329
|
+
this.stats.visibleIdsTotal += ids.size;
|
|
2330
|
+
for (const id of ids) if (!before.has(id)) this.stats.membershipEnters++;
|
|
2331
|
+
for (const id of before) if (!ids.has(id)) this.stats.membershipLeaves++;
|
|
2332
|
+
}
|
|
1521
2333
|
if (entry.correction) {
|
|
1522
|
-
const payload = encodeCorrection(
|
|
2334
|
+
const payload = encodeCorrection(
|
|
2335
|
+
this.internals,
|
|
2336
|
+
membershipSafeDirty(this.ext, entry.correction, memberships)
|
|
2337
|
+
);
|
|
1523
2338
|
if (payload) this.send(entry.clientId, encodeCorrectFrame(payload, entry.lastClientTick));
|
|
1524
2339
|
}
|
|
1525
2340
|
let delta;
|
|
1526
|
-
if (
|
|
2341
|
+
if (spatialDescs.length > 0) {
|
|
2342
|
+
const sourceDirty = entry.pendingJoinAdds ? stripAdds(this.plain, dirty, entry.pendingJoinAdds) : dirty;
|
|
2343
|
+
let shared;
|
|
2344
|
+
if (entry.pendingJoinAdds) {
|
|
2345
|
+
const sharedDirty = sharedViewDirty(this.ext, sourceDirty, entry.role);
|
|
2346
|
+
shared = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
|
|
2347
|
+
if (shared) this.stats.encodesLastFlush++;
|
|
2348
|
+
} else {
|
|
2349
|
+
const key = viewKeyFor(this.ext, entry.role);
|
|
2350
|
+
let cached = byView.get(key);
|
|
2351
|
+
if (cached === void 0) {
|
|
2352
|
+
const sharedDirty = sharedViewDirty(this.ext, dirty, entry.role);
|
|
2353
|
+
cached = isDirtyEmpty3(sharedDirty) ? null : encodeDelta4(this.ext, this.plain, sharedDirty, { tick: this.tick });
|
|
2354
|
+
if (cached) this.stats.encodesLastFlush++;
|
|
2355
|
+
byView.set(key, cached);
|
|
2356
|
+
}
|
|
2357
|
+
shared = cached;
|
|
2358
|
+
}
|
|
2359
|
+
if (shared) this.send(entry.clientId, encodeFrame4(FrameType5.DELTA, shared));
|
|
2360
|
+
const aoiDirty = aoiViewDirty(
|
|
2361
|
+
this.ext,
|
|
2362
|
+
sourceDirty,
|
|
2363
|
+
entry.spatialMembership,
|
|
2364
|
+
memberships
|
|
2365
|
+
);
|
|
2366
|
+
const encodeStarted = performance.now();
|
|
2367
|
+
delta = isDirtyEmpty3(aoiDirty) ? null : encodeDelta4(this.ext, this.plain, aoiDirty, { tick: this.tick });
|
|
2368
|
+
this.stats.aoiEncodeMs += performance.now() - encodeStarted;
|
|
2369
|
+
if (delta) {
|
|
2370
|
+
this.stats.encodesLastFlush++;
|
|
2371
|
+
this.stats.aoiEncodesLastFlush++;
|
|
2372
|
+
}
|
|
2373
|
+
entry.pendingJoinAdds = void 0;
|
|
2374
|
+
entry.spatialMembership = memberships;
|
|
2375
|
+
} else if (entry.pendingJoinAdds) {
|
|
1527
2376
|
delta = encodeViewDelta(
|
|
1528
2377
|
this.ext,
|
|
1529
2378
|
this.plain,
|
|
@@ -1552,21 +2401,29 @@ var RoomCore = class _RoomCore {
|
|
|
1552
2401
|
};
|
|
1553
2402
|
|
|
1554
2403
|
export {
|
|
2404
|
+
HOST_CALL_TIMEOUT_MS,
|
|
1555
2405
|
RoomFullError,
|
|
1556
2406
|
EVENT_RING_SIZE,
|
|
1557
2407
|
inspectState,
|
|
1558
2408
|
RPC_TIMEOUT_MS,
|
|
1559
2409
|
MAX_CATCHUP,
|
|
1560
2410
|
CRASH_AFTER_THROWS,
|
|
2411
|
+
initPhysics,
|
|
2412
|
+
loadedPhysics,
|
|
2413
|
+
resetPhysicsForTests,
|
|
2414
|
+
encodePhysicsSection,
|
|
2415
|
+
decodePhysicsSection,
|
|
1561
2416
|
Mulberry32,
|
|
1562
2417
|
isVisible,
|
|
1563
2418
|
visibleTo,
|
|
1564
2419
|
visibleNames,
|
|
1565
2420
|
viewKeyFor,
|
|
1566
2421
|
encodeViewSnapshot,
|
|
2422
|
+
createVisibilityPolicy,
|
|
1567
2423
|
encodeViewDelta,
|
|
1568
2424
|
catchUpDirty,
|
|
1569
2425
|
SNAPSHOT_FORMAT_VERSION,
|
|
2426
|
+
READABLE_SNAPSHOT_VERSIONS,
|
|
1570
2427
|
parseHibernationBlob,
|
|
1571
2428
|
writeHibernationBlob,
|
|
1572
2429
|
RoomCore
|