@irtio/runtime 0.5.2 → 0.7.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-5ZDQAAFJ.js → chunk-EXPFVRD4.js} +1572 -121
- package/dist/{chunk-HFOMXKSO.js → chunk-ZNNTF7Y3.js} +1 -1
- package/dist/contract-DwxqjeWP.d.ts +426 -0
- package/dist/index.d.ts +41 -3
- package/dist/index.js +26 -2
- package/dist/{room-9ZQoy9yi.d.ts → room-CoQDczh2.d.ts} +259 -7
- package/dist/test/index.d.ts +51 -4
- package/dist/test/index.js +70 -1
- package/dist/worker/index.d.ts +147 -3
- package/dist/worker/index.js +92 -10
- package/package.json +8 -4
- package/dist/contract-B8QSO0MH.d.ts +0 -204
|
@@ -143,10 +143,10 @@ function handleCall(core, clientId, payload) {
|
|
|
143
143
|
return true;
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
|
-
const ctx = core.ctxFor(clientId);
|
|
146
|
+
const ctx = { ...core.ctxFor(clientId), clientTick: call.clientTick || void 0 };
|
|
147
147
|
let result;
|
|
148
148
|
if (desc.name === REQUEST_OWNERSHIP) {
|
|
149
|
-
result = runRequestOwnership(core, clientId, params);
|
|
149
|
+
result = runRequestOwnership(core, clientId, ctx, params);
|
|
150
150
|
} else {
|
|
151
151
|
const impl = core.definition.config.rpc?.[desc.name];
|
|
152
152
|
if (!impl) {
|
|
@@ -175,7 +175,7 @@ function handleCall(core, clientId, payload) {
|
|
|
175
175
|
}
|
|
176
176
|
return true;
|
|
177
177
|
}
|
|
178
|
-
function runRequestOwnership(core, clientId, params) {
|
|
178
|
+
function runRequestOwnership(core, clientId, ctx, params) {
|
|
179
179
|
const name = String(params.entity ?? "");
|
|
180
180
|
const id = String(params.id ?? "");
|
|
181
181
|
const c = collectionDescOf(core.ext, name);
|
|
@@ -185,10 +185,7 @@ function runRequestOwnership(core, clientId, params) {
|
|
|
185
185
|
const handler = core.definition.config.onOwnershipRequest;
|
|
186
186
|
const tracked = trackedEntity(core, name);
|
|
187
187
|
if (handler) {
|
|
188
|
-
const ran = core.tryRun(
|
|
189
|
-
"onOwnershipRequest",
|
|
190
|
-
() => handler(core.anyState, name, id, core.ctxFor(clientId))
|
|
191
|
-
);
|
|
188
|
+
const ran = core.tryRun("onOwnershipRequest", () => handler(core.anyState, name, id, ctx));
|
|
192
189
|
if (ran.ok && ran.value === true && plain.ownerOf(id) !== clientId) {
|
|
193
190
|
tracked.setOwner(id, clientId);
|
|
194
191
|
}
|
|
@@ -230,6 +227,7 @@ function createCallProxy(core, clientId) {
|
|
|
230
227
|
return new Promise((resolve, reject2) => {
|
|
231
228
|
const entry = core.clients.get(clientId);
|
|
232
229
|
if (!entry || !entry.connected) {
|
|
230
|
+
core.scheduleContinuationFlush();
|
|
233
231
|
reject2(new Error(`room.call: ${clientId} is not connected`));
|
|
234
232
|
return;
|
|
235
233
|
}
|
|
@@ -328,6 +326,75 @@ function rejectAllPending(core, reason) {
|
|
|
328
326
|
// src/core/loop.ts
|
|
329
327
|
import { FrameType as FrameType2 } from "@irtio/protocol";
|
|
330
328
|
|
|
329
|
+
// src/core/host-calls.ts
|
|
330
|
+
var states2 = /* @__PURE__ */ new WeakMap();
|
|
331
|
+
function stateOf2(core) {
|
|
332
|
+
let s = states2.get(core);
|
|
333
|
+
if (!s) {
|
|
334
|
+
s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
|
|
335
|
+
states2.set(core, s);
|
|
336
|
+
}
|
|
337
|
+
return s;
|
|
338
|
+
}
|
|
339
|
+
function rejectAsEvent(core, err) {
|
|
340
|
+
core.scheduleContinuationFlush();
|
|
341
|
+
return Promise.reject(err);
|
|
342
|
+
}
|
|
343
|
+
function startHostCall(core, call, map) {
|
|
344
|
+
if (core.stopped) {
|
|
345
|
+
return rejectAsEvent(core, new Error(`room.${call.kind}: the room is stopped`));
|
|
346
|
+
}
|
|
347
|
+
return new Promise((resolve, reject2) => {
|
|
348
|
+
const s = stateOf2(core);
|
|
349
|
+
const reqId = s.nextReqId;
|
|
350
|
+
s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
|
|
351
|
+
s.pending.set(reqId, {
|
|
352
|
+
kind: call.kind,
|
|
353
|
+
deadline: core.host.now() + HOST_CALL_TIMEOUT_MS,
|
|
354
|
+
map,
|
|
355
|
+
resolve,
|
|
356
|
+
reject: reject2
|
|
357
|
+
});
|
|
358
|
+
try {
|
|
359
|
+
core.host.hostCall(reqId, call);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
s.pending.delete(reqId);
|
|
362
|
+
core.scheduleContinuationFlush();
|
|
363
|
+
reject2(err instanceof Error ? err : new Error(String(err)));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (core.mode === "event") {
|
|
367
|
+
core.loop.after(HOST_CALL_TIMEOUT_MS, () => checkHostCallTimeouts(core));
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
function completeHostCall(core, reqId, result) {
|
|
372
|
+
const s = stateOf2(core);
|
|
373
|
+
const pending = s.pending.get(reqId);
|
|
374
|
+
if (!pending) return false;
|
|
375
|
+
s.pending.delete(reqId);
|
|
376
|
+
if (result.ok) pending.resolve(pending.map(result.value));
|
|
377
|
+
else pending.reject(new Error(`${result.code}: ${result.message}`));
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
function checkHostCallTimeouts(core) {
|
|
381
|
+
const s = stateOf2(core);
|
|
382
|
+
if (s.pending.size === 0) return;
|
|
383
|
+
const now = core.host.now();
|
|
384
|
+
for (const [reqId, p] of [...s.pending]) {
|
|
385
|
+
if (p.deadline > now) continue;
|
|
386
|
+
s.pending.delete(reqId);
|
|
387
|
+
p.reject(new Error(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function rejectAllHostCalls(core, reason) {
|
|
391
|
+
const s = stateOf2(core);
|
|
392
|
+
for (const [reqId, p] of [...s.pending]) {
|
|
393
|
+
s.pending.delete(reqId);
|
|
394
|
+
p.reject(new Error(reason));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
331
398
|
// src/core/writes.ts
|
|
332
399
|
import {
|
|
333
400
|
SERVER_OWNER as SERVER_OWNER2,
|
|
@@ -674,6 +741,7 @@ var Loop = class {
|
|
|
674
741
|
this.drainInbound();
|
|
675
742
|
this.fireDueTimers();
|
|
676
743
|
checkTimeouts(this.core);
|
|
744
|
+
checkHostCallTimeouts(this.core);
|
|
677
745
|
const config = this.core.definition.config;
|
|
678
746
|
let failed;
|
|
679
747
|
if (config.tick) {
|
|
@@ -691,6 +759,7 @@ var Loop = class {
|
|
|
691
759
|
physics.reconcile();
|
|
692
760
|
physics.step();
|
|
693
761
|
physics.sync();
|
|
762
|
+
this.core.captureHistory();
|
|
694
763
|
});
|
|
695
764
|
if (!ran.ok) {
|
|
696
765
|
failed = failed === void 0 ? "the physics step" : `${failed} and the physics step`;
|
|
@@ -709,6 +778,7 @@ var Loop = class {
|
|
|
709
778
|
}
|
|
710
779
|
}
|
|
711
780
|
this.core.flush();
|
|
781
|
+
this.core.captureTimeline();
|
|
712
782
|
const elapsed = Math.max(0, host.now() - started);
|
|
713
783
|
const stats = this.core.stats;
|
|
714
784
|
stats.ticks++;
|
|
@@ -748,7 +818,7 @@ var Loop = class {
|
|
|
748
818
|
if (!this.running || this.core.stopped) return;
|
|
749
819
|
const idleMs = this.core.definition.config.idleMs;
|
|
750
820
|
if (idleMs <= 0) return;
|
|
751
|
-
if (this.core.mode === "tick" && this.core
|
|
821
|
+
if (this.core.mode === "tick" && hasHumanClients(this.core)) {
|
|
752
822
|
this.lastActivity = this.core.host.now();
|
|
753
823
|
this.armIdle(idleMs);
|
|
754
824
|
return;
|
|
@@ -815,31 +885,366 @@ var Loop = class {
|
|
|
815
885
|
this.internal.add(handle);
|
|
816
886
|
}
|
|
817
887
|
};
|
|
888
|
+
function hasHumanClients(core) {
|
|
889
|
+
for (const c of core.clients.values()) {
|
|
890
|
+
if (!c.npc) return true;
|
|
891
|
+
}
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
818
894
|
|
|
819
|
-
// src/core/
|
|
820
|
-
import {
|
|
895
|
+
// src/core/matter.ts
|
|
896
|
+
import {
|
|
897
|
+
ByteReader,
|
|
898
|
+
ByteWriter,
|
|
899
|
+
applyChannel2d,
|
|
900
|
+
channelOf2d
|
|
901
|
+
} from "@irtio/schema";
|
|
821
902
|
var engine;
|
|
822
903
|
var loading;
|
|
823
|
-
async function
|
|
904
|
+
async function initMatter() {
|
|
824
905
|
if (engine) return engine;
|
|
825
906
|
loading ??= (async () => {
|
|
826
|
-
const mod = await import("
|
|
907
|
+
const mod = await import("matter-js");
|
|
827
908
|
const ns = mod.default ?? mod;
|
|
828
|
-
await ns.init();
|
|
829
909
|
engine = ns;
|
|
830
910
|
return ns;
|
|
831
911
|
})();
|
|
832
912
|
return loading;
|
|
833
913
|
}
|
|
834
|
-
function
|
|
914
|
+
function loadedMatter() {
|
|
835
915
|
return engine;
|
|
836
916
|
}
|
|
837
|
-
function
|
|
917
|
+
function resetMatterForTests() {
|
|
838
918
|
engine = void 0;
|
|
839
919
|
loading = void 0;
|
|
840
920
|
}
|
|
921
|
+
function encodeMatterBodies(section) {
|
|
922
|
+
const w = new ByteWriter(section.bodies.length * 72 + 8);
|
|
923
|
+
w.varint(section.bodies.length);
|
|
924
|
+
for (const b of section.bodies) {
|
|
925
|
+
w.str(b.collection);
|
|
926
|
+
w.str(b.id);
|
|
927
|
+
w.f64(b.x);
|
|
928
|
+
w.f64(b.y);
|
|
929
|
+
w.f64(b.angle);
|
|
930
|
+
w.f64(b.vx);
|
|
931
|
+
w.f64(b.vy);
|
|
932
|
+
w.f64(b.angularVelocity);
|
|
933
|
+
w.u8(b.sleeping ? 1 : 0);
|
|
934
|
+
}
|
|
935
|
+
return w.finish();
|
|
936
|
+
}
|
|
937
|
+
function decodeMatterBodies(bytes) {
|
|
938
|
+
const r = new ByteReader(bytes);
|
|
939
|
+
const count = r.varint();
|
|
940
|
+
const bodies = [];
|
|
941
|
+
for (let i = 0; i < count; i++) {
|
|
942
|
+
bodies.push({
|
|
943
|
+
collection: r.str(),
|
|
944
|
+
id: r.str(),
|
|
945
|
+
x: r.f64(),
|
|
946
|
+
y: r.f64(),
|
|
947
|
+
angle: r.f64(),
|
|
948
|
+
vx: r.f64(),
|
|
949
|
+
vy: r.f64(),
|
|
950
|
+
angularVelocity: r.f64(),
|
|
951
|
+
sleeping: r.u8() === 1
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
return { bodies };
|
|
955
|
+
}
|
|
956
|
+
function bodyKey(collection, id) {
|
|
957
|
+
return `${collection}\0${id}`;
|
|
958
|
+
}
|
|
959
|
+
var MatterRuntime = class {
|
|
960
|
+
engineKind = "matter2d";
|
|
961
|
+
/** rapier3d only; present so both runtimes satisfy one internal shape. */
|
|
962
|
+
rapier = void 0;
|
|
963
|
+
world = void 0;
|
|
964
|
+
matter;
|
|
965
|
+
engine;
|
|
966
|
+
/** Alias under the name `PhysicsApi` uses, so `room.physics2d` reads through one field. */
|
|
967
|
+
get matterEngine() {
|
|
968
|
+
return this.engine;
|
|
969
|
+
}
|
|
970
|
+
/** `true` when the blob carried no world at all: the caller logs it. */
|
|
971
|
+
rebuilt;
|
|
972
|
+
/**
|
|
973
|
+
* Always `true`. Unlike Rapier's, a matter2d world is never restored as a world — only as
|
|
974
|
+
* per-body state on a world the builder made — so `setup` has to run every time.
|
|
975
|
+
*/
|
|
976
|
+
needsSetup = true;
|
|
977
|
+
core;
|
|
978
|
+
config;
|
|
979
|
+
collections;
|
|
980
|
+
bodies = /* @__PURE__ */ new Map();
|
|
981
|
+
restore;
|
|
982
|
+
stepMs;
|
|
983
|
+
sleepSynced = /* @__PURE__ */ new Set();
|
|
984
|
+
constructor(core, matter, options) {
|
|
985
|
+
const config = core.definition.config.physics;
|
|
986
|
+
if (!config) throw new Error("MatterRuntime: the room config declares no physics");
|
|
987
|
+
this.core = core;
|
|
988
|
+
this.config = config;
|
|
989
|
+
this.matter = matter;
|
|
990
|
+
this.collections = core.ext.collections.filter(
|
|
991
|
+
(c) => c.physics !== void 0
|
|
992
|
+
);
|
|
993
|
+
this.engine = matter.Engine.create();
|
|
994
|
+
this.engine.gravity.x = config.gravity.x;
|
|
995
|
+
this.engine.gravity.y = config.gravity.y;
|
|
996
|
+
this.stepMs = (config.timestep ?? options.defaultTimestep) * 1e3;
|
|
997
|
+
this.rebuilt = options.restore === void 0;
|
|
998
|
+
this.restore = options.restore ? new Map(options.restore.bodies.map((b) => [bodyKey(b.collection, b.id), b])) : void 0;
|
|
999
|
+
}
|
|
1000
|
+
get timestep() {
|
|
1001
|
+
return this.stepMs / 1e3;
|
|
1002
|
+
}
|
|
1003
|
+
runSetup(room) {
|
|
1004
|
+
const setup = this.config.setup;
|
|
1005
|
+
if (!setup) return;
|
|
1006
|
+
this.core.guard("physics.setup", () => setup(this.engine, this.matter, room));
|
|
1007
|
+
}
|
|
1008
|
+
free() {
|
|
1009
|
+
this.bodies.clear();
|
|
1010
|
+
this.matter.Engine.clear(this.engine);
|
|
1011
|
+
}
|
|
1012
|
+
// -------------------------------------------------------------------------
|
|
1013
|
+
// Bodies
|
|
1014
|
+
// -------------------------------------------------------------------------
|
|
1015
|
+
bodyFor(collection, id) {
|
|
1016
|
+
const existing = this.bodies.get(bodyKey(collection, id));
|
|
1017
|
+
if (existing) return existing.body;
|
|
1018
|
+
const desc = this.collections.find((c) => c.name === collection);
|
|
1019
|
+
if (!desc) return void 0;
|
|
1020
|
+
const coll = plainEntity(this.core.plain, collection);
|
|
1021
|
+
const record = coll.get(id);
|
|
1022
|
+
if (record === void 0) return void 0;
|
|
1023
|
+
return this.create(desc, id, record);
|
|
1024
|
+
}
|
|
1025
|
+
create(desc, id, record) {
|
|
1026
|
+
const factory = this.config.bodies?.[desc.name];
|
|
1027
|
+
if (!factory) {
|
|
1028
|
+
this.core.log("error", `irtio: physics.bodies.${desc.name} is missing; no body created`);
|
|
1029
|
+
return void 0;
|
|
1030
|
+
}
|
|
1031
|
+
const spec = this.core.guard(
|
|
1032
|
+
`physics.bodies.${desc.name}`,
|
|
1033
|
+
() => factory(this.matter, record, id)
|
|
1034
|
+
);
|
|
1035
|
+
if (!spec || !spec.body) {
|
|
1036
|
+
this.core.log(
|
|
1037
|
+
"error",
|
|
1038
|
+
`irtio: physics.bodies.${desc.name} returned no { body } for ${JSON.stringify(id)}`
|
|
1039
|
+
);
|
|
1040
|
+
return void 0;
|
|
1041
|
+
}
|
|
1042
|
+
const key = bodyKey(desc.name, id);
|
|
1043
|
+
const constraints = spec.constraints ?? [];
|
|
1044
|
+
this.matter.Composite.add(this.engine.world, [spec.body, ...constraints]);
|
|
1045
|
+
this.bodies.set(key, { body: spec.body, constraints });
|
|
1046
|
+
const saved = this.restore?.get(key);
|
|
1047
|
+
if (saved) {
|
|
1048
|
+
this.restore?.delete(key);
|
|
1049
|
+
this.applyState(spec.body, saved);
|
|
1050
|
+
} else {
|
|
1051
|
+
this.applyRecordToBody(desc, spec.body, record);
|
|
1052
|
+
}
|
|
1053
|
+
return spec.body;
|
|
1054
|
+
}
|
|
1055
|
+
// ---- M6 lane F: rewind ----
|
|
1056
|
+
/**
|
|
1057
|
+
* D72: every tracked body, in the order `sync()` walks them. The matter half of the same
|
|
1058
|
+
* read-only accessor `PhysicsRuntime` carries; see its comment for what calls it and when.
|
|
1059
|
+
*/
|
|
1060
|
+
eachTrackedBody(fn) {
|
|
1061
|
+
for (const desc of this.collections) {
|
|
1062
|
+
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
1063
|
+
for (const id of plainColl.ids()) {
|
|
1064
|
+
const attached = this.bodies.get(bodyKey(desc.name, id));
|
|
1065
|
+
if (attached) fn(desc.name, id, attached.body);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
applyState(body, s) {
|
|
1070
|
+
const M = this.matter;
|
|
1071
|
+
M.Body.setPosition(body, { x: s.x, y: s.y });
|
|
1072
|
+
M.Body.setAngle(body, s.angle);
|
|
1073
|
+
M.Body.setVelocity(body, { x: s.vx, y: s.vy });
|
|
1074
|
+
M.Body.setAngularVelocity(body, s.angularVelocity);
|
|
1075
|
+
if (s.sleeping) M.Sleeping.set(body, true);
|
|
1076
|
+
}
|
|
1077
|
+
applyRecordToBody(desc, body, record) {
|
|
1078
|
+
const physics = desc.physics;
|
|
1079
|
+
if (!physics) return;
|
|
1080
|
+
const t = {
|
|
1081
|
+
x: body.position.x,
|
|
1082
|
+
y: body.position.y,
|
|
1083
|
+
qz: Math.sin(body.angle / 2),
|
|
1084
|
+
qw: Math.cos(body.angle / 2),
|
|
1085
|
+
vx: body.velocity.x,
|
|
1086
|
+
vy: body.velocity.y,
|
|
1087
|
+
wz: body.angularVelocity
|
|
1088
|
+
};
|
|
1089
|
+
for (const [channel, field] of physics.channels) {
|
|
1090
|
+
const raw = record[field];
|
|
1091
|
+
if (typeof raw !== "number") continue;
|
|
1092
|
+
applyChannel2d(channel, raw, t);
|
|
1093
|
+
}
|
|
1094
|
+
this.applyState(body, {
|
|
1095
|
+
collection: desc.name,
|
|
1096
|
+
id: "",
|
|
1097
|
+
x: t.x,
|
|
1098
|
+
y: t.y,
|
|
1099
|
+
angle: 2 * Math.atan2(t.qz, t.qw),
|
|
1100
|
+
vx: t.vx,
|
|
1101
|
+
vy: t.vy,
|
|
1102
|
+
angularVelocity: t.wz,
|
|
1103
|
+
sleeping: false
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
reconcile() {
|
|
1107
|
+
const live = /* @__PURE__ */ new Set();
|
|
1108
|
+
for (const desc of this.collections) {
|
|
1109
|
+
const coll = plainEntity(this.core.plain, desc.name);
|
|
1110
|
+
for (const id of coll.ids()) {
|
|
1111
|
+
const key = bodyKey(desc.name, id);
|
|
1112
|
+
live.add(key);
|
|
1113
|
+
if (this.bodies.has(key)) continue;
|
|
1114
|
+
const record = coll.get(id);
|
|
1115
|
+
if (record !== void 0) this.create(desc, id, record);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
for (const [key, attached] of [...this.bodies]) {
|
|
1119
|
+
if (live.has(key)) continue;
|
|
1120
|
+
this.bodies.delete(key);
|
|
1121
|
+
this.sleepSynced.delete(key);
|
|
1122
|
+
this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
// -------------------------------------------------------------------------
|
|
1126
|
+
// Step and sync
|
|
1127
|
+
// -------------------------------------------------------------------------
|
|
1128
|
+
step() {
|
|
1129
|
+
this.matter.Engine.update(this.engine, this.stepMs);
|
|
1130
|
+
}
|
|
1131
|
+
sync() {
|
|
1132
|
+
for (const desc of this.collections) {
|
|
1133
|
+
const physics = desc.physics;
|
|
1134
|
+
if (!physics) continue;
|
|
1135
|
+
const tracked = this.core.anyState[desc.name];
|
|
1136
|
+
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
1137
|
+
const rounders = roundersFor(desc);
|
|
1138
|
+
for (const id of plainColl.ids()) {
|
|
1139
|
+
const key = bodyKey(desc.name, id);
|
|
1140
|
+
const attached = this.bodies.get(key);
|
|
1141
|
+
if (!attached) continue;
|
|
1142
|
+
const body = attached.body;
|
|
1143
|
+
if (body.isSleeping) {
|
|
1144
|
+
if (this.sleepSynced.has(key)) continue;
|
|
1145
|
+
this.sleepSynced.add(key);
|
|
1146
|
+
} else {
|
|
1147
|
+
this.sleepSynced.delete(key);
|
|
1148
|
+
}
|
|
1149
|
+
const record = tracked.get(id);
|
|
1150
|
+
if (!record) continue;
|
|
1151
|
+
const state = {
|
|
1152
|
+
x: body.position.x,
|
|
1153
|
+
y: body.position.y,
|
|
1154
|
+
angle: body.angle,
|
|
1155
|
+
vx: body.velocity.x,
|
|
1156
|
+
vy: body.velocity.y,
|
|
1157
|
+
angularVelocity: body.angularVelocity
|
|
1158
|
+
};
|
|
1159
|
+
for (const [channel, field] of physics.channels) {
|
|
1160
|
+
const next = (rounders[field] ?? identity)(channelOf2d(channel, state));
|
|
1161
|
+
if (record[field] !== next) record[field] = next;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
// -------------------------------------------------------------------------
|
|
1167
|
+
// Hibernation
|
|
1168
|
+
// -------------------------------------------------------------------------
|
|
1169
|
+
serialize() {
|
|
1170
|
+
const bodies = [];
|
|
1171
|
+
for (const [key, attached] of this.bodies) {
|
|
1172
|
+
const sep = key.indexOf("\0");
|
|
1173
|
+
const b = attached.body;
|
|
1174
|
+
bodies.push({
|
|
1175
|
+
collection: key.slice(0, sep),
|
|
1176
|
+
id: key.slice(sep + 1),
|
|
1177
|
+
x: b.position.x,
|
|
1178
|
+
y: b.position.y,
|
|
1179
|
+
angle: b.angle,
|
|
1180
|
+
vx: b.velocity.x,
|
|
1181
|
+
vy: b.velocity.y,
|
|
1182
|
+
angularVelocity: b.angularVelocity,
|
|
1183
|
+
sleeping: b.isSleeping === true
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
return { bodies };
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
function identity(v) {
|
|
1190
|
+
return v;
|
|
1191
|
+
}
|
|
1192
|
+
function roundersFor(desc) {
|
|
1193
|
+
const out = {};
|
|
1194
|
+
for (const f of desc.fields) {
|
|
1195
|
+
if (f.type.kind === "f32") out[f.name] = Math.fround;
|
|
1196
|
+
}
|
|
1197
|
+
return out;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// src/core/physics.ts
|
|
1201
|
+
import { ByteReader as ByteReader2, ByteWriter as ByteWriter2 } from "@irtio/schema";
|
|
1202
|
+
var engine2;
|
|
1203
|
+
var loading2;
|
|
1204
|
+
async function initPhysics() {
|
|
1205
|
+
if (engine2) return engine2;
|
|
1206
|
+
loading2 ??= (async () => {
|
|
1207
|
+
const mod = await import("@dimforge/rapier3d-compat");
|
|
1208
|
+
const ns = mod.default ?? mod;
|
|
1209
|
+
await ns.init();
|
|
1210
|
+
engine2 = ns;
|
|
1211
|
+
return ns;
|
|
1212
|
+
})();
|
|
1213
|
+
return loading2;
|
|
1214
|
+
}
|
|
1215
|
+
function loadedPhysics() {
|
|
1216
|
+
return engine2;
|
|
1217
|
+
}
|
|
1218
|
+
function resetPhysicsForTests() {
|
|
1219
|
+
engine2 = void 0;
|
|
1220
|
+
loading2 = void 0;
|
|
1221
|
+
rapierStepped = false;
|
|
1222
|
+
firstStepWaiters.length = 0;
|
|
1223
|
+
}
|
|
1224
|
+
var rapierStepped = false;
|
|
1225
|
+
var firstStepWaiters = [];
|
|
1226
|
+
function rapierHasStepped() {
|
|
1227
|
+
return rapierStepped;
|
|
1228
|
+
}
|
|
1229
|
+
function onFirstRapierStep(cb) {
|
|
1230
|
+
if (rapierStepped) {
|
|
1231
|
+
cb();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
firstStepWaiters.push(cb);
|
|
1235
|
+
}
|
|
1236
|
+
function noteRapierStep() {
|
|
1237
|
+
rapierStepped = true;
|
|
1238
|
+
const waiters = firstStepWaiters.splice(0, firstStepWaiters.length);
|
|
1239
|
+
for (const cb of waiters) {
|
|
1240
|
+
try {
|
|
1241
|
+
cb();
|
|
1242
|
+
} catch {
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
841
1246
|
function encodePhysicsSection(section) {
|
|
842
|
-
const w = new
|
|
1247
|
+
const w = new ByteWriter2(section.world.length + section.bodies.length * 24 + 8);
|
|
843
1248
|
w.blob(section.world);
|
|
844
1249
|
w.varint(section.bodies.length);
|
|
845
1250
|
for (const [name, id, handle] of section.bodies) {
|
|
@@ -849,15 +1254,34 @@ function encodePhysicsSection(section) {
|
|
|
849
1254
|
}
|
|
850
1255
|
return w.finish();
|
|
851
1256
|
}
|
|
1257
|
+
var MATTER_TAG = 1;
|
|
1258
|
+
function physicsSectionEngine(bytes) {
|
|
1259
|
+
const r = new ByteReader2(bytes);
|
|
1260
|
+
return r.varint() === 0 && r.u8() === MATTER_TAG ? "matter2d" : "rapier3d";
|
|
1261
|
+
}
|
|
1262
|
+
function encodeMatterSectionEnvelope(payload) {
|
|
1263
|
+
const w = new ByteWriter2(payload.length + 8);
|
|
1264
|
+
w.varint(0);
|
|
1265
|
+
w.u8(MATTER_TAG);
|
|
1266
|
+
w.bytes(payload);
|
|
1267
|
+
return w.finish();
|
|
1268
|
+
}
|
|
1269
|
+
function decodeMatterSectionEnvelope(bytes) {
|
|
1270
|
+
const r = new ByteReader2(bytes);
|
|
1271
|
+
if (r.varint() !== 0 || r.u8() !== MATTER_TAG) {
|
|
1272
|
+
throw new Error("irtio: this physics section was not written by matter2d");
|
|
1273
|
+
}
|
|
1274
|
+
return r.rest();
|
|
1275
|
+
}
|
|
852
1276
|
function decodePhysicsSection(bytes) {
|
|
853
|
-
const r = new
|
|
1277
|
+
const r = new ByteReader2(bytes);
|
|
854
1278
|
const world = r.blob().slice();
|
|
855
1279
|
const count = r.varint();
|
|
856
1280
|
const bodies = [];
|
|
857
1281
|
for (let i = 0; i < count; i++) bodies.push([r.str(), r.str(), r.f64()]);
|
|
858
1282
|
return { world, bodies };
|
|
859
1283
|
}
|
|
860
|
-
function
|
|
1284
|
+
function bodyKey2(collection, id) {
|
|
861
1285
|
return `${collection}\0${id}`;
|
|
862
1286
|
}
|
|
863
1287
|
function planarLockWarning(spec) {
|
|
@@ -880,10 +1304,18 @@ function planarLockWarning(spec) {
|
|
|
880
1304
|
var CUBOID_SHAPE = 1;
|
|
881
1305
|
var ROUND_CUBOID_SHAPE = 12;
|
|
882
1306
|
var PhysicsRuntime = class {
|
|
1307
|
+
engineKind = "rapier3d";
|
|
1308
|
+
/** matter2d only; present so both runtimes satisfy one internal shape. */
|
|
1309
|
+
matter = void 0;
|
|
1310
|
+
matterEngine = void 0;
|
|
883
1311
|
rapier;
|
|
884
1312
|
world;
|
|
885
1313
|
/** `true` when the world was built from scratch and `setup` has to run. */
|
|
886
1314
|
rebuilt;
|
|
1315
|
+
/** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
|
|
1316
|
+
get needsSetup() {
|
|
1317
|
+
return this.rebuilt;
|
|
1318
|
+
}
|
|
887
1319
|
core;
|
|
888
1320
|
config;
|
|
889
1321
|
/** Physics-backed collections, in schema (name-sorted) order. */
|
|
@@ -912,7 +1344,7 @@ var PhysicsRuntime = class {
|
|
|
912
1344
|
this.rebuilt = false;
|
|
913
1345
|
for (const [name, id, handle] of options.restore.bodies) {
|
|
914
1346
|
const body = this.world.getRigidBody(handle);
|
|
915
|
-
if (body) this.bodies.set(
|
|
1347
|
+
if (body) this.bodies.set(bodyKey2(name, id), body);
|
|
916
1348
|
}
|
|
917
1349
|
} else {
|
|
918
1350
|
const g = config.gravity;
|
|
@@ -939,7 +1371,7 @@ var PhysicsRuntime = class {
|
|
|
939
1371
|
// -------------------------------------------------------------------------
|
|
940
1372
|
/** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
|
|
941
1373
|
bodyFor(collection, id) {
|
|
942
|
-
const existing = this.bodies.get(
|
|
1374
|
+
const existing = this.bodies.get(bodyKey2(collection, id));
|
|
943
1375
|
if (existing) return existing;
|
|
944
1376
|
const desc = this.collections.find((c) => c.name === collection);
|
|
945
1377
|
if (!desc) return void 0;
|
|
@@ -973,9 +1405,25 @@ var PhysicsRuntime = class {
|
|
|
973
1405
|
const body = this.world.createRigidBody(spec.body);
|
|
974
1406
|
for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
|
|
975
1407
|
this.applyRecordToBody(desc, body, record);
|
|
976
|
-
this.bodies.set(
|
|
1408
|
+
this.bodies.set(bodyKey2(desc.name, id), body);
|
|
977
1409
|
return body;
|
|
978
1410
|
}
|
|
1411
|
+
// ---- M6 lane F: rewind ----
|
|
1412
|
+
/**
|
|
1413
|
+
* D72: every tracked body, in the order `sync()` walks them (collection order, then instance
|
|
1414
|
+
* order). Read-only: it hands out the live bodies and nothing else, and the map stays private.
|
|
1415
|
+
* The pose history calls this once per tick and the rewind scratch calls it per rewind; a room
|
|
1416
|
+
* that declares no `physics.history` never calls it at all.
|
|
1417
|
+
*/
|
|
1418
|
+
eachTrackedBody(fn) {
|
|
1419
|
+
for (const desc of this.collections) {
|
|
1420
|
+
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
1421
|
+
for (const id of plainColl.ids()) {
|
|
1422
|
+
const body = this.bodies.get(bodyKey2(desc.name, id));
|
|
1423
|
+
if (body) fn(desc.name, id, body);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
979
1427
|
applyRecordToBody(desc, body, record) {
|
|
980
1428
|
const physics = desc.physics;
|
|
981
1429
|
if (!physics) return;
|
|
@@ -1053,7 +1501,7 @@ var PhysicsRuntime = class {
|
|
|
1053
1501
|
for (const desc of this.collections) {
|
|
1054
1502
|
const coll = plainEntity(this.core.plain, desc.name);
|
|
1055
1503
|
for (const id of coll.ids()) {
|
|
1056
|
-
const key =
|
|
1504
|
+
const key = bodyKey2(desc.name, id);
|
|
1057
1505
|
live.add(key);
|
|
1058
1506
|
if (this.bodies.has(key)) continue;
|
|
1059
1507
|
const record = coll.get(id);
|
|
@@ -1072,6 +1520,7 @@ var PhysicsRuntime = class {
|
|
|
1072
1520
|
// -------------------------------------------------------------------------
|
|
1073
1521
|
step() {
|
|
1074
1522
|
this.world.step();
|
|
1523
|
+
if (!rapierStepped) noteRapierStep();
|
|
1075
1524
|
}
|
|
1076
1525
|
/**
|
|
1077
1526
|
* Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
|
|
@@ -1084,9 +1533,9 @@ var PhysicsRuntime = class {
|
|
|
1084
1533
|
if (!physics) continue;
|
|
1085
1534
|
const tracked = this.core.anyState[desc.name];
|
|
1086
1535
|
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
1087
|
-
const rounders =
|
|
1536
|
+
const rounders = roundersFor2(desc);
|
|
1088
1537
|
for (const id of plainColl.ids()) {
|
|
1089
|
-
const key =
|
|
1538
|
+
const key = bodyKey2(desc.name, id);
|
|
1090
1539
|
const body = this.bodies.get(key);
|
|
1091
1540
|
if (!body) continue;
|
|
1092
1541
|
if (body.isSleeping()) {
|
|
@@ -1102,7 +1551,7 @@ var PhysicsRuntime = class {
|
|
|
1102
1551
|
const v = body.linvel();
|
|
1103
1552
|
const w = body.angvel();
|
|
1104
1553
|
for (const [channel, field] of physics.channels) {
|
|
1105
|
-
const next = (rounders[field] ??
|
|
1554
|
+
const next = (rounders[field] ?? identity2)(channelValue(channel, t, r, v, w));
|
|
1106
1555
|
if (record[field] !== next) record[field] = next;
|
|
1107
1556
|
}
|
|
1108
1557
|
}
|
|
@@ -1120,10 +1569,10 @@ var PhysicsRuntime = class {
|
|
|
1120
1569
|
return { world: this.world.takeSnapshot(), bodies };
|
|
1121
1570
|
}
|
|
1122
1571
|
};
|
|
1123
|
-
function
|
|
1572
|
+
function identity2(v) {
|
|
1124
1573
|
return v;
|
|
1125
1574
|
}
|
|
1126
|
-
function
|
|
1575
|
+
function roundersFor2(desc) {
|
|
1127
1576
|
const out = {};
|
|
1128
1577
|
for (const f of desc.fields) {
|
|
1129
1578
|
if (f.type.kind === "f32") out[f.name] = Math.fround;
|
|
@@ -1418,11 +1867,12 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
|
|
|
1418
1867
|
}
|
|
1419
1868
|
|
|
1420
1869
|
// src/core/snapshot.ts
|
|
1421
|
-
import {
|
|
1870
|
+
import { withBuiltins } from "@irtio/protocol";
|
|
1871
|
+
import { ByteReader as ByteReader3, ByteWriter as ByteWriter3, decodeSnapshot } from "@irtio/schema";
|
|
1422
1872
|
var SNAPSHOT_FORMAT_VERSION = 2;
|
|
1423
1873
|
var READABLE_SNAPSHOT_VERSIONS = [1, 2];
|
|
1424
1874
|
function parseHibernationBlob(bytes) {
|
|
1425
|
-
const r = new
|
|
1875
|
+
const r = new ByteReader3(bytes);
|
|
1426
1876
|
const version = r.u8();
|
|
1427
1877
|
if (!READABLE_SNAPSHOT_VERSIONS.includes(version)) {
|
|
1428
1878
|
throw new Error(`RoomCore.restore: unsupported snapshot format version ${version}`);
|
|
@@ -1440,7 +1890,7 @@ function parseHibernationBlob(bytes) {
|
|
|
1440
1890
|
}
|
|
1441
1891
|
function writeHibernationBlob(header, snapshot, physics) {
|
|
1442
1892
|
const version = physics ? 2 : 1;
|
|
1443
|
-
const w = new
|
|
1893
|
+
const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 24);
|
|
1444
1894
|
w.u8(version);
|
|
1445
1895
|
w.u32(header.seed >>> 0);
|
|
1446
1896
|
w.u32(header.rngState >>> 0);
|
|
@@ -1450,21 +1900,90 @@ function writeHibernationBlob(header, snapshot, physics) {
|
|
|
1450
1900
|
w.bytes(snapshot);
|
|
1451
1901
|
return w.finish();
|
|
1452
1902
|
}
|
|
1903
|
+
function decodeSave(bytes, schema) {
|
|
1904
|
+
const parsed = parseHibernationBlob(bytes);
|
|
1905
|
+
const ext = withBuiltins(schema);
|
|
1906
|
+
const plain = decodeSnapshot(ext, parsed.snapshot).state;
|
|
1907
|
+
return {
|
|
1908
|
+
version: parsed.version,
|
|
1909
|
+
seed: parsed.seed,
|
|
1910
|
+
rngState: parsed.rngState,
|
|
1911
|
+
tick: parsed.tick,
|
|
1912
|
+
mode: parsed.mode,
|
|
1913
|
+
hasPhysics: parsed.physics !== void 0,
|
|
1914
|
+
physicsEngine: parsed.physics ? physicsSectionEngine(parsed.physics) : void 0,
|
|
1915
|
+
state: inspectState(ext, plain)
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// src/core/timeline.ts
|
|
1920
|
+
import { EntityCollection as EntityCollection2 } from "@irtio/schema";
|
|
1921
|
+
var DEFAULT_TIMELINE_MAX_TICKS = 3600;
|
|
1922
|
+
var DEFAULT_TIMELINE_MAX_RECORDS = 2e5;
|
|
1923
|
+
function recordsIn(state) {
|
|
1924
|
+
let n = 0;
|
|
1925
|
+
for (const value of Object.values(state)) {
|
|
1926
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1927
|
+
n += Object.keys(value).length;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
return Math.max(1, n);
|
|
1931
|
+
}
|
|
1932
|
+
var TimelineRecorder = class {
|
|
1933
|
+
frames = [];
|
|
1934
|
+
costs = [];
|
|
1935
|
+
records = 0;
|
|
1936
|
+
dropped = 0;
|
|
1937
|
+
maxTicks;
|
|
1938
|
+
maxRecords;
|
|
1939
|
+
constructor(options = {}) {
|
|
1940
|
+
this.maxTicks = Math.max(1, options.maxTicks ?? DEFAULT_TIMELINE_MAX_TICKS);
|
|
1941
|
+
this.maxRecords = Math.max(1, options.maxRecords ?? DEFAULT_TIMELINE_MAX_RECORDS);
|
|
1942
|
+
}
|
|
1943
|
+
collections = {};
|
|
1944
|
+
capture(tick, schema, plain) {
|
|
1945
|
+
const state = structuredClone(inspectState(schema, plain));
|
|
1946
|
+
for (const c of schema.collections) {
|
|
1947
|
+
this.collections[c.name] = plain[c.name] instanceof EntityCollection2 ? "entity" : "single";
|
|
1948
|
+
}
|
|
1949
|
+
const cost = recordsIn(state);
|
|
1950
|
+
this.frames.push({ tick, at: Date.now(), state });
|
|
1951
|
+
this.costs.push(cost);
|
|
1952
|
+
this.records += cost;
|
|
1953
|
+
while (this.frames.length > this.maxTicks || this.records > this.maxRecords && this.frames.length > 1) {
|
|
1954
|
+
this.frames.shift();
|
|
1955
|
+
this.records -= this.costs.shift() ?? 0;
|
|
1956
|
+
this.dropped++;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
dump(roomId) {
|
|
1960
|
+
return {
|
|
1961
|
+
roomId,
|
|
1962
|
+
collections: { ...this.collections },
|
|
1963
|
+
frames: this.frames.map((f) => ({ tick: f.tick, at: f.at, state: f.state })),
|
|
1964
|
+
dropped: this.dropped,
|
|
1965
|
+
maxTicks: this.maxTicks,
|
|
1966
|
+
maxRecords: this.maxRecords
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
};
|
|
1453
1970
|
|
|
1454
1971
|
// src/core/room.ts
|
|
1455
1972
|
import {
|
|
1456
1973
|
FrameType as FrameType5,
|
|
1457
1974
|
PRESENCE_COLLECTION,
|
|
1975
|
+
ProfileLedger,
|
|
1976
|
+
busChannelProblem as busChannelProblem2,
|
|
1458
1977
|
decodeFrame,
|
|
1459
1978
|
encodeCorrectFrame,
|
|
1460
1979
|
encodeFrame as encodeFrame4,
|
|
1461
|
-
withBuiltins
|
|
1980
|
+
withBuiltins as withBuiltins2
|
|
1462
1981
|
} from "@irtio/protocol";
|
|
1463
1982
|
import {
|
|
1464
1983
|
cloneValue as cloneValue2,
|
|
1465
1984
|
createDirtySet as createDirtySet2,
|
|
1466
1985
|
createState,
|
|
1467
|
-
decodeSnapshot,
|
|
1986
|
+
decodeSnapshot as decodeSnapshot2,
|
|
1468
1987
|
encodeDelta as encodeDelta4,
|
|
1469
1988
|
encodeSnapshot as encodeSnapshot2,
|
|
1470
1989
|
isDirtyEmpty as isDirtyEmpty3,
|
|
@@ -1472,73 +1991,469 @@ import {
|
|
|
1472
1991
|
validateForDeploy
|
|
1473
1992
|
} from "@irtio/schema";
|
|
1474
1993
|
|
|
1475
|
-
// src/core/
|
|
1476
|
-
var
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1994
|
+
// src/core/history.ts
|
|
1995
|
+
var HISTORY_MAX_TICKS = 240;
|
|
1996
|
+
var STRIDE = 13;
|
|
1997
|
+
function emptyPose() {
|
|
1998
|
+
return { x: 0, y: 0, z: 0, qx: 0, qy: 0, qz: 0, qw: 1, vx: 0, vy: 0, vz: 0, wx: 0, wy: 0, wz: 0 };
|
|
1999
|
+
}
|
|
2000
|
+
function newEntry() {
|
|
2001
|
+
return { tick: -1, count: 0, collections: [], ids: [], values: new Float64Array(0) };
|
|
2002
|
+
}
|
|
2003
|
+
var PoseHistory = class {
|
|
2004
|
+
depth;
|
|
2005
|
+
entries = [];
|
|
2006
|
+
/** Index of the newest entry in `entries`, or -1 when nothing has been captured. */
|
|
2007
|
+
head = -1;
|
|
2008
|
+
size = 0;
|
|
2009
|
+
/** Reused across every body of every capture: the capture path allocates nothing per body. */
|
|
2010
|
+
scratchPose = emptyPose();
|
|
2011
|
+
constructor(depth) {
|
|
2012
|
+
this.depth = Math.max(1, Math.min(HISTORY_MAX_TICKS, Math.floor(depth)));
|
|
2013
|
+
for (let i = 0; i < this.depth; i++) this.entries.push(newEntry());
|
|
2014
|
+
}
|
|
2015
|
+
get length() {
|
|
2016
|
+
return this.size;
|
|
2017
|
+
}
|
|
2018
|
+
/** The newest tick captured, or `undefined` when the buffer is empty. */
|
|
2019
|
+
get newestTick() {
|
|
2020
|
+
return this.size === 0 ? void 0 : this.entries[this.head].tick;
|
|
2021
|
+
}
|
|
2022
|
+
/** The oldest tick still held, or `undefined` when the buffer is empty. */
|
|
2023
|
+
get oldestTick() {
|
|
2024
|
+
if (this.size === 0) return void 0;
|
|
2025
|
+
const i = (this.head - (this.size - 1) + this.depth * 2) % this.depth;
|
|
2026
|
+
return this.entries[i].tick;
|
|
2027
|
+
}
|
|
2028
|
+
/** Drops everything. Used by the hibernation path, where a buffer cannot survive. */
|
|
2029
|
+
clear() {
|
|
2030
|
+
this.head = -1;
|
|
2031
|
+
this.size = 0;
|
|
2032
|
+
}
|
|
2033
|
+
/** Captures one tick's poses off the live runtime. Called right after `physics.sync()`. */
|
|
2034
|
+
capture(tick, physics) {
|
|
2035
|
+
this.head = this.size === 0 ? 0 : (this.head + 1) % this.depth;
|
|
2036
|
+
if (this.size < this.depth) this.size++;
|
|
2037
|
+
const entry = this.entries[this.head];
|
|
2038
|
+
entry.tick = tick;
|
|
2039
|
+
entry.count = 0;
|
|
2040
|
+
const pose = this.scratchPose;
|
|
2041
|
+
const rapier = physics.engineKind === "rapier3d";
|
|
2042
|
+
physics.eachTrackedBody((collection, id, body) => {
|
|
2043
|
+
if (rapier) readRapierPose(body, pose);
|
|
2044
|
+
else readMatterPose(body, pose);
|
|
2045
|
+
this.push(entry, collection, id, pose);
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
push(entry, collection, id, pose) {
|
|
2049
|
+
const i = entry.count;
|
|
2050
|
+
const need = (i + 1) * STRIDE;
|
|
2051
|
+
if (entry.values.length < need) {
|
|
2052
|
+
const grown = new Float64Array(Math.max(need, entry.values.length * 2, STRIDE * 8));
|
|
2053
|
+
grown.set(entry.values);
|
|
2054
|
+
entry.values = grown;
|
|
2055
|
+
}
|
|
2056
|
+
entry.collections[i] = collection;
|
|
2057
|
+
entry.ids[i] = id;
|
|
2058
|
+
const v = entry.values;
|
|
2059
|
+
const o = i * STRIDE;
|
|
2060
|
+
v[o] = pose.x;
|
|
2061
|
+
v[o + 1] = pose.y;
|
|
2062
|
+
v[o + 2] = pose.z;
|
|
2063
|
+
v[o + 3] = pose.qx;
|
|
2064
|
+
v[o + 4] = pose.qy;
|
|
2065
|
+
v[o + 5] = pose.qz;
|
|
2066
|
+
v[o + 6] = pose.qw;
|
|
2067
|
+
v[o + 7] = pose.vx;
|
|
2068
|
+
v[o + 8] = pose.vy;
|
|
2069
|
+
v[o + 9] = pose.vz;
|
|
2070
|
+
v[o + 10] = pose.wx;
|
|
2071
|
+
v[o + 11] = pose.wy;
|
|
2072
|
+
v[o + 12] = pose.wz;
|
|
2073
|
+
entry.count = i + 1;
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* The entry a rewind to `requested` answers from, clamped into the window the buffer actually
|
|
2077
|
+
* holds. `undefined` only when nothing has been captured at all.
|
|
2078
|
+
*/
|
|
2079
|
+
resolve(requested) {
|
|
2080
|
+
if (this.size === 0) return void 0;
|
|
2081
|
+
const newest = this.entries[this.head].tick;
|
|
2082
|
+
const oldest = this.oldestTick;
|
|
2083
|
+
const want = Number.isFinite(requested) ? Math.floor(requested) : newest;
|
|
2084
|
+
const tick = want < oldest ? oldest : want > newest ? newest : want;
|
|
2085
|
+
const entry = this.at(tick);
|
|
2086
|
+
if (!entry) return void 0;
|
|
2087
|
+
return { entry, tick: entry.tick, clamped: tick !== want };
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* The entry for exactly `tick`. Index arithmetic first (captures are one tick apart, so the
|
|
2091
|
+
* offset from the head is the tick difference), with a scan as insurance: a room whose loop
|
|
2092
|
+
* ever skipped a capture would otherwise be answered with the wrong tick's poses, and answering
|
|
2093
|
+
* for a tick that was not recorded is the one thing this buffer must never do.
|
|
2094
|
+
*/
|
|
2095
|
+
at(tick) {
|
|
2096
|
+
const newest = this.entries[this.head].tick;
|
|
2097
|
+
const offset = newest - tick;
|
|
2098
|
+
if (offset >= 0 && offset < this.size) {
|
|
2099
|
+
const e = this.entries[(this.head - offset + this.depth * 2) % this.depth];
|
|
2100
|
+
if (e.tick === tick) return e;
|
|
2101
|
+
}
|
|
2102
|
+
for (let k = 0; k < this.size; k++) {
|
|
2103
|
+
const e = this.entries[(this.head - k + this.depth * 2) % this.depth];
|
|
2104
|
+
if (e.tick === tick) return e;
|
|
2105
|
+
}
|
|
2106
|
+
return void 0;
|
|
2107
|
+
}
|
|
2108
|
+
};
|
|
2109
|
+
function poseAt(entry, index, into) {
|
|
2110
|
+
const v = entry.values;
|
|
2111
|
+
const o = index * STRIDE;
|
|
2112
|
+
into.x = v[o];
|
|
2113
|
+
into.y = v[o + 1];
|
|
2114
|
+
into.z = v[o + 2];
|
|
2115
|
+
into.qx = v[o + 3];
|
|
2116
|
+
into.qy = v[o + 4];
|
|
2117
|
+
into.qz = v[o + 5];
|
|
2118
|
+
into.qw = v[o + 6];
|
|
2119
|
+
into.vx = v[o + 7];
|
|
2120
|
+
into.vy = v[o + 8];
|
|
2121
|
+
into.vz = v[o + 9];
|
|
2122
|
+
into.wx = v[o + 10];
|
|
2123
|
+
into.wy = v[o + 11];
|
|
2124
|
+
into.wz = v[o + 12];
|
|
2125
|
+
}
|
|
2126
|
+
function readRapierPose(body, into) {
|
|
2127
|
+
const t = body.translation();
|
|
2128
|
+
const r = body.rotation();
|
|
2129
|
+
const v = body.linvel();
|
|
2130
|
+
const w = body.angvel();
|
|
2131
|
+
into.x = t.x;
|
|
2132
|
+
into.y = t.y;
|
|
2133
|
+
into.z = t.z;
|
|
2134
|
+
into.qx = r.x;
|
|
2135
|
+
into.qy = r.y;
|
|
2136
|
+
into.qz = r.z;
|
|
2137
|
+
into.qw = r.w;
|
|
2138
|
+
into.vx = v.x;
|
|
2139
|
+
into.vy = v.y;
|
|
2140
|
+
into.vz = v.z;
|
|
2141
|
+
into.wx = w.x;
|
|
2142
|
+
into.wy = w.y;
|
|
2143
|
+
into.wz = w.z;
|
|
2144
|
+
}
|
|
2145
|
+
function readMatterPose(body, into) {
|
|
2146
|
+
into.x = body.position.x;
|
|
2147
|
+
into.y = body.position.y;
|
|
2148
|
+
into.z = 0;
|
|
2149
|
+
into.qx = 0;
|
|
2150
|
+
into.qy = 0;
|
|
2151
|
+
into.qz = Math.sin(body.angle / 2);
|
|
2152
|
+
into.qw = Math.cos(body.angle / 2);
|
|
2153
|
+
into.vx = body.velocity.x;
|
|
2154
|
+
into.vy = body.velocity.y;
|
|
2155
|
+
into.vz = 0;
|
|
2156
|
+
into.wx = 0;
|
|
2157
|
+
into.wy = 0;
|
|
2158
|
+
into.wz = body.angularVelocity;
|
|
2159
|
+
}
|
|
2160
|
+
function historyDepthOf(config) {
|
|
2161
|
+
const declared = config?.history;
|
|
2162
|
+
if (typeof declared !== "number" || !Number.isFinite(declared) || declared <= 0) return void 0;
|
|
2163
|
+
return Math.min(HISTORY_MAX_TICKS, Math.floor(declared));
|
|
2164
|
+
}
|
|
2165
|
+
function keyOf(collection, id) {
|
|
2166
|
+
return `${collection} ${id}`;
|
|
2167
|
+
}
|
|
2168
|
+
function angleOf(qz, qw) {
|
|
2169
|
+
return 2 * Math.atan2(qz, qw);
|
|
2170
|
+
}
|
|
2171
|
+
var BaseScratch = class {
|
|
2172
|
+
constructor(physics, depth, tickNow) {
|
|
2173
|
+
this.physics = physics;
|
|
2174
|
+
this.depth = depth;
|
|
2175
|
+
this.tickNow = tickNow;
|
|
2176
|
+
}
|
|
2177
|
+
physics;
|
|
2178
|
+
depth;
|
|
2179
|
+
tickNow;
|
|
2180
|
+
bodies = /* @__PURE__ */ new Map();
|
|
2181
|
+
present = /* @__PURE__ */ new Set();
|
|
2182
|
+
pose = emptyPose();
|
|
2183
|
+
view(entry, tick, requested, clamped) {
|
|
2184
|
+
this.sync();
|
|
2185
|
+
this.present.clear();
|
|
2186
|
+
for (let i = 0; i < entry.count; i++) {
|
|
2187
|
+
const key = keyOf(entry.collections[i], entry.ids[i]);
|
|
2188
|
+
const e = this.bodies.get(key);
|
|
2189
|
+
if (!e) continue;
|
|
2190
|
+
poseAt(entry, i, this.pose);
|
|
2191
|
+
this.place(e, this.pose);
|
|
2192
|
+
this.present.add(key);
|
|
2193
|
+
}
|
|
2194
|
+
for (const [key, e] of this.bodies) {
|
|
2195
|
+
if (!this.present.has(key)) this.hide(e);
|
|
2196
|
+
}
|
|
2197
|
+
return this.build(tick, requested, clamped);
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Brings the scratch's body set in step with the live world: a double for every live body that
|
|
2201
|
+
* has none, a rebuild for one whose collider (or part) count changed, and a prune for one that
|
|
2202
|
+
* has been gone longer than the history is deep and so can no longer appear in any entry.
|
|
2203
|
+
*
|
|
2204
|
+
* The prune is conservative rather than exact: `lastLive` only advances when a rewind happens,
|
|
2205
|
+
* so a room that rewinds rarely holds its dead doubles a little longer than it strictly must.
|
|
2206
|
+
* Over-retention is a few hundred bytes; under-retention would be a wrong answer.
|
|
2207
|
+
*/
|
|
2208
|
+
sync() {
|
|
2209
|
+
const now = this.tickNow();
|
|
2210
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2211
|
+
this.physics.eachTrackedBody((collection, id, body) => {
|
|
2212
|
+
const key = keyOf(collection, id);
|
|
2213
|
+
seen.add(key);
|
|
2214
|
+
const parts = this.partsOf(body);
|
|
2215
|
+
const existing = this.bodies.get(key);
|
|
2216
|
+
if (existing && existing.parts === parts) {
|
|
2217
|
+
existing.lastLive = now;
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
if (existing) {
|
|
2221
|
+
this.bodies.delete(key);
|
|
2222
|
+
this.destroy(existing);
|
|
2223
|
+
}
|
|
2224
|
+
const made = this.clone(collection, id, body, parts);
|
|
2225
|
+
if (!made) return;
|
|
2226
|
+
made.lastLive = now;
|
|
2227
|
+
this.bodies.set(key, made);
|
|
2228
|
+
});
|
|
2229
|
+
for (const [key, e] of [...this.bodies]) {
|
|
2230
|
+
if (seen.has(key)) continue;
|
|
2231
|
+
if (now - e.lastLive <= this.depth) continue;
|
|
2232
|
+
this.bodies.delete(key);
|
|
2233
|
+
this.destroy(e);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
function qConj(q) {
|
|
2238
|
+
return { x: -q.x, y: -q.y, z: -q.z, w: q.w };
|
|
2239
|
+
}
|
|
2240
|
+
function qMul(a, b) {
|
|
2241
|
+
return {
|
|
2242
|
+
x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
|
|
2243
|
+
y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
|
|
2244
|
+
z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
|
|
2245
|
+
w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
function qRotate(q, v) {
|
|
2249
|
+
const p = qMul(qMul(q, { x: v.x, y: v.y, z: v.z, w: 0 }), qConj(q));
|
|
2250
|
+
return { x: p.x, y: p.y, z: p.z };
|
|
2251
|
+
}
|
|
2252
|
+
var RapierScratch = class extends BaseScratch {
|
|
2253
|
+
world;
|
|
2254
|
+
rapier;
|
|
2255
|
+
owners = /* @__PURE__ */ new Map();
|
|
2256
|
+
/** Live rigid-body handles the room tracks, so the statics pass knows what to skip. */
|
|
2257
|
+
trackedLive = /* @__PURE__ */ new Set();
|
|
2258
|
+
staticsCloned = false;
|
|
2259
|
+
constructor(physics, depth, tickNow) {
|
|
2260
|
+
super(physics, depth, tickNow);
|
|
2261
|
+
this.rapier = physics.rapier;
|
|
2262
|
+
this.world = new this.rapier.World({ x: 0, y: 0, z: 0 });
|
|
2263
|
+
this.world.timestep = 0;
|
|
2264
|
+
}
|
|
2265
|
+
partsOf(body) {
|
|
2266
|
+
return body.numColliders();
|
|
2267
|
+
}
|
|
2268
|
+
clone(collection, id, body, parts) {
|
|
2269
|
+
const live = body;
|
|
2270
|
+
this.trackedLive.add(live.handle);
|
|
2271
|
+
const made = this.world.createRigidBody(this.rapier.RigidBodyDesc.fixed());
|
|
2272
|
+
const origin = live.translation();
|
|
2273
|
+
const rot = live.rotation();
|
|
2274
|
+
const inv = qConj(rot);
|
|
2275
|
+
for (let i = 0; i < parts; i++) {
|
|
2276
|
+
const c = live.collider(i);
|
|
2277
|
+
const desc = new this.rapier.ColliderDesc(c.shape);
|
|
2278
|
+
desc.setSensor(c.isSensor());
|
|
2279
|
+
const w = c.translation();
|
|
2280
|
+
const local = qRotate(inv, { x: w.x - origin.x, y: w.y - origin.y, z: w.z - origin.z });
|
|
2281
|
+
desc.setTranslation(local.x, local.y, local.z);
|
|
2282
|
+
desc.setRotation(qMul(inv, c.rotation()));
|
|
2283
|
+
this.world.createCollider(desc, made);
|
|
2284
|
+
}
|
|
2285
|
+
this.owners.set(made.handle, { collection, id });
|
|
2286
|
+
return { collection, id, body: made, parts, lastLive: -1 };
|
|
2287
|
+
}
|
|
2288
|
+
place(entry, pose) {
|
|
2289
|
+
const b = entry.body;
|
|
2290
|
+
if (!b.isEnabled()) b.setEnabled(true);
|
|
2291
|
+
b.setTranslation({ x: pose.x, y: pose.y, z: pose.z }, false);
|
|
2292
|
+
b.setRotation({ x: pose.qx, y: pose.qy, z: pose.qz, w: pose.qw }, false);
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* A body the requested tick did not have. `setEnabled(false)` takes the body and its colliders
|
|
2296
|
+
* out of the broad phase, so a query cannot hit it, which is the whole point: a body created
|
|
2297
|
+
* after the tick the shooter was looking at was not on their screen and must not be hittable.
|
|
2298
|
+
*/
|
|
2299
|
+
hide(entry) {
|
|
2300
|
+
if (entry.body.isEnabled()) entry.body.setEnabled(false);
|
|
2301
|
+
}
|
|
2302
|
+
destroy(entry) {
|
|
2303
|
+
this.owners.delete(entry.body.handle);
|
|
2304
|
+
this.world.removeRigidBody(entry.body);
|
|
2305
|
+
}
|
|
2306
|
+
build(tick, requested, clamped) {
|
|
2307
|
+
this.cloneStatics();
|
|
2308
|
+
this.world.step();
|
|
2309
|
+
const owners = this.owners;
|
|
2310
|
+
return {
|
|
2311
|
+
tick,
|
|
2312
|
+
requested,
|
|
2313
|
+
clamped,
|
|
2314
|
+
rapier: {
|
|
2315
|
+
world: this.world,
|
|
2316
|
+
who: (collider) => {
|
|
2317
|
+
const parent = collider.parent();
|
|
2318
|
+
return parent ? owners.get(parent.handle) : void 0;
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
/**
|
|
2324
|
+
* The live world's static geometry, everything `physics.setup` built, copied in once at the
|
|
2325
|
+
* first rewind. It is "as it stands now" rather than "as it stood then": static colliders do not
|
|
2326
|
+
* move, and a room that moves one has told the engine something that is not true.
|
|
2327
|
+
*/
|
|
2328
|
+
cloneStatics() {
|
|
2329
|
+
if (this.staticsCloned) return;
|
|
2330
|
+
this.staticsCloned = true;
|
|
2331
|
+
const live = this.physics.world;
|
|
2332
|
+
if (!live) return;
|
|
2333
|
+
live.forEachCollider((c) => {
|
|
2334
|
+
const parent = c.parent();
|
|
2335
|
+
if (parent !== null && (!parent.isFixed() || this.trackedLive.has(parent.handle))) return;
|
|
2336
|
+
const desc = new this.rapier.ColliderDesc(c.shape);
|
|
2337
|
+
desc.setSensor(c.isSensor());
|
|
2338
|
+
const t = c.translation();
|
|
2339
|
+
desc.setTranslation(t.x, t.y, t.z);
|
|
2340
|
+
desc.setRotation(c.rotation());
|
|
2341
|
+
this.world.createCollider(desc);
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
free() {
|
|
2345
|
+
this.owners.clear();
|
|
2346
|
+
this.world.free();
|
|
2347
|
+
}
|
|
2348
|
+
};
|
|
2349
|
+
var MatterScratch = class extends BaseScratch {
|
|
2350
|
+
matter;
|
|
2351
|
+
owners = /* @__PURE__ */ new Map();
|
|
2352
|
+
/** Rebuilt per rewind: the doubles the requested tick actually had, plus the live statics. */
|
|
2353
|
+
visible = [];
|
|
2354
|
+
constructor(physics, depth, tickNow) {
|
|
2355
|
+
super(physics, depth, tickNow);
|
|
2356
|
+
this.matter = physics.matter;
|
|
2357
|
+
}
|
|
2358
|
+
partsOf(body) {
|
|
2359
|
+
return body.parts.length;
|
|
2360
|
+
}
|
|
2361
|
+
clone(collection, id, body, parts) {
|
|
2362
|
+
const M = this.matter;
|
|
2363
|
+
const live = body;
|
|
2364
|
+
const made = live.parts.length > 1 ? M.Body.create({ parts: live.parts.slice(1).map((p) => this.clonePart(p, live.angle)) }) : this.clonePart(live, live.angle);
|
|
2365
|
+
M.Body.setPosition(made, { x: 0, y: 0 });
|
|
2366
|
+
made.angle = 0;
|
|
2367
|
+
for (const p of made.parts) p.angle = 0;
|
|
2368
|
+
this.owners.set(made, { collection, id });
|
|
2369
|
+
return { collection, id, body: made, parts, lastLive: -1 };
|
|
2370
|
+
}
|
|
2371
|
+
/** One part, un-rotated by the parent's angle so the double starts at angle 0. */
|
|
2372
|
+
clonePart(part, parentAngle) {
|
|
2373
|
+
const M = this.matter;
|
|
2374
|
+
const verts = part.vertices.map((v) => ({ x: v.x, y: v.y }));
|
|
2375
|
+
if (parentAngle !== 0) M.Vertices.rotate(verts, -parentAngle, part.position);
|
|
2376
|
+
const made = M.Body.create({});
|
|
2377
|
+
M.Body.setVertices(made, verts);
|
|
2378
|
+
return made;
|
|
2379
|
+
}
|
|
2380
|
+
place(entry, pose) {
|
|
2381
|
+
const M = this.matter;
|
|
2382
|
+
M.Body.setAngle(entry.body, angleOf(pose.qz, pose.qw));
|
|
2383
|
+
M.Body.setPosition(entry.body, { x: pose.x, y: pose.y });
|
|
2384
|
+
this.visible.push(entry.body);
|
|
2385
|
+
}
|
|
2386
|
+
/** Nothing to undo: `visible` is rebuilt per rewind, so a body nobody placed is not in it. */
|
|
2387
|
+
hide() {
|
|
2388
|
+
}
|
|
2389
|
+
destroy(entry) {
|
|
2390
|
+
this.owners.delete(entry.body);
|
|
2391
|
+
}
|
|
2392
|
+
view(entry, tick, requested, clamped) {
|
|
2393
|
+
this.visible.length = 0;
|
|
2394
|
+
return super.view(entry, tick, requested, clamped);
|
|
2395
|
+
}
|
|
2396
|
+
build(tick, requested, clamped) {
|
|
2397
|
+
const M = this.matter;
|
|
2398
|
+
const engine3 = this.physics.matterEngine;
|
|
2399
|
+
if (engine3) {
|
|
2400
|
+
for (const b of M.Composite.allBodies(engine3.world)) {
|
|
2401
|
+
if (b.isStatic) this.visible.push(b);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
const owners = this.owners;
|
|
2405
|
+
return {
|
|
2406
|
+
tick,
|
|
2407
|
+
requested,
|
|
2408
|
+
clamped,
|
|
2409
|
+
matter: {
|
|
2410
|
+
bodies: this.visible,
|
|
2411
|
+
who: (body) => owners.get(body)
|
|
2412
|
+
}
|
|
2413
|
+
};
|
|
1482
2414
|
}
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
2415
|
+
free() {
|
|
2416
|
+
this.owners.clear();
|
|
2417
|
+
this.visible.length = 0;
|
|
2418
|
+
}
|
|
2419
|
+
};
|
|
2420
|
+
var RewindState = class {
|
|
2421
|
+
history;
|
|
2422
|
+
scratch;
|
|
2423
|
+
inside = false;
|
|
2424
|
+
constructor(depth) {
|
|
2425
|
+
this.history = new PoseHistory(depth);
|
|
2426
|
+
}
|
|
2427
|
+
free() {
|
|
2428
|
+
this.scratch?.free();
|
|
2429
|
+
this.scratch = void 0;
|
|
2430
|
+
}
|
|
2431
|
+
run(physics, tickNow, requested, fn) {
|
|
2432
|
+
if (this.inside) {
|
|
2433
|
+
throw new Error(
|
|
2434
|
+
"room.rewind: already inside a rewind. The second call would repose the same scratch world under the query still reading it, so it is refused rather than answered wrongly."
|
|
2435
|
+
);
|
|
1490
2436
|
}
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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;
|
|
2437
|
+
const resolved = this.history.resolve(requested);
|
|
2438
|
+
if (!resolved) {
|
|
2439
|
+
throw new Error(
|
|
2440
|
+
`room.rewind: this room has recorded no ticks yet, so there is no past to answer from. A woken room starts with an empty history and fills it over its next ${this.history.depth} tick(s).`
|
|
2441
|
+
);
|
|
1507
2442
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
2443
|
+
this.scratch ??= physics.engineKind === "rapier3d" ? new RapierScratch(physics, this.history.depth, tickNow) : new MatterScratch(physics, this.history.depth, tickNow);
|
|
2444
|
+
const view = this.scratch.view(resolved.entry, resolved.tick, requested, resolved.clamped);
|
|
2445
|
+
this.inside = true;
|
|
2446
|
+
try {
|
|
2447
|
+
return fn(view);
|
|
2448
|
+
} finally {
|
|
2449
|
+
this.inside = false;
|
|
1510
2450
|
}
|
|
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
2451
|
}
|
|
1538
|
-
}
|
|
2452
|
+
};
|
|
1539
2453
|
|
|
1540
2454
|
// src/core/messages.ts
|
|
1541
2455
|
import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
|
|
2456
|
+
import { decodeFields as decodeFields2 } from "@irtio/schema";
|
|
1542
2457
|
function toRoomTarget(target) {
|
|
1543
2458
|
switch (target.kind) {
|
|
1544
2459
|
case "all":
|
|
@@ -1549,6 +2464,8 @@ function toRoomTarget(target) {
|
|
|
1549
2464
|
return { role: target.role };
|
|
1550
2465
|
case "server":
|
|
1551
2466
|
return "server";
|
|
2467
|
+
case "voice":
|
|
2468
|
+
return void 0;
|
|
1552
2469
|
}
|
|
1553
2470
|
}
|
|
1554
2471
|
function deliver(core, target, frame, exclude) {
|
|
@@ -1557,7 +2474,28 @@ function deliver(core, target, frame, exclude) {
|
|
|
1557
2474
|
if (entry.clientId === exclude) continue;
|
|
1558
2475
|
if (target.kind === "client" && entry.clientId !== target.clientId) continue;
|
|
1559
2476
|
if (target.kind === "role" && entry.role !== target.role) continue;
|
|
1560
|
-
core.send(entry.clientId, frame);
|
|
2477
|
+
core.send(entry.clientId, frame.slice());
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
function messagesOf(core) {
|
|
2481
|
+
return core.definition.schema.messages ?? [];
|
|
2482
|
+
}
|
|
2483
|
+
function decodeTyped(core, clientId, index, payload) {
|
|
2484
|
+
const desc = messagesOf(core)[index];
|
|
2485
|
+
if (!desc) {
|
|
2486
|
+
core.stats.messagesDropped++;
|
|
2487
|
+
core.log(
|
|
2488
|
+
"warn",
|
|
2489
|
+
`typed MSG from ${clientId}: no message with index ${index} in this schema; dropped`
|
|
2490
|
+
);
|
|
2491
|
+
return void 0;
|
|
2492
|
+
}
|
|
2493
|
+
try {
|
|
2494
|
+
return { name: desc.name, value: decodeFields2(desc.fields, payload) };
|
|
2495
|
+
} catch (err) {
|
|
2496
|
+
core.stats.messagesDropped++;
|
|
2497
|
+
core.log("warn", `typed MSG ${desc.name} from ${clientId} failed to decode:`, err);
|
|
2498
|
+
return void 0;
|
|
1561
2499
|
}
|
|
1562
2500
|
}
|
|
1563
2501
|
function handleMsg(core, clientId, payload) {
|
|
@@ -1569,40 +2507,68 @@ function handleMsg(core, clientId, payload) {
|
|
|
1569
2507
|
core.log("warn", `MSG from ${clientId} failed to decode:`, err);
|
|
1570
2508
|
return false;
|
|
1571
2509
|
}
|
|
2510
|
+
if (msg.target.kind === "voice") {
|
|
2511
|
+
core.log("warn", `voice MSG from ${clientId} reached room code; dropped (supervisor bug)`);
|
|
2512
|
+
return true;
|
|
2513
|
+
}
|
|
2514
|
+
if (msg.typed && msg.target.kind === "server") {
|
|
2515
|
+
core.stats.messagesDropped++;
|
|
2516
|
+
core.log("warn", `typed MSG from ${clientId} addressed the server; dropped`);
|
|
2517
|
+
return true;
|
|
2518
|
+
}
|
|
2519
|
+
const roomTarget = toRoomTarget(msg.target);
|
|
2520
|
+
if (roomTarget === void 0) return true;
|
|
2521
|
+
let typed;
|
|
2522
|
+
if (msg.typed) {
|
|
2523
|
+
typed = decodeTyped(core, clientId, msg.typed.index, msg.payload);
|
|
2524
|
+
if (!typed) return true;
|
|
2525
|
+
}
|
|
1572
2526
|
const onMessage = core.definition.config.onMessage;
|
|
1573
2527
|
if (onMessage) {
|
|
1574
2528
|
const ran = core.tryRun(
|
|
1575
2529
|
"onMessage",
|
|
1576
|
-
() => onMessage(
|
|
1577
|
-
core.anyState,
|
|
1578
|
-
clientId,
|
|
1579
|
-
toRoomTarget(msg.target),
|
|
1580
|
-
msg.payload,
|
|
1581
|
-
core.ctxFor(clientId)
|
|
1582
|
-
)
|
|
2530
|
+
() => onMessage(core.anyState, clientId, roomTarget, msg.payload, core.ctxFor(clientId), typed)
|
|
1583
2531
|
);
|
|
1584
2532
|
if (!ran.ok || ran.value === false) return true;
|
|
1585
2533
|
}
|
|
1586
2534
|
if (msg.target.kind === "server") return true;
|
|
1587
2535
|
const frame = encodeFrame2(
|
|
1588
2536
|
FrameType3.MSG,
|
|
1589
|
-
encodeMsg({
|
|
2537
|
+
encodeMsg({
|
|
2538
|
+
target: { kind: "client", clientId },
|
|
2539
|
+
payload: msg.payload,
|
|
2540
|
+
...msg.typed ? { typed: msg.typed } : {}
|
|
2541
|
+
})
|
|
1590
2542
|
);
|
|
1591
2543
|
deliver(core, msg.target, frame, clientId);
|
|
1592
2544
|
return true;
|
|
1593
2545
|
}
|
|
2546
|
+
function wireTargetOf(target) {
|
|
2547
|
+
return target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
|
|
2548
|
+
}
|
|
1594
2549
|
function sendMessage(core, target, bytes) {
|
|
1595
|
-
const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
|
|
1596
2550
|
const frame = encodeFrame2(
|
|
1597
2551
|
FrameType3.MSG,
|
|
1598
2552
|
encodeMsg({ target: { kind: "server" }, payload: bytes })
|
|
1599
2553
|
);
|
|
1600
|
-
deliver(core,
|
|
2554
|
+
deliver(core, wireTargetOf(target), frame);
|
|
2555
|
+
}
|
|
2556
|
+
function sendTypedMessage(core, index, target, payload) {
|
|
2557
|
+
const frame = encodeFrame2(
|
|
2558
|
+
FrameType3.MSG,
|
|
2559
|
+
encodeMsg({ target: { kind: "server" }, payload, typed: { index } })
|
|
2560
|
+
);
|
|
2561
|
+
deliver(core, wireTargetOf(target), frame);
|
|
1601
2562
|
}
|
|
1602
2563
|
|
|
1603
2564
|
// src/core/room-api.ts
|
|
1604
|
-
import {
|
|
1605
|
-
|
|
2565
|
+
import {
|
|
2566
|
+
FrameType as FrameType4,
|
|
2567
|
+
busChannelProblem,
|
|
2568
|
+
busPayloadProblem,
|
|
2569
|
+
encodeFrame as encodeFrame3
|
|
2570
|
+
} from "@irtio/protocol";
|
|
2571
|
+
import { encodeDelta as encodeDelta3, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
|
|
1606
2572
|
function makePhysicsApi(p) {
|
|
1607
2573
|
return {
|
|
1608
2574
|
get rapier() {
|
|
@@ -1619,6 +2585,22 @@ function makePhysicsApi(p) {
|
|
|
1619
2585
|
}
|
|
1620
2586
|
};
|
|
1621
2587
|
}
|
|
2588
|
+
function makeMatterApi(p) {
|
|
2589
|
+
return {
|
|
2590
|
+
get matter() {
|
|
2591
|
+
return p.matter;
|
|
2592
|
+
},
|
|
2593
|
+
get engine() {
|
|
2594
|
+
return p.matterEngine;
|
|
2595
|
+
},
|
|
2596
|
+
get timestep() {
|
|
2597
|
+
return p.timestep;
|
|
2598
|
+
},
|
|
2599
|
+
body(collection, id) {
|
|
2600
|
+
return p.bodyFor(collection, id);
|
|
2601
|
+
}
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
1622
2604
|
function makeKv(core) {
|
|
1623
2605
|
return {
|
|
1624
2606
|
get(playerId, key) {
|
|
@@ -1632,15 +2614,126 @@ function makeKv(core) {
|
|
|
1632
2614
|
}
|
|
1633
2615
|
};
|
|
1634
2616
|
}
|
|
2617
|
+
function makeLeaderboard(core) {
|
|
2618
|
+
return {
|
|
2619
|
+
submit(board, playerId, score, options) {
|
|
2620
|
+
if (!Number.isInteger(score)) {
|
|
2621
|
+
return rejectAsEvent(
|
|
2622
|
+
core,
|
|
2623
|
+
new Error(
|
|
2624
|
+
`room.leaderboard.submit: score must be a whole number, got ${String(score)} (leaderboards are integer-only, by decision)`
|
|
2625
|
+
)
|
|
2626
|
+
);
|
|
2627
|
+
}
|
|
2628
|
+
return startHostCall(
|
|
2629
|
+
core,
|
|
2630
|
+
{
|
|
2631
|
+
kind: "lbSubmit",
|
|
2632
|
+
board,
|
|
2633
|
+
playerId,
|
|
2634
|
+
score,
|
|
2635
|
+
...options?.bucket === void 0 ? {} : { bucket: options.bucket }
|
|
2636
|
+
},
|
|
2637
|
+
() => void 0
|
|
2638
|
+
);
|
|
2639
|
+
}
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
function makeRatings(core) {
|
|
2643
|
+
return {
|
|
2644
|
+
report(queue, results) {
|
|
2645
|
+
return startHostCall(
|
|
2646
|
+
core,
|
|
2647
|
+
{
|
|
2648
|
+
kind: "ratingReport",
|
|
2649
|
+
queue,
|
|
2650
|
+
// Copied rather than passed through: `results` is the game's own array and the host call
|
|
2651
|
+
// crosses a worker boundary, so a room that mutated it after calling would otherwise be
|
|
2652
|
+
// reporting something it did not say.
|
|
2653
|
+
results: (Array.isArray(results) ? results : []).map((r) => ({
|
|
2654
|
+
playerId: String(r?.playerId),
|
|
2655
|
+
place: Number(r?.place)
|
|
2656
|
+
}))
|
|
2657
|
+
},
|
|
2658
|
+
() => void 0
|
|
2659
|
+
);
|
|
2660
|
+
},
|
|
2661
|
+
set(queue, playerId, value) {
|
|
2662
|
+
return startHostCall(
|
|
2663
|
+
core,
|
|
2664
|
+
{
|
|
2665
|
+
kind: "ratingSet",
|
|
2666
|
+
queue,
|
|
2667
|
+
playerId,
|
|
2668
|
+
rating: Number(value?.rating),
|
|
2669
|
+
...value?.deviation !== void 0 ? { deviation: Number(value.deviation) } : {}
|
|
2670
|
+
},
|
|
2671
|
+
() => void 0
|
|
2672
|
+
);
|
|
2673
|
+
}
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2676
|
+
function makeBus(core) {
|
|
2677
|
+
const refuse = (verb, problem) => {
|
|
2678
|
+
core.log("warn", `room.bus.${verb}: ${problem.code}: ${problem.message}`);
|
|
2679
|
+
};
|
|
2680
|
+
const setSubscribed = (verb, channel) => {
|
|
2681
|
+
const problem = busChannelProblem(channel);
|
|
2682
|
+
if (problem) {
|
|
2683
|
+
refuse(verb, problem);
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
core.host.busSubscribe(channel, verb === "subscribe");
|
|
2687
|
+
};
|
|
2688
|
+
return {
|
|
2689
|
+
publish(channel, payload) {
|
|
2690
|
+
const problem = busChannelProblem(channel) ?? busPayloadProblem(payload);
|
|
2691
|
+
if (problem) {
|
|
2692
|
+
refuse("publish", problem);
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2695
|
+
core.host.busPublish(channel, payload);
|
|
2696
|
+
},
|
|
2697
|
+
subscribe(channel) {
|
|
2698
|
+
setSubscribed("subscribe", channel);
|
|
2699
|
+
},
|
|
2700
|
+
unsubscribe(channel) {
|
|
2701
|
+
setSubscribed("unsubscribe", channel);
|
|
2702
|
+
},
|
|
2703
|
+
send(roomId, payload) {
|
|
2704
|
+
if (typeof roomId !== "string" || roomId === "") {
|
|
2705
|
+
return rejectAsEvent(core, new Error("room.bus.send: a target roomId is required"));
|
|
2706
|
+
}
|
|
2707
|
+
const problem = busPayloadProblem(payload);
|
|
2708
|
+
if (problem) return rejectAsEvent(core, new Error(`${problem.code}: ${problem.message}`));
|
|
2709
|
+
return startHostCall(core, { kind: "busSend", roomId, payload }, () => void 0);
|
|
2710
|
+
}
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
1635
2713
|
function createRoomApi(core, publicUrl) {
|
|
1636
2714
|
let cached;
|
|
2715
|
+
let npcCounter = 0;
|
|
1637
2716
|
let lastNow = Number.NEGATIVE_INFINITY;
|
|
1638
2717
|
let physicsApi;
|
|
2718
|
+
let matterApi;
|
|
1639
2719
|
let kvApi;
|
|
2720
|
+
let busApi;
|
|
2721
|
+
let leaderboardApi;
|
|
2722
|
+
let ratingsApi;
|
|
2723
|
+
const declaredBackfill = core.definition.config.backfill === true;
|
|
2724
|
+
let backfillOpen = declaredBackfill;
|
|
1640
2725
|
const link = () => {
|
|
1641
2726
|
const sep = publicUrl.includes("?") ? "&" : "?";
|
|
1642
2727
|
return `${publicUrl}${sep}room=${encodeURIComponent(core.roomId)}`;
|
|
1643
2728
|
};
|
|
2729
|
+
const messages = {};
|
|
2730
|
+
for (const desc of core.definition.schema.messages ?? []) {
|
|
2731
|
+
messages[desc.name] = {
|
|
2732
|
+
send(target, value) {
|
|
2733
|
+
sendTypedMessage(core, desc.index, target, encodeFields2(desc.fields, value));
|
|
2734
|
+
}
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
1644
2737
|
const room = {
|
|
1645
2738
|
get id() {
|
|
1646
2739
|
return core.roomId;
|
|
@@ -1662,7 +2755,8 @@ function createRoomApi(core, publicUrl) {
|
|
|
1662
2755
|
clientId: c.clientId,
|
|
1663
2756
|
role: c.role,
|
|
1664
2757
|
name: c.name,
|
|
1665
|
-
connected: c.connected
|
|
2758
|
+
connected: c.connected,
|
|
2759
|
+
npc: c.npc
|
|
1666
2760
|
}));
|
|
1667
2761
|
}
|
|
1668
2762
|
return cached;
|
|
@@ -1681,11 +2775,38 @@ function createRoomApi(core, publicUrl) {
|
|
|
1681
2775
|
"room.physics: this room has no physics \u2014 add physics: { engine: 'rapier3d', gravity, bodies } to defineRoom(...)"
|
|
1682
2776
|
);
|
|
1683
2777
|
}
|
|
2778
|
+
if (p.engineKind !== "rapier3d") {
|
|
2779
|
+
throw new Error(
|
|
2780
|
+
"room.physics: this room runs matter2d. Read room.physics2d instead (it has .matter and .engine, where this one has .rapier and .world)"
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
1684
2783
|
return physicsApi ?? (physicsApi = makePhysicsApi(p));
|
|
1685
2784
|
},
|
|
2785
|
+
get physics2d() {
|
|
2786
|
+
const p = core.physics;
|
|
2787
|
+
if (!p) {
|
|
2788
|
+
throw new Error(
|
|
2789
|
+
"room.physics2d: this room has no physics \u2014 add physics: { engine: 'matter2d', gravity, bodies } to defineRoom(...)"
|
|
2790
|
+
);
|
|
2791
|
+
}
|
|
2792
|
+
if (p.engineKind !== "matter2d") {
|
|
2793
|
+
throw new Error(
|
|
2794
|
+
"room.physics2d: this room runs rapier3d. Read room.physics instead (it has .rapier and .world, where this one has .matter and .engine)"
|
|
2795
|
+
);
|
|
2796
|
+
}
|
|
2797
|
+
return matterApi ?? (matterApi = makeMatterApi(p));
|
|
2798
|
+
},
|
|
2799
|
+
// ---- M6 lane F: rewind ----
|
|
2800
|
+
// D72: one line, and deliberately only one. Everything the rewind does — the buffer, the
|
|
2801
|
+
// clamping, the scratch worlds, the reentrancy refusal — lives in `core/history.ts` behind
|
|
2802
|
+
// `RoomCore.rewind`, so this file stays what it says it is: a facade that reads through.
|
|
2803
|
+
rewind(tick, fn) {
|
|
2804
|
+
return core.rewind(tick, fn);
|
|
2805
|
+
},
|
|
1686
2806
|
send(target, bytes) {
|
|
1687
2807
|
sendMessage(core, target, bytes);
|
|
1688
2808
|
},
|
|
2809
|
+
messages,
|
|
1689
2810
|
setRole(clientId, role) {
|
|
1690
2811
|
setRole(core, clientId, role);
|
|
1691
2812
|
cached = void 0;
|
|
@@ -1724,6 +2845,33 @@ function createRoomApi(core, publicUrl) {
|
|
|
1724
2845
|
get kv() {
|
|
1725
2846
|
return kvApi ?? (kvApi = makeKv(core));
|
|
1726
2847
|
},
|
|
2848
|
+
get leaderboard() {
|
|
2849
|
+
return leaderboardApi ?? (leaderboardApi = makeLeaderboard(core));
|
|
2850
|
+
},
|
|
2851
|
+
get ratings() {
|
|
2852
|
+
return ratingsApi ?? (ratingsApi = makeRatings(core));
|
|
2853
|
+
},
|
|
2854
|
+
get backfill() {
|
|
2855
|
+
return {
|
|
2856
|
+
set(open) {
|
|
2857
|
+
if (!declaredBackfill) {
|
|
2858
|
+
core.log(
|
|
2859
|
+
"warn",
|
|
2860
|
+
"room.backfill.set: this room type did not declare `backfill: true`, so the matchmaker will never offer it. The call is ignored."
|
|
2861
|
+
);
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
backfillOpen = open;
|
|
2865
|
+
core.host.setBackfill(open);
|
|
2866
|
+
},
|
|
2867
|
+
get open() {
|
|
2868
|
+
return backfillOpen;
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
},
|
|
2872
|
+
get bus() {
|
|
2873
|
+
return busApi ?? (busApi = makeBus(core));
|
|
2874
|
+
},
|
|
1727
2875
|
alarm(name, atMs) {
|
|
1728
2876
|
if (!isAlarmName(core, name, "room.alarm")) return;
|
|
1729
2877
|
if (!Number.isFinite(atMs)) {
|
|
@@ -1739,6 +2887,27 @@ function createRoomApi(core, publicUrl) {
|
|
|
1739
2887
|
}
|
|
1740
2888
|
core.host.setAlarm(name, void 0);
|
|
1741
2889
|
},
|
|
2890
|
+
spawnNPC(config) {
|
|
2891
|
+
const clientId = `npc-${core.roomId}-${++npcCounter}`;
|
|
2892
|
+
const checked = checkNpcConfig(core, config);
|
|
2893
|
+
if (checked) core.host.spawnNpc(clientId, checked);
|
|
2894
|
+
let despawned = !checked;
|
|
2895
|
+
return {
|
|
2896
|
+
clientId,
|
|
2897
|
+
despawn() {
|
|
2898
|
+
if (despawned) return;
|
|
2899
|
+
despawned = true;
|
|
2900
|
+
core.host.despawnNpc(clientId);
|
|
2901
|
+
}
|
|
2902
|
+
};
|
|
2903
|
+
},
|
|
2904
|
+
despawnNPC(clientId) {
|
|
2905
|
+
if (typeof clientId !== "string" || clientId === "") {
|
|
2906
|
+
core.log("warn", "room.despawnNPC: a client id is required; ignored");
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
core.host.despawnNpc(clientId);
|
|
2910
|
+
},
|
|
1742
2911
|
call(clientId) {
|
|
1743
2912
|
return createCallProxy(core, clientId);
|
|
1744
2913
|
},
|
|
@@ -1793,6 +2962,50 @@ function setRole(core, clientId, role) {
|
|
|
1793
2962
|
if (presence) presence.role = role;
|
|
1794
2963
|
core.invalidateClients();
|
|
1795
2964
|
}
|
|
2965
|
+
function checkNpcConfig(core, config) {
|
|
2966
|
+
if (typeof config !== "object" || config === null) {
|
|
2967
|
+
throw new Error("room.spawnNPC: a config is required, like " + NPC_EXAMPLE);
|
|
2968
|
+
}
|
|
2969
|
+
const brain = config.brain;
|
|
2970
|
+
if (typeof brain !== "object" || brain === null) {
|
|
2971
|
+
throw new Error("room.spawnNPC: config.brain is required, like " + NPC_EXAMPLE);
|
|
2972
|
+
}
|
|
2973
|
+
if (brain.kind !== "script") {
|
|
2974
|
+
throw new Error(
|
|
2975
|
+
`room.spawnNPC: brain.kind ${JSON.stringify(brain.kind)} is not supported. The only brain this version runs is { kind: 'script', script: '<a key of the room's npcs map>' }.`
|
|
2976
|
+
);
|
|
2977
|
+
}
|
|
2978
|
+
if (typeof brain.script !== "string" || brain.script === "") {
|
|
2979
|
+
throw new Error(
|
|
2980
|
+
"room.spawnNPC: brain.script must name an entry in the room definition's npcs map, like " + NPC_EXAMPLE
|
|
2981
|
+
);
|
|
2982
|
+
}
|
|
2983
|
+
const npcs = core.definition.config.npcs;
|
|
2984
|
+
if (!npcs || typeof npcs[brain.script] !== "function") {
|
|
2985
|
+
const known = npcs ? Object.keys(npcs) : [];
|
|
2986
|
+
throw new Error(
|
|
2987
|
+
`room.spawnNPC: no npc script named ${JSON.stringify(brain.script)}. ` + (known.length > 0 ? `This room defines ${known.join(", ")}.` : "This room's defineRoom(...) has no npcs: { \u2026 } map.")
|
|
2988
|
+
);
|
|
2989
|
+
}
|
|
2990
|
+
if (config.role !== void 0 && typeof config.role !== "string") {
|
|
2991
|
+
throw new Error("room.spawnNPC: role must be a string when given");
|
|
2992
|
+
}
|
|
2993
|
+
if (config.name !== void 0 && typeof config.name !== "string") {
|
|
2994
|
+
throw new Error("room.spawnNPC: name must be a string when given");
|
|
2995
|
+
}
|
|
2996
|
+
if (config.seed !== void 0 && !Number.isFinite(config.seed)) {
|
|
2997
|
+
throw new Error("room.spawnNPC: seed must be a finite number when given");
|
|
2998
|
+
}
|
|
2999
|
+
return {
|
|
3000
|
+
brain: { kind: "script", script: brain.script },
|
|
3001
|
+
...config.role !== void 0 ? { role: config.role } : {},
|
|
3002
|
+
...config.name !== void 0 ? { name: config.name } : {},
|
|
3003
|
+
// Seeded from the room's own recorded rng when the caller did not pick one, so a room that
|
|
3004
|
+
// spawns its NPCs the same way twice gets the same NPCs twice.
|
|
3005
|
+
seed: config.seed ?? Math.floor(core.rng.next() * 2147483647)
|
|
3006
|
+
};
|
|
3007
|
+
}
|
|
3008
|
+
var NPC_EXAMPLE = "{ brain: { kind: 'script', script: 'chaser' } }";
|
|
1796
3009
|
|
|
1797
3010
|
// src/core/room.ts
|
|
1798
3011
|
var DEFAULT_PUBLIC_URL = "http://localhost/";
|
|
@@ -1839,7 +3052,8 @@ var RoomCore = class _RoomCore {
|
|
|
1839
3052
|
visibleIdsTotal: 0,
|
|
1840
3053
|
membershipEnters: 0,
|
|
1841
3054
|
membershipLeaves: 0,
|
|
1842
|
-
corrections: 0
|
|
3055
|
+
corrections: 0,
|
|
3056
|
+
messagesDropped: 0
|
|
1843
3057
|
};
|
|
1844
3058
|
tick = 0;
|
|
1845
3059
|
stopped = false;
|
|
@@ -1850,6 +3064,24 @@ var RoomCore = class _RoomCore {
|
|
|
1850
3064
|
* into a timeout in an unrelated assertion ten seconds later. Unset in production.
|
|
1851
3065
|
*/
|
|
1852
3066
|
onHandlerError;
|
|
3067
|
+
/**
|
|
3068
|
+
* D41: the recorded authoritative timeline. Armed by `startRecording()` and off otherwise, so a
|
|
3069
|
+
* room nobody asked to record pays nothing. A room that was asked captures its own state at the
|
|
3070
|
+
* end of every tick, which is the only moment in a tick where that state is settled.
|
|
3071
|
+
*/
|
|
3072
|
+
recorder;
|
|
3073
|
+
/**
|
|
3074
|
+
* D65: the bandwidth ledger, present only when `RoomCoreOptions.profile` asked for one. Every
|
|
3075
|
+
* cost the profiler has is behind this `undefined`.
|
|
3076
|
+
*/
|
|
3077
|
+
ledger;
|
|
3078
|
+
/**
|
|
3079
|
+
* D72: the pose history and the rewind scratch, present only when the room's physics config
|
|
3080
|
+
* declares `history`. Every cost this lane has is behind this `undefined`, and it is
|
|
3081
|
+
* deliberately not in the hibernation blob: a woken room starts with an empty buffer and fills
|
|
3082
|
+
* it again over its next `history` ticks.
|
|
3083
|
+
*/
|
|
3084
|
+
rewindState;
|
|
1853
3085
|
seed;
|
|
1854
3086
|
api;
|
|
1855
3087
|
internals;
|
|
@@ -1861,7 +3093,8 @@ var RoomCore = class _RoomCore {
|
|
|
1861
3093
|
this.host = host;
|
|
1862
3094
|
this.roomId = options.roomId;
|
|
1863
3095
|
this.mode = definition.config.mode;
|
|
1864
|
-
this.ext =
|
|
3096
|
+
this.ext = withBuiltins2(definition.schema);
|
|
3097
|
+
this.ledger = options.profile === true ? new ProfileLedger(this.ext, definition.schema) : void 0;
|
|
1865
3098
|
for (const issue of validateForDeploy(this.ext)) {
|
|
1866
3099
|
if (issue.level === "error") throw new Error(`RoomCore: ${issue.message}`);
|
|
1867
3100
|
host.log("warn", [`irtio: ${issue.message}`]);
|
|
@@ -1870,7 +3103,7 @@ var RoomCore = class _RoomCore {
|
|
|
1870
3103
|
let plain;
|
|
1871
3104
|
if (options.restoreFrom) {
|
|
1872
3105
|
restored = parseHibernationBlob(options.restoreFrom);
|
|
1873
|
-
plain =
|
|
3106
|
+
plain = decodeSnapshot2(this.ext, restored.snapshot).state;
|
|
1874
3107
|
const presence = plainEntity(plain, PRESENCE_COLLECTION);
|
|
1875
3108
|
for (const id of [...presence.ids()]) presence.remove(id);
|
|
1876
3109
|
if (restored.mode !== this.mode) {
|
|
@@ -1892,6 +3125,9 @@ var RoomCore = class _RoomCore {
|
|
|
1892
3125
|
this.loop = new Loop(self);
|
|
1893
3126
|
this.api = createRoomApi(self, options.publicUrl ?? DEFAULT_PUBLIC_URL);
|
|
1894
3127
|
this.physics = this.buildPhysics(restored);
|
|
3128
|
+
const historyDepth = this.physics ? historyDepthOf(definition.config.physics) : void 0;
|
|
3129
|
+
this.rewindState = historyDepth === void 0 ? void 0 : new RewindState(historyDepth);
|
|
3130
|
+
this.subscribeDeclaredChannels();
|
|
1895
3131
|
if (restored) {
|
|
1896
3132
|
const onWake = definition.config.onWake;
|
|
1897
3133
|
if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
|
|
@@ -1900,6 +3136,64 @@ var RoomCore = class _RoomCore {
|
|
|
1900
3136
|
if (onCreate) this.guard("onCreate", () => onCreate(this.state, this.room));
|
|
1901
3137
|
}
|
|
1902
3138
|
}
|
|
3139
|
+
subscribeDeclaredChannels() {
|
|
3140
|
+
const channels = this.busConfig?.channels;
|
|
3141
|
+
if (!channels) return;
|
|
3142
|
+
for (const channel of Object.keys(channels)) {
|
|
3143
|
+
const problem = busChannelProblem2(channel);
|
|
3144
|
+
if (problem) {
|
|
3145
|
+
this.log("warn", `bus.channels: ${problem.message}; that channel is not subscribed`);
|
|
3146
|
+
continue;
|
|
3147
|
+
}
|
|
3148
|
+
this.host.busSubscribe(channel, true);
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
get busConfig() {
|
|
3152
|
+
return this.definition.config.bus;
|
|
3153
|
+
}
|
|
3154
|
+
/**
|
|
3155
|
+
* D59: one published message arriving on a channel this room is subscribed to.
|
|
3156
|
+
*
|
|
3157
|
+
* Same scheduling class as an alarm or an RPC — a discrete event between ticks — so a tick-mode
|
|
3158
|
+
* room never sees a `tick` run half-delivered. `from` is supervisor-stamped, so the handler may
|
|
3159
|
+
* trust it as far as it trusts its own project.
|
|
3160
|
+
*/
|
|
3161
|
+
deliverBusEvent(channel, from, payload) {
|
|
3162
|
+
if (this.stopped) return;
|
|
3163
|
+
const handler = this.busConfig?.channels?.[channel];
|
|
3164
|
+
if (!handler) {
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
this.recordEvent("bus", void 0, channel);
|
|
3168
|
+
this.guard(
|
|
3169
|
+
`bus.channels.${channel}`,
|
|
3170
|
+
() => handler(this.state, { channel, from, payload }, this.room)
|
|
3171
|
+
);
|
|
3172
|
+
this.eventFlush();
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* D59: one directed `room.bus.send` arriving. At-least-once, so this can run twice for one send;
|
|
3176
|
+
* that is the receiver's problem to be idempotent about and the docs say so.
|
|
3177
|
+
*
|
|
3178
|
+
* A throw propagates through `guard` and counts toward the crash threshold exactly as any other
|
|
3179
|
+
* handler throw does. That is deliberate, and it is why the supervisor bounds redelivery: the
|
|
3180
|
+
* two behaviours together would otherwise let one poisonous message close a room on every wake,
|
|
3181
|
+
* forever.
|
|
3182
|
+
*/
|
|
3183
|
+
deliverBusMessage(from, payload) {
|
|
3184
|
+
if (this.stopped) return;
|
|
3185
|
+
const handler = this.busConfig?.onMessage;
|
|
3186
|
+
if (!handler) {
|
|
3187
|
+
this.log(
|
|
3188
|
+
"warn",
|
|
3189
|
+
`a bus message from ${JSON.stringify(from)} was delivered but the room declares no bus.onMessage handler; it is dropped`
|
|
3190
|
+
);
|
|
3191
|
+
return;
|
|
3192
|
+
}
|
|
3193
|
+
this.recordEvent("bus", void 0, "onMessage");
|
|
3194
|
+
this.guard("bus.onMessage", () => handler(this.state, { from, payload }, this.room));
|
|
3195
|
+
this.eventFlush();
|
|
3196
|
+
}
|
|
1903
3197
|
/**
|
|
1904
3198
|
* D22: builds the world, or returns `undefined` for a room with no `physics:` config.
|
|
1905
3199
|
*
|
|
@@ -1914,14 +3208,15 @@ var RoomCore = class _RoomCore {
|
|
|
1914
3208
|
buildPhysics(restored) {
|
|
1915
3209
|
const config = this.definition.config.physics;
|
|
1916
3210
|
if (!config) return void 0;
|
|
1917
|
-
|
|
1918
|
-
|
|
3211
|
+
if (config.engine === "matter2d") return this.buildMatter(config, restored);
|
|
3212
|
+
const engine3 = loadedPhysics();
|
|
3213
|
+
if (!engine3) {
|
|
1919
3214
|
throw new Error(
|
|
1920
3215
|
"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
3216
|
);
|
|
1922
3217
|
}
|
|
1923
3218
|
const section = restored?.physics ? decodePhysicsSection(restored.physics) : void 0;
|
|
1924
|
-
const physics = new PhysicsRuntime(this.internals,
|
|
3219
|
+
const physics = new PhysicsRuntime(this.internals, engine3, {
|
|
1925
3220
|
...section ? { restore: section } : {},
|
|
1926
3221
|
defaultTimestep: 1 / this.definition.config.tickRate
|
|
1927
3222
|
});
|
|
@@ -1936,6 +3231,43 @@ var RoomCore = class _RoomCore {
|
|
|
1936
3231
|
}
|
|
1937
3232
|
return physics;
|
|
1938
3233
|
}
|
|
3234
|
+
/**
|
|
3235
|
+
* D45: the matter2d half. It differs from Rapier's in one structural way — there is no engine
|
|
3236
|
+
* snapshot to restore, so the world is always **built** and per-body state is reapplied on top
|
|
3237
|
+
* of it. `setup` therefore runs on every wake, and only the "there was no world at all" case is
|
|
3238
|
+
* worth logging.
|
|
3239
|
+
*/
|
|
3240
|
+
buildMatter(config, restored) {
|
|
3241
|
+
void config;
|
|
3242
|
+
const matter = loadedMatter();
|
|
3243
|
+
if (!matter) {
|
|
3244
|
+
throw new Error(
|
|
3245
|
+
"RoomCore: this room declares matter2d physics but matter-js is not loaded. The host must `await initMatter()` (from '@irtio/runtime') before constructing the room; handlers are synchronous, so it cannot be imported later."
|
|
3246
|
+
);
|
|
3247
|
+
}
|
|
3248
|
+
let section;
|
|
3249
|
+
if (restored?.physics) {
|
|
3250
|
+
if (physicsSectionEngine(restored.physics) === "matter2d") {
|
|
3251
|
+
section = decodeMatterBodies(decodeMatterSectionEnvelope(restored.physics));
|
|
3252
|
+
} else {
|
|
3253
|
+
this.host.log("warn", [
|
|
3254
|
+
"irtio: this snapshot carries a rapier3d world and the room now runs matter2d. The world is rebuilt from schema state and physics.setup runs again."
|
|
3255
|
+
]);
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
const physics = new MatterRuntime(this.internals, matter, {
|
|
3259
|
+
...section ? { restore: section } : {},
|
|
3260
|
+
defaultTimestep: 1 / this.definition.config.tickRate
|
|
3261
|
+
});
|
|
3262
|
+
if (physics.rebuilt && restored) {
|
|
3263
|
+
this.host.log("info", [
|
|
3264
|
+
`irtio: no matter2d world in this snapshot (format v${restored.version}). Rebuilding it from schema state and re-running physics.setup; contact state is not restored.`
|
|
3265
|
+
]);
|
|
3266
|
+
}
|
|
3267
|
+
physics.runSetup(this.room);
|
|
3268
|
+
physics.reconcile();
|
|
3269
|
+
return physics;
|
|
3270
|
+
}
|
|
1939
3271
|
/** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
|
|
1940
3272
|
static restore(definition, bytes, host, options) {
|
|
1941
3273
|
return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
|
|
@@ -1983,6 +3315,52 @@ var RoomCore = class _RoomCore {
|
|
|
1983
3315
|
if (detail !== void 0) e.detail = detail;
|
|
1984
3316
|
this.events.push(e);
|
|
1985
3317
|
}
|
|
3318
|
+
/**
|
|
3319
|
+
* D41: begin (or restart) recording the authoritative timeline. Calling it again clears what
|
|
3320
|
+
* was recorded, so two scenario runs against one long-lived dev server do not read each
|
|
3321
|
+
* other's ticks.
|
|
3322
|
+
*/
|
|
3323
|
+
startRecording(options = {}) {
|
|
3324
|
+
this.recorder = new TimelineRecorder(options);
|
|
3325
|
+
this.recorder.capture(this.tick, this.ext, this.plain);
|
|
3326
|
+
}
|
|
3327
|
+
/** D41: what has been recorded so far, or `undefined` when nobody armed the recorder. */
|
|
3328
|
+
recording() {
|
|
3329
|
+
return this.recorder?.dump(this.roomId);
|
|
3330
|
+
}
|
|
3331
|
+
/** D41: called at the end of every tick (and every event-mode flush). No-op when unarmed. */
|
|
3332
|
+
captureTimeline() {
|
|
3333
|
+
this.recorder?.capture(this.tick, this.ext, this.plain);
|
|
3334
|
+
}
|
|
3335
|
+
// ---- M6 lane F: rewind ----
|
|
3336
|
+
/**
|
|
3337
|
+
* D72: record this tick's body poses, right after `physics.sync()` — the poses the clients are
|
|
3338
|
+
* about to be told about, under the tick number they will be told it under, which is the tick a
|
|
3339
|
+
* client later stamps its `CALL` with.
|
|
3340
|
+
*/
|
|
3341
|
+
captureHistory() {
|
|
3342
|
+
const physics = this.physics;
|
|
3343
|
+
if (!physics || !this.rewindState) return;
|
|
3344
|
+
this.rewindState.history.capture(this.tick, physics);
|
|
3345
|
+
}
|
|
3346
|
+
/**
|
|
3347
|
+
* D72: `room.rewind(tick, fn)`. The live world is not touched and nothing is re-simulated; `fn`
|
|
3348
|
+
* queries a scratch world holding every tracked body at its pose at `tick`.
|
|
3349
|
+
*/
|
|
3350
|
+
rewind(tick, fn) {
|
|
3351
|
+
const physics = this.physics;
|
|
3352
|
+
if (!physics) {
|
|
3353
|
+
throw new Error(
|
|
3354
|
+
"room.rewind: this room has no physics, so there are no poses to rewind. Add physics: { engine, gravity, bodies, history: <ticks> } to defineRoom(...)"
|
|
3355
|
+
);
|
|
3356
|
+
}
|
|
3357
|
+
if (!this.rewindState) {
|
|
3358
|
+
throw new Error(
|
|
3359
|
+
"room.rewind: this room declares no physics.history, so no poses are kept. Add history: <ticks> to the physics config; it is off by default because it costs heap per tick. See irt.io/docs/concepts/lag-compensation for how deep to make it."
|
|
3360
|
+
);
|
|
3361
|
+
}
|
|
3362
|
+
return this.rewindState.run(physics, () => this.tick, tick, fn);
|
|
3363
|
+
}
|
|
1986
3364
|
/** Live JSON view of the room for the dev page / supervisor admin API. */
|
|
1987
3365
|
inspect() {
|
|
1988
3366
|
return {
|
|
@@ -2017,6 +3395,7 @@ var RoomCore = class _RoomCore {
|
|
|
2017
3395
|
this.loop.stop();
|
|
2018
3396
|
rejectAllPending(this.internals, "room stopped");
|
|
2019
3397
|
rejectAllHostCalls(this.internals, "room stopped");
|
|
3398
|
+
this.rewindState?.free();
|
|
2020
3399
|
this.physics?.free();
|
|
2021
3400
|
}
|
|
2022
3401
|
/**
|
|
@@ -2031,7 +3410,7 @@ var RoomCore = class _RoomCore {
|
|
|
2031
3410
|
* written.
|
|
2032
3411
|
*/
|
|
2033
3412
|
snapshot() {
|
|
2034
|
-
const physics = this.physics ?
|
|
3413
|
+
const physics = this.physics ? encodeSection(this.physics) : void 0;
|
|
2035
3414
|
return writeHibernationBlob(
|
|
2036
3415
|
{ seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
|
|
2037
3416
|
encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
|
|
@@ -2111,6 +3490,7 @@ var RoomCore = class _RoomCore {
|
|
|
2111
3490
|
// join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
|
|
2112
3491
|
// and nothing else here changes.
|
|
2113
3492
|
playerId: options.playerId ?? clientId,
|
|
3493
|
+
npc: options.npc === true,
|
|
2114
3494
|
role,
|
|
2115
3495
|
name,
|
|
2116
3496
|
connected: true,
|
|
@@ -2131,11 +3511,12 @@ var RoomCore = class _RoomCore {
|
|
|
2131
3511
|
const ctx = this.ctxFor(clientId, reconnecting);
|
|
2132
3512
|
this.guard("onJoin", () => onJoin(this.state, ctx));
|
|
2133
3513
|
}
|
|
2134
|
-
this.loop.noteActivity();
|
|
3514
|
+
if (!entry.npc) this.loop.noteActivity();
|
|
2135
3515
|
this.recordEvent("join", clientId, reconnecting ? "reconnect" : void 0);
|
|
2136
3516
|
const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
|
|
2137
3517
|
entry.spatialMembership = memberships;
|
|
2138
3518
|
const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
|
|
3519
|
+
this.ledger?.attributeSnapshot("out", snapshot);
|
|
2139
3520
|
const pending = /* @__PURE__ */ new Map();
|
|
2140
3521
|
for (const [name2, cd] of this.tracked.dirty) {
|
|
2141
3522
|
if (cd.added.size === 0) continue;
|
|
@@ -2161,6 +3542,7 @@ var RoomCore = class _RoomCore {
|
|
|
2161
3542
|
if (isDirtyEmpty3(this.tracked.dirty)) return;
|
|
2162
3543
|
this.tick++;
|
|
2163
3544
|
this.flush();
|
|
3545
|
+
this.captureTimeline();
|
|
2164
3546
|
}
|
|
2165
3547
|
leave(clientId, reason) {
|
|
2166
3548
|
const entry = this.clients.get(clientId);
|
|
@@ -2203,6 +3585,9 @@ var RoomCore = class _RoomCore {
|
|
|
2203
3585
|
role: entry?.role ?? "",
|
|
2204
3586
|
name: entry?.name ?? "",
|
|
2205
3587
|
tick: this.tick,
|
|
3588
|
+
// D72: only an RPC has a stamp, and `rpc.ts` puts it on the ctx it hands the handler. Every
|
|
3589
|
+
// other entry point (join, leave, write, ownership) has no client tick by construction.
|
|
3590
|
+
clientTick: void 0,
|
|
2206
3591
|
reconnecting,
|
|
2207
3592
|
room: this.room
|
|
2208
3593
|
};
|
|
@@ -2232,6 +3617,10 @@ var RoomCore = class _RoomCore {
|
|
|
2232
3617
|
* Draining a bounded number of turns first covers the chains rooms actually write, coalesces
|
|
2233
3618
|
* concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
|
|
2234
3619
|
* the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
|
|
3620
|
+
*
|
|
3621
|
+
* Public on `RoomInternals` (bug #48) so the room API's local rejections — a float score, an
|
|
3622
|
+
* empty bus target, a call to a client that is not connected — flush the state their `.catch()`
|
|
3623
|
+
* writes, instead of leaving it for whatever frame happens to arrive next.
|
|
2235
3624
|
*/
|
|
2236
3625
|
scheduleContinuationFlush() {
|
|
2237
3626
|
if (this.mode !== "event" || this.continuationFlushPending) return;
|
|
@@ -2276,15 +3665,31 @@ var RoomCore = class _RoomCore {
|
|
|
2276
3665
|
// -------------------------------------------------------------------------
|
|
2277
3666
|
// Frames
|
|
2278
3667
|
// -------------------------------------------------------------------------
|
|
2279
|
-
|
|
3668
|
+
/**
|
|
3669
|
+
* D65: `hint` is profiling context and nothing else — the payload object a per-view frame was
|
|
3670
|
+
* built from (so one encode is walked once and replayed per recipient) and the AOI ids whose
|
|
3671
|
+
* ops are visibility churn. Ignored entirely when this room is not profiling, which is why it
|
|
3672
|
+
* is an optional argument rather than a second method.
|
|
3673
|
+
*/
|
|
3674
|
+
send(clientId, frame, hint) {
|
|
2280
3675
|
if (this.stopped) return;
|
|
2281
3676
|
const entry = this.clients.get(clientId);
|
|
2282
3677
|
if (!entry || !entry.connected) return;
|
|
3678
|
+
const size = frame.length;
|
|
3679
|
+
this.ledger?.attribute("out", frame, { ...hint, peer: clientId });
|
|
2283
3680
|
this.host.send(clientId, frame);
|
|
2284
3681
|
this.stats.framesOut++;
|
|
2285
|
-
this.stats.bytesOut +=
|
|
3682
|
+
this.stats.bytesOut += size;
|
|
2286
3683
|
const by = this.stats.bytesOutByClient;
|
|
2287
|
-
by.set(clientId, (by.get(clientId) ?? 0) +
|
|
3684
|
+
by.set(clientId, (by.get(clientId) ?? 0) + size);
|
|
3685
|
+
}
|
|
3686
|
+
/**
|
|
3687
|
+
* D65: the ledger so far. The room's own view, so it counts what `send()` sent and what
|
|
3688
|
+
* `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
|
|
3689
|
+
* `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
|
|
3690
|
+
*/
|
|
3691
|
+
profile() {
|
|
3692
|
+
return this.ledger?.snapshot();
|
|
2288
3693
|
}
|
|
2289
3694
|
badFrame(clientId, reason) {
|
|
2290
3695
|
this.log("warn", `bad frame from ${clientId}: ${reason}`);
|
|
@@ -2300,7 +3705,8 @@ var RoomCore = class _RoomCore {
|
|
|
2300
3705
|
return;
|
|
2301
3706
|
}
|
|
2302
3707
|
this.stats.framesIn++;
|
|
2303
|
-
this.
|
|
3708
|
+
this.ledger?.attribute("in", frame, { peer: clientId });
|
|
3709
|
+
if (this.clients.get(clientId)?.npc !== true) this.loop.noteActivity();
|
|
2304
3710
|
let type;
|
|
2305
3711
|
let payload;
|
|
2306
3712
|
try {
|
|
@@ -2352,6 +3758,7 @@ var RoomCore = class _RoomCore {
|
|
|
2352
3758
|
flush() {
|
|
2353
3759
|
const dirty = this.tracked.flush();
|
|
2354
3760
|
serverWinsCorrections(this.internals, dirty);
|
|
3761
|
+
this.ledger?.newFlush();
|
|
2355
3762
|
this.stats.encodesLastFlush = 0;
|
|
2356
3763
|
this.stats.aoiEncodesLastFlush = 0;
|
|
2357
3764
|
const spatialDescs = this.ext.collections.filter((desc) => desc.visibility === "spatial-grid");
|
|
@@ -2370,11 +3777,23 @@ var RoomCore = class _RoomCore {
|
|
|
2370
3777
|
indexes
|
|
2371
3778
|
);
|
|
2372
3779
|
this.stats.gridQueryMs += performance.now() - queryStarted;
|
|
3780
|
+
let churn;
|
|
2373
3781
|
for (const [name, ids] of memberships) {
|
|
2374
3782
|
const before = entry.spatialMembership.get(name) ?? /* @__PURE__ */ new Set();
|
|
2375
3783
|
this.stats.visibleIdsTotal += ids.size;
|
|
2376
|
-
|
|
2377
|
-
|
|
3784
|
+
const changed = this.ledger ? dirty.get(name) : void 0;
|
|
3785
|
+
let crossed;
|
|
3786
|
+
for (const id of ids) {
|
|
3787
|
+
if (before.has(id)) continue;
|
|
3788
|
+
this.stats.membershipEnters++;
|
|
3789
|
+
if (this.ledger && changed?.added.has(id) !== true) (crossed ??= /* @__PURE__ */ new Set()).add(id);
|
|
3790
|
+
}
|
|
3791
|
+
for (const id of before) {
|
|
3792
|
+
if (ids.has(id)) continue;
|
|
3793
|
+
this.stats.membershipLeaves++;
|
|
3794
|
+
if (this.ledger && changed?.removed.has(id) !== true) (crossed ??= /* @__PURE__ */ new Set()).add(id);
|
|
3795
|
+
}
|
|
3796
|
+
if (crossed) (churn ??= /* @__PURE__ */ new Map()).set(name, crossed);
|
|
2378
3797
|
}
|
|
2379
3798
|
if (entry.correction) {
|
|
2380
3799
|
const payload = encodeCorrection(
|
|
@@ -2407,7 +3826,13 @@ var RoomCore = class _RoomCore {
|
|
|
2407
3826
|
}
|
|
2408
3827
|
shared = cached;
|
|
2409
3828
|
}
|
|
2410
|
-
if (shared)
|
|
3829
|
+
if (shared) {
|
|
3830
|
+
this.send(
|
|
3831
|
+
entry.clientId,
|
|
3832
|
+
encodeFrame4(FrameType5.DELTA, shared),
|
|
3833
|
+
this.ledger ? { shared } : void 0
|
|
3834
|
+
);
|
|
3835
|
+
}
|
|
2411
3836
|
const aoiDirty = aoiViewDirty(
|
|
2412
3837
|
this.ext,
|
|
2413
3838
|
sourceDirty,
|
|
@@ -2443,13 +3868,25 @@ var RoomCore = class _RoomCore {
|
|
|
2443
3868
|
}
|
|
2444
3869
|
delta = cached;
|
|
2445
3870
|
}
|
|
2446
|
-
if (delta)
|
|
3871
|
+
if (delta) {
|
|
3872
|
+
this.send(
|
|
3873
|
+
entry.clientId,
|
|
3874
|
+
encodeFrame4(FrameType5.DELTA, delta),
|
|
3875
|
+
this.ledger ? {
|
|
3876
|
+
...churn ? { churn } : {},
|
|
3877
|
+
...spatialDescs.length > 0 ? {} : { shared: delta }
|
|
3878
|
+
} : void 0
|
|
3879
|
+
);
|
|
3880
|
+
}
|
|
2447
3881
|
}
|
|
2448
3882
|
entry.correction = void 0;
|
|
2449
3883
|
entry.accepted.clear();
|
|
2450
3884
|
}
|
|
2451
3885
|
}
|
|
2452
3886
|
};
|
|
3887
|
+
function encodeSection(physics) {
|
|
3888
|
+
return physics.engineKind === "matter2d" ? encodeMatterSectionEnvelope(encodeMatterBodies(physics.serialize())) : encodePhysicsSection(physics.serialize());
|
|
3889
|
+
}
|
|
2453
3890
|
|
|
2454
3891
|
export {
|
|
2455
3892
|
HOST_CALL_TIMEOUT_MS,
|
|
@@ -2459,10 +3896,20 @@ export {
|
|
|
2459
3896
|
RPC_TIMEOUT_MS,
|
|
2460
3897
|
MAX_CATCHUP,
|
|
2461
3898
|
CRASH_AFTER_THROWS,
|
|
3899
|
+
initMatter,
|
|
3900
|
+
loadedMatter,
|
|
3901
|
+
resetMatterForTests,
|
|
3902
|
+
encodeMatterBodies,
|
|
3903
|
+
decodeMatterBodies,
|
|
2462
3904
|
initPhysics,
|
|
2463
3905
|
loadedPhysics,
|
|
2464
3906
|
resetPhysicsForTests,
|
|
3907
|
+
rapierHasStepped,
|
|
3908
|
+
onFirstRapierStep,
|
|
2465
3909
|
encodePhysicsSection,
|
|
3910
|
+
physicsSectionEngine,
|
|
3911
|
+
encodeMatterSectionEnvelope,
|
|
3912
|
+
decodeMatterSectionEnvelope,
|
|
2466
3913
|
decodePhysicsSection,
|
|
2467
3914
|
Mulberry32,
|
|
2468
3915
|
isVisible,
|
|
@@ -2477,5 +3924,9 @@ export {
|
|
|
2477
3924
|
READABLE_SNAPSHOT_VERSIONS,
|
|
2478
3925
|
parseHibernationBlob,
|
|
2479
3926
|
writeHibernationBlob,
|
|
3927
|
+
decodeSave,
|
|
3928
|
+
DEFAULT_TIMELINE_MAX_TICKS,
|
|
3929
|
+
DEFAULT_TIMELINE_MAX_RECORDS,
|
|
3930
|
+
TimelineRecorder,
|
|
2480
3931
|
RoomCore
|
|
2481
3932
|
};
|