@irtio/runtime 0.5.1 → 0.6.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-K42HA75G.js} +963 -114
- package/dist/{chunk-HFOMXKSO.js → chunk-VMCE3LRO.js} +1 -1
- package/dist/contract-BjMsoJIV.d.ts +415 -0
- package/dist/index.d.ts +41 -3
- package/dist/index.js +26 -2
- package/dist/{room-9ZQoy9yi.d.ts → room-CfnEjlcg.d.ts} +214 -7
- package/dist/test/index.d.ts +51 -4
- package/dist/test/index.js +70 -1
- package/dist/worker/index.d.ts +156 -3
- package/dist/worker/index.js +96 -10
- package/package.json +8 -4
- package/dist/contract-B8QSO0MH.d.ts +0 -204
|
@@ -230,6 +230,7 @@ function createCallProxy(core, clientId) {
|
|
|
230
230
|
return new Promise((resolve, reject2) => {
|
|
231
231
|
const entry = core.clients.get(clientId);
|
|
232
232
|
if (!entry || !entry.connected) {
|
|
233
|
+
core.scheduleContinuationFlush();
|
|
233
234
|
reject2(new Error(`room.call: ${clientId} is not connected`));
|
|
234
235
|
return;
|
|
235
236
|
}
|
|
@@ -328,6 +329,75 @@ function rejectAllPending(core, reason) {
|
|
|
328
329
|
// src/core/loop.ts
|
|
329
330
|
import { FrameType as FrameType2 } from "@irtio/protocol";
|
|
330
331
|
|
|
332
|
+
// src/core/host-calls.ts
|
|
333
|
+
var states2 = /* @__PURE__ */ new WeakMap();
|
|
334
|
+
function stateOf2(core) {
|
|
335
|
+
let s = states2.get(core);
|
|
336
|
+
if (!s) {
|
|
337
|
+
s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
|
|
338
|
+
states2.set(core, s);
|
|
339
|
+
}
|
|
340
|
+
return s;
|
|
341
|
+
}
|
|
342
|
+
function rejectAsEvent(core, err) {
|
|
343
|
+
core.scheduleContinuationFlush();
|
|
344
|
+
return Promise.reject(err);
|
|
345
|
+
}
|
|
346
|
+
function startHostCall(core, call, map) {
|
|
347
|
+
if (core.stopped) {
|
|
348
|
+
return rejectAsEvent(core, new Error(`room.${call.kind}: the room is stopped`));
|
|
349
|
+
}
|
|
350
|
+
return new Promise((resolve, reject2) => {
|
|
351
|
+
const s = stateOf2(core);
|
|
352
|
+
const reqId = s.nextReqId;
|
|
353
|
+
s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
|
|
354
|
+
s.pending.set(reqId, {
|
|
355
|
+
kind: call.kind,
|
|
356
|
+
deadline: core.host.now() + HOST_CALL_TIMEOUT_MS,
|
|
357
|
+
map,
|
|
358
|
+
resolve,
|
|
359
|
+
reject: reject2
|
|
360
|
+
});
|
|
361
|
+
try {
|
|
362
|
+
core.host.hostCall(reqId, call);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
s.pending.delete(reqId);
|
|
365
|
+
core.scheduleContinuationFlush();
|
|
366
|
+
reject2(err instanceof Error ? err : new Error(String(err)));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (core.mode === "event") {
|
|
370
|
+
core.loop.after(HOST_CALL_TIMEOUT_MS, () => checkHostCallTimeouts(core));
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
function completeHostCall(core, reqId, result) {
|
|
375
|
+
const s = stateOf2(core);
|
|
376
|
+
const pending = s.pending.get(reqId);
|
|
377
|
+
if (!pending) return false;
|
|
378
|
+
s.pending.delete(reqId);
|
|
379
|
+
if (result.ok) pending.resolve(pending.map(result.value));
|
|
380
|
+
else pending.reject(new Error(`${result.code}: ${result.message}`));
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
function checkHostCallTimeouts(core) {
|
|
384
|
+
const s = stateOf2(core);
|
|
385
|
+
if (s.pending.size === 0) return;
|
|
386
|
+
const now = core.host.now();
|
|
387
|
+
for (const [reqId, p] of [...s.pending]) {
|
|
388
|
+
if (p.deadline > now) continue;
|
|
389
|
+
s.pending.delete(reqId);
|
|
390
|
+
p.reject(new Error(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function rejectAllHostCalls(core, reason) {
|
|
394
|
+
const s = stateOf2(core);
|
|
395
|
+
for (const [reqId, p] of [...s.pending]) {
|
|
396
|
+
s.pending.delete(reqId);
|
|
397
|
+
p.reject(new Error(reason));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
331
401
|
// src/core/writes.ts
|
|
332
402
|
import {
|
|
333
403
|
SERVER_OWNER as SERVER_OWNER2,
|
|
@@ -674,6 +744,7 @@ var Loop = class {
|
|
|
674
744
|
this.drainInbound();
|
|
675
745
|
this.fireDueTimers();
|
|
676
746
|
checkTimeouts(this.core);
|
|
747
|
+
checkHostCallTimeouts(this.core);
|
|
677
748
|
const config = this.core.definition.config;
|
|
678
749
|
let failed;
|
|
679
750
|
if (config.tick) {
|
|
@@ -709,6 +780,7 @@ var Loop = class {
|
|
|
709
780
|
}
|
|
710
781
|
}
|
|
711
782
|
this.core.flush();
|
|
783
|
+
this.core.captureTimeline();
|
|
712
784
|
const elapsed = Math.max(0, host.now() - started);
|
|
713
785
|
const stats = this.core.stats;
|
|
714
786
|
stats.ticks++;
|
|
@@ -748,7 +820,7 @@ var Loop = class {
|
|
|
748
820
|
if (!this.running || this.core.stopped) return;
|
|
749
821
|
const idleMs = this.core.definition.config.idleMs;
|
|
750
822
|
if (idleMs <= 0) return;
|
|
751
|
-
if (this.core.mode === "tick" && this.core
|
|
823
|
+
if (this.core.mode === "tick" && hasHumanClients(this.core)) {
|
|
752
824
|
this.lastActivity = this.core.host.now();
|
|
753
825
|
this.armIdle(idleMs);
|
|
754
826
|
return;
|
|
@@ -815,31 +887,352 @@ var Loop = class {
|
|
|
815
887
|
this.internal.add(handle);
|
|
816
888
|
}
|
|
817
889
|
};
|
|
890
|
+
function hasHumanClients(core) {
|
|
891
|
+
for (const c of core.clients.values()) {
|
|
892
|
+
if (!c.npc) return true;
|
|
893
|
+
}
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
818
896
|
|
|
819
|
-
// src/core/
|
|
820
|
-
import {
|
|
897
|
+
// src/core/matter.ts
|
|
898
|
+
import {
|
|
899
|
+
ByteReader,
|
|
900
|
+
ByteWriter,
|
|
901
|
+
applyChannel2d,
|
|
902
|
+
channelOf2d
|
|
903
|
+
} from "@irtio/schema";
|
|
821
904
|
var engine;
|
|
822
905
|
var loading;
|
|
823
|
-
async function
|
|
906
|
+
async function initMatter() {
|
|
824
907
|
if (engine) return engine;
|
|
825
908
|
loading ??= (async () => {
|
|
826
|
-
const mod = await import("
|
|
909
|
+
const mod = await import("matter-js");
|
|
827
910
|
const ns = mod.default ?? mod;
|
|
828
|
-
await ns.init();
|
|
829
911
|
engine = ns;
|
|
830
912
|
return ns;
|
|
831
913
|
})();
|
|
832
914
|
return loading;
|
|
833
915
|
}
|
|
834
|
-
function
|
|
916
|
+
function loadedMatter() {
|
|
835
917
|
return engine;
|
|
836
918
|
}
|
|
837
|
-
function
|
|
919
|
+
function resetMatterForTests() {
|
|
838
920
|
engine = void 0;
|
|
839
921
|
loading = void 0;
|
|
840
922
|
}
|
|
923
|
+
function encodeMatterBodies(section) {
|
|
924
|
+
const w = new ByteWriter(section.bodies.length * 72 + 8);
|
|
925
|
+
w.varint(section.bodies.length);
|
|
926
|
+
for (const b of section.bodies) {
|
|
927
|
+
w.str(b.collection);
|
|
928
|
+
w.str(b.id);
|
|
929
|
+
w.f64(b.x);
|
|
930
|
+
w.f64(b.y);
|
|
931
|
+
w.f64(b.angle);
|
|
932
|
+
w.f64(b.vx);
|
|
933
|
+
w.f64(b.vy);
|
|
934
|
+
w.f64(b.angularVelocity);
|
|
935
|
+
w.u8(b.sleeping ? 1 : 0);
|
|
936
|
+
}
|
|
937
|
+
return w.finish();
|
|
938
|
+
}
|
|
939
|
+
function decodeMatterBodies(bytes) {
|
|
940
|
+
const r = new ByteReader(bytes);
|
|
941
|
+
const count = r.varint();
|
|
942
|
+
const bodies = [];
|
|
943
|
+
for (let i = 0; i < count; i++) {
|
|
944
|
+
bodies.push({
|
|
945
|
+
collection: r.str(),
|
|
946
|
+
id: r.str(),
|
|
947
|
+
x: r.f64(),
|
|
948
|
+
y: r.f64(),
|
|
949
|
+
angle: r.f64(),
|
|
950
|
+
vx: r.f64(),
|
|
951
|
+
vy: r.f64(),
|
|
952
|
+
angularVelocity: r.f64(),
|
|
953
|
+
sleeping: r.u8() === 1
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
return { bodies };
|
|
957
|
+
}
|
|
958
|
+
function bodyKey(collection, id) {
|
|
959
|
+
return `${collection}\0${id}`;
|
|
960
|
+
}
|
|
961
|
+
var MatterRuntime = class {
|
|
962
|
+
engineKind = "matter2d";
|
|
963
|
+
/** rapier3d only; present so both runtimes satisfy one internal shape. */
|
|
964
|
+
rapier = void 0;
|
|
965
|
+
world = void 0;
|
|
966
|
+
matter;
|
|
967
|
+
engine;
|
|
968
|
+
/** Alias under the name `PhysicsApi` uses, so `room.physics2d` reads through one field. */
|
|
969
|
+
get matterEngine() {
|
|
970
|
+
return this.engine;
|
|
971
|
+
}
|
|
972
|
+
/** `true` when the blob carried no world at all: the caller logs it. */
|
|
973
|
+
rebuilt;
|
|
974
|
+
/**
|
|
975
|
+
* Always `true`. Unlike Rapier's, a matter2d world is never restored as a world — only as
|
|
976
|
+
* per-body state on a world the builder made — so `setup` has to run every time.
|
|
977
|
+
*/
|
|
978
|
+
needsSetup = true;
|
|
979
|
+
core;
|
|
980
|
+
config;
|
|
981
|
+
collections;
|
|
982
|
+
bodies = /* @__PURE__ */ new Map();
|
|
983
|
+
restore;
|
|
984
|
+
stepMs;
|
|
985
|
+
sleepSynced = /* @__PURE__ */ new Set();
|
|
986
|
+
constructor(core, matter, options) {
|
|
987
|
+
const config = core.definition.config.physics;
|
|
988
|
+
if (!config) throw new Error("MatterRuntime: the room config declares no physics");
|
|
989
|
+
this.core = core;
|
|
990
|
+
this.config = config;
|
|
991
|
+
this.matter = matter;
|
|
992
|
+
this.collections = core.ext.collections.filter(
|
|
993
|
+
(c) => c.physics !== void 0
|
|
994
|
+
);
|
|
995
|
+
this.engine = matter.Engine.create();
|
|
996
|
+
this.engine.gravity.x = config.gravity.x;
|
|
997
|
+
this.engine.gravity.y = config.gravity.y;
|
|
998
|
+
this.stepMs = (config.timestep ?? options.defaultTimestep) * 1e3;
|
|
999
|
+
this.rebuilt = options.restore === void 0;
|
|
1000
|
+
this.restore = options.restore ? new Map(options.restore.bodies.map((b) => [bodyKey(b.collection, b.id), b])) : void 0;
|
|
1001
|
+
}
|
|
1002
|
+
get timestep() {
|
|
1003
|
+
return this.stepMs / 1e3;
|
|
1004
|
+
}
|
|
1005
|
+
runSetup(room) {
|
|
1006
|
+
const setup = this.config.setup;
|
|
1007
|
+
if (!setup) return;
|
|
1008
|
+
this.core.guard("physics.setup", () => setup(this.engine, this.matter, room));
|
|
1009
|
+
}
|
|
1010
|
+
free() {
|
|
1011
|
+
this.bodies.clear();
|
|
1012
|
+
this.matter.Engine.clear(this.engine);
|
|
1013
|
+
}
|
|
1014
|
+
// -------------------------------------------------------------------------
|
|
1015
|
+
// Bodies
|
|
1016
|
+
// -------------------------------------------------------------------------
|
|
1017
|
+
bodyFor(collection, id) {
|
|
1018
|
+
const existing = this.bodies.get(bodyKey(collection, id));
|
|
1019
|
+
if (existing) return existing.body;
|
|
1020
|
+
const desc = this.collections.find((c) => c.name === collection);
|
|
1021
|
+
if (!desc) return void 0;
|
|
1022
|
+
const coll = plainEntity(this.core.plain, collection);
|
|
1023
|
+
const record = coll.get(id);
|
|
1024
|
+
if (record === void 0) return void 0;
|
|
1025
|
+
return this.create(desc, id, record);
|
|
1026
|
+
}
|
|
1027
|
+
create(desc, id, record) {
|
|
1028
|
+
const factory = this.config.bodies?.[desc.name];
|
|
1029
|
+
if (!factory) {
|
|
1030
|
+
this.core.log("error", `irtio: physics.bodies.${desc.name} is missing; no body created`);
|
|
1031
|
+
return void 0;
|
|
1032
|
+
}
|
|
1033
|
+
const spec = this.core.guard(
|
|
1034
|
+
`physics.bodies.${desc.name}`,
|
|
1035
|
+
() => factory(this.matter, record, id)
|
|
1036
|
+
);
|
|
1037
|
+
if (!spec || !spec.body) {
|
|
1038
|
+
this.core.log(
|
|
1039
|
+
"error",
|
|
1040
|
+
`irtio: physics.bodies.${desc.name} returned no { body } for ${JSON.stringify(id)}`
|
|
1041
|
+
);
|
|
1042
|
+
return void 0;
|
|
1043
|
+
}
|
|
1044
|
+
const key = bodyKey(desc.name, id);
|
|
1045
|
+
const constraints = spec.constraints ?? [];
|
|
1046
|
+
this.matter.Composite.add(this.engine.world, [spec.body, ...constraints]);
|
|
1047
|
+
this.bodies.set(key, { body: spec.body, constraints });
|
|
1048
|
+
const saved = this.restore?.get(key);
|
|
1049
|
+
if (saved) {
|
|
1050
|
+
this.restore?.delete(key);
|
|
1051
|
+
this.applyState(spec.body, saved);
|
|
1052
|
+
} else {
|
|
1053
|
+
this.applyRecordToBody(desc, spec.body, record);
|
|
1054
|
+
}
|
|
1055
|
+
return spec.body;
|
|
1056
|
+
}
|
|
1057
|
+
applyState(body, s) {
|
|
1058
|
+
const M = this.matter;
|
|
1059
|
+
M.Body.setPosition(body, { x: s.x, y: s.y });
|
|
1060
|
+
M.Body.setAngle(body, s.angle);
|
|
1061
|
+
M.Body.setVelocity(body, { x: s.vx, y: s.vy });
|
|
1062
|
+
M.Body.setAngularVelocity(body, s.angularVelocity);
|
|
1063
|
+
if (s.sleeping) M.Sleeping.set(body, true);
|
|
1064
|
+
}
|
|
1065
|
+
applyRecordToBody(desc, body, record) {
|
|
1066
|
+
const physics = desc.physics;
|
|
1067
|
+
if (!physics) return;
|
|
1068
|
+
const t = {
|
|
1069
|
+
x: body.position.x,
|
|
1070
|
+
y: body.position.y,
|
|
1071
|
+
qz: Math.sin(body.angle / 2),
|
|
1072
|
+
qw: Math.cos(body.angle / 2),
|
|
1073
|
+
vx: body.velocity.x,
|
|
1074
|
+
vy: body.velocity.y,
|
|
1075
|
+
wz: body.angularVelocity
|
|
1076
|
+
};
|
|
1077
|
+
for (const [channel, field] of physics.channels) {
|
|
1078
|
+
const raw = record[field];
|
|
1079
|
+
if (typeof raw !== "number") continue;
|
|
1080
|
+
applyChannel2d(channel, raw, t);
|
|
1081
|
+
}
|
|
1082
|
+
this.applyState(body, {
|
|
1083
|
+
collection: desc.name,
|
|
1084
|
+
id: "",
|
|
1085
|
+
x: t.x,
|
|
1086
|
+
y: t.y,
|
|
1087
|
+
angle: 2 * Math.atan2(t.qz, t.qw),
|
|
1088
|
+
vx: t.vx,
|
|
1089
|
+
vy: t.vy,
|
|
1090
|
+
angularVelocity: t.wz,
|
|
1091
|
+
sleeping: false
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
reconcile() {
|
|
1095
|
+
const live = /* @__PURE__ */ new Set();
|
|
1096
|
+
for (const desc of this.collections) {
|
|
1097
|
+
const coll = plainEntity(this.core.plain, desc.name);
|
|
1098
|
+
for (const id of coll.ids()) {
|
|
1099
|
+
const key = bodyKey(desc.name, id);
|
|
1100
|
+
live.add(key);
|
|
1101
|
+
if (this.bodies.has(key)) continue;
|
|
1102
|
+
const record = coll.get(id);
|
|
1103
|
+
if (record !== void 0) this.create(desc, id, record);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
for (const [key, attached] of [...this.bodies]) {
|
|
1107
|
+
if (live.has(key)) continue;
|
|
1108
|
+
this.bodies.delete(key);
|
|
1109
|
+
this.sleepSynced.delete(key);
|
|
1110
|
+
this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// -------------------------------------------------------------------------
|
|
1114
|
+
// Step and sync
|
|
1115
|
+
// -------------------------------------------------------------------------
|
|
1116
|
+
step() {
|
|
1117
|
+
this.matter.Engine.update(this.engine, this.stepMs);
|
|
1118
|
+
}
|
|
1119
|
+
sync() {
|
|
1120
|
+
for (const desc of this.collections) {
|
|
1121
|
+
const physics = desc.physics;
|
|
1122
|
+
if (!physics) continue;
|
|
1123
|
+
const tracked = this.core.anyState[desc.name];
|
|
1124
|
+
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
1125
|
+
const rounders = roundersFor(desc);
|
|
1126
|
+
for (const id of plainColl.ids()) {
|
|
1127
|
+
const key = bodyKey(desc.name, id);
|
|
1128
|
+
const attached = this.bodies.get(key);
|
|
1129
|
+
if (!attached) continue;
|
|
1130
|
+
const body = attached.body;
|
|
1131
|
+
if (body.isSleeping) {
|
|
1132
|
+
if (this.sleepSynced.has(key)) continue;
|
|
1133
|
+
this.sleepSynced.add(key);
|
|
1134
|
+
} else {
|
|
1135
|
+
this.sleepSynced.delete(key);
|
|
1136
|
+
}
|
|
1137
|
+
const record = tracked.get(id);
|
|
1138
|
+
if (!record) continue;
|
|
1139
|
+
const state = {
|
|
1140
|
+
x: body.position.x,
|
|
1141
|
+
y: body.position.y,
|
|
1142
|
+
angle: body.angle,
|
|
1143
|
+
vx: body.velocity.x,
|
|
1144
|
+
vy: body.velocity.y,
|
|
1145
|
+
angularVelocity: body.angularVelocity
|
|
1146
|
+
};
|
|
1147
|
+
for (const [channel, field] of physics.channels) {
|
|
1148
|
+
const next = (rounders[field] ?? identity)(channelOf2d(channel, state));
|
|
1149
|
+
if (record[field] !== next) record[field] = next;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// -------------------------------------------------------------------------
|
|
1155
|
+
// Hibernation
|
|
1156
|
+
// -------------------------------------------------------------------------
|
|
1157
|
+
serialize() {
|
|
1158
|
+
const bodies = [];
|
|
1159
|
+
for (const [key, attached] of this.bodies) {
|
|
1160
|
+
const sep = key.indexOf("\0");
|
|
1161
|
+
const b = attached.body;
|
|
1162
|
+
bodies.push({
|
|
1163
|
+
collection: key.slice(0, sep),
|
|
1164
|
+
id: key.slice(sep + 1),
|
|
1165
|
+
x: b.position.x,
|
|
1166
|
+
y: b.position.y,
|
|
1167
|
+
angle: b.angle,
|
|
1168
|
+
vx: b.velocity.x,
|
|
1169
|
+
vy: b.velocity.y,
|
|
1170
|
+
angularVelocity: b.angularVelocity,
|
|
1171
|
+
sleeping: b.isSleeping === true
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
return { bodies };
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
function identity(v) {
|
|
1178
|
+
return v;
|
|
1179
|
+
}
|
|
1180
|
+
function roundersFor(desc) {
|
|
1181
|
+
const out = {};
|
|
1182
|
+
for (const f of desc.fields) {
|
|
1183
|
+
if (f.type.kind === "f32") out[f.name] = Math.fround;
|
|
1184
|
+
}
|
|
1185
|
+
return out;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// src/core/physics.ts
|
|
1189
|
+
import { ByteReader as ByteReader2, ByteWriter as ByteWriter2 } from "@irtio/schema";
|
|
1190
|
+
var engine2;
|
|
1191
|
+
var loading2;
|
|
1192
|
+
async function initPhysics() {
|
|
1193
|
+
if (engine2) return engine2;
|
|
1194
|
+
loading2 ??= (async () => {
|
|
1195
|
+
const mod = await import("@dimforge/rapier3d-compat");
|
|
1196
|
+
const ns = mod.default ?? mod;
|
|
1197
|
+
await ns.init();
|
|
1198
|
+
engine2 = ns;
|
|
1199
|
+
return ns;
|
|
1200
|
+
})();
|
|
1201
|
+
return loading2;
|
|
1202
|
+
}
|
|
1203
|
+
function loadedPhysics() {
|
|
1204
|
+
return engine2;
|
|
1205
|
+
}
|
|
1206
|
+
function resetPhysicsForTests() {
|
|
1207
|
+
engine2 = void 0;
|
|
1208
|
+
loading2 = void 0;
|
|
1209
|
+
rapierStepped = false;
|
|
1210
|
+
firstStepWaiters.length = 0;
|
|
1211
|
+
}
|
|
1212
|
+
var rapierStepped = false;
|
|
1213
|
+
var firstStepWaiters = [];
|
|
1214
|
+
function rapierHasStepped() {
|
|
1215
|
+
return rapierStepped;
|
|
1216
|
+
}
|
|
1217
|
+
function onFirstRapierStep(cb) {
|
|
1218
|
+
if (rapierStepped) {
|
|
1219
|
+
cb();
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
firstStepWaiters.push(cb);
|
|
1223
|
+
}
|
|
1224
|
+
function noteRapierStep() {
|
|
1225
|
+
rapierStepped = true;
|
|
1226
|
+
const waiters = firstStepWaiters.splice(0, firstStepWaiters.length);
|
|
1227
|
+
for (const cb of waiters) {
|
|
1228
|
+
try {
|
|
1229
|
+
cb();
|
|
1230
|
+
} catch {
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
841
1234
|
function encodePhysicsSection(section) {
|
|
842
|
-
const w = new
|
|
1235
|
+
const w = new ByteWriter2(section.world.length + section.bodies.length * 24 + 8);
|
|
843
1236
|
w.blob(section.world);
|
|
844
1237
|
w.varint(section.bodies.length);
|
|
845
1238
|
for (const [name, id, handle] of section.bodies) {
|
|
@@ -849,15 +1242,34 @@ function encodePhysicsSection(section) {
|
|
|
849
1242
|
}
|
|
850
1243
|
return w.finish();
|
|
851
1244
|
}
|
|
1245
|
+
var MATTER_TAG = 1;
|
|
1246
|
+
function physicsSectionEngine(bytes) {
|
|
1247
|
+
const r = new ByteReader2(bytes);
|
|
1248
|
+
return r.varint() === 0 && r.u8() === MATTER_TAG ? "matter2d" : "rapier3d";
|
|
1249
|
+
}
|
|
1250
|
+
function encodeMatterSectionEnvelope(payload) {
|
|
1251
|
+
const w = new ByteWriter2(payload.length + 8);
|
|
1252
|
+
w.varint(0);
|
|
1253
|
+
w.u8(MATTER_TAG);
|
|
1254
|
+
w.bytes(payload);
|
|
1255
|
+
return w.finish();
|
|
1256
|
+
}
|
|
1257
|
+
function decodeMatterSectionEnvelope(bytes) {
|
|
1258
|
+
const r = new ByteReader2(bytes);
|
|
1259
|
+
if (r.varint() !== 0 || r.u8() !== MATTER_TAG) {
|
|
1260
|
+
throw new Error("irtio: this physics section was not written by matter2d");
|
|
1261
|
+
}
|
|
1262
|
+
return r.rest();
|
|
1263
|
+
}
|
|
852
1264
|
function decodePhysicsSection(bytes) {
|
|
853
|
-
const r = new
|
|
1265
|
+
const r = new ByteReader2(bytes);
|
|
854
1266
|
const world = r.blob().slice();
|
|
855
1267
|
const count = r.varint();
|
|
856
1268
|
const bodies = [];
|
|
857
1269
|
for (let i = 0; i < count; i++) bodies.push([r.str(), r.str(), r.f64()]);
|
|
858
1270
|
return { world, bodies };
|
|
859
1271
|
}
|
|
860
|
-
function
|
|
1272
|
+
function bodyKey2(collection, id) {
|
|
861
1273
|
return `${collection}\0${id}`;
|
|
862
1274
|
}
|
|
863
1275
|
function planarLockWarning(spec) {
|
|
@@ -880,10 +1292,18 @@ function planarLockWarning(spec) {
|
|
|
880
1292
|
var CUBOID_SHAPE = 1;
|
|
881
1293
|
var ROUND_CUBOID_SHAPE = 12;
|
|
882
1294
|
var PhysicsRuntime = class {
|
|
1295
|
+
engineKind = "rapier3d";
|
|
1296
|
+
/** matter2d only; present so both runtimes satisfy one internal shape. */
|
|
1297
|
+
matter = void 0;
|
|
1298
|
+
matterEngine = void 0;
|
|
883
1299
|
rapier;
|
|
884
1300
|
world;
|
|
885
1301
|
/** `true` when the world was built from scratch and `setup` has to run. */
|
|
886
1302
|
rebuilt;
|
|
1303
|
+
/** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
|
|
1304
|
+
get needsSetup() {
|
|
1305
|
+
return this.rebuilt;
|
|
1306
|
+
}
|
|
887
1307
|
core;
|
|
888
1308
|
config;
|
|
889
1309
|
/** Physics-backed collections, in schema (name-sorted) order. */
|
|
@@ -912,7 +1332,7 @@ var PhysicsRuntime = class {
|
|
|
912
1332
|
this.rebuilt = false;
|
|
913
1333
|
for (const [name, id, handle] of options.restore.bodies) {
|
|
914
1334
|
const body = this.world.getRigidBody(handle);
|
|
915
|
-
if (body) this.bodies.set(
|
|
1335
|
+
if (body) this.bodies.set(bodyKey2(name, id), body);
|
|
916
1336
|
}
|
|
917
1337
|
} else {
|
|
918
1338
|
const g = config.gravity;
|
|
@@ -939,7 +1359,7 @@ var PhysicsRuntime = class {
|
|
|
939
1359
|
// -------------------------------------------------------------------------
|
|
940
1360
|
/** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
|
|
941
1361
|
bodyFor(collection, id) {
|
|
942
|
-
const existing = this.bodies.get(
|
|
1362
|
+
const existing = this.bodies.get(bodyKey2(collection, id));
|
|
943
1363
|
if (existing) return existing;
|
|
944
1364
|
const desc = this.collections.find((c) => c.name === collection);
|
|
945
1365
|
if (!desc) return void 0;
|
|
@@ -973,7 +1393,7 @@ var PhysicsRuntime = class {
|
|
|
973
1393
|
const body = this.world.createRigidBody(spec.body);
|
|
974
1394
|
for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
|
|
975
1395
|
this.applyRecordToBody(desc, body, record);
|
|
976
|
-
this.bodies.set(
|
|
1396
|
+
this.bodies.set(bodyKey2(desc.name, id), body);
|
|
977
1397
|
return body;
|
|
978
1398
|
}
|
|
979
1399
|
applyRecordToBody(desc, body, record) {
|
|
@@ -1053,7 +1473,7 @@ var PhysicsRuntime = class {
|
|
|
1053
1473
|
for (const desc of this.collections) {
|
|
1054
1474
|
const coll = plainEntity(this.core.plain, desc.name);
|
|
1055
1475
|
for (const id of coll.ids()) {
|
|
1056
|
-
const key =
|
|
1476
|
+
const key = bodyKey2(desc.name, id);
|
|
1057
1477
|
live.add(key);
|
|
1058
1478
|
if (this.bodies.has(key)) continue;
|
|
1059
1479
|
const record = coll.get(id);
|
|
@@ -1072,6 +1492,7 @@ var PhysicsRuntime = class {
|
|
|
1072
1492
|
// -------------------------------------------------------------------------
|
|
1073
1493
|
step() {
|
|
1074
1494
|
this.world.step();
|
|
1495
|
+
if (!rapierStepped) noteRapierStep();
|
|
1075
1496
|
}
|
|
1076
1497
|
/**
|
|
1077
1498
|
* Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
|
|
@@ -1084,9 +1505,9 @@ var PhysicsRuntime = class {
|
|
|
1084
1505
|
if (!physics) continue;
|
|
1085
1506
|
const tracked = this.core.anyState[desc.name];
|
|
1086
1507
|
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
1087
|
-
const rounders =
|
|
1508
|
+
const rounders = roundersFor2(desc);
|
|
1088
1509
|
for (const id of plainColl.ids()) {
|
|
1089
|
-
const key =
|
|
1510
|
+
const key = bodyKey2(desc.name, id);
|
|
1090
1511
|
const body = this.bodies.get(key);
|
|
1091
1512
|
if (!body) continue;
|
|
1092
1513
|
if (body.isSleeping()) {
|
|
@@ -1102,7 +1523,7 @@ var PhysicsRuntime = class {
|
|
|
1102
1523
|
const v = body.linvel();
|
|
1103
1524
|
const w = body.angvel();
|
|
1104
1525
|
for (const [channel, field] of physics.channels) {
|
|
1105
|
-
const next = (rounders[field] ??
|
|
1526
|
+
const next = (rounders[field] ?? identity2)(channelValue(channel, t, r, v, w));
|
|
1106
1527
|
if (record[field] !== next) record[field] = next;
|
|
1107
1528
|
}
|
|
1108
1529
|
}
|
|
@@ -1120,10 +1541,10 @@ var PhysicsRuntime = class {
|
|
|
1120
1541
|
return { world: this.world.takeSnapshot(), bodies };
|
|
1121
1542
|
}
|
|
1122
1543
|
};
|
|
1123
|
-
function
|
|
1544
|
+
function identity2(v) {
|
|
1124
1545
|
return v;
|
|
1125
1546
|
}
|
|
1126
|
-
function
|
|
1547
|
+
function roundersFor2(desc) {
|
|
1127
1548
|
const out = {};
|
|
1128
1549
|
for (const f of desc.fields) {
|
|
1129
1550
|
if (f.type.kind === "f32") out[f.name] = Math.fround;
|
|
@@ -1418,11 +1839,12 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
|
|
|
1418
1839
|
}
|
|
1419
1840
|
|
|
1420
1841
|
// src/core/snapshot.ts
|
|
1421
|
-
import {
|
|
1842
|
+
import { withBuiltins } from "@irtio/protocol";
|
|
1843
|
+
import { ByteReader as ByteReader3, ByteWriter as ByteWriter3, decodeSnapshot } from "@irtio/schema";
|
|
1422
1844
|
var SNAPSHOT_FORMAT_VERSION = 2;
|
|
1423
1845
|
var READABLE_SNAPSHOT_VERSIONS = [1, 2];
|
|
1424
1846
|
function parseHibernationBlob(bytes) {
|
|
1425
|
-
const r = new
|
|
1847
|
+
const r = new ByteReader3(bytes);
|
|
1426
1848
|
const version = r.u8();
|
|
1427
1849
|
if (!READABLE_SNAPSHOT_VERSIONS.includes(version)) {
|
|
1428
1850
|
throw new Error(`RoomCore.restore: unsupported snapshot format version ${version}`);
|
|
@@ -1440,7 +1862,7 @@ function parseHibernationBlob(bytes) {
|
|
|
1440
1862
|
}
|
|
1441
1863
|
function writeHibernationBlob(header, snapshot, physics) {
|
|
1442
1864
|
const version = physics ? 2 : 1;
|
|
1443
|
-
const w = new
|
|
1865
|
+
const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 24);
|
|
1444
1866
|
w.u8(version);
|
|
1445
1867
|
w.u32(header.seed >>> 0);
|
|
1446
1868
|
w.u32(header.rngState >>> 0);
|
|
@@ -1450,21 +1872,90 @@ function writeHibernationBlob(header, snapshot, physics) {
|
|
|
1450
1872
|
w.bytes(snapshot);
|
|
1451
1873
|
return w.finish();
|
|
1452
1874
|
}
|
|
1875
|
+
function decodeSave(bytes, schema) {
|
|
1876
|
+
const parsed = parseHibernationBlob(bytes);
|
|
1877
|
+
const ext = withBuiltins(schema);
|
|
1878
|
+
const plain = decodeSnapshot(ext, parsed.snapshot).state;
|
|
1879
|
+
return {
|
|
1880
|
+
version: parsed.version,
|
|
1881
|
+
seed: parsed.seed,
|
|
1882
|
+
rngState: parsed.rngState,
|
|
1883
|
+
tick: parsed.tick,
|
|
1884
|
+
mode: parsed.mode,
|
|
1885
|
+
hasPhysics: parsed.physics !== void 0,
|
|
1886
|
+
physicsEngine: parsed.physics ? physicsSectionEngine(parsed.physics) : void 0,
|
|
1887
|
+
state: inspectState(ext, plain)
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// src/core/timeline.ts
|
|
1892
|
+
import { EntityCollection as EntityCollection2 } from "@irtio/schema";
|
|
1893
|
+
var DEFAULT_TIMELINE_MAX_TICKS = 3600;
|
|
1894
|
+
var DEFAULT_TIMELINE_MAX_RECORDS = 2e5;
|
|
1895
|
+
function recordsIn(state) {
|
|
1896
|
+
let n = 0;
|
|
1897
|
+
for (const value of Object.values(state)) {
|
|
1898
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1899
|
+
n += Object.keys(value).length;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
return Math.max(1, n);
|
|
1903
|
+
}
|
|
1904
|
+
var TimelineRecorder = class {
|
|
1905
|
+
frames = [];
|
|
1906
|
+
costs = [];
|
|
1907
|
+
records = 0;
|
|
1908
|
+
dropped = 0;
|
|
1909
|
+
maxTicks;
|
|
1910
|
+
maxRecords;
|
|
1911
|
+
constructor(options = {}) {
|
|
1912
|
+
this.maxTicks = Math.max(1, options.maxTicks ?? DEFAULT_TIMELINE_MAX_TICKS);
|
|
1913
|
+
this.maxRecords = Math.max(1, options.maxRecords ?? DEFAULT_TIMELINE_MAX_RECORDS);
|
|
1914
|
+
}
|
|
1915
|
+
collections = {};
|
|
1916
|
+
capture(tick, schema, plain) {
|
|
1917
|
+
const state = structuredClone(inspectState(schema, plain));
|
|
1918
|
+
for (const c of schema.collections) {
|
|
1919
|
+
this.collections[c.name] = plain[c.name] instanceof EntityCollection2 ? "entity" : "single";
|
|
1920
|
+
}
|
|
1921
|
+
const cost = recordsIn(state);
|
|
1922
|
+
this.frames.push({ tick, at: Date.now(), state });
|
|
1923
|
+
this.costs.push(cost);
|
|
1924
|
+
this.records += cost;
|
|
1925
|
+
while (this.frames.length > this.maxTicks || this.records > this.maxRecords && this.frames.length > 1) {
|
|
1926
|
+
this.frames.shift();
|
|
1927
|
+
this.records -= this.costs.shift() ?? 0;
|
|
1928
|
+
this.dropped++;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
dump(roomId) {
|
|
1932
|
+
return {
|
|
1933
|
+
roomId,
|
|
1934
|
+
collections: { ...this.collections },
|
|
1935
|
+
frames: this.frames.map((f) => ({ tick: f.tick, at: f.at, state: f.state })),
|
|
1936
|
+
dropped: this.dropped,
|
|
1937
|
+
maxTicks: this.maxTicks,
|
|
1938
|
+
maxRecords: this.maxRecords
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
};
|
|
1453
1942
|
|
|
1454
1943
|
// src/core/room.ts
|
|
1455
1944
|
import {
|
|
1456
1945
|
FrameType as FrameType5,
|
|
1457
1946
|
PRESENCE_COLLECTION,
|
|
1947
|
+
ProfileLedger,
|
|
1948
|
+
busChannelProblem as busChannelProblem2,
|
|
1458
1949
|
decodeFrame,
|
|
1459
1950
|
encodeCorrectFrame,
|
|
1460
1951
|
encodeFrame as encodeFrame4,
|
|
1461
|
-
withBuiltins
|
|
1952
|
+
withBuiltins as withBuiltins2
|
|
1462
1953
|
} from "@irtio/protocol";
|
|
1463
1954
|
import {
|
|
1464
1955
|
cloneValue as cloneValue2,
|
|
1465
1956
|
createDirtySet as createDirtySet2,
|
|
1466
1957
|
createState,
|
|
1467
|
-
decodeSnapshot,
|
|
1958
|
+
decodeSnapshot as decodeSnapshot2,
|
|
1468
1959
|
encodeDelta as encodeDelta4,
|
|
1469
1960
|
encodeSnapshot as encodeSnapshot2,
|
|
1470
1961
|
isDirtyEmpty as isDirtyEmpty3,
|
|
@@ -1472,71 +1963,6 @@ import {
|
|
|
1472
1963
|
validateForDeploy
|
|
1473
1964
|
} from "@irtio/schema";
|
|
1474
1965
|
|
|
1475
|
-
// src/core/host-calls.ts
|
|
1476
|
-
var states2 = /* @__PURE__ */ new WeakMap();
|
|
1477
|
-
function stateOf2(core) {
|
|
1478
|
-
let s = states2.get(core);
|
|
1479
|
-
if (!s) {
|
|
1480
|
-
s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
|
|
1481
|
-
states2.set(core, s);
|
|
1482
|
-
}
|
|
1483
|
-
return s;
|
|
1484
|
-
}
|
|
1485
|
-
function startHostCall(core, call, map) {
|
|
1486
|
-
return new Promise((resolve, reject2) => {
|
|
1487
|
-
if (core.stopped) {
|
|
1488
|
-
reject2(new Error(`room.${call.kind}: the room is stopped`));
|
|
1489
|
-
return;
|
|
1490
|
-
}
|
|
1491
|
-
const s = stateOf2(core);
|
|
1492
|
-
const reqId = s.nextReqId;
|
|
1493
|
-
s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
|
|
1494
|
-
s.pending.set(reqId, {
|
|
1495
|
-
kind: call.kind,
|
|
1496
|
-
deadline: core.host.now() + HOST_CALL_TIMEOUT_MS,
|
|
1497
|
-
map,
|
|
1498
|
-
resolve,
|
|
1499
|
-
reject: reject2
|
|
1500
|
-
});
|
|
1501
|
-
try {
|
|
1502
|
-
core.host.hostCall(reqId, call);
|
|
1503
|
-
} catch (err) {
|
|
1504
|
-
s.pending.delete(reqId);
|
|
1505
|
-
reject2(err instanceof Error ? err : new Error(String(err)));
|
|
1506
|
-
return;
|
|
1507
|
-
}
|
|
1508
|
-
if (core.mode === "event") {
|
|
1509
|
-
core.loop.after(HOST_CALL_TIMEOUT_MS, () => checkHostCallTimeouts(core));
|
|
1510
|
-
}
|
|
1511
|
-
});
|
|
1512
|
-
}
|
|
1513
|
-
function completeHostCall(core, reqId, result) {
|
|
1514
|
-
const s = stateOf2(core);
|
|
1515
|
-
const pending = s.pending.get(reqId);
|
|
1516
|
-
if (!pending) return false;
|
|
1517
|
-
s.pending.delete(reqId);
|
|
1518
|
-
if (result.ok) pending.resolve(pending.map(result.value));
|
|
1519
|
-
else pending.reject(new Error(`${result.code}: ${result.message}`));
|
|
1520
|
-
return true;
|
|
1521
|
-
}
|
|
1522
|
-
function checkHostCallTimeouts(core) {
|
|
1523
|
-
const s = stateOf2(core);
|
|
1524
|
-
if (s.pending.size === 0) return;
|
|
1525
|
-
const now = core.host.now();
|
|
1526
|
-
for (const [reqId, p] of [...s.pending]) {
|
|
1527
|
-
if (p.deadline > now) continue;
|
|
1528
|
-
s.pending.delete(reqId);
|
|
1529
|
-
p.reject(new Error(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
function rejectAllHostCalls(core, reason) {
|
|
1533
|
-
const s = stateOf2(core);
|
|
1534
|
-
for (const [reqId, p] of [...s.pending]) {
|
|
1535
|
-
s.pending.delete(reqId);
|
|
1536
|
-
p.reject(new Error(reason));
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
1966
|
// src/core/messages.ts
|
|
1541
1967
|
import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
|
|
1542
1968
|
function toRoomTarget(target) {
|
|
@@ -1549,6 +1975,8 @@ function toRoomTarget(target) {
|
|
|
1549
1975
|
return { role: target.role };
|
|
1550
1976
|
case "server":
|
|
1551
1977
|
return "server";
|
|
1978
|
+
case "voice":
|
|
1979
|
+
return void 0;
|
|
1552
1980
|
}
|
|
1553
1981
|
}
|
|
1554
1982
|
function deliver(core, target, frame, exclude) {
|
|
@@ -1569,17 +1997,17 @@ function handleMsg(core, clientId, payload) {
|
|
|
1569
1997
|
core.log("warn", `MSG from ${clientId} failed to decode:`, err);
|
|
1570
1998
|
return false;
|
|
1571
1999
|
}
|
|
2000
|
+
if (msg.target.kind === "voice") {
|
|
2001
|
+
core.log("warn", `voice MSG from ${clientId} reached room code; dropped (supervisor bug)`);
|
|
2002
|
+
return true;
|
|
2003
|
+
}
|
|
2004
|
+
const roomTarget = toRoomTarget(msg.target);
|
|
2005
|
+
if (roomTarget === void 0) return true;
|
|
1572
2006
|
const onMessage = core.definition.config.onMessage;
|
|
1573
2007
|
if (onMessage) {
|
|
1574
2008
|
const ran = core.tryRun(
|
|
1575
2009
|
"onMessage",
|
|
1576
|
-
() => onMessage(
|
|
1577
|
-
core.anyState,
|
|
1578
|
-
clientId,
|
|
1579
|
-
toRoomTarget(msg.target),
|
|
1580
|
-
msg.payload,
|
|
1581
|
-
core.ctxFor(clientId)
|
|
1582
|
-
)
|
|
2010
|
+
() => onMessage(core.anyState, clientId, roomTarget, msg.payload, core.ctxFor(clientId))
|
|
1583
2011
|
);
|
|
1584
2012
|
if (!ran.ok || ran.value === false) return true;
|
|
1585
2013
|
}
|
|
@@ -1601,7 +2029,12 @@ function sendMessage(core, target, bytes) {
|
|
|
1601
2029
|
}
|
|
1602
2030
|
|
|
1603
2031
|
// src/core/room-api.ts
|
|
1604
|
-
import {
|
|
2032
|
+
import {
|
|
2033
|
+
FrameType as FrameType4,
|
|
2034
|
+
busChannelProblem,
|
|
2035
|
+
busPayloadProblem,
|
|
2036
|
+
encodeFrame as encodeFrame3
|
|
2037
|
+
} from "@irtio/protocol";
|
|
1605
2038
|
import { encodeDelta as encodeDelta3, isDirtyEmpty as isDirtyEmpty2 } from "@irtio/schema";
|
|
1606
2039
|
function makePhysicsApi(p) {
|
|
1607
2040
|
return {
|
|
@@ -1619,6 +2052,22 @@ function makePhysicsApi(p) {
|
|
|
1619
2052
|
}
|
|
1620
2053
|
};
|
|
1621
2054
|
}
|
|
2055
|
+
function makeMatterApi(p) {
|
|
2056
|
+
return {
|
|
2057
|
+
get matter() {
|
|
2058
|
+
return p.matter;
|
|
2059
|
+
},
|
|
2060
|
+
get engine() {
|
|
2061
|
+
return p.matterEngine;
|
|
2062
|
+
},
|
|
2063
|
+
get timestep() {
|
|
2064
|
+
return p.timestep;
|
|
2065
|
+
},
|
|
2066
|
+
body(collection, id) {
|
|
2067
|
+
return p.bodyFor(collection, id);
|
|
2068
|
+
}
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
1622
2071
|
function makeKv(core) {
|
|
1623
2072
|
return {
|
|
1624
2073
|
get(playerId, key) {
|
|
@@ -1632,11 +2081,104 @@ function makeKv(core) {
|
|
|
1632
2081
|
}
|
|
1633
2082
|
};
|
|
1634
2083
|
}
|
|
2084
|
+
function makeLeaderboard(core) {
|
|
2085
|
+
return {
|
|
2086
|
+
submit(board, playerId, score) {
|
|
2087
|
+
if (!Number.isInteger(score)) {
|
|
2088
|
+
return rejectAsEvent(
|
|
2089
|
+
core,
|
|
2090
|
+
new Error(
|
|
2091
|
+
`room.leaderboard.submit: score must be a whole number, got ${String(score)} (leaderboards are integer-only, by decision)`
|
|
2092
|
+
)
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
return startHostCall(core, { kind: "lbSubmit", board, playerId, score }, () => void 0);
|
|
2096
|
+
}
|
|
2097
|
+
};
|
|
2098
|
+
}
|
|
2099
|
+
function makeRatings(core) {
|
|
2100
|
+
return {
|
|
2101
|
+
report(queue, results) {
|
|
2102
|
+
return startHostCall(
|
|
2103
|
+
core,
|
|
2104
|
+
{
|
|
2105
|
+
kind: "ratingReport",
|
|
2106
|
+
queue,
|
|
2107
|
+
// Copied rather than passed through: `results` is the game's own array and the host call
|
|
2108
|
+
// crosses a worker boundary, so a room that mutated it after calling would otherwise be
|
|
2109
|
+
// reporting something it did not say.
|
|
2110
|
+
results: (Array.isArray(results) ? results : []).map((r) => ({
|
|
2111
|
+
playerId: String(r?.playerId),
|
|
2112
|
+
place: Number(r?.place)
|
|
2113
|
+
}))
|
|
2114
|
+
},
|
|
2115
|
+
() => void 0
|
|
2116
|
+
);
|
|
2117
|
+
},
|
|
2118
|
+
set(queue, playerId, value) {
|
|
2119
|
+
return startHostCall(
|
|
2120
|
+
core,
|
|
2121
|
+
{
|
|
2122
|
+
kind: "ratingSet",
|
|
2123
|
+
queue,
|
|
2124
|
+
playerId,
|
|
2125
|
+
rating: Number(value?.rating),
|
|
2126
|
+
...value?.deviation !== void 0 ? { deviation: Number(value.deviation) } : {}
|
|
2127
|
+
},
|
|
2128
|
+
() => void 0
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
function makeBus(core) {
|
|
2134
|
+
const refuse = (verb, problem) => {
|
|
2135
|
+
core.log("warn", `room.bus.${verb}: ${problem.code}: ${problem.message}`);
|
|
2136
|
+
};
|
|
2137
|
+
const setSubscribed = (verb, channel) => {
|
|
2138
|
+
const problem = busChannelProblem(channel);
|
|
2139
|
+
if (problem) {
|
|
2140
|
+
refuse(verb, problem);
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
core.host.busSubscribe(channel, verb === "subscribe");
|
|
2144
|
+
};
|
|
2145
|
+
return {
|
|
2146
|
+
publish(channel, payload) {
|
|
2147
|
+
const problem = busChannelProblem(channel) ?? busPayloadProblem(payload);
|
|
2148
|
+
if (problem) {
|
|
2149
|
+
refuse("publish", problem);
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
core.host.busPublish(channel, payload);
|
|
2153
|
+
},
|
|
2154
|
+
subscribe(channel) {
|
|
2155
|
+
setSubscribed("subscribe", channel);
|
|
2156
|
+
},
|
|
2157
|
+
unsubscribe(channel) {
|
|
2158
|
+
setSubscribed("unsubscribe", channel);
|
|
2159
|
+
},
|
|
2160
|
+
send(roomId, payload) {
|
|
2161
|
+
if (typeof roomId !== "string" || roomId === "") {
|
|
2162
|
+
return rejectAsEvent(core, new Error("room.bus.send: a target roomId is required"));
|
|
2163
|
+
}
|
|
2164
|
+
const problem = busPayloadProblem(payload);
|
|
2165
|
+
if (problem) return rejectAsEvent(core, new Error(`${problem.code}: ${problem.message}`));
|
|
2166
|
+
return startHostCall(core, { kind: "busSend", roomId, payload }, () => void 0);
|
|
2167
|
+
}
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
1635
2170
|
function createRoomApi(core, publicUrl) {
|
|
1636
2171
|
let cached;
|
|
2172
|
+
let npcCounter = 0;
|
|
1637
2173
|
let lastNow = Number.NEGATIVE_INFINITY;
|
|
1638
2174
|
let physicsApi;
|
|
2175
|
+
let matterApi;
|
|
1639
2176
|
let kvApi;
|
|
2177
|
+
let busApi;
|
|
2178
|
+
let leaderboardApi;
|
|
2179
|
+
let ratingsApi;
|
|
2180
|
+
const declaredBackfill = core.definition.config.backfill === true;
|
|
2181
|
+
let backfillOpen = declaredBackfill;
|
|
1640
2182
|
const link = () => {
|
|
1641
2183
|
const sep = publicUrl.includes("?") ? "&" : "?";
|
|
1642
2184
|
return `${publicUrl}${sep}room=${encodeURIComponent(core.roomId)}`;
|
|
@@ -1662,7 +2204,8 @@ function createRoomApi(core, publicUrl) {
|
|
|
1662
2204
|
clientId: c.clientId,
|
|
1663
2205
|
role: c.role,
|
|
1664
2206
|
name: c.name,
|
|
1665
|
-
connected: c.connected
|
|
2207
|
+
connected: c.connected,
|
|
2208
|
+
npc: c.npc
|
|
1666
2209
|
}));
|
|
1667
2210
|
}
|
|
1668
2211
|
return cached;
|
|
@@ -1681,8 +2224,27 @@ function createRoomApi(core, publicUrl) {
|
|
|
1681
2224
|
"room.physics: this room has no physics \u2014 add physics: { engine: 'rapier3d', gravity, bodies } to defineRoom(...)"
|
|
1682
2225
|
);
|
|
1683
2226
|
}
|
|
2227
|
+
if (p.engineKind !== "rapier3d") {
|
|
2228
|
+
throw new Error(
|
|
2229
|
+
"room.physics: this room runs matter2d. Read room.physics2d instead (it has .matter and .engine, where this one has .rapier and .world)"
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
1684
2232
|
return physicsApi ?? (physicsApi = makePhysicsApi(p));
|
|
1685
2233
|
},
|
|
2234
|
+
get physics2d() {
|
|
2235
|
+
const p = core.physics;
|
|
2236
|
+
if (!p) {
|
|
2237
|
+
throw new Error(
|
|
2238
|
+
"room.physics2d: this room has no physics \u2014 add physics: { engine: 'matter2d', gravity, bodies } to defineRoom(...)"
|
|
2239
|
+
);
|
|
2240
|
+
}
|
|
2241
|
+
if (p.engineKind !== "matter2d") {
|
|
2242
|
+
throw new Error(
|
|
2243
|
+
"room.physics2d: this room runs rapier3d. Read room.physics instead (it has .rapier and .world, where this one has .matter and .engine)"
|
|
2244
|
+
);
|
|
2245
|
+
}
|
|
2246
|
+
return matterApi ?? (matterApi = makeMatterApi(p));
|
|
2247
|
+
},
|
|
1686
2248
|
send(target, bytes) {
|
|
1687
2249
|
sendMessage(core, target, bytes);
|
|
1688
2250
|
},
|
|
@@ -1724,6 +2286,33 @@ function createRoomApi(core, publicUrl) {
|
|
|
1724
2286
|
get kv() {
|
|
1725
2287
|
return kvApi ?? (kvApi = makeKv(core));
|
|
1726
2288
|
},
|
|
2289
|
+
get leaderboard() {
|
|
2290
|
+
return leaderboardApi ?? (leaderboardApi = makeLeaderboard(core));
|
|
2291
|
+
},
|
|
2292
|
+
get ratings() {
|
|
2293
|
+
return ratingsApi ?? (ratingsApi = makeRatings(core));
|
|
2294
|
+
},
|
|
2295
|
+
get backfill() {
|
|
2296
|
+
return {
|
|
2297
|
+
set(open) {
|
|
2298
|
+
if (!declaredBackfill) {
|
|
2299
|
+
core.log(
|
|
2300
|
+
"warn",
|
|
2301
|
+
"room.backfill.set: this room type did not declare `backfill: true`, so the matchmaker will never offer it. The call is ignored."
|
|
2302
|
+
);
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
backfillOpen = open;
|
|
2306
|
+
core.host.setBackfill(open);
|
|
2307
|
+
},
|
|
2308
|
+
get open() {
|
|
2309
|
+
return backfillOpen;
|
|
2310
|
+
}
|
|
2311
|
+
};
|
|
2312
|
+
},
|
|
2313
|
+
get bus() {
|
|
2314
|
+
return busApi ?? (busApi = makeBus(core));
|
|
2315
|
+
},
|
|
1727
2316
|
alarm(name, atMs) {
|
|
1728
2317
|
if (!isAlarmName(core, name, "room.alarm")) return;
|
|
1729
2318
|
if (!Number.isFinite(atMs)) {
|
|
@@ -1739,6 +2328,27 @@ function createRoomApi(core, publicUrl) {
|
|
|
1739
2328
|
}
|
|
1740
2329
|
core.host.setAlarm(name, void 0);
|
|
1741
2330
|
},
|
|
2331
|
+
spawnNPC(config) {
|
|
2332
|
+
const clientId = `npc-${core.roomId}-${++npcCounter}`;
|
|
2333
|
+
const checked = checkNpcConfig(core, config);
|
|
2334
|
+
if (checked) core.host.spawnNpc(clientId, checked);
|
|
2335
|
+
let despawned = !checked;
|
|
2336
|
+
return {
|
|
2337
|
+
clientId,
|
|
2338
|
+
despawn() {
|
|
2339
|
+
if (despawned) return;
|
|
2340
|
+
despawned = true;
|
|
2341
|
+
core.host.despawnNpc(clientId);
|
|
2342
|
+
}
|
|
2343
|
+
};
|
|
2344
|
+
},
|
|
2345
|
+
despawnNPC(clientId) {
|
|
2346
|
+
if (typeof clientId !== "string" || clientId === "") {
|
|
2347
|
+
core.log("warn", "room.despawnNPC: a client id is required; ignored");
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
core.host.despawnNpc(clientId);
|
|
2351
|
+
},
|
|
1742
2352
|
call(clientId) {
|
|
1743
2353
|
return createCallProxy(core, clientId);
|
|
1744
2354
|
},
|
|
@@ -1793,6 +2403,50 @@ function setRole(core, clientId, role) {
|
|
|
1793
2403
|
if (presence) presence.role = role;
|
|
1794
2404
|
core.invalidateClients();
|
|
1795
2405
|
}
|
|
2406
|
+
function checkNpcConfig(core, config) {
|
|
2407
|
+
if (typeof config !== "object" || config === null) {
|
|
2408
|
+
throw new Error("room.spawnNPC: a config is required, like " + NPC_EXAMPLE);
|
|
2409
|
+
}
|
|
2410
|
+
const brain = config.brain;
|
|
2411
|
+
if (typeof brain !== "object" || brain === null) {
|
|
2412
|
+
throw new Error("room.spawnNPC: config.brain is required, like " + NPC_EXAMPLE);
|
|
2413
|
+
}
|
|
2414
|
+
if (brain.kind !== "script") {
|
|
2415
|
+
throw new Error(
|
|
2416
|
+
`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>' }.`
|
|
2417
|
+
);
|
|
2418
|
+
}
|
|
2419
|
+
if (typeof brain.script !== "string" || brain.script === "") {
|
|
2420
|
+
throw new Error(
|
|
2421
|
+
"room.spawnNPC: brain.script must name an entry in the room definition's npcs map, like " + NPC_EXAMPLE
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
const npcs = core.definition.config.npcs;
|
|
2425
|
+
if (!npcs || typeof npcs[brain.script] !== "function") {
|
|
2426
|
+
const known = npcs ? Object.keys(npcs) : [];
|
|
2427
|
+
throw new Error(
|
|
2428
|
+
`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.")
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
if (config.role !== void 0 && typeof config.role !== "string") {
|
|
2432
|
+
throw new Error("room.spawnNPC: role must be a string when given");
|
|
2433
|
+
}
|
|
2434
|
+
if (config.name !== void 0 && typeof config.name !== "string") {
|
|
2435
|
+
throw new Error("room.spawnNPC: name must be a string when given");
|
|
2436
|
+
}
|
|
2437
|
+
if (config.seed !== void 0 && !Number.isFinite(config.seed)) {
|
|
2438
|
+
throw new Error("room.spawnNPC: seed must be a finite number when given");
|
|
2439
|
+
}
|
|
2440
|
+
return {
|
|
2441
|
+
brain: { kind: "script", script: brain.script },
|
|
2442
|
+
...config.role !== void 0 ? { role: config.role } : {},
|
|
2443
|
+
...config.name !== void 0 ? { name: config.name } : {},
|
|
2444
|
+
// Seeded from the room's own recorded rng when the caller did not pick one, so a room that
|
|
2445
|
+
// spawns its NPCs the same way twice gets the same NPCs twice.
|
|
2446
|
+
seed: config.seed ?? Math.floor(core.rng.next() * 2147483647)
|
|
2447
|
+
};
|
|
2448
|
+
}
|
|
2449
|
+
var NPC_EXAMPLE = "{ brain: { kind: 'script', script: 'chaser' } }";
|
|
1796
2450
|
|
|
1797
2451
|
// src/core/room.ts
|
|
1798
2452
|
var DEFAULT_PUBLIC_URL = "http://localhost/";
|
|
@@ -1850,6 +2504,17 @@ var RoomCore = class _RoomCore {
|
|
|
1850
2504
|
* into a timeout in an unrelated assertion ten seconds later. Unset in production.
|
|
1851
2505
|
*/
|
|
1852
2506
|
onHandlerError;
|
|
2507
|
+
/**
|
|
2508
|
+
* D41: the recorded authoritative timeline. Armed by `startRecording()` and off otherwise, so a
|
|
2509
|
+
* room nobody asked to record pays nothing. A room that was asked captures its own state at the
|
|
2510
|
+
* end of every tick, which is the only moment in a tick where that state is settled.
|
|
2511
|
+
*/
|
|
2512
|
+
recorder;
|
|
2513
|
+
/**
|
|
2514
|
+
* D65: the bandwidth ledger, present only when `RoomCoreOptions.profile` asked for one. Every
|
|
2515
|
+
* cost the profiler has is behind this `undefined`.
|
|
2516
|
+
*/
|
|
2517
|
+
ledger;
|
|
1853
2518
|
seed;
|
|
1854
2519
|
api;
|
|
1855
2520
|
internals;
|
|
@@ -1861,7 +2526,8 @@ var RoomCore = class _RoomCore {
|
|
|
1861
2526
|
this.host = host;
|
|
1862
2527
|
this.roomId = options.roomId;
|
|
1863
2528
|
this.mode = definition.config.mode;
|
|
1864
|
-
this.ext =
|
|
2529
|
+
this.ext = withBuiltins2(definition.schema);
|
|
2530
|
+
this.ledger = options.profile === true ? new ProfileLedger(this.ext, definition.schema) : void 0;
|
|
1865
2531
|
for (const issue of validateForDeploy(this.ext)) {
|
|
1866
2532
|
if (issue.level === "error") throw new Error(`RoomCore: ${issue.message}`);
|
|
1867
2533
|
host.log("warn", [`irtio: ${issue.message}`]);
|
|
@@ -1870,7 +2536,7 @@ var RoomCore = class _RoomCore {
|
|
|
1870
2536
|
let plain;
|
|
1871
2537
|
if (options.restoreFrom) {
|
|
1872
2538
|
restored = parseHibernationBlob(options.restoreFrom);
|
|
1873
|
-
plain =
|
|
2539
|
+
plain = decodeSnapshot2(this.ext, restored.snapshot).state;
|
|
1874
2540
|
const presence = plainEntity(plain, PRESENCE_COLLECTION);
|
|
1875
2541
|
for (const id of [...presence.ids()]) presence.remove(id);
|
|
1876
2542
|
if (restored.mode !== this.mode) {
|
|
@@ -1892,6 +2558,7 @@ var RoomCore = class _RoomCore {
|
|
|
1892
2558
|
this.loop = new Loop(self);
|
|
1893
2559
|
this.api = createRoomApi(self, options.publicUrl ?? DEFAULT_PUBLIC_URL);
|
|
1894
2560
|
this.physics = this.buildPhysics(restored);
|
|
2561
|
+
this.subscribeDeclaredChannels();
|
|
1895
2562
|
if (restored) {
|
|
1896
2563
|
const onWake = definition.config.onWake;
|
|
1897
2564
|
if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
|
|
@@ -1900,6 +2567,64 @@ var RoomCore = class _RoomCore {
|
|
|
1900
2567
|
if (onCreate) this.guard("onCreate", () => onCreate(this.state, this.room));
|
|
1901
2568
|
}
|
|
1902
2569
|
}
|
|
2570
|
+
subscribeDeclaredChannels() {
|
|
2571
|
+
const channels = this.busConfig?.channels;
|
|
2572
|
+
if (!channels) return;
|
|
2573
|
+
for (const channel of Object.keys(channels)) {
|
|
2574
|
+
const problem = busChannelProblem2(channel);
|
|
2575
|
+
if (problem) {
|
|
2576
|
+
this.log("warn", `bus.channels: ${problem.message}; that channel is not subscribed`);
|
|
2577
|
+
continue;
|
|
2578
|
+
}
|
|
2579
|
+
this.host.busSubscribe(channel, true);
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
get busConfig() {
|
|
2583
|
+
return this.definition.config.bus;
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* D59: one published message arriving on a channel this room is subscribed to.
|
|
2587
|
+
*
|
|
2588
|
+
* Same scheduling class as an alarm or an RPC — a discrete event between ticks — so a tick-mode
|
|
2589
|
+
* room never sees a `tick` run half-delivered. `from` is supervisor-stamped, so the handler may
|
|
2590
|
+
* trust it as far as it trusts its own project.
|
|
2591
|
+
*/
|
|
2592
|
+
deliverBusEvent(channel, from, payload) {
|
|
2593
|
+
if (this.stopped) return;
|
|
2594
|
+
const handler = this.busConfig?.channels?.[channel];
|
|
2595
|
+
if (!handler) {
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
this.recordEvent("bus", void 0, channel);
|
|
2599
|
+
this.guard(
|
|
2600
|
+
`bus.channels.${channel}`,
|
|
2601
|
+
() => handler(this.state, { channel, from, payload }, this.room)
|
|
2602
|
+
);
|
|
2603
|
+
this.eventFlush();
|
|
2604
|
+
}
|
|
2605
|
+
/**
|
|
2606
|
+
* D59: one directed `room.bus.send` arriving. At-least-once, so this can run twice for one send;
|
|
2607
|
+
* that is the receiver's problem to be idempotent about and the docs say so.
|
|
2608
|
+
*
|
|
2609
|
+
* A throw propagates through `guard` and counts toward the crash threshold exactly as any other
|
|
2610
|
+
* handler throw does. That is deliberate, and it is why the supervisor bounds redelivery: the
|
|
2611
|
+
* two behaviours together would otherwise let one poisonous message close a room on every wake,
|
|
2612
|
+
* forever.
|
|
2613
|
+
*/
|
|
2614
|
+
deliverBusMessage(from, payload) {
|
|
2615
|
+
if (this.stopped) return;
|
|
2616
|
+
const handler = this.busConfig?.onMessage;
|
|
2617
|
+
if (!handler) {
|
|
2618
|
+
this.log(
|
|
2619
|
+
"warn",
|
|
2620
|
+
`a bus message from ${JSON.stringify(from)} was delivered but the room declares no bus.onMessage handler; it is dropped`
|
|
2621
|
+
);
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
this.recordEvent("bus", void 0, "onMessage");
|
|
2625
|
+
this.guard("bus.onMessage", () => handler(this.state, { from, payload }, this.room));
|
|
2626
|
+
this.eventFlush();
|
|
2627
|
+
}
|
|
1903
2628
|
/**
|
|
1904
2629
|
* D22: builds the world, or returns `undefined` for a room with no `physics:` config.
|
|
1905
2630
|
*
|
|
@@ -1914,14 +2639,15 @@ var RoomCore = class _RoomCore {
|
|
|
1914
2639
|
buildPhysics(restored) {
|
|
1915
2640
|
const config = this.definition.config.physics;
|
|
1916
2641
|
if (!config) return void 0;
|
|
1917
|
-
|
|
1918
|
-
|
|
2642
|
+
if (config.engine === "matter2d") return this.buildMatter(config, restored);
|
|
2643
|
+
const engine3 = loadedPhysics();
|
|
2644
|
+
if (!engine3) {
|
|
1919
2645
|
throw new Error(
|
|
1920
2646
|
"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
2647
|
);
|
|
1922
2648
|
}
|
|
1923
2649
|
const section = restored?.physics ? decodePhysicsSection(restored.physics) : void 0;
|
|
1924
|
-
const physics = new PhysicsRuntime(this.internals,
|
|
2650
|
+
const physics = new PhysicsRuntime(this.internals, engine3, {
|
|
1925
2651
|
...section ? { restore: section } : {},
|
|
1926
2652
|
defaultTimestep: 1 / this.definition.config.tickRate
|
|
1927
2653
|
});
|
|
@@ -1936,6 +2662,43 @@ var RoomCore = class _RoomCore {
|
|
|
1936
2662
|
}
|
|
1937
2663
|
return physics;
|
|
1938
2664
|
}
|
|
2665
|
+
/**
|
|
2666
|
+
* D45: the matter2d half. It differs from Rapier's in one structural way — there is no engine
|
|
2667
|
+
* snapshot to restore, so the world is always **built** and per-body state is reapplied on top
|
|
2668
|
+
* of it. `setup` therefore runs on every wake, and only the "there was no world at all" case is
|
|
2669
|
+
* worth logging.
|
|
2670
|
+
*/
|
|
2671
|
+
buildMatter(config, restored) {
|
|
2672
|
+
void config;
|
|
2673
|
+
const matter = loadedMatter();
|
|
2674
|
+
if (!matter) {
|
|
2675
|
+
throw new Error(
|
|
2676
|
+
"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."
|
|
2677
|
+
);
|
|
2678
|
+
}
|
|
2679
|
+
let section;
|
|
2680
|
+
if (restored?.physics) {
|
|
2681
|
+
if (physicsSectionEngine(restored.physics) === "matter2d") {
|
|
2682
|
+
section = decodeMatterBodies(decodeMatterSectionEnvelope(restored.physics));
|
|
2683
|
+
} else {
|
|
2684
|
+
this.host.log("warn", [
|
|
2685
|
+
"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."
|
|
2686
|
+
]);
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
const physics = new MatterRuntime(this.internals, matter, {
|
|
2690
|
+
...section ? { restore: section } : {},
|
|
2691
|
+
defaultTimestep: 1 / this.definition.config.tickRate
|
|
2692
|
+
});
|
|
2693
|
+
if (physics.rebuilt && restored) {
|
|
2694
|
+
this.host.log("info", [
|
|
2695
|
+
`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.`
|
|
2696
|
+
]);
|
|
2697
|
+
}
|
|
2698
|
+
physics.runSetup(this.room);
|
|
2699
|
+
physics.reconcile();
|
|
2700
|
+
return physics;
|
|
2701
|
+
}
|
|
1939
2702
|
/** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
|
|
1940
2703
|
static restore(definition, bytes, host, options) {
|
|
1941
2704
|
return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
|
|
@@ -1983,6 +2746,23 @@ var RoomCore = class _RoomCore {
|
|
|
1983
2746
|
if (detail !== void 0) e.detail = detail;
|
|
1984
2747
|
this.events.push(e);
|
|
1985
2748
|
}
|
|
2749
|
+
/**
|
|
2750
|
+
* D41: begin (or restart) recording the authoritative timeline. Calling it again clears what
|
|
2751
|
+
* was recorded, so two scenario runs against one long-lived dev server do not read each
|
|
2752
|
+
* other's ticks.
|
|
2753
|
+
*/
|
|
2754
|
+
startRecording(options = {}) {
|
|
2755
|
+
this.recorder = new TimelineRecorder(options);
|
|
2756
|
+
this.recorder.capture(this.tick, this.ext, this.plain);
|
|
2757
|
+
}
|
|
2758
|
+
/** D41: what has been recorded so far, or `undefined` when nobody armed the recorder. */
|
|
2759
|
+
recording() {
|
|
2760
|
+
return this.recorder?.dump(this.roomId);
|
|
2761
|
+
}
|
|
2762
|
+
/** D41: called at the end of every tick (and every event-mode flush). No-op when unarmed. */
|
|
2763
|
+
captureTimeline() {
|
|
2764
|
+
this.recorder?.capture(this.tick, this.ext, this.plain);
|
|
2765
|
+
}
|
|
1986
2766
|
/** Live JSON view of the room for the dev page / supervisor admin API. */
|
|
1987
2767
|
inspect() {
|
|
1988
2768
|
return {
|
|
@@ -2031,7 +2811,7 @@ var RoomCore = class _RoomCore {
|
|
|
2031
2811
|
* written.
|
|
2032
2812
|
*/
|
|
2033
2813
|
snapshot() {
|
|
2034
|
-
const physics = this.physics ?
|
|
2814
|
+
const physics = this.physics ? encodeSection(this.physics) : void 0;
|
|
2035
2815
|
return writeHibernationBlob(
|
|
2036
2816
|
{ seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
|
|
2037
2817
|
encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
|
|
@@ -2111,6 +2891,7 @@ var RoomCore = class _RoomCore {
|
|
|
2111
2891
|
// join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
|
|
2112
2892
|
// and nothing else here changes.
|
|
2113
2893
|
playerId: options.playerId ?? clientId,
|
|
2894
|
+
npc: options.npc === true,
|
|
2114
2895
|
role,
|
|
2115
2896
|
name,
|
|
2116
2897
|
connected: true,
|
|
@@ -2131,11 +2912,12 @@ var RoomCore = class _RoomCore {
|
|
|
2131
2912
|
const ctx = this.ctxFor(clientId, reconnecting);
|
|
2132
2913
|
this.guard("onJoin", () => onJoin(this.state, ctx));
|
|
2133
2914
|
}
|
|
2134
|
-
this.loop.noteActivity();
|
|
2915
|
+
if (!entry.npc) this.loop.noteActivity();
|
|
2135
2916
|
this.recordEvent("join", clientId, reconnecting ? "reconnect" : void 0);
|
|
2136
2917
|
const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
|
|
2137
2918
|
entry.spatialMembership = memberships;
|
|
2138
2919
|
const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
|
|
2920
|
+
this.ledger?.attributeSnapshot("out", snapshot);
|
|
2139
2921
|
const pending = /* @__PURE__ */ new Map();
|
|
2140
2922
|
for (const [name2, cd] of this.tracked.dirty) {
|
|
2141
2923
|
if (cd.added.size === 0) continue;
|
|
@@ -2161,6 +2943,7 @@ var RoomCore = class _RoomCore {
|
|
|
2161
2943
|
if (isDirtyEmpty3(this.tracked.dirty)) return;
|
|
2162
2944
|
this.tick++;
|
|
2163
2945
|
this.flush();
|
|
2946
|
+
this.captureTimeline();
|
|
2164
2947
|
}
|
|
2165
2948
|
leave(clientId, reason) {
|
|
2166
2949
|
const entry = this.clients.get(clientId);
|
|
@@ -2232,6 +3015,10 @@ var RoomCore = class _RoomCore {
|
|
|
2232
3015
|
* Draining a bounded number of turns first covers the chains rooms actually write, coalesces
|
|
2233
3016
|
* concurrent completions into one flush, and — unlike a `setTimeout(0)` — keeps working under
|
|
2234
3017
|
* the harness's synchronous fake clock, where a macrotask would fire *before* the microtasks.
|
|
3018
|
+
*
|
|
3019
|
+
* Public on `RoomInternals` (bug #48) so the room API's local rejections — a float score, an
|
|
3020
|
+
* empty bus target, a call to a client that is not connected — flush the state their `.catch()`
|
|
3021
|
+
* writes, instead of leaving it for whatever frame happens to arrive next.
|
|
2235
3022
|
*/
|
|
2236
3023
|
scheduleContinuationFlush() {
|
|
2237
3024
|
if (this.mode !== "event" || this.continuationFlushPending) return;
|
|
@@ -2276,15 +3063,31 @@ var RoomCore = class _RoomCore {
|
|
|
2276
3063
|
// -------------------------------------------------------------------------
|
|
2277
3064
|
// Frames
|
|
2278
3065
|
// -------------------------------------------------------------------------
|
|
2279
|
-
|
|
3066
|
+
/**
|
|
3067
|
+
* D65: `hint` is profiling context and nothing else — the payload object a per-view frame was
|
|
3068
|
+
* built from (so one encode is walked once and replayed per recipient) and the AOI ids whose
|
|
3069
|
+
* ops are visibility churn. Ignored entirely when this room is not profiling, which is why it
|
|
3070
|
+
* is an optional argument rather than a second method.
|
|
3071
|
+
*/
|
|
3072
|
+
send(clientId, frame, hint) {
|
|
2280
3073
|
if (this.stopped) return;
|
|
2281
3074
|
const entry = this.clients.get(clientId);
|
|
2282
3075
|
if (!entry || !entry.connected) return;
|
|
3076
|
+
const size = frame.length;
|
|
3077
|
+
this.ledger?.attribute("out", frame, { ...hint, peer: clientId });
|
|
2283
3078
|
this.host.send(clientId, frame);
|
|
2284
3079
|
this.stats.framesOut++;
|
|
2285
|
-
this.stats.bytesOut +=
|
|
3080
|
+
this.stats.bytesOut += size;
|
|
2286
3081
|
const by = this.stats.bytesOutByClient;
|
|
2287
|
-
by.set(clientId, (by.get(clientId) ?? 0) +
|
|
3082
|
+
by.set(clientId, (by.get(clientId) ?? 0) + size);
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* D65: the ledger so far. The room's own view, so it counts what `send()` sent and what
|
|
3086
|
+
* `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
|
|
3087
|
+
* `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
|
|
3088
|
+
*/
|
|
3089
|
+
profile() {
|
|
3090
|
+
return this.ledger?.snapshot();
|
|
2288
3091
|
}
|
|
2289
3092
|
badFrame(clientId, reason) {
|
|
2290
3093
|
this.log("warn", `bad frame from ${clientId}: ${reason}`);
|
|
@@ -2300,7 +3103,8 @@ var RoomCore = class _RoomCore {
|
|
|
2300
3103
|
return;
|
|
2301
3104
|
}
|
|
2302
3105
|
this.stats.framesIn++;
|
|
2303
|
-
this.
|
|
3106
|
+
this.ledger?.attribute("in", frame, { peer: clientId });
|
|
3107
|
+
if (this.clients.get(clientId)?.npc !== true) this.loop.noteActivity();
|
|
2304
3108
|
let type;
|
|
2305
3109
|
let payload;
|
|
2306
3110
|
try {
|
|
@@ -2352,6 +3156,7 @@ var RoomCore = class _RoomCore {
|
|
|
2352
3156
|
flush() {
|
|
2353
3157
|
const dirty = this.tracked.flush();
|
|
2354
3158
|
serverWinsCorrections(this.internals, dirty);
|
|
3159
|
+
this.ledger?.newFlush();
|
|
2355
3160
|
this.stats.encodesLastFlush = 0;
|
|
2356
3161
|
this.stats.aoiEncodesLastFlush = 0;
|
|
2357
3162
|
const spatialDescs = this.ext.collections.filter((desc) => desc.visibility === "spatial-grid");
|
|
@@ -2370,11 +3175,23 @@ var RoomCore = class _RoomCore {
|
|
|
2370
3175
|
indexes
|
|
2371
3176
|
);
|
|
2372
3177
|
this.stats.gridQueryMs += performance.now() - queryStarted;
|
|
3178
|
+
let churn;
|
|
2373
3179
|
for (const [name, ids] of memberships) {
|
|
2374
3180
|
const before = entry.spatialMembership.get(name) ?? /* @__PURE__ */ new Set();
|
|
2375
3181
|
this.stats.visibleIdsTotal += ids.size;
|
|
2376
|
-
|
|
2377
|
-
|
|
3182
|
+
const changed = this.ledger ? dirty.get(name) : void 0;
|
|
3183
|
+
let crossed;
|
|
3184
|
+
for (const id of ids) {
|
|
3185
|
+
if (before.has(id)) continue;
|
|
3186
|
+
this.stats.membershipEnters++;
|
|
3187
|
+
if (this.ledger && changed?.added.has(id) !== true) (crossed ??= /* @__PURE__ */ new Set()).add(id);
|
|
3188
|
+
}
|
|
3189
|
+
for (const id of before) {
|
|
3190
|
+
if (ids.has(id)) continue;
|
|
3191
|
+
this.stats.membershipLeaves++;
|
|
3192
|
+
if (this.ledger && changed?.removed.has(id) !== true) (crossed ??= /* @__PURE__ */ new Set()).add(id);
|
|
3193
|
+
}
|
|
3194
|
+
if (crossed) (churn ??= /* @__PURE__ */ new Map()).set(name, crossed);
|
|
2378
3195
|
}
|
|
2379
3196
|
if (entry.correction) {
|
|
2380
3197
|
const payload = encodeCorrection(
|
|
@@ -2407,7 +3224,13 @@ var RoomCore = class _RoomCore {
|
|
|
2407
3224
|
}
|
|
2408
3225
|
shared = cached;
|
|
2409
3226
|
}
|
|
2410
|
-
if (shared)
|
|
3227
|
+
if (shared) {
|
|
3228
|
+
this.send(
|
|
3229
|
+
entry.clientId,
|
|
3230
|
+
encodeFrame4(FrameType5.DELTA, shared),
|
|
3231
|
+
this.ledger ? { shared } : void 0
|
|
3232
|
+
);
|
|
3233
|
+
}
|
|
2411
3234
|
const aoiDirty = aoiViewDirty(
|
|
2412
3235
|
this.ext,
|
|
2413
3236
|
sourceDirty,
|
|
@@ -2443,13 +3266,25 @@ var RoomCore = class _RoomCore {
|
|
|
2443
3266
|
}
|
|
2444
3267
|
delta = cached;
|
|
2445
3268
|
}
|
|
2446
|
-
if (delta)
|
|
3269
|
+
if (delta) {
|
|
3270
|
+
this.send(
|
|
3271
|
+
entry.clientId,
|
|
3272
|
+
encodeFrame4(FrameType5.DELTA, delta),
|
|
3273
|
+
this.ledger ? {
|
|
3274
|
+
...churn ? { churn } : {},
|
|
3275
|
+
...spatialDescs.length > 0 ? {} : { shared: delta }
|
|
3276
|
+
} : void 0
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
2447
3279
|
}
|
|
2448
3280
|
entry.correction = void 0;
|
|
2449
3281
|
entry.accepted.clear();
|
|
2450
3282
|
}
|
|
2451
3283
|
}
|
|
2452
3284
|
};
|
|
3285
|
+
function encodeSection(physics) {
|
|
3286
|
+
return physics.engineKind === "matter2d" ? encodeMatterSectionEnvelope(encodeMatterBodies(physics.serialize())) : encodePhysicsSection(physics.serialize());
|
|
3287
|
+
}
|
|
2453
3288
|
|
|
2454
3289
|
export {
|
|
2455
3290
|
HOST_CALL_TIMEOUT_MS,
|
|
@@ -2459,10 +3294,20 @@ export {
|
|
|
2459
3294
|
RPC_TIMEOUT_MS,
|
|
2460
3295
|
MAX_CATCHUP,
|
|
2461
3296
|
CRASH_AFTER_THROWS,
|
|
3297
|
+
initMatter,
|
|
3298
|
+
loadedMatter,
|
|
3299
|
+
resetMatterForTests,
|
|
3300
|
+
encodeMatterBodies,
|
|
3301
|
+
decodeMatterBodies,
|
|
2462
3302
|
initPhysics,
|
|
2463
3303
|
loadedPhysics,
|
|
2464
3304
|
resetPhysicsForTests,
|
|
3305
|
+
rapierHasStepped,
|
|
3306
|
+
onFirstRapierStep,
|
|
2465
3307
|
encodePhysicsSection,
|
|
3308
|
+
physicsSectionEngine,
|
|
3309
|
+
encodeMatterSectionEnvelope,
|
|
3310
|
+
decodeMatterSectionEnvelope,
|
|
2466
3311
|
decodePhysicsSection,
|
|
2467
3312
|
Mulberry32,
|
|
2468
3313
|
isVisible,
|
|
@@ -2477,5 +3322,9 @@ export {
|
|
|
2477
3322
|
READABLE_SNAPSHOT_VERSIONS,
|
|
2478
3323
|
parseHibernationBlob,
|
|
2479
3324
|
writeHibernationBlob,
|
|
3325
|
+
decodeSave,
|
|
3326
|
+
DEFAULT_TIMELINE_MAX_TICKS,
|
|
3327
|
+
DEFAULT_TIMELINE_MAX_RECORDS,
|
|
3328
|
+
TimelineRecorder,
|
|
2480
3329
|
RoomCore
|
|
2481
3330
|
};
|