@irtio/runtime 1.0.0 → 3.1.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-YWDRE77H.js → chunk-3BCTP4YS.js} +79 -6
- package/dist/{chunk-LT46QFLW.js → chunk-NPRIYYNM.js} +1951 -1009
- package/dist/{contract-UEKTud1Z.d.ts → contract-BoCNAPZT.d.ts} +146 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +14 -2
- package/dist/{room-uhj3b-8E.d.ts → room-CGbiPxi3.d.ts} +311 -11
- package/dist/test/index.d.ts +30 -8
- package/dist/test/index.js +21 -8
- package/dist/worker/index.d.ts +41 -9
- package/dist/worker/index.js +34 -7
- package/package.json +9 -4
|
@@ -8,6 +8,15 @@ var RoomFullError = class extends Error {
|
|
|
8
8
|
maxClients;
|
|
9
9
|
name = "RoomFullError";
|
|
10
10
|
};
|
|
11
|
+
var AdmissionError = class extends Error {
|
|
12
|
+
constructor(reason) {
|
|
13
|
+
super(reason);
|
|
14
|
+
this.reason = reason;
|
|
15
|
+
}
|
|
16
|
+
reason;
|
|
17
|
+
name = "AdmissionError";
|
|
18
|
+
};
|
|
19
|
+
var PHYSICS_STEP_EMA_ALPHA = 0.1;
|
|
11
20
|
|
|
12
21
|
// src/core/caps.ts
|
|
13
22
|
import {
|
|
@@ -212,25 +221,73 @@ function inspectState(schema, plain) {
|
|
|
212
221
|
return out;
|
|
213
222
|
}
|
|
214
223
|
|
|
215
|
-
// src/core/rpc.ts
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
224
|
+
// src/core/rpc-trace.ts
|
|
225
|
+
var RPC_TRACE_SIZE = 200;
|
|
226
|
+
var RPC_TRACE_SUMMARY_MAX = 200;
|
|
227
|
+
function summarize(value) {
|
|
228
|
+
if (value === void 0) return void 0;
|
|
229
|
+
let text;
|
|
230
|
+
try {
|
|
231
|
+
text = JSON.stringify(value) ?? String(value);
|
|
232
|
+
} catch {
|
|
233
|
+
text = String(value);
|
|
234
|
+
}
|
|
235
|
+
return text.length > RPC_TRACE_SUMMARY_MAX ? `${text.slice(0, RPC_TRACE_SUMMARY_MAX)}\u2026` : text;
|
|
236
|
+
}
|
|
237
|
+
var RpcTraceRing = class {
|
|
238
|
+
items = [];
|
|
239
|
+
lastByClient = /* @__PURE__ */ new Map();
|
|
240
|
+
seq = 0;
|
|
241
|
+
push(entry) {
|
|
242
|
+
const filed = { ...entry, seq: ++this.seq };
|
|
243
|
+
this.items.push(filed);
|
|
244
|
+
if (this.items.length > RPC_TRACE_SIZE) this.items.shift();
|
|
245
|
+
this.lastByClient.set(filed.clientId, {
|
|
246
|
+
tick: filed.tick,
|
|
247
|
+
clientTick: filed.clientTick,
|
|
248
|
+
lag: filed.clientTick === void 0 ? void 0 : filed.tick - filed.clientTick,
|
|
249
|
+
rpc: filed.rpc,
|
|
250
|
+
seq: filed.seq
|
|
251
|
+
});
|
|
252
|
+
if (this.lastByClient.size > RPC_TRACE_SIZE) {
|
|
253
|
+
const oldest = this.lastByClient.keys().next();
|
|
254
|
+
if (!oldest.done && oldest.value !== filed.clientId) this.lastByClient.delete(oldest.value);
|
|
255
|
+
}
|
|
256
|
+
return filed;
|
|
257
|
+
}
|
|
258
|
+
list() {
|
|
259
|
+
return this.items;
|
|
260
|
+
}
|
|
261
|
+
/** Client id → its last traced call. Survives the entry itself being evicted from the ring. */
|
|
262
|
+
stampLag() {
|
|
263
|
+
return this.lastByClient;
|
|
264
|
+
}
|
|
265
|
+
/** A client that left takes its stamp row with it; the ring entries stay. */
|
|
266
|
+
forget(clientId) {
|
|
267
|
+
this.lastByClient.delete(clientId);
|
|
268
|
+
}
|
|
269
|
+
/** JSON shape served by `/__irt/rpc-trace.json` and carried on `inspect`. */
|
|
270
|
+
dump() {
|
|
271
|
+
return {
|
|
272
|
+
entries: [...this.items],
|
|
273
|
+
stampLag: [...this.lastByClient].map(([clientId, lag]) => ({ clientId, ...lag }))
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// src/core/views.ts
|
|
229
279
|
import {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
280
|
+
collectionDirty,
|
|
281
|
+
createDirtySet,
|
|
282
|
+
createFieldMask,
|
|
283
|
+
encodeDelta,
|
|
284
|
+
encodeSnapshot as encodeSnapshot2,
|
|
285
|
+
filterDirty,
|
|
286
|
+
isDirtyEmpty,
|
|
287
|
+
markAdd,
|
|
288
|
+
markPath,
|
|
289
|
+
markRemove,
|
|
290
|
+
mergeDirty
|
|
234
291
|
} from "@irtio/schema";
|
|
235
292
|
|
|
236
293
|
// src/core/internals.ts
|
|
@@ -259,128 +316,400 @@ function deepEqual(a, b) {
|
|
|
259
316
|
return true;
|
|
260
317
|
}
|
|
261
318
|
|
|
262
|
-
// src/core/
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
states2.set(core, s);
|
|
271
|
-
}
|
|
272
|
-
return s;
|
|
273
|
-
}
|
|
274
|
-
function keyOf(collection, id) {
|
|
275
|
-
return `${collection} ${id}`;
|
|
276
|
-
}
|
|
277
|
-
function policyCollections(core) {
|
|
278
|
-
return core.ext.collections.filter(
|
|
279
|
-
(c) => c.kind === "entity" && c.ownership !== void 0
|
|
280
|
-
);
|
|
319
|
+
// src/core/spatial.ts
|
|
320
|
+
function cellFor(grid, value) {
|
|
321
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
322
|
+
const record = value;
|
|
323
|
+
const x = record[grid.x];
|
|
324
|
+
const y = record[grid.y];
|
|
325
|
+
if (typeof x !== "number" || typeof y !== "number") return void 0;
|
|
326
|
+
return { x: Math.floor(x / grid.cell), y: Math.floor(y / grid.cell) };
|
|
281
327
|
}
|
|
282
|
-
function
|
|
283
|
-
return
|
|
328
|
+
function cellKey(cell) {
|
|
329
|
+
return `${cell.x},${cell.y}`;
|
|
284
330
|
}
|
|
285
|
-
function
|
|
286
|
-
|
|
331
|
+
function buildSpatialIndex(plain, desc) {
|
|
332
|
+
if (!desc.grid || desc.kind !== "entity") {
|
|
333
|
+
throw new Error(`${desc.name}: cannot build an index without an entity grid`);
|
|
334
|
+
}
|
|
335
|
+
const collection = plainEntity(plain, desc.name);
|
|
336
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
337
|
+
const order = /* @__PURE__ */ new Map();
|
|
338
|
+
let n = 0;
|
|
339
|
+
for (const id of collection.ids()) {
|
|
340
|
+
order.set(id, n++);
|
|
341
|
+
const cell = cellFor(desc.grid, collection.get(id));
|
|
342
|
+
if (!cell) continue;
|
|
343
|
+
const key = cellKey(cell);
|
|
344
|
+
let ids = buckets.get(key);
|
|
345
|
+
if (!ids) {
|
|
346
|
+
ids = /* @__PURE__ */ new Set();
|
|
347
|
+
buckets.set(key, ids);
|
|
348
|
+
}
|
|
349
|
+
ids.add(id);
|
|
350
|
+
}
|
|
351
|
+
return { desc, buckets, collection, order };
|
|
287
352
|
}
|
|
288
|
-
function
|
|
289
|
-
|
|
353
|
+
function querySpatialIndex(index, clientId, role) {
|
|
354
|
+
const grid = index.desc.grid;
|
|
355
|
+
if ((grid.wideRoles ?? []).includes(role)) return new Set(index.collection.ids());
|
|
356
|
+
const anchor = index.collection.get(clientId);
|
|
357
|
+
const anchorCell = cellFor(grid, anchor);
|
|
358
|
+
if (!anchor || !anchorCell) return /* @__PURE__ */ new Set();
|
|
359
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
360
|
+
for (let x = anchorCell.x - grid.radius; x <= anchorCell.x + grid.radius; x++) {
|
|
361
|
+
for (let y = anchorCell.y - grid.radius; y <= anchorCell.y + grid.radius; y++) {
|
|
362
|
+
const ids = index.buckets.get(cellKey({ x, y }));
|
|
363
|
+
if (ids) for (const id of ids) candidates.add(id);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
candidates.add(clientId);
|
|
367
|
+
const ranked = [];
|
|
368
|
+
for (const id of candidates) if (index.order.has(id)) ranked.push(id);
|
|
369
|
+
ranked.sort((a, b) => index.order.get(a) - index.order.get(b));
|
|
370
|
+
return new Set(ranked);
|
|
290
371
|
}
|
|
291
|
-
function
|
|
292
|
-
const
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const coll = plainEntity(core.plain, c.name);
|
|
297
|
-
for (const id of [...coll.ids()]) {
|
|
298
|
-
if (coll.ownerOf(id) !== clientId) continue;
|
|
299
|
-
const hold = {
|
|
300
|
-
collection: c.name,
|
|
301
|
-
id,
|
|
302
|
-
holder: playerId,
|
|
303
|
-
deadlineMs: now + policy.holdMs,
|
|
304
|
-
// `then` is the D77 spec's own name for the leave action; a hold is plain data nothing
|
|
305
|
-
// ever awaits.
|
|
306
|
-
// biome-ignore lint/suspicious/noThenProperty: see above
|
|
307
|
-
then: policy.then
|
|
308
|
-
};
|
|
309
|
-
if (policy.holdMs <= 0) {
|
|
310
|
-
act(core, hold);
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
s.holds.set(keyOf(c.name, id), hold);
|
|
372
|
+
function buildSpatialIndexes(collections, plain) {
|
|
373
|
+
const indexes = /* @__PURE__ */ new Map();
|
|
374
|
+
for (const desc of collections) {
|
|
375
|
+
if (desc.visibility === "spatial-grid" && desc.grid) {
|
|
376
|
+
indexes.set(desc.name, buildSpatialIndex(plain, desc));
|
|
314
377
|
}
|
|
315
378
|
}
|
|
379
|
+
return indexes;
|
|
316
380
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
381
|
+
|
|
382
|
+
// src/core/views.ts
|
|
383
|
+
var cache = /* @__PURE__ */ new WeakMap();
|
|
384
|
+
function infoOf(ext) {
|
|
385
|
+
let i = cache.get(ext);
|
|
386
|
+
if (!i) {
|
|
387
|
+
const scoped = /* @__PURE__ */ new Set();
|
|
388
|
+
for (const c of ext.collections) {
|
|
389
|
+
if (c.visibility === "role") for (const r of c.roles ?? []) scoped.add(r);
|
|
390
|
+
}
|
|
391
|
+
i = { scoped, names: /* @__PURE__ */ new Map() };
|
|
392
|
+
cache.set(ext, i);
|
|
328
393
|
}
|
|
329
|
-
return
|
|
394
|
+
return i;
|
|
330
395
|
}
|
|
331
|
-
function
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
396
|
+
function isVisible(c, role) {
|
|
397
|
+
if (c.visibility === "server") return false;
|
|
398
|
+
return c.visibility !== "role" || (c.roles ?? []).includes(role);
|
|
399
|
+
}
|
|
400
|
+
function visibleTo(role) {
|
|
401
|
+
return (c) => isVisible(c, role);
|
|
402
|
+
}
|
|
403
|
+
function visibleNames(ext, role) {
|
|
404
|
+
const info = infoOf(ext);
|
|
405
|
+
let set = info.names.get(role);
|
|
406
|
+
if (!set) {
|
|
407
|
+
set = new Set(
|
|
408
|
+
ext.collections.filter((c) => isVisible(c, role)).map((c) => c.name)
|
|
409
|
+
);
|
|
410
|
+
info.names.set(role, set);
|
|
339
411
|
}
|
|
412
|
+
return set;
|
|
340
413
|
}
|
|
341
|
-
function
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
case "nearest": {
|
|
353
|
-
const to = nearestConnected(core, hold.collection, hold.id);
|
|
354
|
-
tracked.setOwner(hold.id, to ?? SERVER_OWNER);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
case "random": {
|
|
358
|
-
const candidates = connectedClients(core);
|
|
359
|
-
if (candidates.length === 0) {
|
|
360
|
-
tracked.setOwner(hold.id, SERVER_OWNER);
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
const pick = candidates[Math.floor(core.rng.next() * candidates.length)];
|
|
364
|
-
tracked.setOwner(hold.id, pick);
|
|
365
|
-
return;
|
|
414
|
+
function viewKeyFor(ext, role) {
|
|
415
|
+
return infoOf(ext).scoped.has(role) ? role : "all";
|
|
416
|
+
}
|
|
417
|
+
function encodeViewSnapshot(ext, plain, role, tick, memberships) {
|
|
418
|
+
return encodeSnapshot2(
|
|
419
|
+
ext,
|
|
420
|
+
plain,
|
|
421
|
+
{ tick },
|
|
422
|
+
{
|
|
423
|
+
collections: visibleTo(role),
|
|
424
|
+
entities: (c, id) => c.visibility !== "spatial-grid" || (memberships?.get(c.name)?.has(id) ?? false)
|
|
366
425
|
}
|
|
367
|
-
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
426
|
+
);
|
|
370
427
|
}
|
|
371
|
-
function
|
|
372
|
-
const out =
|
|
373
|
-
for (const
|
|
428
|
+
function spatialMemberships(ext, plain, clientId, role, indexes = buildSpatialIndexes(ext.collections, plain)) {
|
|
429
|
+
const out = /* @__PURE__ */ new Map();
|
|
430
|
+
for (const [name, index] of indexes) out.set(name, querySpatialIndex(index, clientId, role));
|
|
374
431
|
return out;
|
|
375
432
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
433
|
+
var aoiMemo = /* @__PURE__ */ new WeakMap();
|
|
434
|
+
function inClientView(core, clientId, desc, id) {
|
|
435
|
+
const role = core.clients.get(clientId)?.role ?? "";
|
|
436
|
+
if (!isVisible(desc, role)) return false;
|
|
437
|
+
if (desc.visibility !== "spatial-grid") return true;
|
|
438
|
+
let memo = aoiMemo.get(core);
|
|
439
|
+
if (!memo || memo.tick !== core.tick) {
|
|
440
|
+
memo = { tick: core.tick, byClient: /* @__PURE__ */ new Map() };
|
|
441
|
+
aoiMemo.set(core, memo);
|
|
442
|
+
}
|
|
443
|
+
const key = `${clientId}\0${role}`;
|
|
444
|
+
let mine = memo.byClient.get(key);
|
|
445
|
+
if (!mine) {
|
|
446
|
+
mine = spatialMemberships(core.ext, core.plain, clientId, role);
|
|
447
|
+
memo.byClient.set(key, mine);
|
|
448
|
+
}
|
|
449
|
+
return mine.get(desc.name)?.has(id) ?? false;
|
|
380
450
|
}
|
|
381
|
-
function
|
|
382
|
-
const
|
|
383
|
-
|
|
451
|
+
function createVisibilityPolicy(ext, plain, clientId, role) {
|
|
452
|
+
const memberships = spatialMemberships(ext, plain, clientId, role);
|
|
453
|
+
return {
|
|
454
|
+
memberships,
|
|
455
|
+
maySeeCollection: (desc) => isVisible(desc, role),
|
|
456
|
+
maySeeEntity: (desc, id) => isVisible(desc, role) && (desc.visibility !== "spatial-grid" || (memberships.get(desc.name)?.has(id) ?? false)),
|
|
457
|
+
describeSpatial: (desc, ids) => {
|
|
458
|
+
const grid = desc.grid;
|
|
459
|
+
if (desc.visibility !== "spatial-grid" || !grid) return void 0;
|
|
460
|
+
const coll = plainEntity(plain, desc.name);
|
|
461
|
+
const at = (id) => {
|
|
462
|
+
const cell = cellFor(grid, coll.get(id));
|
|
463
|
+
return cell ? `${id}@(${cell.x},${cell.y})` : `${id}@(no position)`;
|
|
464
|
+
};
|
|
465
|
+
return `viewer ${at(clientId)}, ${desc.name} ${ids.map(at).join(" ")}, radius ${grid.radius}`;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function sharedViewDirty(ext, dirty, role) {
|
|
470
|
+
return filterDirty(dirty, (name) => {
|
|
471
|
+
const desc = ext.collection(name);
|
|
472
|
+
return desc.visibility !== "spatial-grid" && isVisible(desc, role);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
function aoiViewDirty(ext, dirty, previous, current) {
|
|
476
|
+
const out = createDirtySet();
|
|
477
|
+
for (const desc of ext.collections) {
|
|
478
|
+
if (desc.visibility !== "spatial-grid") continue;
|
|
479
|
+
const before = previous.get(desc.name) ?? /* @__PURE__ */ new Set();
|
|
480
|
+
const now = current.get(desc.name) ?? /* @__PURE__ */ new Set();
|
|
481
|
+
for (const id of before) if (!now.has(id)) markRemove(out, desc.name, id);
|
|
482
|
+
for (const id of now) if (!before.has(id)) markAdd(out, desc.name, id);
|
|
483
|
+
const changed = dirty.get(desc.name);
|
|
484
|
+
if (!changed) continue;
|
|
485
|
+
const visibleChanges = createDirtySet();
|
|
486
|
+
const collection = collectionDirty(visibleChanges, desc.name);
|
|
487
|
+
for (const id of changed.added) if (now.has(id)) collection.added.add(id);
|
|
488
|
+
for (const id of changed.removed) if (before.has(id)) collection.removed.add(id);
|
|
489
|
+
for (const [id, record] of changed.updated) {
|
|
490
|
+
if (before.has(id) && now.has(id)) collection.updated.set(id, record);
|
|
491
|
+
}
|
|
492
|
+
mergeDirty(out, visibleChanges);
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
function membershipSafeDirty(ext, dirty, memberships) {
|
|
497
|
+
const out = createDirtySet();
|
|
498
|
+
for (const [name, changed] of dirty) {
|
|
499
|
+
const desc = ext.collection(name);
|
|
500
|
+
if (desc.visibility !== "spatial-grid") {
|
|
501
|
+
out.set(name, changed);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const visible = memberships.get(name) ?? /* @__PURE__ */ new Set();
|
|
505
|
+
const kept = collectionDirty(out, name);
|
|
506
|
+
for (const id of changed.added) if (visible.has(id)) kept.added.add(id);
|
|
507
|
+
for (const id of changed.removed) if (visible.has(id)) kept.removed.add(id);
|
|
508
|
+
for (const [id, record] of changed.updated) if (visible.has(id)) kept.updated.set(id, record);
|
|
509
|
+
}
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
function encodeViewDelta(ext, plain, dirty, role, tick) {
|
|
513
|
+
const keep = visibleNames(ext, role);
|
|
514
|
+
const filtered = filterDirty(dirty, (name) => keep.has(name));
|
|
515
|
+
if (isDirtyEmpty(filtered)) return null;
|
|
516
|
+
return encodeDelta(ext, plain, filtered, { tick });
|
|
517
|
+
}
|
|
518
|
+
function stripAdds(plain, dirty, suppress) {
|
|
519
|
+
if (suppress.size === 0) return dirty;
|
|
520
|
+
const out = /* @__PURE__ */ new Map();
|
|
521
|
+
for (const [name, cd] of dirty) {
|
|
522
|
+
const captured = suppress.get(name);
|
|
523
|
+
if (!captured || captured.size === 0 || cd.added.size === 0) {
|
|
524
|
+
out.set(name, cd);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const coll = plainEntity(plain, name);
|
|
528
|
+
let added = cd.added;
|
|
529
|
+
for (const id of captured.keys()) {
|
|
530
|
+
if (!added.has(id)) continue;
|
|
531
|
+
const c = captured.get(id);
|
|
532
|
+
if (coll.ownerOf(id) !== c.owner || !deepEqual(coll.get(id), c.value)) continue;
|
|
533
|
+
if (added === cd.added) added = new Set(cd.added);
|
|
534
|
+
added.delete(id);
|
|
535
|
+
}
|
|
536
|
+
out.set(name, added === cd.added ? cd : { added, removed: cd.removed, updated: cd.updated });
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
function catchUpDirty(ext, plain, fromRole, toRole) {
|
|
541
|
+
const dirty = createDirtySet();
|
|
542
|
+
for (const c of ext.collections) {
|
|
543
|
+
const was = isVisible(c, fromRole);
|
|
544
|
+
const now = isVisible(c, toRole);
|
|
545
|
+
if (was === now) continue;
|
|
546
|
+
if (c.kind === "singleton") {
|
|
547
|
+
if (!now) continue;
|
|
548
|
+
const mask = createFieldMask();
|
|
549
|
+
for (const f of c.fields) markPath(mask, [f.index]);
|
|
550
|
+
collectionDirty(dirty, c.name).updated.set("", { mask, owner: false });
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const coll = plainEntity(plain, c.name);
|
|
554
|
+
for (const id of [...coll.ids()]) {
|
|
555
|
+
if (now) markAdd(dirty, c.name, id);
|
|
556
|
+
else markRemove(dirty, c.name, id);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return dirty;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/core/rpc.ts
|
|
563
|
+
import {
|
|
564
|
+
ErrorCode as ErrorCode2,
|
|
565
|
+
FrameType as FrameType2,
|
|
566
|
+
OWNERSHIP_ASK,
|
|
567
|
+
clampErrorMessage,
|
|
568
|
+
decodeCall,
|
|
569
|
+
decodeReply,
|
|
570
|
+
encodeCall,
|
|
571
|
+
encodeFrame as encodeFrame2,
|
|
572
|
+
encodeReply,
|
|
573
|
+
isReservedRpcName,
|
|
574
|
+
rpcByIdOf,
|
|
575
|
+
rpcTable
|
|
576
|
+
} from "@irtio/protocol";
|
|
577
|
+
import {
|
|
578
|
+
SERVER_OWNER as SERVER_OWNER2,
|
|
579
|
+
decodeFields,
|
|
580
|
+
encodeFields,
|
|
581
|
+
validateValue
|
|
582
|
+
} from "@irtio/schema";
|
|
583
|
+
import { DenyError } from "@irtio/server";
|
|
584
|
+
|
|
585
|
+
// src/core/ownership.ts
|
|
586
|
+
import { SERVER_OWNER, defaultRecord } from "@irtio/schema";
|
|
587
|
+
var HOLD_ACTIONS = /* @__PURE__ */ new Set([
|
|
588
|
+
"release",
|
|
589
|
+
"nearest",
|
|
590
|
+
"random",
|
|
591
|
+
"remove"
|
|
592
|
+
]);
|
|
593
|
+
var ASK_WINDOW_MS = 5e3;
|
|
594
|
+
var states2 = /* @__PURE__ */ new WeakMap();
|
|
595
|
+
function stateOf2(core) {
|
|
596
|
+
let s = states2.get(core);
|
|
597
|
+
if (!s) {
|
|
598
|
+
s = { holds: /* @__PURE__ */ new Map(), asks: /* @__PURE__ */ new Map(), askUnknownWarned: false };
|
|
599
|
+
states2.set(core, s);
|
|
600
|
+
}
|
|
601
|
+
return s;
|
|
602
|
+
}
|
|
603
|
+
function keyOf(collection, id) {
|
|
604
|
+
return `${collection} ${id}`;
|
|
605
|
+
}
|
|
606
|
+
function policyCollections(core) {
|
|
607
|
+
return core.ext.collections.filter(
|
|
608
|
+
(c) => c.kind === "entity" && c.ownership !== void 0
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
function wallNow(core) {
|
|
612
|
+
return core.wallNow();
|
|
613
|
+
}
|
|
614
|
+
function holdOn(core, collection, id) {
|
|
615
|
+
return states2.get(core)?.holds.get(keyOf(collection, id));
|
|
616
|
+
}
|
|
617
|
+
function holdCount(core) {
|
|
618
|
+
return states2.get(core)?.holds.size ?? 0;
|
|
619
|
+
}
|
|
620
|
+
function placeHolds(core, clientId, playerId) {
|
|
621
|
+
const s = stateOf2(core);
|
|
622
|
+
const now = wallNow(core);
|
|
623
|
+
for (const c of policyCollections(core)) {
|
|
624
|
+
const policy = c.ownership;
|
|
625
|
+
const coll = plainEntity(core.plain, c.name);
|
|
626
|
+
for (const id of [...coll.ids()]) {
|
|
627
|
+
if (coll.ownerOf(id) !== clientId) continue;
|
|
628
|
+
const hold = {
|
|
629
|
+
collection: c.name,
|
|
630
|
+
id,
|
|
631
|
+
holder: playerId,
|
|
632
|
+
deadlineMs: now + policy.holdMs,
|
|
633
|
+
// `then` is the D77 spec's own name for the leave action; a hold is plain data nothing
|
|
634
|
+
// ever awaits.
|
|
635
|
+
// biome-ignore lint/suspicious/noThenProperty: see above
|
|
636
|
+
then: policy.then
|
|
637
|
+
};
|
|
638
|
+
if (policy.holdMs <= 0) {
|
|
639
|
+
act(core, hold);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
s.holds.set(keyOf(c.name, id), hold);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function reclaimHolds(core, clientId, playerId) {
|
|
647
|
+
const s = states2.get(core);
|
|
648
|
+
if (!s || s.holds.size === 0) return 0;
|
|
649
|
+
let reclaimed = 0;
|
|
650
|
+
for (const [key, hold] of [...s.holds]) {
|
|
651
|
+
if (hold.holder !== playerId) continue;
|
|
652
|
+
s.holds.delete(key);
|
|
653
|
+
const coll = plainEntity(core.plain, hold.collection);
|
|
654
|
+
if (!coll.has(hold.id)) continue;
|
|
655
|
+
trackedEntity(core, hold.collection).setOwner(hold.id, clientId);
|
|
656
|
+
reclaimed++;
|
|
657
|
+
}
|
|
658
|
+
return reclaimed;
|
|
659
|
+
}
|
|
660
|
+
function expireHolds(core) {
|
|
661
|
+
const s = states2.get(core);
|
|
662
|
+
if (!s || s.holds.size === 0) return;
|
|
663
|
+
const now = wallNow(core);
|
|
664
|
+
for (const [key, hold] of [...s.holds]) {
|
|
665
|
+
if (hold.deadlineMs > now) continue;
|
|
666
|
+
s.holds.delete(key);
|
|
667
|
+
act(core, hold);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function act(core, hold) {
|
|
671
|
+
const coll = plainEntity(core.plain, hold.collection);
|
|
672
|
+
if (!coll.has(hold.id)) return;
|
|
673
|
+
const tracked = trackedEntity(core, hold.collection);
|
|
674
|
+
switch (hold.then) {
|
|
675
|
+
case "remove":
|
|
676
|
+
tracked.remove(hold.id);
|
|
677
|
+
return;
|
|
678
|
+
case "release":
|
|
679
|
+
tracked.setOwner(hold.id, SERVER_OWNER);
|
|
680
|
+
return;
|
|
681
|
+
case "nearest": {
|
|
682
|
+
const to = nearestConnected(core, hold.collection, hold.id);
|
|
683
|
+
tracked.setOwner(hold.id, to ?? SERVER_OWNER);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
case "random": {
|
|
687
|
+
const candidates = connectedClients(core);
|
|
688
|
+
if (candidates.length === 0) {
|
|
689
|
+
tracked.setOwner(hold.id, SERVER_OWNER);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const pick = candidates[Math.floor(core.rng.next() * candidates.length)];
|
|
693
|
+
tracked.setOwner(hold.id, pick);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
default:
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function connectedClients(core) {
|
|
701
|
+
const out = [];
|
|
702
|
+
for (const entry of core.clients.values()) if (entry.connected) out.push(entry.clientId);
|
|
703
|
+
return out;
|
|
704
|
+
}
|
|
705
|
+
function numberAt(record, field) {
|
|
706
|
+
if (!record) return void 0;
|
|
707
|
+
const v = record[field];
|
|
708
|
+
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
709
|
+
}
|
|
710
|
+
function distanceTo(core, policy, object, clientId) {
|
|
711
|
+
const position = policy.position;
|
|
712
|
+
if (!position || !object) return void 0;
|
|
384
713
|
const players = plainEntity(core.plain, position.players);
|
|
385
714
|
const player = players.get(clientId);
|
|
386
715
|
const px = numberAt(player, position.x);
|
|
@@ -511,11 +840,20 @@ function restoreHolds(core, bytes) {
|
|
|
511
840
|
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
512
841
|
for (const h of parsed) {
|
|
513
842
|
if (typeof h?.collection !== "string" || typeof h.id !== "string" || typeof h.holder !== "string" || typeof h.deadlineMs !== "number") {
|
|
843
|
+
core.log("warn", "irtio: dropping a hold that is not a hold record:", h);
|
|
514
844
|
continue;
|
|
515
845
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
846
|
+
if (!HOLD_ACTIONS.has(h.then)) {
|
|
847
|
+
core.log(
|
|
848
|
+
"warn",
|
|
849
|
+
`irtio: dropping the hold on ${h.collection}.${h.id} \u2014 unknown leave action`,
|
|
850
|
+
h.then
|
|
851
|
+
);
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
s.holds.set(keyOf(h.collection, h.id), h);
|
|
855
|
+
}
|
|
856
|
+
} catch (err) {
|
|
519
857
|
core.log("warn", "hold deadlines did not decode; the room wakes holding nothing:", err);
|
|
520
858
|
}
|
|
521
859
|
}
|
|
@@ -542,7 +880,11 @@ function errorText(code, detail) {
|
|
|
542
880
|
return detail === void 0 ? code : `${code}: ${detail}`;
|
|
543
881
|
}
|
|
544
882
|
function sendReply(core, clientId, reqId, result, error) {
|
|
545
|
-
const payload = result === null ? encodeReply({
|
|
883
|
+
const payload = result === null ? encodeReply({
|
|
884
|
+
reqId,
|
|
885
|
+
ok: false,
|
|
886
|
+
error: clampErrorMessage(error ?? errorText("E_INTERNAL"))
|
|
887
|
+
}) : encodeReply({ reqId, ok: true, result });
|
|
546
888
|
core.send(clientId, encodeFrame2(FrameType2.REPLY, payload));
|
|
547
889
|
}
|
|
548
890
|
function handleCall(core, clientId, payload) {
|
|
@@ -581,21 +923,46 @@ function handleCall(core, clientId, payload) {
|
|
|
581
923
|
);
|
|
582
924
|
return true;
|
|
583
925
|
}
|
|
926
|
+
const armed = core.rpcTraceArmed;
|
|
927
|
+
const traceEarly = armed ? (detail) => {
|
|
928
|
+
core.recordRpc({
|
|
929
|
+
clientId,
|
|
930
|
+
rpc: desc.name,
|
|
931
|
+
tick: core.tick,
|
|
932
|
+
outcome: "error",
|
|
933
|
+
result: detail
|
|
934
|
+
});
|
|
935
|
+
} : void 0;
|
|
584
936
|
let params;
|
|
585
937
|
try {
|
|
586
938
|
params = decodeFields(desc.params, call.params);
|
|
587
939
|
} catch (err) {
|
|
940
|
+
traceEarly?.(errorText("E_RPC_BAD_PARAMS", String(err)));
|
|
588
941
|
sendReply(core, clientId, call.reqId, null, errorText("E_RPC_BAD_PARAMS", String(err)));
|
|
589
942
|
return true;
|
|
590
943
|
}
|
|
591
944
|
for (const f of desc.params) {
|
|
592
945
|
const problem = validateValue(f.type, params[f.name], f.name);
|
|
593
946
|
if (problem) {
|
|
947
|
+
traceEarly?.(errorText("E_RPC_BAD_PARAMS", problem));
|
|
594
948
|
sendReply(core, clientId, call.reqId, null, errorText("E_RPC_BAD_PARAMS", problem));
|
|
595
949
|
return true;
|
|
596
950
|
}
|
|
597
951
|
}
|
|
598
952
|
const ctx = { ...core.ctxFor(clientId), clientTick: call.clientTick || void 0 };
|
|
953
|
+
const trace = armed ? (outcome, result2) => {
|
|
954
|
+
core.recordRpc({
|
|
955
|
+
clientId,
|
|
956
|
+
rpc: desc.name,
|
|
957
|
+
tick: ctx.tick,
|
|
958
|
+
...ctx.clientTick !== void 0 ? { clientTick: ctx.clientTick } : {},
|
|
959
|
+
outcome,
|
|
960
|
+
...(() => {
|
|
961
|
+
const summary = summarize(result2);
|
|
962
|
+
return summary === void 0 ? {} : { result: summary };
|
|
963
|
+
})()
|
|
964
|
+
});
|
|
965
|
+
} : void 0;
|
|
599
966
|
let result;
|
|
600
967
|
if (desc.name === REQUEST_OWNERSHIP) {
|
|
601
968
|
result = runRequestOwnership(core, clientId, ctx, params, call.reqId, desc);
|
|
@@ -603,27 +970,38 @@ function handleCall(core, clientId, payload) {
|
|
|
603
970
|
} else {
|
|
604
971
|
const impl = core.definition.config.rpc?.[desc.name];
|
|
605
972
|
if (!impl) {
|
|
973
|
+
trace?.("error", "E_RPC_UNKNOWN");
|
|
606
974
|
sendReply(core, clientId, call.reqId, null, errorText("E_RPC_UNKNOWN", desc.name));
|
|
607
975
|
return true;
|
|
608
976
|
}
|
|
609
977
|
try {
|
|
610
978
|
result = impl(core.anyState, params, ctx);
|
|
611
979
|
} catch (err) {
|
|
980
|
+
if (err instanceof DenyError) {
|
|
981
|
+
trace?.(`deny:${err.reason}`);
|
|
982
|
+
sendReply(core, clientId, call.reqId, null, err.message);
|
|
983
|
+
return true;
|
|
984
|
+
}
|
|
612
985
|
core.stats.handlerErrors++;
|
|
613
986
|
core.log("error", `rpc.${desc.name} threw:`, err);
|
|
614
|
-
|
|
987
|
+
trace?.("error", err instanceof Error ? err.message : String(err));
|
|
988
|
+
sendReply(core, clientId, call.reqId, null, errorText("E_INTERNAL", `rpc ${desc.name}`));
|
|
615
989
|
return true;
|
|
616
990
|
}
|
|
617
991
|
}
|
|
618
992
|
if (!desc.returns) {
|
|
993
|
+
trace?.("ok");
|
|
619
994
|
sendReply(core, clientId, call.reqId, new Uint8Array(0));
|
|
620
995
|
return true;
|
|
621
996
|
}
|
|
622
997
|
try {
|
|
623
|
-
|
|
998
|
+
const bytes = encodeFields(desc.returns, result ?? {});
|
|
999
|
+
trace?.("ok", result ?? {});
|
|
1000
|
+
sendReply(core, clientId, call.reqId, bytes);
|
|
624
1001
|
} catch (err) {
|
|
625
1002
|
core.log("error", `rpc.${desc.name} returned a value that failed to encode:`, err);
|
|
626
1003
|
core.stats.handlerErrors++;
|
|
1004
|
+
trace?.("error", `E_INTERNAL: ${String(err)}`);
|
|
627
1005
|
sendReply(core, clientId, call.reqId, null, errorText("E_INTERNAL", `rpc ${desc.name}`));
|
|
628
1006
|
}
|
|
629
1007
|
return true;
|
|
@@ -633,8 +1011,11 @@ function runRequestOwnership(core, clientId, ctx, params, reqId, desc) {
|
|
|
633
1011
|
const id = String(params.id ?? "");
|
|
634
1012
|
const c = collectionDescOf(core.ext, name);
|
|
635
1013
|
if (!c || c.kind !== "entity" || c.serverOwned) return refused("gone");
|
|
1014
|
+
if (!isVisible(c, core.clients.get(clientId)?.role ?? "")) return refused("gone");
|
|
636
1015
|
const plain = plainEntity(core.plain, name);
|
|
637
1016
|
if (!plain.has(id)) return refused("gone");
|
|
1017
|
+
if (plain.ownerOf(id) !== clientId && !inClientView(core, clientId, c, id))
|
|
1018
|
+
return refused("gone");
|
|
638
1019
|
const handler = core.definition.config.onOwnershipRequest;
|
|
639
1020
|
const tracked = trackedEntity(core, name);
|
|
640
1021
|
if (!handler && c.ownership) {
|
|
@@ -891,6 +1272,10 @@ function rejectAllPending(core, reason) {
|
|
|
891
1272
|
import { FrameType as FrameType3 } from "@irtio/protocol";
|
|
892
1273
|
|
|
893
1274
|
// src/core/host-calls.ts
|
|
1275
|
+
import { clampErrorMessage as clampErrorMessage2 } from "@irtio/protocol";
|
|
1276
|
+
function refusal(text) {
|
|
1277
|
+
return new Error(clampErrorMessage2(text));
|
|
1278
|
+
}
|
|
894
1279
|
var states4 = /* @__PURE__ */ new WeakMap();
|
|
895
1280
|
function stateOf4(core) {
|
|
896
1281
|
let s = states4.get(core);
|
|
@@ -902,6 +1287,7 @@ function stateOf4(core) {
|
|
|
902
1287
|
}
|
|
903
1288
|
function rejectAsEvent(core, err) {
|
|
904
1289
|
core.scheduleContinuationFlush();
|
|
1290
|
+
err.message = clampErrorMessage2(err.message);
|
|
905
1291
|
return Promise.reject(err);
|
|
906
1292
|
}
|
|
907
1293
|
function resolveAsEvent(core, value) {
|
|
@@ -910,7 +1296,7 @@ function resolveAsEvent(core, value) {
|
|
|
910
1296
|
}
|
|
911
1297
|
function startHostCall(core, call, map) {
|
|
912
1298
|
if (core.stopped) {
|
|
913
|
-
return rejectAsEvent(core,
|
|
1299
|
+
return rejectAsEvent(core, refusal(`room.${call.kind}: the room is stopped`));
|
|
914
1300
|
}
|
|
915
1301
|
return new Promise((resolve, reject2) => {
|
|
916
1302
|
const s = stateOf4(core);
|
|
@@ -928,7 +1314,8 @@ function startHostCall(core, call, map) {
|
|
|
928
1314
|
} catch (err) {
|
|
929
1315
|
s.pending.delete(reqId);
|
|
930
1316
|
core.scheduleContinuationFlush();
|
|
931
|
-
|
|
1317
|
+
if (err instanceof Error) err.message = clampErrorMessage2(err.message);
|
|
1318
|
+
reject2(err instanceof Error ? err : refusal(String(err)));
|
|
932
1319
|
return;
|
|
933
1320
|
}
|
|
934
1321
|
if (core.mode === "event") {
|
|
@@ -942,7 +1329,7 @@ function completeHostCall(core, reqId, result) {
|
|
|
942
1329
|
if (!pending) return false;
|
|
943
1330
|
s.pending.delete(reqId);
|
|
944
1331
|
if (result.ok) pending.resolve(pending.map(result.value));
|
|
945
|
-
else pending.reject(
|
|
1332
|
+
else pending.reject(refusal(`${result.code}: ${result.message}`));
|
|
946
1333
|
return true;
|
|
947
1334
|
}
|
|
948
1335
|
function checkHostCallTimeouts(core) {
|
|
@@ -952,14 +1339,14 @@ function checkHostCallTimeouts(core) {
|
|
|
952
1339
|
for (const [reqId, p] of [...s.pending]) {
|
|
953
1340
|
if (p.deadline > now) continue;
|
|
954
1341
|
s.pending.delete(reqId);
|
|
955
|
-
p.reject(
|
|
1342
|
+
p.reject(refusal(`E_HOST_TIMEOUT: room.${p.kind} timed out after ${HOST_CALL_TIMEOUT_MS}ms`));
|
|
956
1343
|
}
|
|
957
1344
|
}
|
|
958
1345
|
function rejectAllHostCalls(core, reason) {
|
|
959
1346
|
const s = stateOf4(core);
|
|
960
1347
|
for (const [reqId, p] of [...s.pending]) {
|
|
961
1348
|
s.pending.delete(reqId);
|
|
962
|
-
p.reject(
|
|
1349
|
+
p.reject(refusal(reason));
|
|
963
1350
|
}
|
|
964
1351
|
}
|
|
965
1352
|
|
|
@@ -968,7 +1355,8 @@ import {
|
|
|
968
1355
|
SERVER_OWNER as SERVER_OWNER3,
|
|
969
1356
|
cloneValue,
|
|
970
1357
|
decodeDelta,
|
|
971
|
-
|
|
1358
|
+
defaultValue,
|
|
1359
|
+
encodeDelta as encodeDelta2,
|
|
972
1360
|
markField,
|
|
973
1361
|
normalizeRecord
|
|
974
1362
|
} from "@irtio/schema";
|
|
@@ -1038,6 +1426,9 @@ function applyChanged(fields, target, prev, next) {
|
|
|
1038
1426
|
}
|
|
1039
1427
|
function correctLeaves(core, clientId, collection, id, leaves) {
|
|
1040
1428
|
if (leaves.length === 0) return;
|
|
1429
|
+
const entry = core.clients.get(clientId);
|
|
1430
|
+
const desc = collectionDescOf(core.ext, collection);
|
|
1431
|
+
if (desc && !isVisible(desc, entry?.role ?? "")) return;
|
|
1041
1432
|
const dirty = core.correctionFor(clientId);
|
|
1042
1433
|
if (!dirty) return;
|
|
1043
1434
|
for (const leaf of leaves) markField(dirty, collection, id, leaf.path);
|
|
@@ -1077,7 +1468,33 @@ function reject(core, clientId, c, op, reason) {
|
|
|
1077
1468
|
if (!recordOf(core.plain, c, op.id)) return;
|
|
1078
1469
|
correctLeaves(core, clientId, c.name, op.id, leafPaths(fieldList(c), op.mask));
|
|
1079
1470
|
}
|
|
1471
|
+
function ownerMayNotWrite(c, field) {
|
|
1472
|
+
const simulated = c.physics;
|
|
1473
|
+
if (simulated) {
|
|
1474
|
+
if (simulated.intents.includes(field)) return void 0;
|
|
1475
|
+
return simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`;
|
|
1476
|
+
}
|
|
1477
|
+
if (c.serverFields?.has(field)) return `${field} (serverField)`;
|
|
1478
|
+
return void 0;
|
|
1479
|
+
}
|
|
1080
1480
|
function applyOp(core, clientId, c, op) {
|
|
1481
|
+
const senderRole = core.clients.get(clientId)?.role ?? "";
|
|
1482
|
+
if (!isVisible(c, senderRole)) {
|
|
1483
|
+
core.recordEvent("write-rejected", clientId, `${c.name}: not visible to role`);
|
|
1484
|
+
core.log(
|
|
1485
|
+
"warn",
|
|
1486
|
+
`WRITE rejected (collection not visible to role ${JSON.stringify(senderRole)}) from ${clientId} on ${c.name}`
|
|
1487
|
+
);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
if (c.kind === "entity" && c.visibility === "spatial-grid") {
|
|
1491
|
+
const coll = plainEntity(core.plain, c.name);
|
|
1492
|
+
if (coll.has(op.id) && coll.ownerOf(op.id) !== clientId && !inClientView(core, clientId, c, op.id)) {
|
|
1493
|
+
core.recordEvent("write-rejected", clientId, `${c.name}: ${op.id} is not in view`);
|
|
1494
|
+
core.log("warn", `WRITE rejected (id not in view) from ${clientId} on ${c.name}`);
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1081
1498
|
if (op.op === "add" || op.op === "remove") {
|
|
1082
1499
|
if (c.kind !== "entity" || c.serverOwned || !c.clientCreate) {
|
|
1083
1500
|
reject(
|
|
@@ -1119,16 +1536,14 @@ function applyOp(core, clientId, c, op) {
|
|
|
1119
1536
|
const next = cloneValue(current);
|
|
1120
1537
|
applyMaskedPatch(fields, next, op.patch, op.mask);
|
|
1121
1538
|
const requested = cloneValue(next);
|
|
1122
|
-
|
|
1123
|
-
if (simulated) {
|
|
1539
|
+
if (c.physics || c.serverFields) {
|
|
1124
1540
|
const refused2 = [];
|
|
1125
1541
|
for (const leaf of leafPaths(fields, op.mask)) {
|
|
1126
1542
|
const field = leaf.names[0];
|
|
1127
|
-
|
|
1543
|
+
const why = ownerMayNotWrite(c, field);
|
|
1544
|
+
if (why === void 0) continue;
|
|
1128
1545
|
next[field] = cloneValue(prev[field]);
|
|
1129
|
-
refused2.push(
|
|
1130
|
-
simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`
|
|
1131
|
-
);
|
|
1546
|
+
refused2.push(why);
|
|
1132
1547
|
}
|
|
1133
1548
|
if (refused2.length > 0) {
|
|
1134
1549
|
core.recordEvent("write-rejected", clientId, `${c.name}: ${refused2.join(", ")}`);
|
|
@@ -1184,8 +1599,32 @@ function applyAdd(core, clientId, c, op) {
|
|
|
1184
1599
|
return;
|
|
1185
1600
|
}
|
|
1186
1601
|
if (!admitGrowth(core, clientId, maxRecordBytes(c))) return;
|
|
1602
|
+
let value = op.value;
|
|
1603
|
+
if (c.serverFields) {
|
|
1604
|
+
const stripped = [];
|
|
1605
|
+
const copy = { ...value };
|
|
1606
|
+
for (const f of c.fields) {
|
|
1607
|
+
if (!c.serverFields.has(f.name)) continue;
|
|
1608
|
+
const reset = defaultValue(f.type);
|
|
1609
|
+
if (deepEqual(copy[f.name], reset)) continue;
|
|
1610
|
+
copy[f.name] = reset;
|
|
1611
|
+
stripped.push(f.name);
|
|
1612
|
+
}
|
|
1613
|
+
if (stripped.length > 0) {
|
|
1614
|
+
core.recordEvent(
|
|
1615
|
+
"write-rejected",
|
|
1616
|
+
clientId,
|
|
1617
|
+
`${c.name}: ${stripped.join(", ")} (serverField)`
|
|
1618
|
+
);
|
|
1619
|
+
core.log(
|
|
1620
|
+
"warn",
|
|
1621
|
+
`add to ${c.name}.${op.id} from ${clientId} set server-only fields; reset to their defaults: ${stripped.join(", ")}`
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
value = copy;
|
|
1625
|
+
}
|
|
1187
1626
|
try {
|
|
1188
|
-
trackedEntity(core, c.name).add(op.id,
|
|
1627
|
+
trackedEntity(core, c.name).add(op.id, value, { owner: clientId });
|
|
1189
1628
|
} catch (err) {
|
|
1190
1629
|
reject(core, clientId, c, op, `add rejected: ${String(err)}`);
|
|
1191
1630
|
return;
|
|
@@ -1245,6 +1684,7 @@ function serverWinsCorrections(core, dirty) {
|
|
|
1245
1684
|
if (owner === void 0 || owner === SERVER_OWNER3) continue;
|
|
1246
1685
|
const entry = core.clients.get(owner);
|
|
1247
1686
|
if (!entry || !entry.connected) continue;
|
|
1687
|
+
if (!isVisible(c, entry.role)) continue;
|
|
1248
1688
|
const value = coll.get(id);
|
|
1249
1689
|
if (value === void 0) continue;
|
|
1250
1690
|
const accepted = entry.accepted.get(`${name}\0${id}`);
|
|
@@ -1268,12 +1708,13 @@ function encodeCorrection(core, dirty) {
|
|
|
1268
1708
|
}
|
|
1269
1709
|
}
|
|
1270
1710
|
if (empty) return null;
|
|
1271
|
-
return
|
|
1711
|
+
return encodeDelta2(core.ext, core.plain, dirty, { tick: core.tick });
|
|
1272
1712
|
}
|
|
1273
1713
|
|
|
1274
1714
|
// src/core/loop.ts
|
|
1275
1715
|
var MAX_CATCHUP = 5;
|
|
1276
1716
|
var CRASH_AFTER_THROWS = 3;
|
|
1717
|
+
var TIMER_SLEEP_GUARD_MS = 250;
|
|
1277
1718
|
var Loop = class {
|
|
1278
1719
|
constructor(core) {
|
|
1279
1720
|
this.core = core;
|
|
@@ -1292,6 +1733,14 @@ var Loop = class {
|
|
|
1292
1733
|
consecutiveThrows = 0;
|
|
1293
1734
|
lastActivity = 0;
|
|
1294
1735
|
slept = false;
|
|
1736
|
+
/**
|
|
1737
|
+
* Whether this idle stretch has already spent its one deferral for a nearly-due room timer.
|
|
1738
|
+
*
|
|
1739
|
+
* Once, deliberately. A `setInterval(100)` in an otherwise silent room must not be able to pin
|
|
1740
|
+
* the room resident forever — pending timers are explicitly *not* a reason to stay awake, the
|
|
1741
|
+
* supervisor wakes the room at the due time instead. Reset by real activity.
|
|
1742
|
+
*/
|
|
1743
|
+
timerDeferred = false;
|
|
1295
1744
|
get intervalMs() {
|
|
1296
1745
|
return 1e3 / this.core.definition.config.tickRate;
|
|
1297
1746
|
}
|
|
@@ -1336,11 +1785,40 @@ var Loop = class {
|
|
|
1336
1785
|
this.applyFrame(f);
|
|
1337
1786
|
}
|
|
1338
1787
|
}
|
|
1339
|
-
/**
|
|
1788
|
+
/**
|
|
1789
|
+
* Applies one queued/immediate frame. Returns `false` on a malformed payload.
|
|
1790
|
+
*
|
|
1791
|
+
* The `try` is containment, not error handling, and it is deliberately *here* rather than around
|
|
1792
|
+
* the whole tick. Room code never throws through this line — `applyWrite` runs its validators
|
|
1793
|
+
* under `core.tryRun`, and `handleCall` wraps the RPC handler in a `try` of its own that does
|
|
1794
|
+
* the same bookkeeping by hand (`DenyError` answered as a refusal, anything else counted on
|
|
1795
|
+
* `core.stats.handlerErrors` and answered `E_INTERNAL`) — so a throw that reaches this frame is by
|
|
1796
|
+
* definition ours: a decode or an apply step that met a value it did not expect. Before this,
|
|
1797
|
+
* such a throw went out through `runTick`/`applyEvent` uncaught and took the worker down, which
|
|
1798
|
+
* meant one crafted frame could end a room (bugs.md: the non-finite `f32`, fixed at the decode
|
|
1799
|
+
* in `@irtio/schema`, reached `normalizeValue` from outside `applyWrite`'s own try).
|
|
1800
|
+
*
|
|
1801
|
+
* Containing it at the frame keeps the blast radius the size of the mistake: the frame is
|
|
1802
|
+
* *this* client's, so it is refused as malformed and the caller kicks that connection, and the
|
|
1803
|
+
* room — every other player in it — keeps ticking. Wrapping the tick instead would have swallowed
|
|
1804
|
+
* the frame's identity along with the throw and left the room running against half-applied
|
|
1805
|
+
* state with nobody to attribute it to.
|
|
1806
|
+
*
|
|
1807
|
+
* Nothing about how a *handler* throw is treated changes: those never arrive here.
|
|
1808
|
+
*/
|
|
1340
1809
|
applyFrame(f) {
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1810
|
+
try {
|
|
1811
|
+
if (f.type === FrameType3.WRITE) return applyWrite(this.core, f.clientId, f.payload).ok;
|
|
1812
|
+
if (f.type === FrameType3.CALL) return handleCall(this.core, f.clientId, f.payload);
|
|
1813
|
+
return true;
|
|
1814
|
+
} catch (err) {
|
|
1815
|
+
this.core.log(
|
|
1816
|
+
"error",
|
|
1817
|
+
`frame type ${f.type} from ${f.clientId} threw while being applied; refusing it:`,
|
|
1818
|
+
err
|
|
1819
|
+
);
|
|
1820
|
+
return false;
|
|
1821
|
+
}
|
|
1344
1822
|
}
|
|
1345
1823
|
// -------------------------------------------------------------------------
|
|
1346
1824
|
// Tick mode
|
|
@@ -1401,7 +1879,12 @@ var Loop = class {
|
|
|
1401
1879
|
if (physics) {
|
|
1402
1880
|
const ran = this.core.tryRun("physics", () => {
|
|
1403
1881
|
physics.reconcile();
|
|
1882
|
+
const stepStarted = performance.now();
|
|
1404
1883
|
physics.step();
|
|
1884
|
+
const stepMs = performance.now() - stepStarted;
|
|
1885
|
+
const s = this.core.stats;
|
|
1886
|
+
s.physicsStepMs = stepMs;
|
|
1887
|
+
s.physicsStepMsAvg = s.ticks === 0 ? stepMs : s.physicsStepMsAvg + PHYSICS_STEP_EMA_ALPHA * (stepMs - s.physicsStepMsAvg);
|
|
1405
1888
|
physics.sync();
|
|
1406
1889
|
this.core.captureHistory();
|
|
1407
1890
|
});
|
|
@@ -1450,6 +1933,7 @@ var Loop = class {
|
|
|
1450
1933
|
noteActivity() {
|
|
1451
1934
|
this.lastActivity = this.core.host.now();
|
|
1452
1935
|
this.slept = false;
|
|
1936
|
+
this.timerDeferred = false;
|
|
1453
1937
|
if (this.running && this.idleHandle === void 0) {
|
|
1454
1938
|
this.armIdle(this.core.definition.config.idleMs);
|
|
1455
1939
|
}
|
|
@@ -1469,6 +1953,12 @@ var Loop = class {
|
|
|
1469
1953
|
}
|
|
1470
1954
|
const waited = this.core.host.now() - this.lastActivity;
|
|
1471
1955
|
if (waited >= idleMs) {
|
|
1956
|
+
const earliest = this.earliestTimerAt();
|
|
1957
|
+
if (!this.timerDeferred && earliest !== void 0 && earliest - this.core.host.now() <= TIMER_SLEEP_GUARD_MS) {
|
|
1958
|
+
this.timerDeferred = true;
|
|
1959
|
+
this.armIdle(TIMER_SLEEP_GUARD_MS);
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1472
1962
|
if (!this.slept) {
|
|
1473
1963
|
this.slept = true;
|
|
1474
1964
|
this.core.host.sleep();
|
|
@@ -1490,6 +1980,7 @@ var Loop = class {
|
|
|
1490
1980
|
periodTicks,
|
|
1491
1981
|
periodMs: Math.max(0, ms),
|
|
1492
1982
|
dueTick: this.core.tick + periodTicks,
|
|
1983
|
+
dueAtMs: this.core.host.now() + Math.max(0, ms),
|
|
1493
1984
|
handle: void 0
|
|
1494
1985
|
};
|
|
1495
1986
|
this.timers.set(id, timer);
|
|
@@ -1501,8 +1992,10 @@ var Loop = class {
|
|
|
1501
1992
|
timer.handle = void 0;
|
|
1502
1993
|
if (this.core.stopped) return;
|
|
1503
1994
|
if (!this.timers.has(timer.id)) return;
|
|
1504
|
-
if (timer.repeat)
|
|
1505
|
-
|
|
1995
|
+
if (timer.repeat) {
|
|
1996
|
+
timer.dueAtMs = this.core.host.now() + timer.periodMs;
|
|
1997
|
+
this.armHostTimer(timer);
|
|
1998
|
+
} else this.timers.delete(timer.id);
|
|
1506
1999
|
this.core.tick++;
|
|
1507
2000
|
this.core.guard("room timer", timer.fn);
|
|
1508
2001
|
this.core.flush();
|
|
@@ -1514,6 +2007,29 @@ var Loop = class {
|
|
|
1514
2007
|
this.timers.delete(handle);
|
|
1515
2008
|
if (t.handle !== void 0) this.core.host.clearTimeout(t.handle);
|
|
1516
2009
|
}
|
|
2010
|
+
/**
|
|
2011
|
+
* The earliest pending room timer's due time on `host.now()`'s clock, or `undefined` when the
|
|
2012
|
+
* room has none armed.
|
|
2013
|
+
*
|
|
2014
|
+
* Read at `serialize()`, before `clearAllTimers()` drops the callbacks. The callbacks are
|
|
2015
|
+
* closures and cannot be persisted — but the *time* can, and handing it to the supervisor is
|
|
2016
|
+
* what turns "your timer is gone" into "your room is awake again by then", which is a contract
|
|
2017
|
+
* room code can actually catch up against in `onWake`.
|
|
2018
|
+
*
|
|
2019
|
+
* Event mode reads the absolute due time stamped on the record at arm time; tick mode derives
|
|
2020
|
+
* it from `dueTick`, because there a timer fires on the first tick at or after its deadline and
|
|
2021
|
+
* the tick counter is the clock that decides it.
|
|
2022
|
+
*/
|
|
2023
|
+
earliestTimerAt() {
|
|
2024
|
+
if (this.timers.size === 0) return void 0;
|
|
2025
|
+
const now = this.core.host.now();
|
|
2026
|
+
let best;
|
|
2027
|
+
for (const t of this.timers.values()) {
|
|
2028
|
+
const at = this.core.mode === "event" ? t.dueAtMs : now + Math.max(0, t.dueTick - this.core.tick) * this.intervalMs;
|
|
2029
|
+
if (best === void 0 || at < best) best = at;
|
|
2030
|
+
}
|
|
2031
|
+
return best;
|
|
2032
|
+
}
|
|
1517
2033
|
clearAllTimers() {
|
|
1518
2034
|
for (const t of this.timers.values()) {
|
|
1519
2035
|
if (t.handle !== void 0) this.core.host.clearTimeout(t.handle);
|
|
@@ -1536,139 +2052,373 @@ function hasHumanClients(core) {
|
|
|
1536
2052
|
return false;
|
|
1537
2053
|
}
|
|
1538
2054
|
|
|
1539
|
-
// src/core/
|
|
1540
|
-
import {
|
|
1541
|
-
ByteReader,
|
|
1542
|
-
ByteWriter,
|
|
1543
|
-
applyChannel2d,
|
|
1544
|
-
channelOf2d
|
|
1545
|
-
} from "@irtio/schema";
|
|
2055
|
+
// src/core/physics.ts
|
|
2056
|
+
import { ByteReader, ByteWriter } from "@irtio/schema";
|
|
1546
2057
|
var engine;
|
|
1547
2058
|
var loading;
|
|
1548
|
-
async function
|
|
2059
|
+
async function initPhysics() {
|
|
1549
2060
|
if (engine) return engine;
|
|
1550
2061
|
loading ??= (async () => {
|
|
1551
|
-
const mod = await import("
|
|
2062
|
+
const mod = await import("@dimforge/rapier3d-compat");
|
|
1552
2063
|
const ns = mod.default ?? mod;
|
|
2064
|
+
await ns.init();
|
|
1553
2065
|
engine = ns;
|
|
1554
2066
|
return ns;
|
|
1555
2067
|
})();
|
|
1556
2068
|
return loading;
|
|
1557
2069
|
}
|
|
1558
|
-
function
|
|
2070
|
+
function loadedPhysics() {
|
|
1559
2071
|
return engine;
|
|
1560
2072
|
}
|
|
1561
|
-
function
|
|
2073
|
+
function resetPhysicsForTests() {
|
|
1562
2074
|
engine = void 0;
|
|
1563
2075
|
loading = void 0;
|
|
2076
|
+
rapierStepped = false;
|
|
2077
|
+
firstStepWaiters.length = 0;
|
|
1564
2078
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
2079
|
+
var rapierStepped = false;
|
|
2080
|
+
var firstStepWaiters = [];
|
|
2081
|
+
function rapierHasStepped() {
|
|
2082
|
+
return rapierStepped;
|
|
2083
|
+
}
|
|
2084
|
+
function onFirstRapierStep(cb) {
|
|
2085
|
+
if (rapierStepped) {
|
|
2086
|
+
cb();
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
firstStepWaiters.push(cb);
|
|
2090
|
+
}
|
|
2091
|
+
function noteRapierStep() {
|
|
2092
|
+
rapierStepped = true;
|
|
2093
|
+
const waiters = firstStepWaiters.splice(0, firstStepWaiters.length);
|
|
2094
|
+
for (const cb of waiters) {
|
|
2095
|
+
try {
|
|
2096
|
+
cb();
|
|
2097
|
+
} catch {
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
function encodePhysicsSection(section) {
|
|
2102
|
+
const skipped = section.skipped ?? [];
|
|
2103
|
+
const unsynced = section.unsynced ?? [];
|
|
2104
|
+
const w = new ByteWriter(
|
|
2105
|
+
section.world.length + (section.bodies.length + skipped.length + unsynced.length) * 24 + 16
|
|
2106
|
+
);
|
|
2107
|
+
w.blob(section.world);
|
|
1567
2108
|
w.varint(section.bodies.length);
|
|
1568
|
-
for (const
|
|
1569
|
-
w.str(
|
|
1570
|
-
w.str(
|
|
1571
|
-
w.f64(
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
w.
|
|
1575
|
-
w.
|
|
1576
|
-
w.
|
|
1577
|
-
|
|
2109
|
+
for (const [name, id, handle] of section.bodies) {
|
|
2110
|
+
w.str(name);
|
|
2111
|
+
w.str(id);
|
|
2112
|
+
w.f64(handle);
|
|
2113
|
+
}
|
|
2114
|
+
if (skipped.length > 0 || unsynced.length > 0) {
|
|
2115
|
+
w.varint(skipped.length);
|
|
2116
|
+
for (const key of skipped) w.str(key);
|
|
2117
|
+
w.varint(unsynced.length);
|
|
2118
|
+
for (const [key, handle] of unsynced) {
|
|
2119
|
+
w.str(key);
|
|
2120
|
+
w.f64(handle);
|
|
2121
|
+
}
|
|
1578
2122
|
}
|
|
1579
2123
|
return w.finish();
|
|
1580
2124
|
}
|
|
1581
|
-
|
|
2125
|
+
var MATTER_TAG = 1;
|
|
2126
|
+
var RAPIER2D_TAG = 2;
|
|
2127
|
+
function physicsSectionEngine(bytes) {
|
|
2128
|
+
const r = new ByteReader(bytes);
|
|
2129
|
+
if (r.varint() !== 0) return "rapier3d";
|
|
2130
|
+
const tag = r.u8();
|
|
2131
|
+
if (tag === MATTER_TAG) return "matter2d";
|
|
2132
|
+
if (tag === RAPIER2D_TAG) return "rapier2d";
|
|
2133
|
+
return "rapier3d";
|
|
2134
|
+
}
|
|
2135
|
+
function encodeRapier2dSectionEnvelope(payload) {
|
|
2136
|
+
const w = new ByteWriter(payload.length + 8);
|
|
2137
|
+
w.varint(0);
|
|
2138
|
+
w.u8(RAPIER2D_TAG);
|
|
2139
|
+
w.bytes(payload);
|
|
2140
|
+
return w.finish();
|
|
2141
|
+
}
|
|
2142
|
+
function decodeRapier2dSectionEnvelope(bytes) {
|
|
2143
|
+
const r = new ByteReader(bytes);
|
|
2144
|
+
if (r.varint() !== 0 || r.u8() !== RAPIER2D_TAG) {
|
|
2145
|
+
throw new Error("irtio: this physics section was not written by rapier2d");
|
|
2146
|
+
}
|
|
2147
|
+
return r.rest();
|
|
2148
|
+
}
|
|
2149
|
+
function encodeMatterSectionEnvelope(payload) {
|
|
2150
|
+
const w = new ByteWriter(payload.length + 8);
|
|
2151
|
+
w.varint(0);
|
|
2152
|
+
w.u8(MATTER_TAG);
|
|
2153
|
+
w.bytes(payload);
|
|
2154
|
+
return w.finish();
|
|
2155
|
+
}
|
|
2156
|
+
function decodeMatterSectionEnvelope(bytes) {
|
|
2157
|
+
const r = new ByteReader(bytes);
|
|
2158
|
+
if (r.varint() !== 0 || r.u8() !== MATTER_TAG) {
|
|
2159
|
+
throw new Error("irtio: this physics section was not written by matter2d");
|
|
2160
|
+
}
|
|
2161
|
+
return r.rest();
|
|
2162
|
+
}
|
|
2163
|
+
function decodePhysicsSection(bytes) {
|
|
1582
2164
|
const r = new ByteReader(bytes);
|
|
2165
|
+
const world = r.blob().slice();
|
|
1583
2166
|
const count = r.varint();
|
|
1584
2167
|
const bodies = [];
|
|
1585
|
-
for (let i = 0; i < count; i++)
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
angularVelocity: r.f64(),
|
|
1595
|
-
sleeping: r.u8() === 1
|
|
1596
|
-
});
|
|
2168
|
+
for (let i = 0; i < count; i++) bodies.push([r.str(), r.str(), r.f64()]);
|
|
2169
|
+
if (r.eof) return { world, bodies };
|
|
2170
|
+
const skippedCount = r.varint();
|
|
2171
|
+
const skipped = [];
|
|
2172
|
+
for (let i = 0; i < skippedCount; i++) skipped.push(r.str());
|
|
2173
|
+
const unsynced = [];
|
|
2174
|
+
if (!r.eof) {
|
|
2175
|
+
const unsyncedCount = r.varint();
|
|
2176
|
+
for (let i = 0; i < unsyncedCount; i++) unsynced.push([r.str(), r.f64()]);
|
|
1597
2177
|
}
|
|
1598
|
-
return {
|
|
2178
|
+
return {
|
|
2179
|
+
world,
|
|
2180
|
+
bodies,
|
|
2181
|
+
...skipped.length > 0 ? { skipped } : {},
|
|
2182
|
+
...unsynced.length > 0 ? { unsynced } : {}
|
|
2183
|
+
};
|
|
1599
2184
|
}
|
|
1600
2185
|
function bodyKey(collection, id) {
|
|
1601
2186
|
return `${collection}\0${id}`;
|
|
1602
2187
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
2188
|
+
function planarLockWarning(spec) {
|
|
2189
|
+
const boxed = (spec.colliders ?? []).some(
|
|
2190
|
+
(c) => c.shape.type === CUBOID_SHAPE || c.shape.type === ROUND_CUBOID_SHAPE
|
|
2191
|
+
);
|
|
2192
|
+
if (!boxed) return void 0;
|
|
2193
|
+
const b = spec.body;
|
|
2194
|
+
const t = [b.translationsEnabledX, b.translationsEnabledY, b.translationsEnabledZ];
|
|
2195
|
+
const r = [b.rotationsEnabledX, b.rotationsEnabledY, b.rotationsEnabledZ];
|
|
2196
|
+
const axes = ["x", "y", "z"];
|
|
2197
|
+
for (let i = 0; i < 3; i++) {
|
|
2198
|
+
if (t[i]) continue;
|
|
2199
|
+
if (r[(i + 1) % 3] || r[(i + 2) % 3]) continue;
|
|
2200
|
+
const free = axes[i] === "z" ? "enabledRotations(true, false, true)" : "one of the other two rotations";
|
|
2201
|
+
return `locks translation on ${axes[i]} and both rotations across that plane on a box-shaped body. That removes friction entirely: the box slides at a constant speed until it hits something. Free one out-of-plane rotation \u2014 in the xy plane that is ${free}, and it costs nothing, because geometry symmetric about the plane generates no torque about it.`;
|
|
1614
2202
|
}
|
|
1615
|
-
|
|
2203
|
+
return void 0;
|
|
2204
|
+
}
|
|
2205
|
+
var CUBOID_SHAPE = 1;
|
|
2206
|
+
var ROUND_CUBOID_SHAPE = 12;
|
|
2207
|
+
function makeUnsyncedRegistry(ops) {
|
|
2208
|
+
const { map, build, destroy, onCreated } = ops;
|
|
2209
|
+
return {
|
|
2210
|
+
create(key, spec) {
|
|
2211
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
2212
|
+
throw new Error("physics.unsynced.create: key must be a non-empty string");
|
|
2213
|
+
}
|
|
2214
|
+
const existing = map.get(key);
|
|
2215
|
+
if (existing !== void 0) return existing;
|
|
2216
|
+
const body = build(spec);
|
|
2217
|
+
map.set(key, body);
|
|
2218
|
+
onCreated?.(key, body);
|
|
2219
|
+
return body;
|
|
2220
|
+
},
|
|
2221
|
+
get(key) {
|
|
2222
|
+
return map.get(key);
|
|
2223
|
+
},
|
|
2224
|
+
has(key) {
|
|
2225
|
+
return map.has(key);
|
|
2226
|
+
},
|
|
2227
|
+
remove(key) {
|
|
2228
|
+
const body = map.get(key);
|
|
2229
|
+
if (body === void 0) return false;
|
|
2230
|
+
map.delete(key);
|
|
2231
|
+
destroy(body);
|
|
2232
|
+
return true;
|
|
2233
|
+
},
|
|
2234
|
+
get size() {
|
|
2235
|
+
return map.size;
|
|
2236
|
+
},
|
|
2237
|
+
keys() {
|
|
2238
|
+
return map.keys();
|
|
2239
|
+
},
|
|
2240
|
+
*[Symbol.iterator]() {
|
|
2241
|
+
for (const entry of map) yield entry;
|
|
2242
|
+
}
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
var PhysicsRuntime = class {
|
|
2246
|
+
engineKind = "rapier3d";
|
|
2247
|
+
/** matter2d only; present so both runtimes satisfy one internal shape. */
|
|
2248
|
+
matter = void 0;
|
|
2249
|
+
matterEngine = void 0;
|
|
2250
|
+
rapier;
|
|
2251
|
+
world;
|
|
2252
|
+
/** `true` when the world was built from scratch and `setup` has to run. */
|
|
1616
2253
|
rebuilt;
|
|
1617
|
-
/**
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
needsSetup = true;
|
|
2254
|
+
/** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
|
|
2255
|
+
get needsSetup() {
|
|
2256
|
+
return this.rebuilt;
|
|
2257
|
+
}
|
|
1622
2258
|
core;
|
|
1623
2259
|
config;
|
|
2260
|
+
/** Physics-backed collections, in schema (name-sorted) order. */
|
|
1624
2261
|
collections;
|
|
1625
2262
|
bodies = /* @__PURE__ */ new Map();
|
|
1626
|
-
|
|
1627
|
-
|
|
2263
|
+
/** Collections already warned about the friction-killing 2D lock recipe (bug 6). */
|
|
2264
|
+
warnedPlanar = /* @__PURE__ */ new Set();
|
|
2265
|
+
/**
|
|
2266
|
+
* Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
|
|
2267
|
+
* to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
|
|
2268
|
+
* tick it falls asleep, or the schema keeps a phantom residual velocity forever (and a
|
|
2269
|
+
* world rebuilt from schema state would wake it with a kick). Found by the drift check.
|
|
2270
|
+
*/
|
|
1628
2271
|
sleepSynced = /* @__PURE__ */ new Set();
|
|
1629
2272
|
/**
|
|
1630
|
-
* The plain record object each body was built from
|
|
1631
|
-
*
|
|
1632
|
-
*
|
|
1633
|
-
*
|
|
2273
|
+
* The plain record object each body was built from, by body key. `add()` always installs a
|
|
2274
|
+
* **new** record object (`normalizeRecord` builds one), so an id that is removed and re-added
|
|
2275
|
+
* between two reconciles — the pooled-projectile shape — is identifiable by identity alone,
|
|
2276
|
+
* which is the only way to see it from here: both edits happened before this module looked.
|
|
2277
|
+
* Without it the old body kept its pose and velocity and the new row's spawn values were lost.
|
|
2278
|
+
*
|
|
2279
|
+
* Unknown means adopt, never rebuild: a world restored from a hibernation blob has bodies with
|
|
2280
|
+
* no recorded source, and rebuilding those would throw away exactly what the blob preserved.
|
|
1634
2281
|
*/
|
|
1635
2282
|
sourceRecords = /* @__PURE__ */ new Map();
|
|
1636
|
-
/** `collection.field` pairs already warned about a channel
|
|
2283
|
+
/** `collection.field` pairs already warned about a channel value the engine would not take. */
|
|
1637
2284
|
warnedChannels = /* @__PURE__ */ new Set();
|
|
1638
|
-
|
|
2285
|
+
// ---- M7 planned feature 3 (b): per-entity physics skip ----
|
|
2286
|
+
/**
|
|
2287
|
+
* Entities whose body the room has switched off. See {@link setSkipped} for what "off" means and
|
|
2288
|
+
* why it is spelled as "no body" rather than as "a body that is not stepped".
|
|
2289
|
+
*/
|
|
2290
|
+
skippedKeys = /* @__PURE__ */ new Set();
|
|
2291
|
+
// ---- end M7 planned feature 3 (b) ----
|
|
2292
|
+
// ---- M7 planned feature 3 (a): physics-only unsynced bodies ----
|
|
2293
|
+
/**
|
|
2294
|
+
* Bodies with no schema row: rope anchors, dead-reckoned proxies, one-off sensors. Keyed by the
|
|
2295
|
+
* room's own string rather than by `collection\0id`, because there is no collection and no id.
|
|
2296
|
+
*/
|
|
2297
|
+
unsyncedBodies = /* @__PURE__ */ new Map();
|
|
2298
|
+
unsynced;
|
|
2299
|
+
// ---- end M7 planned feature 3 (a) ----
|
|
2300
|
+
constructor(core, rapier, options) {
|
|
1639
2301
|
const config = core.definition.config.physics;
|
|
1640
|
-
if (!config) throw new Error("
|
|
2302
|
+
if (!config) throw new Error("PhysicsRuntime: the room config declares no physics");
|
|
1641
2303
|
this.core = core;
|
|
1642
2304
|
this.config = config;
|
|
1643
|
-
this.
|
|
2305
|
+
this.rapier = rapier;
|
|
1644
2306
|
this.collections = core.ext.collections.filter(
|
|
1645
2307
|
(c) => c.physics !== void 0
|
|
1646
2308
|
);
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
2309
|
+
if (options.restore) {
|
|
2310
|
+
this.world = rapier.World.restoreSnapshot(options.restore.world);
|
|
2311
|
+
this.rebuilt = false;
|
|
2312
|
+
for (const [name, id, handle] of options.restore.bodies) {
|
|
2313
|
+
const body = this.world.getRigidBody(handle);
|
|
2314
|
+
if (body) this.bodies.set(bodyKey(name, id), body);
|
|
2315
|
+
}
|
|
2316
|
+
for (const key of options.restore.skipped ?? []) this.skippedKeys.add(key);
|
|
2317
|
+
for (const [key, handle] of options.restore.unsynced ?? []) {
|
|
2318
|
+
const body = this.world.getRigidBody(handle);
|
|
2319
|
+
if (body) this.unsyncedBodies.set(key, body);
|
|
2320
|
+
}
|
|
2321
|
+
} else {
|
|
2322
|
+
const g = config.gravity;
|
|
2323
|
+
this.world = new rapier.World({ x: g.x, y: g.y, z: g.z });
|
|
2324
|
+
this.rebuilt = true;
|
|
2325
|
+
}
|
|
2326
|
+
this.world.timestep = config.timestep ?? options.defaultTimestep;
|
|
2327
|
+
this.unsynced = makeUnsyncedRegistry({
|
|
2328
|
+
map: this.unsyncedBodies,
|
|
2329
|
+
build: (spec) => {
|
|
2330
|
+
const body = this.world.createRigidBody(spec.body);
|
|
2331
|
+
for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
|
|
2332
|
+
return body;
|
|
2333
|
+
},
|
|
2334
|
+
destroy: (body) => this.destroyBody(body)
|
|
2335
|
+
});
|
|
1653
2336
|
}
|
|
1654
2337
|
get timestep() {
|
|
1655
|
-
return this.
|
|
2338
|
+
return this.world.timestep;
|
|
1656
2339
|
}
|
|
2340
|
+
/** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
|
|
1657
2341
|
runSetup(room) {
|
|
1658
2342
|
const setup = this.config.setup;
|
|
1659
2343
|
if (!setup) return;
|
|
1660
|
-
this.core.guard("physics.setup", () => setup(this.
|
|
2344
|
+
this.core.guard("physics.setup", () => setup(this.world, this.rapier, room));
|
|
1661
2345
|
}
|
|
1662
2346
|
free() {
|
|
1663
2347
|
this.bodies.clear();
|
|
1664
|
-
this.
|
|
2348
|
+
this.unsyncedBodies.clear();
|
|
2349
|
+
this.world.free();
|
|
1665
2350
|
}
|
|
1666
2351
|
// -------------------------------------------------------------------------
|
|
1667
2352
|
// Bodies
|
|
1668
2353
|
// -------------------------------------------------------------------------
|
|
2354
|
+
/**
|
|
2355
|
+
* bugs.md #99: every teardown path goes through here rather than calling `removeRigidBody`
|
|
2356
|
+
* directly.
|
|
2357
|
+
*
|
|
2358
|
+
* A sleeping body is parked in an island wired through the contact graph. Pulling a body out
|
|
2359
|
+
* from under one leaves the sleeper resting on nothing — and, in the failure this guards, leaves
|
|
2360
|
+
* the island holding a body that no longer exists. Waking the contacting bodies first makes the
|
|
2361
|
+
* removal a plain awake-island edit, and it is also what a room wants physically: the crate the
|
|
2362
|
+
* floor vanished under should fall.
|
|
2363
|
+
*
|
|
2364
|
+
* The cost is one narrow-phase lookup per collider of the *victim* — `contactPairsWith` walks
|
|
2365
|
+
* only that collider's own contact pairs — against the alternative of waking the world, which
|
|
2366
|
+
* would un-sleep every dormant body in the room on every teardown. Sensors are skipped
|
|
2367
|
+
* deliberately: an intersection pair is not an island edge, so it cannot be the thing at risk.
|
|
2368
|
+
*/
|
|
2369
|
+
destroyBody(body) {
|
|
2370
|
+
const colliders = body.numColliders();
|
|
2371
|
+
for (let i = 0; i < colliders; i++) {
|
|
2372
|
+
this.world.contactPairsWith(body.collider(i), (other) => {
|
|
2373
|
+
other.parent()?.wakeUp();
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
this.world.removeRigidBody(body);
|
|
2377
|
+
}
|
|
2378
|
+
// ---- M7 planned feature 3 (b): per-entity physics skip ----
|
|
2379
|
+
/**
|
|
2380
|
+
* Switches one entity's body off, or back on.
|
|
2381
|
+
*
|
|
2382
|
+
* Undercity feedback 29: hundreds of dormant-by-design entities want "exists and syncs, but do
|
|
2383
|
+
* not step or mirror until I say so". Rapier's own sleeping approximates it and still costs a
|
|
2384
|
+
* broadphase entry and a sync slot per row per tick, and a sleeping body wakes on contact,
|
|
2385
|
+
* which a dormant spider must not.
|
|
2386
|
+
*
|
|
2387
|
+
* **Skipped means no body.** The body is destroyed on the way in and rebuilt from the schema
|
|
2388
|
+
* row on the way out, on the next `reconcile`. The alternative — keep the body, exclude it from
|
|
2389
|
+
* the step — is not something an engine offers per body without also excluding it from
|
|
2390
|
+
* collision, and it would leave the cost this exists to remove (a body in the world, in the
|
|
2391
|
+
* broadphase, walked by `sync`) exactly where it was. The row is untouched and goes on syncing
|
|
2392
|
+
* as ordinary state, which is what the ask asked for; a room that moves a skipped entity by
|
|
2393
|
+
* assigning its fields is moving it, and unskipping resumes from wherever the row now says.
|
|
2394
|
+
*
|
|
2395
|
+
* What is lost across a skip is what a body has and a row does not: contacts, and velocity
|
|
2396
|
+
* beyond the f32 the row carries. A skipped entity resumes at rest at its row's pose unless the
|
|
2397
|
+
* row's velocity channels say otherwise.
|
|
2398
|
+
*/
|
|
2399
|
+
setSkipped(collection, id, on) {
|
|
2400
|
+
const key = bodyKey(collection, id);
|
|
2401
|
+
if (!on) {
|
|
2402
|
+
this.skippedKeys.delete(key);
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
this.skippedKeys.add(key);
|
|
2406
|
+
const existing = this.bodies.get(key);
|
|
2407
|
+
if (!existing) return;
|
|
2408
|
+
this.bodies.delete(key);
|
|
2409
|
+
this.sleepSynced.delete(key);
|
|
2410
|
+
this.sourceRecords.delete(key);
|
|
2411
|
+
this.destroyBody(existing);
|
|
2412
|
+
}
|
|
2413
|
+
isSkipped(collection, id) {
|
|
2414
|
+
return this.skippedKeys.has(bodyKey(collection, id));
|
|
2415
|
+
}
|
|
2416
|
+
// ---- end M7 planned feature 3 (b) ----
|
|
2417
|
+
/** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
|
|
1669
2418
|
bodyFor(collection, id) {
|
|
1670
2419
|
const existing = this.bodies.get(bodyKey(collection, id));
|
|
1671
|
-
if (existing) return existing
|
|
2420
|
+
if (existing) return existing;
|
|
2421
|
+
if (this.skippedKeys.has(bodyKey(collection, id))) return void 0;
|
|
1672
2422
|
const desc = this.collections.find((c) => c.name === collection);
|
|
1673
2423
|
if (!desc) return void 0;
|
|
1674
2424
|
const coll = plainEntity(this.core.plain, collection);
|
|
@@ -1684,7 +2434,7 @@ var MatterRuntime = class {
|
|
|
1684
2434
|
}
|
|
1685
2435
|
const spec = this.core.guard(
|
|
1686
2436
|
`physics.bodies.${desc.name}`,
|
|
1687
|
-
() => factory(this.
|
|
2437
|
+
() => factory(this.rapier, record, id)
|
|
1688
2438
|
);
|
|
1689
2439
|
if (!spec || !spec.body) {
|
|
1690
2440
|
this.core.log(
|
|
@@ -1693,83 +2443,145 @@ var MatterRuntime = class {
|
|
|
1693
2443
|
);
|
|
1694
2444
|
return void 0;
|
|
1695
2445
|
}
|
|
1696
|
-
const
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
2446
|
+
const planar = planarLockWarning(spec);
|
|
2447
|
+
if (planar !== void 0 && !this.warnedPlanar.has(desc.name)) {
|
|
2448
|
+
this.warnedPlanar.add(desc.name);
|
|
2449
|
+
this.core.log("warn", `irtio: physics.bodies.${desc.name} ${planar}`);
|
|
2450
|
+
}
|
|
2451
|
+
const body = this.world.createRigidBody(spec.body);
|
|
2452
|
+
for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
|
|
2453
|
+
this.warnLockedChannels(desc, spec, record);
|
|
2454
|
+
this.applyRecordToBody(desc, body, record);
|
|
2455
|
+
this.bodies.set(bodyKey(desc.name, id), body);
|
|
2456
|
+
this.sourceRecords.set(bodyKey(desc.name, id), record);
|
|
2457
|
+
return body;
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* A spawn value the engine will not take, said once per field.
|
|
2461
|
+
*
|
|
2462
|
+
* Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
|
|
2463
|
+
* accepts one on a fixed body and then ignores it — both read back as "the field was applied"
|
|
2464
|
+
* and behave as if it never was. The desc says which, before the body exists, so the check is
|
|
2465
|
+
* on the desc: the usual case is the 2D-in-3D recipe (`enabledTranslations(true, true, false)`)
|
|
2466
|
+
* meeting a row that spawns with a `vz`. Named by field, because the field is what the author
|
|
2467
|
+
* wrote and what they will grep for.
|
|
2468
|
+
*/
|
|
2469
|
+
warnLockedChannels(desc, spec, record) {
|
|
2470
|
+
const physics = desc.physics;
|
|
2471
|
+
if (!physics) return;
|
|
2472
|
+
const b = spec.body;
|
|
2473
|
+
const frozen = b.status !== 0;
|
|
2474
|
+
const enabled = {
|
|
2475
|
+
vx: b.translationsEnabledX,
|
|
2476
|
+
vy: b.translationsEnabledY,
|
|
2477
|
+
vz: b.translationsEnabledZ,
|
|
2478
|
+
wx: b.rotationsEnabledX,
|
|
2479
|
+
wy: b.rotationsEnabledY,
|
|
2480
|
+
wz: b.rotationsEnabledZ
|
|
2481
|
+
};
|
|
2482
|
+
for (const [channel, field] of physics.channels) {
|
|
2483
|
+
const allowed = enabled[channel];
|
|
2484
|
+
if (allowed === void 0) continue;
|
|
2485
|
+
const raw = record[field];
|
|
2486
|
+
if (typeof raw !== "number" || raw === 0) continue;
|
|
2487
|
+
if (allowed && !frozen) continue;
|
|
2488
|
+
const key = `${desc.name}.${field}`;
|
|
2489
|
+
if (this.warnedChannels.has(key)) continue;
|
|
2490
|
+
this.warnedChannels.add(key);
|
|
2491
|
+
this.core.log(
|
|
2492
|
+
"warn",
|
|
2493
|
+
`irtio: ${desc.name}.${field} (body channel ${channel}) was added as ${raw}, but ` + (frozen ? `physics.bodies.${desc.name} builds a non-dynamic body, which never moves under it` : `that degree of freedom is disabled by physics.bodies.${desc.name}`) + " \u2014 the value was dropped"
|
|
2494
|
+
);
|
|
1706
2495
|
}
|
|
1707
|
-
this.sourceRecords.set(key, record);
|
|
1708
|
-
return spec.body;
|
|
1709
2496
|
}
|
|
1710
2497
|
// ---- M6 lane F: rewind ----
|
|
1711
2498
|
/**
|
|
1712
|
-
* D72: every tracked body, in the order `sync()` walks them
|
|
1713
|
-
*
|
|
2499
|
+
* D72: every tracked body, in the order `sync()` walks them (collection order, then instance
|
|
2500
|
+
* order). Read-only: it hands out the live bodies and nothing else, and the map stays private.
|
|
2501
|
+
* The pose history calls this once per tick and the rewind scratch calls it per rewind; a room
|
|
2502
|
+
* that declares no `physics.history` never calls it at all.
|
|
1714
2503
|
*/
|
|
1715
2504
|
eachTrackedBody(fn) {
|
|
1716
2505
|
for (const desc of this.collections) {
|
|
1717
2506
|
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
1718
2507
|
for (const id of plainColl.ids()) {
|
|
1719
|
-
const
|
|
1720
|
-
if (
|
|
1721
|
-
fn(desc.name, id, attached.body, plainColl.get(id));
|
|
2508
|
+
const body = this.bodies.get(bodyKey(desc.name, id));
|
|
2509
|
+
if (body) fn(desc.name, id, body, plainColl.get(id));
|
|
1722
2510
|
}
|
|
1723
2511
|
}
|
|
1724
2512
|
}
|
|
1725
|
-
applyState(body, s) {
|
|
1726
|
-
const M = this.matter;
|
|
1727
|
-
M.Body.setPosition(body, { x: s.x, y: s.y });
|
|
1728
|
-
M.Body.setAngle(body, s.angle);
|
|
1729
|
-
M.Body.setVelocity(body, { x: s.vx, y: s.vy });
|
|
1730
|
-
M.Body.setAngularVelocity(body, s.angularVelocity);
|
|
1731
|
-
if (s.sleeping) M.Sleeping.set(body, true);
|
|
1732
|
-
}
|
|
1733
2513
|
applyRecordToBody(desc, body, record) {
|
|
1734
2514
|
const physics = desc.physics;
|
|
1735
2515
|
if (!physics) return;
|
|
1736
|
-
const t = {
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
};
|
|
2516
|
+
const t = { ...body.translation() };
|
|
2517
|
+
const r = { ...body.rotation() };
|
|
2518
|
+
const v = { ...body.linvel() };
|
|
2519
|
+
const w = { ...body.angvel() };
|
|
2520
|
+
let setT = false;
|
|
2521
|
+
let setR = false;
|
|
2522
|
+
let setV = false;
|
|
2523
|
+
let setW = false;
|
|
1745
2524
|
for (const [channel, field] of physics.channels) {
|
|
1746
2525
|
const raw = record[field];
|
|
1747
2526
|
if (typeof raw !== "number") continue;
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
2527
|
+
switch (channel) {
|
|
2528
|
+
case "x":
|
|
2529
|
+
case "y":
|
|
2530
|
+
case "z":
|
|
2531
|
+
t[channel] = raw;
|
|
2532
|
+
setT = true;
|
|
2533
|
+
break;
|
|
2534
|
+
case "qx":
|
|
2535
|
+
r.x = raw;
|
|
2536
|
+
setR = true;
|
|
2537
|
+
break;
|
|
2538
|
+
case "qy":
|
|
2539
|
+
r.y = raw;
|
|
2540
|
+
setR = true;
|
|
2541
|
+
break;
|
|
2542
|
+
case "qz":
|
|
2543
|
+
r.z = raw;
|
|
2544
|
+
setR = true;
|
|
2545
|
+
break;
|
|
2546
|
+
case "qw":
|
|
2547
|
+
r.w = raw;
|
|
2548
|
+
setR = true;
|
|
2549
|
+
break;
|
|
2550
|
+
case "vx":
|
|
2551
|
+
v.x = raw;
|
|
2552
|
+
setV = true;
|
|
2553
|
+
break;
|
|
2554
|
+
case "vy":
|
|
2555
|
+
v.y = raw;
|
|
2556
|
+
setV = true;
|
|
2557
|
+
break;
|
|
2558
|
+
case "vz":
|
|
2559
|
+
v.z = raw;
|
|
2560
|
+
setV = true;
|
|
2561
|
+
break;
|
|
2562
|
+
case "wx":
|
|
2563
|
+
w.x = raw;
|
|
2564
|
+
setW = true;
|
|
2565
|
+
break;
|
|
2566
|
+
case "wy":
|
|
2567
|
+
w.y = raw;
|
|
2568
|
+
setW = true;
|
|
2569
|
+
break;
|
|
2570
|
+
case "wz":
|
|
2571
|
+
w.z = raw;
|
|
2572
|
+
setW = true;
|
|
2573
|
+
break;
|
|
1758
2574
|
}
|
|
1759
|
-
applyChannel2d(channel, raw, t);
|
|
1760
2575
|
}
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
y: t.y,
|
|
1766
|
-
angle: 2 * Math.atan2(t.qz, t.qw),
|
|
1767
|
-
vx: t.vx,
|
|
1768
|
-
vy: t.vy,
|
|
1769
|
-
angularVelocity: t.wz,
|
|
1770
|
-
sleeping: false
|
|
1771
|
-
});
|
|
2576
|
+
if (setT) body.setTranslation(t, true);
|
|
2577
|
+
if (setR) body.setRotation(r, true);
|
|
2578
|
+
if (setV) body.setLinvel(v, true);
|
|
2579
|
+
if (setW) body.setAngvel(w, true);
|
|
1772
2580
|
}
|
|
2581
|
+
/**
|
|
2582
|
+
* Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
|
|
2583
|
+
* tick, right before the step, in collection order then instance order.
|
|
2584
|
+
*/
|
|
1773
2585
|
reconcile() {
|
|
1774
2586
|
const live = /* @__PURE__ */ new Set();
|
|
1775
2587
|
for (const desc of this.collections) {
|
|
@@ -1777,6 +2589,7 @@ var MatterRuntime = class {
|
|
|
1777
2589
|
for (const id of coll.ids()) {
|
|
1778
2590
|
const key = bodyKey(desc.name, id);
|
|
1779
2591
|
live.add(key);
|
|
2592
|
+
if (this.skippedKeys.has(key)) continue;
|
|
1780
2593
|
const record = coll.get(id);
|
|
1781
2594
|
const existing = this.bodies.get(key);
|
|
1782
2595
|
if (existing) {
|
|
@@ -1790,25 +2603,32 @@ var MatterRuntime = class {
|
|
|
1790
2603
|
this.bodies.delete(key);
|
|
1791
2604
|
this.sleepSynced.delete(key);
|
|
1792
2605
|
this.sourceRecords.delete(key);
|
|
1793
|
-
this.
|
|
2606
|
+
this.destroyBody(existing);
|
|
1794
2607
|
}
|
|
1795
2608
|
if (record !== void 0) this.create(desc, id, record);
|
|
1796
2609
|
}
|
|
1797
2610
|
}
|
|
1798
|
-
for (const [key,
|
|
2611
|
+
for (const [key, body] of [...this.bodies]) {
|
|
1799
2612
|
if (live.has(key)) continue;
|
|
1800
2613
|
this.bodies.delete(key);
|
|
1801
2614
|
this.sleepSynced.delete(key);
|
|
1802
2615
|
this.sourceRecords.delete(key);
|
|
1803
|
-
this.
|
|
2616
|
+
this.destroyBody(body);
|
|
1804
2617
|
}
|
|
2618
|
+
for (const key of [...this.skippedKeys]) if (!live.has(key)) this.skippedKeys.delete(key);
|
|
1805
2619
|
}
|
|
1806
2620
|
// -------------------------------------------------------------------------
|
|
1807
2621
|
// Step and sync
|
|
1808
2622
|
// -------------------------------------------------------------------------
|
|
1809
2623
|
step() {
|
|
1810
|
-
this.
|
|
2624
|
+
this.world.step();
|
|
2625
|
+
if (!rapierStepped) noteRapierStep();
|
|
1811
2626
|
}
|
|
2627
|
+
/**
|
|
2628
|
+
* Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
|
|
2629
|
+
* Values are `Math.fround`ed for f32 fields, so what room code reads is exactly what the wire
|
|
2630
|
+
* carries — and so a field that has not really moved does not re-dirty every tick.
|
|
2631
|
+
*/
|
|
1812
2632
|
sync() {
|
|
1813
2633
|
for (const desc of this.collections) {
|
|
1814
2634
|
const physics = desc.physics;
|
|
@@ -1818,10 +2638,9 @@ var MatterRuntime = class {
|
|
|
1818
2638
|
const rounders = roundersFor(desc);
|
|
1819
2639
|
for (const id of plainColl.ids()) {
|
|
1820
2640
|
const key = bodyKey(desc.name, id);
|
|
1821
|
-
const
|
|
1822
|
-
if (!
|
|
1823
|
-
|
|
1824
|
-
if (body.isSleeping) {
|
|
2641
|
+
const body = this.bodies.get(key);
|
|
2642
|
+
if (!body) continue;
|
|
2643
|
+
if (body.isSleeping()) {
|
|
1825
2644
|
if (this.sleepSynced.has(key)) continue;
|
|
1826
2645
|
this.sleepSynced.add(key);
|
|
1827
2646
|
} else {
|
|
@@ -1829,16 +2648,12 @@ var MatterRuntime = class {
|
|
|
1829
2648
|
}
|
|
1830
2649
|
const record = tracked.get(id);
|
|
1831
2650
|
if (!record) continue;
|
|
1832
|
-
const
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
vx: body.velocity.x,
|
|
1837
|
-
vy: body.velocity.y,
|
|
1838
|
-
angularVelocity: body.angularVelocity
|
|
1839
|
-
};
|
|
2651
|
+
const t = body.translation();
|
|
2652
|
+
const r = body.rotation();
|
|
2653
|
+
const v = body.linvel();
|
|
2654
|
+
const w = body.angvel();
|
|
1840
2655
|
for (const [channel, field] of physics.channels) {
|
|
1841
|
-
const next = (rounders[field] ?? identity)(
|
|
2656
|
+
const next = (rounders[field] ?? identity)(channelValue(channel, t, r, v, w));
|
|
1842
2657
|
if (record[field] !== next) record[field] = next;
|
|
1843
2658
|
}
|
|
1844
2659
|
}
|
|
@@ -1849,22 +2664,19 @@ var MatterRuntime = class {
|
|
|
1849
2664
|
// -------------------------------------------------------------------------
|
|
1850
2665
|
serialize() {
|
|
1851
2666
|
const bodies = [];
|
|
1852
|
-
for (const [key,
|
|
2667
|
+
for (const [key, body] of this.bodies) {
|
|
1853
2668
|
const sep = key.indexOf("\0");
|
|
1854
|
-
|
|
1855
|
-
bodies.push({
|
|
1856
|
-
collection: key.slice(0, sep),
|
|
1857
|
-
id: key.slice(sep + 1),
|
|
1858
|
-
x: b.position.x,
|
|
1859
|
-
y: b.position.y,
|
|
1860
|
-
angle: b.angle,
|
|
1861
|
-
vx: b.velocity.x,
|
|
1862
|
-
vy: b.velocity.y,
|
|
1863
|
-
angularVelocity: b.angularVelocity,
|
|
1864
|
-
sleeping: b.isSleeping === true
|
|
1865
|
-
});
|
|
2669
|
+
bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
|
|
1866
2670
|
}
|
|
1867
|
-
|
|
2671
|
+
const skipped = [...this.skippedKeys];
|
|
2672
|
+
const unsynced = [];
|
|
2673
|
+
for (const [key, body] of this.unsyncedBodies) unsynced.push([key, body.handle]);
|
|
2674
|
+
return {
|
|
2675
|
+
world: this.world.takeSnapshot(),
|
|
2676
|
+
bodies,
|
|
2677
|
+
...skipped.length > 0 ? { skipped } : {},
|
|
2678
|
+
...unsynced.length > 0 ? { unsynced } : {}
|
|
2679
|
+
};
|
|
1868
2680
|
}
|
|
1869
2681
|
};
|
|
1870
2682
|
function identity(v) {
|
|
@@ -1877,215 +2689,305 @@ function roundersFor(desc) {
|
|
|
1877
2689
|
}
|
|
1878
2690
|
return out;
|
|
1879
2691
|
}
|
|
2692
|
+
function channelValue(channel, t, r, v, w) {
|
|
2693
|
+
switch (channel) {
|
|
2694
|
+
case "x":
|
|
2695
|
+
return t.x;
|
|
2696
|
+
case "y":
|
|
2697
|
+
return t.y;
|
|
2698
|
+
case "z":
|
|
2699
|
+
return t.z;
|
|
2700
|
+
case "qx":
|
|
2701
|
+
return r.x;
|
|
2702
|
+
case "qy":
|
|
2703
|
+
return r.y;
|
|
2704
|
+
case "qz":
|
|
2705
|
+
return r.z;
|
|
2706
|
+
case "qw":
|
|
2707
|
+
return r.w;
|
|
2708
|
+
case "vx":
|
|
2709
|
+
return v.x;
|
|
2710
|
+
case "vy":
|
|
2711
|
+
return v.y;
|
|
2712
|
+
case "vz":
|
|
2713
|
+
return v.z;
|
|
2714
|
+
case "wx":
|
|
2715
|
+
return w.x;
|
|
2716
|
+
case "wy":
|
|
2717
|
+
return w.y;
|
|
2718
|
+
default:
|
|
2719
|
+
return w.z;
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
1880
2722
|
|
|
1881
|
-
// src/core/
|
|
1882
|
-
import {
|
|
2723
|
+
// src/core/matter.ts
|
|
2724
|
+
import {
|
|
2725
|
+
ByteReader as ByteReader2,
|
|
2726
|
+
ByteWriter as ByteWriter2,
|
|
2727
|
+
applyChannel2d,
|
|
2728
|
+
channelOf2d
|
|
2729
|
+
} from "@irtio/schema";
|
|
1883
2730
|
var engine2;
|
|
1884
2731
|
var loading2;
|
|
1885
|
-
async function
|
|
2732
|
+
async function initMatter() {
|
|
1886
2733
|
if (engine2) return engine2;
|
|
1887
2734
|
loading2 ??= (async () => {
|
|
1888
|
-
const mod = await import("
|
|
2735
|
+
const mod = await import("matter-js");
|
|
1889
2736
|
const ns = mod.default ?? mod;
|
|
1890
|
-
await ns.init();
|
|
1891
2737
|
engine2 = ns;
|
|
1892
2738
|
return ns;
|
|
1893
2739
|
})();
|
|
1894
2740
|
return loading2;
|
|
1895
2741
|
}
|
|
1896
|
-
function
|
|
2742
|
+
function loadedMatter() {
|
|
1897
2743
|
return engine2;
|
|
1898
2744
|
}
|
|
1899
|
-
function
|
|
2745
|
+
function resetMatterForTests() {
|
|
1900
2746
|
engine2 = void 0;
|
|
1901
2747
|
loading2 = void 0;
|
|
1902
|
-
rapierStepped = false;
|
|
1903
|
-
firstStepWaiters.length = 0;
|
|
1904
|
-
}
|
|
1905
|
-
var rapierStepped = false;
|
|
1906
|
-
var firstStepWaiters = [];
|
|
1907
|
-
function rapierHasStepped() {
|
|
1908
|
-
return rapierStepped;
|
|
1909
|
-
}
|
|
1910
|
-
function onFirstRapierStep(cb) {
|
|
1911
|
-
if (rapierStepped) {
|
|
1912
|
-
cb();
|
|
1913
|
-
return;
|
|
1914
|
-
}
|
|
1915
|
-
firstStepWaiters.push(cb);
|
|
1916
|
-
}
|
|
1917
|
-
function noteRapierStep() {
|
|
1918
|
-
rapierStepped = true;
|
|
1919
|
-
const waiters = firstStepWaiters.splice(0, firstStepWaiters.length);
|
|
1920
|
-
for (const cb of waiters) {
|
|
1921
|
-
try {
|
|
1922
|
-
cb();
|
|
1923
|
-
} catch {
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
2748
|
}
|
|
1927
|
-
function
|
|
1928
|
-
const
|
|
1929
|
-
|
|
2749
|
+
function encodeMatterBodies(section) {
|
|
2750
|
+
const skipped = section.skipped ?? [];
|
|
2751
|
+
const unsynced = section.unsynced ?? [];
|
|
2752
|
+
const w = new ByteWriter2(
|
|
2753
|
+
section.bodies.length * 72 + skipped.length * 24 + unsynced.length * 72 + 16
|
|
2754
|
+
);
|
|
1930
2755
|
w.varint(section.bodies.length);
|
|
1931
|
-
for (const
|
|
1932
|
-
w.str(
|
|
1933
|
-
w.str(id);
|
|
1934
|
-
w.f64(
|
|
2756
|
+
for (const b of section.bodies) {
|
|
2757
|
+
w.str(b.collection);
|
|
2758
|
+
w.str(b.id);
|
|
2759
|
+
w.f64(b.x);
|
|
2760
|
+
w.f64(b.y);
|
|
2761
|
+
w.f64(b.angle);
|
|
2762
|
+
w.f64(b.vx);
|
|
2763
|
+
w.f64(b.vy);
|
|
2764
|
+
w.f64(b.angularVelocity);
|
|
2765
|
+
w.u8(b.sleeping ? 1 : 0);
|
|
1935
2766
|
}
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
w.varint(0);
|
|
1951
|
-
w.u8(RAPIER2D_TAG);
|
|
1952
|
-
w.bytes(payload);
|
|
1953
|
-
return w.finish();
|
|
1954
|
-
}
|
|
1955
|
-
function decodeRapier2dSectionEnvelope(bytes) {
|
|
1956
|
-
const r = new ByteReader2(bytes);
|
|
1957
|
-
if (r.varint() !== 0 || r.u8() !== RAPIER2D_TAG) {
|
|
1958
|
-
throw new Error("irtio: this physics section was not written by rapier2d");
|
|
2767
|
+
if (skipped.length > 0 || unsynced.length > 0) {
|
|
2768
|
+
w.varint(skipped.length);
|
|
2769
|
+
for (const key of skipped) w.str(key);
|
|
2770
|
+
w.varint(unsynced.length);
|
|
2771
|
+
for (const b of unsynced) {
|
|
2772
|
+
w.str(b.key);
|
|
2773
|
+
w.f64(b.x);
|
|
2774
|
+
w.f64(b.y);
|
|
2775
|
+
w.f64(b.angle);
|
|
2776
|
+
w.f64(b.vx);
|
|
2777
|
+
w.f64(b.vy);
|
|
2778
|
+
w.f64(b.angularVelocity);
|
|
2779
|
+
w.u8(b.sleeping ? 1 : 0);
|
|
2780
|
+
}
|
|
1959
2781
|
}
|
|
1960
|
-
return r.rest();
|
|
1961
|
-
}
|
|
1962
|
-
function encodeMatterSectionEnvelope(payload) {
|
|
1963
|
-
const w = new ByteWriter2(payload.length + 8);
|
|
1964
|
-
w.varint(0);
|
|
1965
|
-
w.u8(MATTER_TAG);
|
|
1966
|
-
w.bytes(payload);
|
|
1967
2782
|
return w.finish();
|
|
1968
2783
|
}
|
|
1969
|
-
function
|
|
1970
|
-
const r = new ByteReader2(bytes);
|
|
1971
|
-
if (r.varint() !== 0 || r.u8() !== MATTER_TAG) {
|
|
1972
|
-
throw new Error("irtio: this physics section was not written by matter2d");
|
|
1973
|
-
}
|
|
1974
|
-
return r.rest();
|
|
1975
|
-
}
|
|
1976
|
-
function decodePhysicsSection(bytes) {
|
|
2784
|
+
function decodeMatterBodies(bytes) {
|
|
1977
2785
|
const r = new ByteReader2(bytes);
|
|
1978
|
-
const world = r.blob().slice();
|
|
1979
2786
|
const count = r.varint();
|
|
1980
2787
|
const bodies = [];
|
|
1981
|
-
for (let i = 0; i < count; i++)
|
|
1982
|
-
|
|
2788
|
+
for (let i = 0; i < count; i++) {
|
|
2789
|
+
bodies.push({
|
|
2790
|
+
collection: r.str(),
|
|
2791
|
+
id: r.str(),
|
|
2792
|
+
x: r.f64(),
|
|
2793
|
+
y: r.f64(),
|
|
2794
|
+
angle: r.f64(),
|
|
2795
|
+
vx: r.f64(),
|
|
2796
|
+
vy: r.f64(),
|
|
2797
|
+
angularVelocity: r.f64(),
|
|
2798
|
+
sleeping: r.u8() === 1
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
if (r.eof) return { bodies };
|
|
2802
|
+
const skippedCount = r.varint();
|
|
2803
|
+
const skipped = [];
|
|
2804
|
+
for (let i = 0; i < skippedCount; i++) skipped.push(r.str());
|
|
2805
|
+
const unsynced = [];
|
|
2806
|
+
if (!r.eof) {
|
|
2807
|
+
const unsyncedCount = r.varint();
|
|
2808
|
+
for (let i = 0; i < unsyncedCount; i++) {
|
|
2809
|
+
unsynced.push({
|
|
2810
|
+
key: r.str(),
|
|
2811
|
+
x: r.f64(),
|
|
2812
|
+
y: r.f64(),
|
|
2813
|
+
angle: r.f64(),
|
|
2814
|
+
vx: r.f64(),
|
|
2815
|
+
vy: r.f64(),
|
|
2816
|
+
angularVelocity: r.f64(),
|
|
2817
|
+
sleeping: r.u8() === 1
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
return {
|
|
2822
|
+
bodies,
|
|
2823
|
+
...skipped.length > 0 ? { skipped } : {},
|
|
2824
|
+
...unsynced.length > 0 ? { unsynced } : {}
|
|
2825
|
+
};
|
|
1983
2826
|
}
|
|
1984
2827
|
function bodyKey2(collection, id) {
|
|
1985
2828
|
return `${collection}\0${id}`;
|
|
1986
2829
|
}
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
if (r[(i + 1) % 3] || r[(i + 2) % 3]) continue;
|
|
1999
|
-
const free = axes[i] === "z" ? "enabledRotations(true, false, true)" : "one of the other two rotations";
|
|
2000
|
-
return `locks translation on ${axes[i]} and both rotations across that plane on a box-shaped body. That removes friction entirely: the box slides at a constant speed until it hits something. Free one out-of-plane rotation \u2014 in the xy plane that is ${free}, and it costs nothing, because geometry symmetric about the plane generates no torque about it.`;
|
|
2830
|
+
var PLANAR_CHANNELS = /* @__PURE__ */ new Set(["x", "y", "qz", "qw", "vx", "vy", "wz"]);
|
|
2831
|
+
var MatterRuntime = class {
|
|
2832
|
+
engineKind = "matter2d";
|
|
2833
|
+
/** rapier3d only; present so both runtimes satisfy one internal shape. */
|
|
2834
|
+
rapier = void 0;
|
|
2835
|
+
world = void 0;
|
|
2836
|
+
matter;
|
|
2837
|
+
engine;
|
|
2838
|
+
/** Alias under the name `PhysicsApi` uses, so `room.physics2d` reads through one field. */
|
|
2839
|
+
get matterEngine() {
|
|
2840
|
+
return this.engine;
|
|
2001
2841
|
}
|
|
2002
|
-
|
|
2003
|
-
}
|
|
2004
|
-
var CUBOID_SHAPE = 1;
|
|
2005
|
-
var ROUND_CUBOID_SHAPE = 12;
|
|
2006
|
-
var PhysicsRuntime = class {
|
|
2007
|
-
engineKind = "rapier3d";
|
|
2008
|
-
/** matter2d only; present so both runtimes satisfy one internal shape. */
|
|
2009
|
-
matter = void 0;
|
|
2010
|
-
matterEngine = void 0;
|
|
2011
|
-
rapier;
|
|
2012
|
-
world;
|
|
2013
|
-
/** `true` when the world was built from scratch and `setup` has to run. */
|
|
2842
|
+
/** `true` when the blob carried no world at all: the caller logs it. */
|
|
2014
2843
|
rebuilt;
|
|
2015
|
-
/**
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2844
|
+
/**
|
|
2845
|
+
* Always `true`. Unlike Rapier's, a matter2d world is never restored as a world — only as
|
|
2846
|
+
* per-body state on a world the builder made — so `setup` has to run every time.
|
|
2847
|
+
*/
|
|
2848
|
+
needsSetup = true;
|
|
2019
2849
|
core;
|
|
2020
2850
|
config;
|
|
2021
|
-
/** Physics-backed collections, in schema (name-sorted) order. */
|
|
2022
2851
|
collections;
|
|
2023
2852
|
bodies = /* @__PURE__ */ new Map();
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
/**
|
|
2027
|
-
* Bodies whose sleep has already been synced. Rapier zeroes a body's velocity when it puts it
|
|
2028
|
-
* to sleep — *after* the last awake-tick sync — so a body must be synced **once more** on the
|
|
2029
|
-
* tick it falls asleep, or the schema keeps a phantom residual velocity forever (and a
|
|
2030
|
-
* world rebuilt from schema state would wake it with a kick). Found by the drift check.
|
|
2031
|
-
*/
|
|
2853
|
+
restore;
|
|
2854
|
+
stepMs;
|
|
2032
2855
|
sleepSynced = /* @__PURE__ */ new Set();
|
|
2033
2856
|
/**
|
|
2034
|
-
* The plain record object each body was built from
|
|
2035
|
-
*
|
|
2036
|
-
*
|
|
2037
|
-
*
|
|
2038
|
-
* Without it the old body kept its pose and velocity and the new row's spawn values were lost.
|
|
2039
|
-
*
|
|
2040
|
-
* Unknown means adopt, never rebuild: a world restored from a hibernation blob has bodies with
|
|
2041
|
-
* no recorded source, and rebuilding those would throw away exactly what the blob preserved.
|
|
2857
|
+
* The plain record object each body was built from. See `PhysicsRuntime.sourceRecords`: `add()`
|
|
2858
|
+
* installs a new record object, so an id removed and re-added between two reconciles (a pooled
|
|
2859
|
+
* projectile) is identifiable by identity, and is the one case where a live body has to be
|
|
2860
|
+
* rebuilt so the new row's spawn pose and velocity are honoured.
|
|
2042
2861
|
*/
|
|
2043
2862
|
sourceRecords = /* @__PURE__ */ new Map();
|
|
2044
|
-
/** `collection.field` pairs already warned about a channel
|
|
2863
|
+
/** `collection.field` pairs already warned about a channel a plane cannot hold. */
|
|
2045
2864
|
warnedChannels = /* @__PURE__ */ new Set();
|
|
2046
|
-
|
|
2865
|
+
// ---- M7 planned feature 3 (b): per-entity physics skip ----
|
|
2866
|
+
/** See `PhysicsRuntime.skippedKeys`: entities whose body the room has switched off. */
|
|
2867
|
+
skippedKeys = /* @__PURE__ */ new Set();
|
|
2868
|
+
// ---- end M7 planned feature 3 (b) ----
|
|
2869
|
+
// ---- M7 planned feature 3 (a): physics-only unsynced bodies ----
|
|
2870
|
+
unsyncedBodies = /* @__PURE__ */ new Map();
|
|
2871
|
+
/** Saved unsynced state, consumed as `setup` rebuilds each body under the same key. */
|
|
2872
|
+
restoreUnsynced;
|
|
2873
|
+
/** One-shot: the first reconcile after a wake reports whatever `setup` did not rebuild. */
|
|
2874
|
+
reportedMissingUnsynced = false;
|
|
2875
|
+
unsynced;
|
|
2876
|
+
// ---- end M7 planned feature 3 (a) ----
|
|
2877
|
+
constructor(core, matter, options) {
|
|
2047
2878
|
const config = core.definition.config.physics;
|
|
2048
|
-
if (!config) throw new Error("
|
|
2879
|
+
if (!config) throw new Error("MatterRuntime: the room config declares no physics");
|
|
2049
2880
|
this.core = core;
|
|
2050
2881
|
this.config = config;
|
|
2051
|
-
this.
|
|
2882
|
+
this.matter = matter;
|
|
2052
2883
|
this.collections = core.ext.collections.filter(
|
|
2053
2884
|
(c) => c.physics !== void 0
|
|
2054
2885
|
);
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2886
|
+
this.engine = matter.Engine.create();
|
|
2887
|
+
this.engine.gravity.x = config.gravity.x;
|
|
2888
|
+
this.engine.gravity.y = config.gravity.y;
|
|
2889
|
+
this.stepMs = (config.timestep ?? options.defaultTimestep) * 1e3;
|
|
2890
|
+
this.rebuilt = options.restore === void 0;
|
|
2891
|
+
this.restore = options.restore ? new Map(options.restore.bodies.map((b) => [bodyKey2(b.collection, b.id), b])) : void 0;
|
|
2892
|
+
for (const key of options.restore?.skipped ?? []) this.skippedKeys.add(key);
|
|
2893
|
+
const savedUnsynced = options.restore?.unsynced;
|
|
2894
|
+
this.restoreUnsynced = savedUnsynced ? new Map(savedUnsynced.map((b) => [b.key, b])) : void 0;
|
|
2895
|
+
this.unsynced = makeUnsyncedRegistry({
|
|
2896
|
+
map: this.unsyncedBodies,
|
|
2897
|
+
build: (spec) => {
|
|
2898
|
+
const constraints = spec.constraints ?? [];
|
|
2899
|
+
this.matter.Composite.add(this.engine.world, [spec.body, ...constraints]);
|
|
2900
|
+
return { body: spec.body, constraints };
|
|
2901
|
+
},
|
|
2902
|
+
destroy: (attached) => {
|
|
2903
|
+
this.wakeContactsOf(attached.body);
|
|
2904
|
+
this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
|
|
2905
|
+
},
|
|
2906
|
+
// The matter2d half of surviving hibernation: `setup` rebuilds the body, and the saved pose
|
|
2907
|
+
// and velocity are put back onto it, exactly as they are for an entity-backed body.
|
|
2908
|
+
onCreated: (key, attached) => {
|
|
2909
|
+
const saved = this.restoreUnsynced?.get(key);
|
|
2910
|
+
if (!saved) return;
|
|
2911
|
+
this.restoreUnsynced?.delete(key);
|
|
2912
|
+
this.applyState(attached.body, {
|
|
2913
|
+
collection: "",
|
|
2914
|
+
id: key,
|
|
2915
|
+
x: saved.x,
|
|
2916
|
+
y: saved.y,
|
|
2917
|
+
angle: saved.angle,
|
|
2918
|
+
vx: saved.vx,
|
|
2919
|
+
vy: saved.vy,
|
|
2920
|
+
angularVelocity: saved.angularVelocity,
|
|
2921
|
+
sleeping: saved.sleeping
|
|
2922
|
+
});
|
|
2061
2923
|
}
|
|
2062
|
-
}
|
|
2063
|
-
const g = config.gravity;
|
|
2064
|
-
this.world = new rapier.World({ x: g.x, y: g.y, z: g.z });
|
|
2065
|
-
this.rebuilt = true;
|
|
2066
|
-
}
|
|
2067
|
-
this.world.timestep = config.timestep ?? options.defaultTimestep;
|
|
2924
|
+
});
|
|
2068
2925
|
}
|
|
2069
2926
|
get timestep() {
|
|
2070
|
-
return this.
|
|
2927
|
+
return this.stepMs / 1e3;
|
|
2071
2928
|
}
|
|
2072
|
-
/** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
|
|
2073
2929
|
runSetup(room) {
|
|
2074
2930
|
const setup = this.config.setup;
|
|
2075
2931
|
if (!setup) return;
|
|
2076
|
-
this.core.guard("physics.setup", () => setup(this.
|
|
2932
|
+
this.core.guard("physics.setup", () => setup(this.engine, this.matter, room));
|
|
2077
2933
|
}
|
|
2078
2934
|
free() {
|
|
2079
2935
|
this.bodies.clear();
|
|
2080
|
-
this.
|
|
2936
|
+
this.unsyncedBodies.clear();
|
|
2937
|
+
this.matter.Engine.clear(this.engine);
|
|
2081
2938
|
}
|
|
2082
2939
|
// -------------------------------------------------------------------------
|
|
2083
2940
|
// Bodies
|
|
2084
2941
|
// -------------------------------------------------------------------------
|
|
2085
|
-
/**
|
|
2942
|
+
/**
|
|
2943
|
+
* bugs.md #100, the matter2d half. Rapier's bindings wake a contact island themselves when a
|
|
2944
|
+
* member is removed (`PhysicsRuntime.destroyBody` / `Rapier2dRuntime.destroyBody`); matter.js
|
|
2945
|
+
* does not, so a body sleeping against a victim about to be removed is left sleeping and
|
|
2946
|
+
* motionless in mid-air once the victim is gone.
|
|
2947
|
+
*
|
|
2948
|
+
* `enableSleeping` is a property the room's own `setup` sets on `engine` (there is no config
|
|
2949
|
+
* flag for it), so this only runs the geometry query when the room has actually turned sleeping
|
|
2950
|
+
* on. `Matter.Query.collides` is matter's public narrow-phase check against a body's current
|
|
2951
|
+
* vertices, run here against every other body in the world; it costs nothing extra to compute
|
|
2952
|
+
* (matter.js does not expose a per-body contact list the way rapier's bindings do) but is bounded
|
|
2953
|
+
* by the same room body count that every reconcile already walks. Sensors are skipped, matching
|
|
2954
|
+
* the rapier fix: an intersection pair is not an island edge.
|
|
2955
|
+
*/
|
|
2956
|
+
wakeContactsOf(victim) {
|
|
2957
|
+
if (!this.engine.enableSleeping) return;
|
|
2958
|
+
const M = this.matter;
|
|
2959
|
+
const others = M.Composite.allBodies(this.engine.world).filter((b) => b !== victim);
|
|
2960
|
+
for (const collision of M.Query.collides(victim, others)) {
|
|
2961
|
+
const other = collision.bodyA === victim ? collision.bodyB : collision.bodyA;
|
|
2962
|
+
if (other.isSensor) continue;
|
|
2963
|
+
if (other.isSleeping) M.Sleeping.set(other, false);
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
// ---- M7 planned feature 3 (b): per-entity physics skip ----
|
|
2967
|
+
/** The matter.js half of `PhysicsRuntime.setSkipped`; its comment is the whole argument. */
|
|
2968
|
+
setSkipped(collection, id, on) {
|
|
2969
|
+
const key = bodyKey2(collection, id);
|
|
2970
|
+
if (!on) {
|
|
2971
|
+
this.skippedKeys.delete(key);
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
this.skippedKeys.add(key);
|
|
2975
|
+
const existing = this.bodies.get(key);
|
|
2976
|
+
if (!existing) return;
|
|
2977
|
+
this.bodies.delete(key);
|
|
2978
|
+
this.sleepSynced.delete(key);
|
|
2979
|
+
this.sourceRecords.delete(key);
|
|
2980
|
+
this.wakeContactsOf(existing.body);
|
|
2981
|
+
this.matter.Composite.remove(this.engine.world, [existing.body, ...existing.constraints]);
|
|
2982
|
+
}
|
|
2983
|
+
isSkipped(collection, id) {
|
|
2984
|
+
return this.skippedKeys.has(bodyKey2(collection, id));
|
|
2985
|
+
}
|
|
2986
|
+
// ---- end M7 planned feature 3 (b) ----
|
|
2086
2987
|
bodyFor(collection, id) {
|
|
2087
2988
|
const existing = this.bodies.get(bodyKey2(collection, id));
|
|
2088
|
-
if (existing) return existing;
|
|
2989
|
+
if (existing) return existing.body;
|
|
2990
|
+
if (this.skippedKeys.has(bodyKey2(collection, id))) return void 0;
|
|
2089
2991
|
const desc = this.collections.find((c) => c.name === collection);
|
|
2090
2992
|
if (!desc) return void 0;
|
|
2091
2993
|
const coll = plainEntity(this.core.plain, collection);
|
|
@@ -2101,7 +3003,7 @@ var PhysicsRuntime = class {
|
|
|
2101
3003
|
}
|
|
2102
3004
|
const spec = this.core.guard(
|
|
2103
3005
|
`physics.bodies.${desc.name}`,
|
|
2104
|
-
() => factory(this.
|
|
3006
|
+
() => factory(this.matter, record, id)
|
|
2105
3007
|
);
|
|
2106
3008
|
if (!spec || !spec.body) {
|
|
2107
3009
|
this.core.log(
|
|
@@ -2110,152 +3012,102 @@ var PhysicsRuntime = class {
|
|
|
2110
3012
|
);
|
|
2111
3013
|
return void 0;
|
|
2112
3014
|
}
|
|
2113
|
-
const
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
this.sourceRecords.set(bodyKey2(desc.name, id), record);
|
|
2124
|
-
return body;
|
|
2125
|
-
}
|
|
2126
|
-
/**
|
|
2127
|
-
* A spawn value the engine will not take, said once per field.
|
|
2128
|
-
*
|
|
2129
|
-
* Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
|
|
2130
|
-
* accepts one on a fixed body and then ignores it — both read back as "the field was applied"
|
|
2131
|
-
* and behave as if it never was. The desc says which, before the body exists, so the check is
|
|
2132
|
-
* on the desc: the usual case is the 2D-in-3D recipe (`enabledTranslations(true, true, false)`)
|
|
2133
|
-
* meeting a row that spawns with a `vz`. Named by field, because the field is what the author
|
|
2134
|
-
* wrote and what they will grep for.
|
|
2135
|
-
*/
|
|
2136
|
-
warnLockedChannels(desc, spec, record) {
|
|
2137
|
-
const physics = desc.physics;
|
|
2138
|
-
if (!physics) return;
|
|
2139
|
-
const b = spec.body;
|
|
2140
|
-
const frozen = b.status !== 0;
|
|
2141
|
-
const enabled = {
|
|
2142
|
-
vx: b.translationsEnabledX,
|
|
2143
|
-
vy: b.translationsEnabledY,
|
|
2144
|
-
vz: b.translationsEnabledZ,
|
|
2145
|
-
wx: b.rotationsEnabledX,
|
|
2146
|
-
wy: b.rotationsEnabledY,
|
|
2147
|
-
wz: b.rotationsEnabledZ
|
|
2148
|
-
};
|
|
2149
|
-
for (const [channel, field] of physics.channels) {
|
|
2150
|
-
const allowed = enabled[channel];
|
|
2151
|
-
if (allowed === void 0) continue;
|
|
2152
|
-
const raw = record[field];
|
|
2153
|
-
if (typeof raw !== "number" || raw === 0) continue;
|
|
2154
|
-
if (allowed && !frozen) continue;
|
|
2155
|
-
const key = `${desc.name}.${field}`;
|
|
2156
|
-
if (this.warnedChannels.has(key)) continue;
|
|
2157
|
-
this.warnedChannels.add(key);
|
|
2158
|
-
this.core.log(
|
|
2159
|
-
"warn",
|
|
2160
|
-
`irtio: ${desc.name}.${field} (body channel ${channel}) was added as ${raw}, but ` + (frozen ? `physics.bodies.${desc.name} builds a non-dynamic body, which never moves under it` : `that degree of freedom is disabled by physics.bodies.${desc.name}`) + " \u2014 the value was dropped"
|
|
2161
|
-
);
|
|
3015
|
+
const key = bodyKey2(desc.name, id);
|
|
3016
|
+
const constraints = spec.constraints ?? [];
|
|
3017
|
+
this.matter.Composite.add(this.engine.world, [spec.body, ...constraints]);
|
|
3018
|
+
this.bodies.set(key, { body: spec.body, constraints });
|
|
3019
|
+
const saved = this.restore?.get(key);
|
|
3020
|
+
if (saved) {
|
|
3021
|
+
this.restore?.delete(key);
|
|
3022
|
+
this.applyState(spec.body, saved);
|
|
3023
|
+
} else {
|
|
3024
|
+
this.applyRecordToBody(desc, spec.body, record);
|
|
2162
3025
|
}
|
|
3026
|
+
this.sourceRecords.set(key, record);
|
|
3027
|
+
return spec.body;
|
|
2163
3028
|
}
|
|
2164
3029
|
// ---- M6 lane F: rewind ----
|
|
2165
3030
|
/**
|
|
2166
|
-
* D72: every tracked body, in the order `sync()` walks them
|
|
2167
|
-
*
|
|
2168
|
-
* The pose history calls this once per tick and the rewind scratch calls it per rewind; a room
|
|
2169
|
-
* that declares no `physics.history` never calls it at all.
|
|
3031
|
+
* D72: every tracked body, in the order `sync()` walks them. The matter half of the same
|
|
3032
|
+
* read-only accessor `PhysicsRuntime` carries; see its comment for what calls it and when.
|
|
2170
3033
|
*/
|
|
2171
3034
|
eachTrackedBody(fn) {
|
|
2172
3035
|
for (const desc of this.collections) {
|
|
2173
3036
|
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
2174
3037
|
for (const id of plainColl.ids()) {
|
|
2175
|
-
const
|
|
2176
|
-
if (
|
|
3038
|
+
const attached = this.bodies.get(bodyKey2(desc.name, id));
|
|
3039
|
+
if (attached)
|
|
3040
|
+
fn(desc.name, id, attached.body, plainColl.get(id));
|
|
2177
3041
|
}
|
|
2178
3042
|
}
|
|
2179
3043
|
}
|
|
3044
|
+
applyState(body, s) {
|
|
3045
|
+
const M = this.matter;
|
|
3046
|
+
M.Body.setPosition(body, { x: s.x, y: s.y });
|
|
3047
|
+
M.Body.setAngle(body, s.angle);
|
|
3048
|
+
M.Body.setVelocity(body, { x: s.vx, y: s.vy });
|
|
3049
|
+
M.Body.setAngularVelocity(body, s.angularVelocity);
|
|
3050
|
+
if (s.sleeping) M.Sleeping.set(body, true);
|
|
3051
|
+
}
|
|
2180
3052
|
applyRecordToBody(desc, body, record) {
|
|
2181
3053
|
const physics = desc.physics;
|
|
2182
3054
|
if (!physics) return;
|
|
2183
|
-
const t = {
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
3055
|
+
const t = {
|
|
3056
|
+
x: body.position.x,
|
|
3057
|
+
y: body.position.y,
|
|
3058
|
+
qz: Math.sin(body.angle / 2),
|
|
3059
|
+
qw: Math.cos(body.angle / 2),
|
|
3060
|
+
vx: body.velocity.x,
|
|
3061
|
+
vy: body.velocity.y,
|
|
3062
|
+
wz: body.angularVelocity
|
|
3063
|
+
};
|
|
2191
3064
|
for (const [channel, field] of physics.channels) {
|
|
2192
3065
|
const raw = record[field];
|
|
2193
3066
|
if (typeof raw !== "number") continue;
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
break;
|
|
2205
|
-
case "qy":
|
|
2206
|
-
r.y = raw;
|
|
2207
|
-
setR = true;
|
|
2208
|
-
break;
|
|
2209
|
-
case "qz":
|
|
2210
|
-
r.z = raw;
|
|
2211
|
-
setR = true;
|
|
2212
|
-
break;
|
|
2213
|
-
case "qw":
|
|
2214
|
-
r.w = raw;
|
|
2215
|
-
setR = true;
|
|
2216
|
-
break;
|
|
2217
|
-
case "vx":
|
|
2218
|
-
v.x = raw;
|
|
2219
|
-
setV = true;
|
|
2220
|
-
break;
|
|
2221
|
-
case "vy":
|
|
2222
|
-
v.y = raw;
|
|
2223
|
-
setV = true;
|
|
2224
|
-
break;
|
|
2225
|
-
case "vz":
|
|
2226
|
-
v.z = raw;
|
|
2227
|
-
setV = true;
|
|
2228
|
-
break;
|
|
2229
|
-
case "wx":
|
|
2230
|
-
w.x = raw;
|
|
2231
|
-
setW = true;
|
|
2232
|
-
break;
|
|
2233
|
-
case "wy":
|
|
2234
|
-
w.y = raw;
|
|
2235
|
-
setW = true;
|
|
2236
|
-
break;
|
|
2237
|
-
case "wz":
|
|
2238
|
-
w.z = raw;
|
|
2239
|
-
setW = true;
|
|
2240
|
-
break;
|
|
3067
|
+
if (raw !== 0 && !PLANAR_CHANNELS.has(channel)) {
|
|
3068
|
+
const key = `${desc.name}.${field}`;
|
|
3069
|
+
if (!this.warnedChannels.has(key)) {
|
|
3070
|
+
this.warnedChannels.add(key);
|
|
3071
|
+
this.core.log(
|
|
3072
|
+
"warn",
|
|
3073
|
+
`irtio: ${desc.name}.${field} maps body channel ${channel}, which a matter2d room cannot hold (the world is a plane); the ${raw} it was added with was dropped`
|
|
3074
|
+
);
|
|
3075
|
+
}
|
|
3076
|
+
continue;
|
|
2241
3077
|
}
|
|
3078
|
+
applyChannel2d(channel, raw, t);
|
|
2242
3079
|
}
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
3080
|
+
this.applyState(body, {
|
|
3081
|
+
collection: desc.name,
|
|
3082
|
+
id: "",
|
|
3083
|
+
x: t.x,
|
|
3084
|
+
y: t.y,
|
|
3085
|
+
angle: 2 * Math.atan2(t.qz, t.qw),
|
|
3086
|
+
vx: t.vx,
|
|
3087
|
+
vy: t.vy,
|
|
3088
|
+
angularVelocity: t.wz,
|
|
3089
|
+
sleeping: false
|
|
3090
|
+
});
|
|
2247
3091
|
}
|
|
2248
|
-
/**
|
|
2249
|
-
* Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
|
|
2250
|
-
* tick, right before the step, in collection order then instance order.
|
|
2251
|
-
*/
|
|
2252
3092
|
reconcile() {
|
|
3093
|
+
if (!this.reportedMissingUnsynced && this.restoreUnsynced) {
|
|
3094
|
+
this.reportedMissingUnsynced = true;
|
|
3095
|
+
const missing = [...this.restoreUnsynced.keys()];
|
|
3096
|
+
this.restoreUnsynced = void 0;
|
|
3097
|
+
if (missing.length > 0) {
|
|
3098
|
+
this.core.log(
|
|
3099
|
+
"warn",
|
|
3100
|
+
`irtio: matter2d rebuilds its world on every wake, so an unsynced body only comes back if physics.setup creates it again under the same key. These did not come back and their saved state was dropped: ${missing.join(", ")}`
|
|
3101
|
+
);
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
2253
3104
|
const live = /* @__PURE__ */ new Set();
|
|
2254
3105
|
for (const desc of this.collections) {
|
|
2255
3106
|
const coll = plainEntity(this.core.plain, desc.name);
|
|
2256
3107
|
for (const id of coll.ids()) {
|
|
2257
3108
|
const key = bodyKey2(desc.name, id);
|
|
2258
3109
|
live.add(key);
|
|
3110
|
+
if (this.skippedKeys.has(key)) continue;
|
|
2259
3111
|
const record = coll.get(id);
|
|
2260
3112
|
const existing = this.bodies.get(key);
|
|
2261
3113
|
if (existing) {
|
|
@@ -2269,31 +3121,28 @@ var PhysicsRuntime = class {
|
|
|
2269
3121
|
this.bodies.delete(key);
|
|
2270
3122
|
this.sleepSynced.delete(key);
|
|
2271
3123
|
this.sourceRecords.delete(key);
|
|
2272
|
-
this.
|
|
3124
|
+
this.wakeContactsOf(existing.body);
|
|
3125
|
+
this.matter.Composite.remove(this.engine.world, [existing.body, ...existing.constraints]);
|
|
2273
3126
|
}
|
|
2274
3127
|
if (record !== void 0) this.create(desc, id, record);
|
|
2275
3128
|
}
|
|
2276
3129
|
}
|
|
2277
|
-
for (const [key,
|
|
3130
|
+
for (const [key, attached] of [...this.bodies]) {
|
|
2278
3131
|
if (live.has(key)) continue;
|
|
2279
3132
|
this.bodies.delete(key);
|
|
2280
3133
|
this.sleepSynced.delete(key);
|
|
2281
3134
|
this.sourceRecords.delete(key);
|
|
2282
|
-
this.
|
|
3135
|
+
this.wakeContactsOf(attached.body);
|
|
3136
|
+
this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
|
|
2283
3137
|
}
|
|
3138
|
+
for (const key of [...this.skippedKeys]) if (!live.has(key)) this.skippedKeys.delete(key);
|
|
2284
3139
|
}
|
|
2285
3140
|
// -------------------------------------------------------------------------
|
|
2286
3141
|
// Step and sync
|
|
2287
3142
|
// -------------------------------------------------------------------------
|
|
2288
3143
|
step() {
|
|
2289
|
-
this.
|
|
2290
|
-
if (!rapierStepped) noteRapierStep();
|
|
3144
|
+
this.matter.Engine.update(this.engine, this.stepMs);
|
|
2291
3145
|
}
|
|
2292
|
-
/**
|
|
2293
|
-
* Body → schema. Writes through the tracked proxies, so movement produces ordinary deltas.
|
|
2294
|
-
* Values are `Math.fround`ed for f32 fields, so what room code reads is exactly what the wire
|
|
2295
|
-
* carries — and so a field that has not really moved does not re-dirty every tick.
|
|
2296
|
-
*/
|
|
2297
3146
|
sync() {
|
|
2298
3147
|
for (const desc of this.collections) {
|
|
2299
3148
|
const physics = desc.physics;
|
|
@@ -2303,9 +3152,10 @@ var PhysicsRuntime = class {
|
|
|
2303
3152
|
const rounders = roundersFor2(desc);
|
|
2304
3153
|
for (const id of plainColl.ids()) {
|
|
2305
3154
|
const key = bodyKey2(desc.name, id);
|
|
2306
|
-
const
|
|
2307
|
-
if (!
|
|
2308
|
-
|
|
3155
|
+
const attached = this.bodies.get(key);
|
|
3156
|
+
if (!attached) continue;
|
|
3157
|
+
const body = attached.body;
|
|
3158
|
+
if (body.isSleeping) {
|
|
2309
3159
|
if (this.sleepSynced.has(key)) continue;
|
|
2310
3160
|
this.sleepSynced.add(key);
|
|
2311
3161
|
} else {
|
|
@@ -2313,12 +3163,16 @@ var PhysicsRuntime = class {
|
|
|
2313
3163
|
}
|
|
2314
3164
|
const record = tracked.get(id);
|
|
2315
3165
|
if (!record) continue;
|
|
2316
|
-
const
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
3166
|
+
const state = {
|
|
3167
|
+
x: body.position.x,
|
|
3168
|
+
y: body.position.y,
|
|
3169
|
+
angle: body.angle,
|
|
3170
|
+
vx: body.velocity.x,
|
|
3171
|
+
vy: body.velocity.y,
|
|
3172
|
+
angularVelocity: body.angularVelocity
|
|
3173
|
+
};
|
|
2320
3174
|
for (const [channel, field] of physics.channels) {
|
|
2321
|
-
const next = (rounders[field] ?? identity2)(
|
|
3175
|
+
const next = (rounders[field] ?? identity2)(channelOf2d(channel, state));
|
|
2322
3176
|
if (record[field] !== next) record[field] = next;
|
|
2323
3177
|
}
|
|
2324
3178
|
}
|
|
@@ -2329,52 +3183,52 @@ var PhysicsRuntime = class {
|
|
|
2329
3183
|
// -------------------------------------------------------------------------
|
|
2330
3184
|
serialize() {
|
|
2331
3185
|
const bodies = [];
|
|
2332
|
-
for (const [key,
|
|
3186
|
+
for (const [key, attached] of this.bodies) {
|
|
2333
3187
|
const sep = key.indexOf("\0");
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
3188
|
+
const b = attached.body;
|
|
3189
|
+
bodies.push({
|
|
3190
|
+
collection: key.slice(0, sep),
|
|
3191
|
+
id: key.slice(sep + 1),
|
|
3192
|
+
x: b.position.x,
|
|
3193
|
+
y: b.position.y,
|
|
3194
|
+
angle: b.angle,
|
|
3195
|
+
vx: b.velocity.x,
|
|
3196
|
+
vy: b.velocity.y,
|
|
3197
|
+
angularVelocity: b.angularVelocity,
|
|
3198
|
+
sleeping: b.isSleeping === true
|
|
3199
|
+
});
|
|
3200
|
+
}
|
|
3201
|
+
const skipped = [...this.skippedKeys];
|
|
3202
|
+
const unsynced = [];
|
|
3203
|
+
for (const [key, attached] of this.unsyncedBodies) {
|
|
3204
|
+
const b = attached.body;
|
|
3205
|
+
unsynced.push({
|
|
3206
|
+
key,
|
|
3207
|
+
x: b.position.x,
|
|
3208
|
+
y: b.position.y,
|
|
3209
|
+
angle: b.angle,
|
|
3210
|
+
vx: b.velocity.x,
|
|
3211
|
+
vy: b.velocity.y,
|
|
3212
|
+
angularVelocity: b.angularVelocity,
|
|
3213
|
+
sleeping: b.isSleeping === true
|
|
3214
|
+
});
|
|
3215
|
+
}
|
|
3216
|
+
return {
|
|
3217
|
+
bodies,
|
|
3218
|
+
...skipped.length > 0 ? { skipped } : {},
|
|
3219
|
+
...unsynced.length > 0 ? { unsynced } : {}
|
|
3220
|
+
};
|
|
2346
3221
|
}
|
|
2347
|
-
|
|
3222
|
+
};
|
|
3223
|
+
function identity2(v) {
|
|
3224
|
+
return v;
|
|
2348
3225
|
}
|
|
2349
|
-
function
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
case "y":
|
|
2354
|
-
return t.y;
|
|
2355
|
-
case "z":
|
|
2356
|
-
return t.z;
|
|
2357
|
-
case "qx":
|
|
2358
|
-
return r.x;
|
|
2359
|
-
case "qy":
|
|
2360
|
-
return r.y;
|
|
2361
|
-
case "qz":
|
|
2362
|
-
return r.z;
|
|
2363
|
-
case "qw":
|
|
2364
|
-
return r.w;
|
|
2365
|
-
case "vx":
|
|
2366
|
-
return v.x;
|
|
2367
|
-
case "vy":
|
|
2368
|
-
return v.y;
|
|
2369
|
-
case "vz":
|
|
2370
|
-
return v.z;
|
|
2371
|
-
case "wx":
|
|
2372
|
-
return w.x;
|
|
2373
|
-
case "wy":
|
|
2374
|
-
return w.y;
|
|
2375
|
-
default:
|
|
2376
|
-
return w.z;
|
|
3226
|
+
function roundersFor2(desc) {
|
|
3227
|
+
const out = {};
|
|
3228
|
+
for (const f of desc.fields) {
|
|
3229
|
+
if (f.type.kind === "f32") out[f.name] = Math.fround;
|
|
2377
3230
|
}
|
|
3231
|
+
return out;
|
|
2378
3232
|
}
|
|
2379
3233
|
|
|
2380
3234
|
// src/core/random.ts
|
|
@@ -2457,6 +3311,15 @@ var Rapier2dRuntime = class {
|
|
|
2457
3311
|
sourceRecords = /* @__PURE__ */ new Map();
|
|
2458
3312
|
/** `collection.field` pairs already warned about a channel value the engine would not take. */
|
|
2459
3313
|
warnedChannels = /* @__PURE__ */ new Set();
|
|
3314
|
+
// ---- M7 planned feature 3 (b): per-entity physics skip ----
|
|
3315
|
+
/** See `PhysicsRuntime.skippedKeys`: entities whose body the room has switched off. */
|
|
3316
|
+
skippedKeys = /* @__PURE__ */ new Set();
|
|
3317
|
+
// ---- end M7 planned feature 3 (b) ----
|
|
3318
|
+
// ---- M7 planned feature 3 (a): physics-only unsynced bodies ----
|
|
3319
|
+
/** See `PhysicsRuntime.unsyncedBodies`: bodies the world steps that no schema row describes. */
|
|
3320
|
+
unsyncedBodies = /* @__PURE__ */ new Map();
|
|
3321
|
+
unsynced;
|
|
3322
|
+
// ---- end M7 planned feature 3 (a) ----
|
|
2460
3323
|
constructor(core, rapier, options) {
|
|
2461
3324
|
const config = core.definition.config.physics;
|
|
2462
3325
|
if (!config) throw new Error("Rapier2dRuntime: the room config declares no physics");
|
|
@@ -2473,12 +3336,26 @@ var Rapier2dRuntime = class {
|
|
|
2473
3336
|
const body = this.world.getRigidBody(handle);
|
|
2474
3337
|
if (body) this.bodies.set(bodyKey3(name, id), body);
|
|
2475
3338
|
}
|
|
3339
|
+
for (const key of options.restore.skipped ?? []) this.skippedKeys.add(key);
|
|
3340
|
+
for (const [key, handle] of options.restore.unsynced ?? []) {
|
|
3341
|
+
const body = this.world.getRigidBody(handle);
|
|
3342
|
+
if (body) this.unsyncedBodies.set(key, body);
|
|
3343
|
+
}
|
|
2476
3344
|
} else {
|
|
2477
3345
|
const g = config.gravity;
|
|
2478
3346
|
this.world = new rapier.World({ x: g.x, y: g.y });
|
|
2479
3347
|
this.rebuilt = true;
|
|
2480
3348
|
}
|
|
2481
3349
|
this.world.timestep = config.timestep ?? options.defaultTimestep;
|
|
3350
|
+
this.unsynced = makeUnsyncedRegistry({
|
|
3351
|
+
map: this.unsyncedBodies,
|
|
3352
|
+
build: (spec) => {
|
|
3353
|
+
const body = this.world.createRigidBody(spec.body);
|
|
3354
|
+
for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
|
|
3355
|
+
return body;
|
|
3356
|
+
},
|
|
3357
|
+
destroy: (body) => this.destroyBody(body)
|
|
3358
|
+
});
|
|
2482
3359
|
}
|
|
2483
3360
|
get timestep() {
|
|
2484
3361
|
return this.world.timestep;
|
|
@@ -2491,15 +3368,51 @@ var Rapier2dRuntime = class {
|
|
|
2491
3368
|
}
|
|
2492
3369
|
free() {
|
|
2493
3370
|
this.bodies.clear();
|
|
3371
|
+
this.unsyncedBodies.clear();
|
|
2494
3372
|
this.world.free();
|
|
2495
3373
|
}
|
|
2496
3374
|
// -------------------------------------------------------------------------
|
|
2497
3375
|
// Bodies
|
|
2498
3376
|
// -------------------------------------------------------------------------
|
|
3377
|
+
/**
|
|
3378
|
+
* bugs.md #99, planar half: the teardown door every removal goes through. See
|
|
3379
|
+
* `PhysicsRuntime.destroyBody` for the argument — one narrow-phase walk of the victim's own
|
|
3380
|
+
* contact pairs, so a body resting on what is about to vanish is awake when it does.
|
|
3381
|
+
*/
|
|
3382
|
+
destroyBody(body) {
|
|
3383
|
+
const colliders = body.numColliders();
|
|
3384
|
+
for (let i = 0; i < colliders; i++) {
|
|
3385
|
+
this.world.contactPairsWith(body.collider(i), (other) => {
|
|
3386
|
+
other.parent()?.wakeUp();
|
|
3387
|
+
});
|
|
3388
|
+
}
|
|
3389
|
+
this.world.removeRigidBody(body);
|
|
3390
|
+
}
|
|
3391
|
+
// ---- M7 planned feature 3 (b): per-entity physics skip ----
|
|
3392
|
+
/** The planar half of `PhysicsRuntime.setSkipped`; its comment is the whole argument. */
|
|
3393
|
+
setSkipped(collection, id, on) {
|
|
3394
|
+
const key = bodyKey3(collection, id);
|
|
3395
|
+
if (!on) {
|
|
3396
|
+
this.skippedKeys.delete(key);
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3399
|
+
this.skippedKeys.add(key);
|
|
3400
|
+
const existing = this.bodies.get(key);
|
|
3401
|
+
if (!existing) return;
|
|
3402
|
+
this.bodies.delete(key);
|
|
3403
|
+
this.sleepSynced.delete(key);
|
|
3404
|
+
this.sourceRecords.delete(key);
|
|
3405
|
+
this.destroyBody(existing);
|
|
3406
|
+
}
|
|
3407
|
+
isSkipped(collection, id) {
|
|
3408
|
+
return this.skippedKeys.has(bodyKey3(collection, id));
|
|
3409
|
+
}
|
|
3410
|
+
// ---- end M7 planned feature 3 (b) ----
|
|
2499
3411
|
/** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
|
|
2500
3412
|
bodyFor(collection, id) {
|
|
2501
3413
|
const existing = this.bodies.get(bodyKey3(collection, id));
|
|
2502
3414
|
if (existing) return existing;
|
|
3415
|
+
if (this.skippedKeys.has(bodyKey3(collection, id))) return void 0;
|
|
2503
3416
|
const desc = this.collections.find((c) => c.name === collection);
|
|
2504
3417
|
if (!desc) return void 0;
|
|
2505
3418
|
const coll = plainEntity(this.core.plain, collection);
|
|
@@ -2618,6 +3531,7 @@ var Rapier2dRuntime = class {
|
|
|
2618
3531
|
for (const id of coll.ids()) {
|
|
2619
3532
|
const key = bodyKey3(desc.name, id);
|
|
2620
3533
|
live.add(key);
|
|
3534
|
+
if (this.skippedKeys.has(key)) continue;
|
|
2621
3535
|
const record = coll.get(id);
|
|
2622
3536
|
const existing = this.bodies.get(key);
|
|
2623
3537
|
if (existing) {
|
|
@@ -2626,337 +3540,107 @@ var Rapier2dRuntime = class {
|
|
|
2626
3540
|
if (source === void 0) {
|
|
2627
3541
|
this.sourceRecords.set(key, record);
|
|
2628
3542
|
continue;
|
|
2629
|
-
}
|
|
2630
|
-
if (source === record) continue;
|
|
2631
|
-
this.bodies.delete(key);
|
|
2632
|
-
this.sleepSynced.delete(key);
|
|
2633
|
-
this.sourceRecords.delete(key);
|
|
2634
|
-
this.
|
|
2635
|
-
}
|
|
2636
|
-
if (record !== void 0) this.create(desc, id, record);
|
|
2637
|
-
}
|
|
2638
|
-
}
|
|
2639
|
-
for (const [key, body] of [...this.bodies]) {
|
|
2640
|
-
if (live.has(key)) continue;
|
|
2641
|
-
this.bodies.delete(key);
|
|
2642
|
-
this.sleepSynced.delete(key);
|
|
2643
|
-
this.sourceRecords.delete(key);
|
|
2644
|
-
this.world.removeRigidBody(body);
|
|
2645
|
-
}
|
|
2646
|
-
}
|
|
2647
|
-
// -------------------------------------------------------------------------
|
|
2648
|
-
// Step and sync
|
|
2649
|
-
// -------------------------------------------------------------------------
|
|
2650
|
-
step() {
|
|
2651
|
-
this.world.step();
|
|
2652
|
-
if (!rapierHasStepped()) noteRapierStep();
|
|
2653
|
-
}
|
|
2654
|
-
/**
|
|
2655
|
-
* Body → schema, through the tracked proxies, so movement leaves the room as an ordinary delta.
|
|
2656
|
-
* Values are `Math.fround`ed for f32 fields, so room code reads exactly what the wire carries.
|
|
2657
|
-
*/
|
|
2658
|
-
sync() {
|
|
2659
|
-
for (const desc of this.collections) {
|
|
2660
|
-
const physics = desc.physics;
|
|
2661
|
-
if (!physics) continue;
|
|
2662
|
-
const tracked = this.core.anyState[desc.name];
|
|
2663
|
-
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
2664
|
-
const rounders = roundersFor3(desc);
|
|
2665
|
-
for (const id of plainColl.ids()) {
|
|
2666
|
-
const key = bodyKey3(desc.name, id);
|
|
2667
|
-
const body = this.bodies.get(key);
|
|
2668
|
-
if (!body) continue;
|
|
2669
|
-
if (body.isSleeping()) {
|
|
2670
|
-
if (this.sleepSynced.has(key)) continue;
|
|
2671
|
-
this.sleepSynced.add(key);
|
|
2672
|
-
} else {
|
|
2673
|
-
this.sleepSynced.delete(key);
|
|
2674
|
-
}
|
|
2675
|
-
const record = tracked.get(id);
|
|
2676
|
-
if (!record) continue;
|
|
2677
|
-
const t = body.translation();
|
|
2678
|
-
const v = body.linvel();
|
|
2679
|
-
const state = {
|
|
2680
|
-
x: t.x,
|
|
2681
|
-
y: t.y,
|
|
2682
|
-
angle: body.rotation(),
|
|
2683
|
-
vx: v.x,
|
|
2684
|
-
vy: v.y,
|
|
2685
|
-
angularVelocity: body.angvel()
|
|
2686
|
-
};
|
|
2687
|
-
for (const [channel, field] of physics.channels) {
|
|
2688
|
-
const next = (rounders[field] ?? identity3)(channelOf2d2(channel, state));
|
|
2689
|
-
if (record[field] !== next) record[field] = next;
|
|
2690
|
-
}
|
|
2691
|
-
}
|
|
2692
|
-
}
|
|
2693
|
-
}
|
|
2694
|
-
// -------------------------------------------------------------------------
|
|
2695
|
-
// Hibernation
|
|
2696
|
-
// -------------------------------------------------------------------------
|
|
2697
|
-
/**
|
|
2698
|
-
* The same `PhysicsSection` the 3D runtime writes — a real engine snapshot plus the
|
|
2699
|
-
* entity↔handle map, whose handles must ride as f64 for the reason spelled out on
|
|
2700
|
-
* {@link PhysicsSection}. What tells the two apart on the way back in is the envelope
|
|
2701
|
-
* `core/physics.ts` wraps this in, not anything in here.
|
|
2702
|
-
*/
|
|
2703
|
-
serialize() {
|
|
2704
|
-
const bodies = [];
|
|
2705
|
-
for (const [key, body] of this.bodies) {
|
|
2706
|
-
const sep = key.indexOf("\0");
|
|
2707
|
-
bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
|
|
2708
|
-
}
|
|
2709
|
-
return { world: this.world.takeSnapshot(), bodies };
|
|
2710
|
-
}
|
|
2711
|
-
};
|
|
2712
|
-
function identity3(v) {
|
|
2713
|
-
return v;
|
|
2714
|
-
}
|
|
2715
|
-
function roundersFor3(desc) {
|
|
2716
|
-
const out = {};
|
|
2717
|
-
for (const f of desc.fields) {
|
|
2718
|
-
if (f.type.kind === "f32") out[f.name] = Math.fround;
|
|
2719
|
-
}
|
|
2720
|
-
return out;
|
|
2721
|
-
}
|
|
2722
|
-
|
|
2723
|
-
// src/core/views.ts
|
|
2724
|
-
import {
|
|
2725
|
-
collectionDirty,
|
|
2726
|
-
createDirtySet,
|
|
2727
|
-
createFieldMask,
|
|
2728
|
-
encodeDelta as encodeDelta2,
|
|
2729
|
-
encodeSnapshot as encodeSnapshot2,
|
|
2730
|
-
filterDirty,
|
|
2731
|
-
isDirtyEmpty,
|
|
2732
|
-
markAdd,
|
|
2733
|
-
markPath,
|
|
2734
|
-
markRemove,
|
|
2735
|
-
mergeDirty
|
|
2736
|
-
} from "@irtio/schema";
|
|
2737
|
-
|
|
2738
|
-
// src/core/spatial.ts
|
|
2739
|
-
function cellFor(grid, value) {
|
|
2740
|
-
if (typeof value !== "object" || value === null) return void 0;
|
|
2741
|
-
const record = value;
|
|
2742
|
-
const x = record[grid.x];
|
|
2743
|
-
const y = record[grid.y];
|
|
2744
|
-
if (typeof x !== "number" || typeof y !== "number") return void 0;
|
|
2745
|
-
return { x: Math.floor(x / grid.cell), y: Math.floor(y / grid.cell) };
|
|
2746
|
-
}
|
|
2747
|
-
function cellKey(cell) {
|
|
2748
|
-
return `${cell.x},${cell.y}`;
|
|
2749
|
-
}
|
|
2750
|
-
function buildSpatialIndex(plain, desc) {
|
|
2751
|
-
if (!desc.grid || desc.kind !== "entity") {
|
|
2752
|
-
throw new Error(`${desc.name}: cannot build an index without an entity grid`);
|
|
2753
|
-
}
|
|
2754
|
-
const collection = plainEntity(plain, desc.name);
|
|
2755
|
-
const buckets = /* @__PURE__ */ new Map();
|
|
2756
|
-
const order = /* @__PURE__ */ new Map();
|
|
2757
|
-
let n = 0;
|
|
2758
|
-
for (const id of collection.ids()) {
|
|
2759
|
-
order.set(id, n++);
|
|
2760
|
-
const cell = cellFor(desc.grid, collection.get(id));
|
|
2761
|
-
if (!cell) continue;
|
|
2762
|
-
const key = cellKey(cell);
|
|
2763
|
-
let ids = buckets.get(key);
|
|
2764
|
-
if (!ids) {
|
|
2765
|
-
ids = /* @__PURE__ */ new Set();
|
|
2766
|
-
buckets.set(key, ids);
|
|
2767
|
-
}
|
|
2768
|
-
ids.add(id);
|
|
2769
|
-
}
|
|
2770
|
-
return { desc, buckets, collection, order };
|
|
2771
|
-
}
|
|
2772
|
-
function querySpatialIndex(index, clientId, role) {
|
|
2773
|
-
const grid = index.desc.grid;
|
|
2774
|
-
if ((grid.wideRoles ?? []).includes(role)) return new Set(index.collection.ids());
|
|
2775
|
-
const anchor = index.collection.get(clientId);
|
|
2776
|
-
const anchorCell = cellFor(grid, anchor);
|
|
2777
|
-
if (!anchor || !anchorCell) return /* @__PURE__ */ new Set();
|
|
2778
|
-
const candidates = /* @__PURE__ */ new Set();
|
|
2779
|
-
for (let x = anchorCell.x - grid.radius; x <= anchorCell.x + grid.radius; x++) {
|
|
2780
|
-
for (let y = anchorCell.y - grid.radius; y <= anchorCell.y + grid.radius; y++) {
|
|
2781
|
-
const ids = index.buckets.get(cellKey({ x, y }));
|
|
2782
|
-
if (ids) for (const id of ids) candidates.add(id);
|
|
2783
|
-
}
|
|
2784
|
-
}
|
|
2785
|
-
candidates.add(clientId);
|
|
2786
|
-
const ranked = [];
|
|
2787
|
-
for (const id of candidates) if (index.order.has(id)) ranked.push(id);
|
|
2788
|
-
ranked.sort((a, b) => index.order.get(a) - index.order.get(b));
|
|
2789
|
-
return new Set(ranked);
|
|
2790
|
-
}
|
|
2791
|
-
function buildSpatialIndexes(collections, plain) {
|
|
2792
|
-
const indexes = /* @__PURE__ */ new Map();
|
|
2793
|
-
for (const desc of collections) {
|
|
2794
|
-
if (desc.visibility === "spatial-grid" && desc.grid) {
|
|
2795
|
-
indexes.set(desc.name, buildSpatialIndex(plain, desc));
|
|
2796
|
-
}
|
|
2797
|
-
}
|
|
2798
|
-
return indexes;
|
|
2799
|
-
}
|
|
2800
|
-
|
|
2801
|
-
// src/core/views.ts
|
|
2802
|
-
var cache = /* @__PURE__ */ new WeakMap();
|
|
2803
|
-
function infoOf(ext) {
|
|
2804
|
-
let i = cache.get(ext);
|
|
2805
|
-
if (!i) {
|
|
2806
|
-
const scoped = /* @__PURE__ */ new Set();
|
|
2807
|
-
for (const c of ext.collections) {
|
|
2808
|
-
if (c.visibility === "role") for (const r of c.roles ?? []) scoped.add(r);
|
|
2809
|
-
}
|
|
2810
|
-
i = { scoped, names: /* @__PURE__ */ new Map() };
|
|
2811
|
-
cache.set(ext, i);
|
|
2812
|
-
}
|
|
2813
|
-
return i;
|
|
2814
|
-
}
|
|
2815
|
-
function isVisible(c, role) {
|
|
2816
|
-
return c.visibility !== "role" || (c.roles ?? []).includes(role);
|
|
2817
|
-
}
|
|
2818
|
-
function visibleTo(role) {
|
|
2819
|
-
return (c) => isVisible(c, role);
|
|
2820
|
-
}
|
|
2821
|
-
function visibleNames(ext, role) {
|
|
2822
|
-
const info = infoOf(ext);
|
|
2823
|
-
let set = info.names.get(role);
|
|
2824
|
-
if (!set) {
|
|
2825
|
-
set = new Set(
|
|
2826
|
-
ext.collections.filter((c) => isVisible(c, role)).map((c) => c.name)
|
|
2827
|
-
);
|
|
2828
|
-
info.names.set(role, set);
|
|
2829
|
-
}
|
|
2830
|
-
return set;
|
|
2831
|
-
}
|
|
2832
|
-
function viewKeyFor(ext, role) {
|
|
2833
|
-
return infoOf(ext).scoped.has(role) ? role : "all";
|
|
2834
|
-
}
|
|
2835
|
-
function encodeViewSnapshot(ext, plain, role, tick, memberships) {
|
|
2836
|
-
return encodeSnapshot2(
|
|
2837
|
-
ext,
|
|
2838
|
-
plain,
|
|
2839
|
-
{ tick },
|
|
2840
|
-
{
|
|
2841
|
-
collections: visibleTo(role),
|
|
2842
|
-
entities: (c, id) => c.visibility !== "spatial-grid" || (memberships?.get(c.name)?.has(id) ?? false)
|
|
2843
|
-
}
|
|
2844
|
-
);
|
|
2845
|
-
}
|
|
2846
|
-
function spatialMemberships(ext, plain, clientId, role, indexes = buildSpatialIndexes(ext.collections, plain)) {
|
|
2847
|
-
const out = /* @__PURE__ */ new Map();
|
|
2848
|
-
for (const [name, index] of indexes) out.set(name, querySpatialIndex(index, clientId, role));
|
|
2849
|
-
return out;
|
|
2850
|
-
}
|
|
2851
|
-
function createVisibilityPolicy(ext, plain, clientId, role) {
|
|
2852
|
-
const memberships = spatialMemberships(ext, plain, clientId, role);
|
|
2853
|
-
return {
|
|
2854
|
-
memberships,
|
|
2855
|
-
maySeeCollection: (desc) => isVisible(desc, role),
|
|
2856
|
-
maySeeEntity: (desc, id) => isVisible(desc, role) && (desc.visibility !== "spatial-grid" || (memberships.get(desc.name)?.has(id) ?? false)),
|
|
2857
|
-
describeSpatial: (desc, ids) => {
|
|
2858
|
-
const grid = desc.grid;
|
|
2859
|
-
if (desc.visibility !== "spatial-grid" || !grid) return void 0;
|
|
2860
|
-
const coll = plainEntity(plain, desc.name);
|
|
2861
|
-
const at = (id) => {
|
|
2862
|
-
const cell = cellFor(grid, coll.get(id));
|
|
2863
|
-
return cell ? `${id}@(${cell.x},${cell.y})` : `${id}@(no position)`;
|
|
2864
|
-
};
|
|
2865
|
-
return `viewer ${at(clientId)}, ${desc.name} ${ids.map(at).join(" ")}, radius ${grid.radius}`;
|
|
2866
|
-
}
|
|
2867
|
-
};
|
|
2868
|
-
}
|
|
2869
|
-
function sharedViewDirty(ext, dirty, role) {
|
|
2870
|
-
return filterDirty(dirty, (name) => {
|
|
2871
|
-
const desc = ext.collection(name);
|
|
2872
|
-
return desc.visibility !== "spatial-grid" && isVisible(desc, role);
|
|
2873
|
-
});
|
|
2874
|
-
}
|
|
2875
|
-
function aoiViewDirty(ext, dirty, previous, current) {
|
|
2876
|
-
const out = createDirtySet();
|
|
2877
|
-
for (const desc of ext.collections) {
|
|
2878
|
-
if (desc.visibility !== "spatial-grid") continue;
|
|
2879
|
-
const before = previous.get(desc.name) ?? /* @__PURE__ */ new Set();
|
|
2880
|
-
const now = current.get(desc.name) ?? /* @__PURE__ */ new Set();
|
|
2881
|
-
for (const id of before) if (!now.has(id)) markRemove(out, desc.name, id);
|
|
2882
|
-
for (const id of now) if (!before.has(id)) markAdd(out, desc.name, id);
|
|
2883
|
-
const changed = dirty.get(desc.name);
|
|
2884
|
-
if (!changed) continue;
|
|
2885
|
-
const visibleChanges = createDirtySet();
|
|
2886
|
-
const collection = collectionDirty(visibleChanges, desc.name);
|
|
2887
|
-
for (const id of changed.added) if (now.has(id)) collection.added.add(id);
|
|
2888
|
-
for (const id of changed.removed) if (before.has(id)) collection.removed.add(id);
|
|
2889
|
-
for (const [id, record] of changed.updated) {
|
|
2890
|
-
if (before.has(id) && now.has(id)) collection.updated.set(id, record);
|
|
3543
|
+
}
|
|
3544
|
+
if (source === record) continue;
|
|
3545
|
+
this.bodies.delete(key);
|
|
3546
|
+
this.sleepSynced.delete(key);
|
|
3547
|
+
this.sourceRecords.delete(key);
|
|
3548
|
+
this.destroyBody(existing);
|
|
3549
|
+
}
|
|
3550
|
+
if (record !== void 0) this.create(desc, id, record);
|
|
3551
|
+
}
|
|
2891
3552
|
}
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
for (const [name, changed] of dirty) {
|
|
2899
|
-
const desc = ext.collection(name);
|
|
2900
|
-
if (desc.visibility !== "spatial-grid") {
|
|
2901
|
-
out.set(name, changed);
|
|
2902
|
-
continue;
|
|
3553
|
+
for (const [key, body] of [...this.bodies]) {
|
|
3554
|
+
if (live.has(key)) continue;
|
|
3555
|
+
this.bodies.delete(key);
|
|
3556
|
+
this.sleepSynced.delete(key);
|
|
3557
|
+
this.sourceRecords.delete(key);
|
|
3558
|
+
this.destroyBody(body);
|
|
2903
3559
|
}
|
|
2904
|
-
const
|
|
2905
|
-
const kept = collectionDirty(out, name);
|
|
2906
|
-
for (const id of changed.added) if (visible.has(id)) kept.added.add(id);
|
|
2907
|
-
for (const id of changed.removed) if (visible.has(id)) kept.removed.add(id);
|
|
2908
|
-
for (const [id, record] of changed.updated) if (visible.has(id)) kept.updated.set(id, record);
|
|
3560
|
+
for (const key of [...this.skippedKeys]) if (!live.has(key)) this.skippedKeys.delete(key);
|
|
2909
3561
|
}
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
const
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
3562
|
+
// -------------------------------------------------------------------------
|
|
3563
|
+
// Step and sync
|
|
3564
|
+
// -------------------------------------------------------------------------
|
|
3565
|
+
step() {
|
|
3566
|
+
this.world.step();
|
|
3567
|
+
if (!rapierHasStepped()) noteRapierStep();
|
|
3568
|
+
}
|
|
3569
|
+
/**
|
|
3570
|
+
* Body → schema, through the tracked proxies, so movement leaves the room as an ordinary delta.
|
|
3571
|
+
* Values are `Math.fround`ed for f32 fields, so room code reads exactly what the wire carries.
|
|
3572
|
+
*/
|
|
3573
|
+
sync() {
|
|
3574
|
+
for (const desc of this.collections) {
|
|
3575
|
+
const physics = desc.physics;
|
|
3576
|
+
if (!physics) continue;
|
|
3577
|
+
const tracked = this.core.anyState[desc.name];
|
|
3578
|
+
const plainColl = plainEntity(this.core.plain, desc.name);
|
|
3579
|
+
const rounders = roundersFor3(desc);
|
|
3580
|
+
for (const id of plainColl.ids()) {
|
|
3581
|
+
const key = bodyKey3(desc.name, id);
|
|
3582
|
+
const body = this.bodies.get(key);
|
|
3583
|
+
if (!body) continue;
|
|
3584
|
+
if (body.isSleeping()) {
|
|
3585
|
+
if (this.sleepSynced.has(key)) continue;
|
|
3586
|
+
this.sleepSynced.add(key);
|
|
3587
|
+
} else {
|
|
3588
|
+
this.sleepSynced.delete(key);
|
|
3589
|
+
}
|
|
3590
|
+
const record = tracked.get(id);
|
|
3591
|
+
if (!record) continue;
|
|
3592
|
+
const t = body.translation();
|
|
3593
|
+
const v = body.linvel();
|
|
3594
|
+
const state = {
|
|
3595
|
+
x: t.x,
|
|
3596
|
+
y: t.y,
|
|
3597
|
+
angle: body.rotation(),
|
|
3598
|
+
vx: v.x,
|
|
3599
|
+
vy: v.y,
|
|
3600
|
+
angularVelocity: body.angvel()
|
|
3601
|
+
};
|
|
3602
|
+
for (const [channel, field] of physics.channels) {
|
|
3603
|
+
const next = (rounders[field] ?? identity3)(channelOf2d2(channel, state));
|
|
3604
|
+
if (record[field] !== next) record[field] = next;
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
2926
3607
|
}
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
3608
|
+
}
|
|
3609
|
+
// -------------------------------------------------------------------------
|
|
3610
|
+
// Hibernation
|
|
3611
|
+
// -------------------------------------------------------------------------
|
|
3612
|
+
/**
|
|
3613
|
+
* The same `PhysicsSection` the 3D runtime writes — a real engine snapshot plus the
|
|
3614
|
+
* entity↔handle map, whose handles must ride as f64 for the reason spelled out on
|
|
3615
|
+
* {@link PhysicsSection}. What tells the two apart on the way back in is the envelope
|
|
3616
|
+
* `core/physics.ts` wraps this in, not anything in here.
|
|
3617
|
+
*/
|
|
3618
|
+
serialize() {
|
|
3619
|
+
const bodies = [];
|
|
3620
|
+
for (const [key, body] of this.bodies) {
|
|
3621
|
+
const sep = key.indexOf("\0");
|
|
3622
|
+
bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
|
|
2935
3623
|
}
|
|
2936
|
-
|
|
3624
|
+
const skipped = [...this.skippedKeys];
|
|
3625
|
+
const unsynced = [];
|
|
3626
|
+
for (const [key, body] of this.unsyncedBodies) unsynced.push([key, body.handle]);
|
|
3627
|
+
return {
|
|
3628
|
+
world: this.world.takeSnapshot(),
|
|
3629
|
+
bodies,
|
|
3630
|
+
...skipped.length > 0 ? { skipped } : {},
|
|
3631
|
+
...unsynced.length > 0 ? { unsynced } : {}
|
|
3632
|
+
};
|
|
2937
3633
|
}
|
|
2938
|
-
|
|
3634
|
+
};
|
|
3635
|
+
function identity3(v) {
|
|
3636
|
+
return v;
|
|
2939
3637
|
}
|
|
2940
|
-
function
|
|
2941
|
-
const
|
|
2942
|
-
for (const
|
|
2943
|
-
|
|
2944
|
-
const now = isVisible(c, toRole);
|
|
2945
|
-
if (was === now) continue;
|
|
2946
|
-
if (c.kind === "singleton") {
|
|
2947
|
-
if (!now) continue;
|
|
2948
|
-
const mask = createFieldMask();
|
|
2949
|
-
for (const f of c.fields) markPath(mask, [f.index]);
|
|
2950
|
-
collectionDirty(dirty, c.name).updated.set("", { mask, owner: false });
|
|
2951
|
-
continue;
|
|
2952
|
-
}
|
|
2953
|
-
const coll = plainEntity(plain, c.name);
|
|
2954
|
-
for (const id of [...coll.ids()]) {
|
|
2955
|
-
if (now) markAdd(dirty, c.name, id);
|
|
2956
|
-
else markRemove(dirty, c.name, id);
|
|
2957
|
-
}
|
|
3638
|
+
function roundersFor3(desc) {
|
|
3639
|
+
const out = {};
|
|
3640
|
+
for (const f of desc.fields) {
|
|
3641
|
+
if (f.type.kind === "f32") out[f.name] = Math.fround;
|
|
2958
3642
|
}
|
|
2959
|
-
return
|
|
3643
|
+
return out;
|
|
2960
3644
|
}
|
|
2961
3645
|
|
|
2962
3646
|
// src/core/snapshot.ts
|
|
@@ -3089,15 +3773,17 @@ import {
|
|
|
3089
3773
|
} from "@irtio/protocol";
|
|
3090
3774
|
import {
|
|
3091
3775
|
cloneValue as cloneValue2,
|
|
3092
|
-
createDirtySet as
|
|
3776
|
+
createDirtySet as createDirtySet3,
|
|
3093
3777
|
createState,
|
|
3094
3778
|
decodeSnapshot as decodeSnapshot3,
|
|
3095
3779
|
encodeDelta as encodeDelta5,
|
|
3096
3780
|
encodeSnapshot as encodeSnapshot4,
|
|
3781
|
+
filterDirty as filterDirty2,
|
|
3097
3782
|
isDirtyEmpty as isDirtyEmpty4,
|
|
3098
3783
|
track,
|
|
3099
3784
|
validateForDeploy
|
|
3100
3785
|
} from "@irtio/schema";
|
|
3786
|
+
import { DenyError as DenyError2 } from "@irtio/server";
|
|
3101
3787
|
|
|
3102
3788
|
// src/core/history.ts
|
|
3103
3789
|
var HISTORY_MAX_TICKS = 240;
|
|
@@ -4541,9 +5227,10 @@ function takeClip(core, seconds) {
|
|
|
4541
5227
|
if (!replayArmed(core)) {
|
|
4542
5228
|
return rejectAsEvent(
|
|
4543
5229
|
core,
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
5230
|
+
// Bug #81: written to fit the platform's 96-byte bound rather than be clamped to it. Every
|
|
5231
|
+
// refusal below is, for the same reason: a message the clamp cuts loses its fix line, which
|
|
5232
|
+
// is the half worth reading.
|
|
5233
|
+
new Error("room.replay.clip: this room did not declare `replay:`, so nothing is recorded")
|
|
4547
5234
|
);
|
|
4548
5235
|
}
|
|
4549
5236
|
const state = stateOf6(core);
|
|
@@ -4558,7 +5245,7 @@ function takeClip(core, seconds) {
|
|
|
4558
5245
|
return rejectAsEvent(
|
|
4559
5246
|
core,
|
|
4560
5247
|
new Error(
|
|
4561
|
-
`E_CLIP_COOLDOWN: room.replay.clip allows
|
|
5248
|
+
`E_CLIP_COOLDOWN: room.replay.clip allows ${CLIP_BURST} per room, one refilled every ${CLIP_COOLDOWN_MS}ms`
|
|
4562
5249
|
)
|
|
4563
5250
|
);
|
|
4564
5251
|
}
|
|
@@ -4566,7 +5253,7 @@ function takeClip(core, seconds) {
|
|
|
4566
5253
|
return rejectAsEvent(
|
|
4567
5254
|
core,
|
|
4568
5255
|
new Error(
|
|
4569
|
-
`E_CLIP_DISARMED: recording stopped
|
|
5256
|
+
`E_CLIP_DISARMED: recording stopped: a keyframe was larger than the ${REPLAY_RING_MAX_BYTES}-byte ring`
|
|
4570
5257
|
)
|
|
4571
5258
|
);
|
|
4572
5259
|
}
|
|
@@ -4582,7 +5269,7 @@ function takeClip(core, seconds) {
|
|
|
4582
5269
|
if (bytes === void 0) {
|
|
4583
5270
|
return rejectAsEvent(
|
|
4584
5271
|
core,
|
|
4585
|
-
new Error("E_CLIP_EMPTY: the replay ring is empty
|
|
5272
|
+
new Error("E_CLIP_EMPTY: the replay ring is empty; this room has not flushed a change yet")
|
|
4586
5273
|
);
|
|
4587
5274
|
}
|
|
4588
5275
|
if (bytes.length > CLIP_MAX_BYTES) {
|
|
@@ -4605,9 +5292,7 @@ function takeExcerpt(core, options) {
|
|
|
4605
5292
|
if (!replayArmed(core)) {
|
|
4606
5293
|
return rejectAsEvent(
|
|
4607
5294
|
core,
|
|
4608
|
-
new Error(
|
|
4609
|
-
"E_REPLAY_UNARMED: room.replay.excerpt: this room type did not declare `replay:`, so nothing is being recorded. Add `replay: { seconds: 60 }` to the room config to arm the ring."
|
|
4610
|
-
)
|
|
5295
|
+
new Error("E_REPLAY_UNARMED: room.replay.excerpt: this room did not declare `replay:`")
|
|
4611
5296
|
);
|
|
4612
5297
|
}
|
|
4613
5298
|
const seconds = options?.seconds;
|
|
@@ -4615,7 +5300,7 @@ function takeExcerpt(core, options) {
|
|
|
4615
5300
|
return rejectAsEvent(
|
|
4616
5301
|
core,
|
|
4617
5302
|
new Error(
|
|
4618
|
-
"E_EXCERPT_BAD_ARGS: room.replay.excerpt needs { seconds } as a positive number, got " + JSON.stringify(seconds)
|
|
5303
|
+
"E_EXCERPT_BAD_ARGS: room.replay.excerpt needs { seconds } as a positive number, got " + String(JSON.stringify(seconds)).slice(0, 8)
|
|
4619
5304
|
)
|
|
4620
5305
|
);
|
|
4621
5306
|
}
|
|
@@ -4623,7 +5308,7 @@ function takeExcerpt(core, options) {
|
|
|
4623
5308
|
return rejectAsEvent(
|
|
4624
5309
|
core,
|
|
4625
5310
|
new Error(
|
|
4626
|
-
`E_EXCERPT_TOO_LONG:
|
|
5311
|
+
`E_EXCERPT_TOO_LONG: at most ${EXCERPT_MAX_SECONDS} seconds an excerpt, and ${seconds} was asked for`
|
|
4627
5312
|
)
|
|
4628
5313
|
);
|
|
4629
5314
|
}
|
|
@@ -4632,9 +5317,7 @@ function takeExcerpt(core, options) {
|
|
|
4632
5317
|
return rejectAsEvent(
|
|
4633
5318
|
core,
|
|
4634
5319
|
new Error(
|
|
4635
|
-
`E_EXCERPT_BAD_ARGS:
|
|
4636
|
-
badName
|
|
4637
|
-
)}, which is not an entity collection in this room's schema. Singleton collections are not excerptable.`
|
|
5320
|
+
`E_EXCERPT_BAD_ARGS: ${JSON.stringify(badName)} is not an entity collection here; singletons are not excerptable`
|
|
4638
5321
|
)
|
|
4639
5322
|
);
|
|
4640
5323
|
}
|
|
@@ -4643,7 +5326,7 @@ function takeExcerpt(core, options) {
|
|
|
4643
5326
|
return rejectAsEvent(
|
|
4644
5327
|
core,
|
|
4645
5328
|
new Error(
|
|
4646
|
-
`E_EXCERPT_DISARMED: recording stopped
|
|
5329
|
+
`E_EXCERPT_DISARMED: recording stopped: a keyframe was larger than the ${REPLAY_RING_MAX_BYTES}-byte ring`
|
|
4647
5330
|
)
|
|
4648
5331
|
);
|
|
4649
5332
|
}
|
|
@@ -4658,7 +5341,7 @@ function takeExcerpt(core, options) {
|
|
|
4658
5341
|
return rejectAsEvent(
|
|
4659
5342
|
core,
|
|
4660
5343
|
new Error(
|
|
4661
|
-
`E_EXCERPT_COOLDOWN: room.replay.excerpt allows
|
|
5344
|
+
`E_EXCERPT_COOLDOWN: room.replay.excerpt allows ${EXCERPT_BURST} per room, one every ${EXCERPT_COOLDOWN_MS}ms`
|
|
4662
5345
|
)
|
|
4663
5346
|
);
|
|
4664
5347
|
}
|
|
@@ -4672,7 +5355,7 @@ function takeExcerpt(core, options) {
|
|
|
4672
5355
|
return rejectAsEvent(
|
|
4673
5356
|
core,
|
|
4674
5357
|
new Error(
|
|
4675
|
-
`E_EXCERPT_TOO_LARGE:
|
|
5358
|
+
`E_EXCERPT_TOO_LARGE: about ${err.bytes} bytes and the limit is ${EXCERPT_MAX_BYTES}; narrow collections/ids`
|
|
4676
5359
|
)
|
|
4677
5360
|
);
|
|
4678
5361
|
}
|
|
@@ -4848,11 +5531,25 @@ function noteRecordingRepliesDropped(core) {
|
|
|
4848
5531
|
// src/core/room-api.ts
|
|
4849
5532
|
import {
|
|
4850
5533
|
FrameType as FrameType5,
|
|
5534
|
+
HELLO_ROLE_MAX_BYTES,
|
|
4851
5535
|
busChannelProblem,
|
|
4852
5536
|
busPayloadProblem,
|
|
4853
5537
|
encodeFrame as encodeFrame4
|
|
4854
5538
|
} from "@irtio/protocol";
|
|
4855
|
-
import { encodeDelta as encodeDelta4, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty3 } from "@irtio/schema";
|
|
5539
|
+
import { createDirtySet as createDirtySet2, encodeDelta as encodeDelta4, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty3, utf8Length } from "@irtio/schema";
|
|
5540
|
+
function skipApi(p) {
|
|
5541
|
+
return {
|
|
5542
|
+
skip(collection, id, on = true) {
|
|
5543
|
+
p.setSkipped(collection, id, on);
|
|
5544
|
+
},
|
|
5545
|
+
skipped(collection, id) {
|
|
5546
|
+
return p.isSkipped(collection, id);
|
|
5547
|
+
},
|
|
5548
|
+
// M7 pf3 (a): handed through untouched. The registry is built by the engine runtime, which is
|
|
5549
|
+
// the only place that knows how to build and destroy one of that engine's bodies.
|
|
5550
|
+
unsynced: p.unsynced
|
|
5551
|
+
};
|
|
5552
|
+
}
|
|
4856
5553
|
function makePhysicsApi(p) {
|
|
4857
5554
|
return {
|
|
4858
5555
|
get rapier() {
|
|
@@ -4866,7 +5563,8 @@ function makePhysicsApi(p) {
|
|
|
4866
5563
|
},
|
|
4867
5564
|
body(collection, id) {
|
|
4868
5565
|
return p.bodyFor(collection, id);
|
|
4869
|
-
}
|
|
5566
|
+
},
|
|
5567
|
+
...skipApi(p)
|
|
4870
5568
|
};
|
|
4871
5569
|
}
|
|
4872
5570
|
function makeMatterApi(p) {
|
|
@@ -4882,7 +5580,8 @@ function makeMatterApi(p) {
|
|
|
4882
5580
|
},
|
|
4883
5581
|
body(collection, id) {
|
|
4884
5582
|
return p.bodyFor(collection, id);
|
|
4885
|
-
}
|
|
5583
|
+
},
|
|
5584
|
+
...skipApi(p)
|
|
4886
5585
|
};
|
|
4887
5586
|
}
|
|
4888
5587
|
function makeRapier2dApi(p) {
|
|
@@ -4898,6 +5597,17 @@ function makeRapier2dApi(p) {
|
|
|
4898
5597
|
},
|
|
4899
5598
|
body(collection, id) {
|
|
4900
5599
|
return p.bodyFor(collection, id);
|
|
5600
|
+
},
|
|
5601
|
+
...skipApi(p)
|
|
5602
|
+
};
|
|
5603
|
+
}
|
|
5604
|
+
function makeMetrics(core) {
|
|
5605
|
+
return {
|
|
5606
|
+
get physicsStepMs() {
|
|
5607
|
+
return core.stats.physicsStepMs;
|
|
5608
|
+
},
|
|
5609
|
+
get physicsStepMsAvg() {
|
|
5610
|
+
return core.stats.physicsStepMsAvg;
|
|
4901
5611
|
}
|
|
4902
5612
|
};
|
|
4903
5613
|
}
|
|
@@ -5017,6 +5727,7 @@ function createRoomApi(core, publicUrl) {
|
|
|
5017
5727
|
let physicsApi;
|
|
5018
5728
|
let matterApi;
|
|
5019
5729
|
let rapier2dApi;
|
|
5730
|
+
let metricsApi;
|
|
5020
5731
|
let kvApi;
|
|
5021
5732
|
let busApi;
|
|
5022
5733
|
let leaderboardApi;
|
|
@@ -5076,6 +5787,43 @@ function createRoomApi(core, publicUrl) {
|
|
|
5076
5787
|
random() {
|
|
5077
5788
|
return core.rng.next();
|
|
5078
5789
|
},
|
|
5790
|
+
// ---- M7 planned feature 3 (f): room.metrics ----
|
|
5791
|
+
// Undercity feedback 2: no timing has ever existed around the platform-stepped world, so a
|
|
5792
|
+
// room that wanted to know what its physics cost had to step its own engine outside the
|
|
5793
|
+
// `physics:` block and lose everything the block does for it. Live getters over `RoomStats`,
|
|
5794
|
+
// not a copied snapshot, so reading `room.metrics` allocates nothing on a tick loop.
|
|
5795
|
+
get metrics() {
|
|
5796
|
+
return metricsApi ?? (metricsApi = makeMetrics(core));
|
|
5797
|
+
},
|
|
5798
|
+
// ---- end M7 planned feature 3 (f) ----
|
|
5799
|
+
// ---- M7 planned feature 3 (g): the stale-writer query ----
|
|
5800
|
+
// Undercity feedback 20. A backgrounded tab stops writing and keeps its socket up, so the
|
|
5801
|
+
// room goes on attacking the last position it reported. Everything needed to see that is
|
|
5802
|
+
// already kept — `lastAppliedTick` is stamped on every judged WRITE for the correction
|
|
5803
|
+
// header (bug 1) — so this is a read over existing bookkeeping and not a new mechanism: no
|
|
5804
|
+
// callback, no config, no wire change, and no opinion about what a room should do about it.
|
|
5805
|
+
// The policy is the room's, and it differs per game (freeze the avatar, stop targeting it,
|
|
5806
|
+
// hand it to an NPC driver, kick it), which is exactly why there is no hook here.
|
|
5807
|
+
writerStaleness(clientId) {
|
|
5808
|
+
const entry = core.clients.get(clientId);
|
|
5809
|
+
if (!entry) return void 0;
|
|
5810
|
+
const since = entry.lastAppliedTick === 0 ? entry.joinedTick : entry.lastAppliedTick;
|
|
5811
|
+
const ticks = core.tick - since;
|
|
5812
|
+
return ticks > 0 ? ticks : 0;
|
|
5813
|
+
},
|
|
5814
|
+
staleWriters(ticks) {
|
|
5815
|
+
if (!Number.isFinite(ticks) || ticks < 0) {
|
|
5816
|
+
throw new Error(`room.staleWriters: ticks must be a non-negative number, got ${ticks}`);
|
|
5817
|
+
}
|
|
5818
|
+
const out = [];
|
|
5819
|
+
for (const entry of core.clients.values()) {
|
|
5820
|
+
if (!entry.connected) continue;
|
|
5821
|
+
const since = entry.lastAppliedTick === 0 ? entry.joinedTick : entry.lastAppliedTick;
|
|
5822
|
+
if (core.tick - since >= ticks) out.push(entry.clientId);
|
|
5823
|
+
}
|
|
5824
|
+
return out;
|
|
5825
|
+
},
|
|
5826
|
+
// ---- end M7 planned feature 3 (g) ----
|
|
5079
5827
|
// D22. A single object, built once: `room.physics` is read on every tick of a physics room.
|
|
5080
5828
|
// In a room with no `physics:` config, touching it is a mistake worth naming loudly rather
|
|
5081
5829
|
// than a silent `undefined` that shows up three frames later as "cannot read world of
|
|
@@ -5140,6 +5888,11 @@ function createRoomApi(core, publicUrl) {
|
|
|
5140
5888
|
kick(clientId, reason) {
|
|
5141
5889
|
core.host.kick(clientId, "E_KICKED", reason);
|
|
5142
5890
|
},
|
|
5891
|
+
// ---- M8 lane 5: moderation ----
|
|
5892
|
+
ban(clientId, options) {
|
|
5893
|
+
return core.banClient(clientId, options?.reason, options?.minutes);
|
|
5894
|
+
},
|
|
5895
|
+
// ---- end M8 lane 5 ----
|
|
5143
5896
|
close(reason) {
|
|
5144
5897
|
core.host.close(reason);
|
|
5145
5898
|
},
|
|
@@ -5295,28 +6048,40 @@ function isAlarmName(core, name, where) {
|
|
|
5295
6048
|
}
|
|
5296
6049
|
return true;
|
|
5297
6050
|
}
|
|
6051
|
+
function checkRoleValue(role) {
|
|
6052
|
+
const bytes = utf8Length(role);
|
|
6053
|
+
if (bytes > HELLO_ROLE_MAX_BYTES) {
|
|
6054
|
+
throw new Error(
|
|
6055
|
+
`room.setRole: role exceeds ${HELLO_ROLE_MAX_BYTES} UTF-8 bytes (${bytes}): ${JSON.stringify(role)}. A longer role cannot be encoded into WELCOME, so the room would fail every later wake.`
|
|
6056
|
+
);
|
|
6057
|
+
}
|
|
6058
|
+
for (let i = 0; i < role.length; i++) {
|
|
6059
|
+
const code = role.charCodeAt(i);
|
|
6060
|
+
if (code > 31 && code !== 127) continue;
|
|
6061
|
+
throw new Error(
|
|
6062
|
+
`room.setRole: role contains a control character (0x${code.toString(16)}): ${JSON.stringify(role)}. Roles are printed into logs, events and operator tooling.`
|
|
6063
|
+
);
|
|
6064
|
+
}
|
|
6065
|
+
}
|
|
5298
6066
|
function setRole(core, clientId, role) {
|
|
5299
6067
|
const entry = core.clients.get(clientId);
|
|
5300
6068
|
if (!entry) {
|
|
5301
6069
|
core.log("warn", `room.setRole: unknown client ${clientId}`);
|
|
5302
6070
|
return;
|
|
5303
6071
|
}
|
|
6072
|
+
checkRoleValue(role);
|
|
5304
6073
|
const roles = core.definition.schema.roles;
|
|
5305
6074
|
if (roles.length > 0 && !roles.includes(role)) {
|
|
5306
6075
|
core.log("warn", `room.setRole: ${JSON.stringify(role)} is not a schema role; ignored`);
|
|
5307
6076
|
return;
|
|
5308
6077
|
}
|
|
5309
6078
|
if (entry.role === role) return;
|
|
5310
|
-
const dirty = catchUpDirty(core.ext, core.plain, entry.role, role);
|
|
5311
|
-
|
|
5312
|
-
core.send(
|
|
5313
|
-
clientId,
|
|
5314
|
-
encodeFrame4(FrameType5.DELTA, encodeDelta4(core.ext, core.plain, dirty, { tick: core.tick }))
|
|
5315
|
-
);
|
|
5316
|
-
}
|
|
5317
|
-
entry.role = role;
|
|
6079
|
+
const dirty = entry.welcomed ? catchUpDirty(core.ext, core.plain, entry.role, role) : createDirtySet2();
|
|
6080
|
+
const catchUp = isDirtyEmpty3(dirty) ? void 0 : encodeFrame4(FrameType5.DELTA, encodeDelta4(core.ext, core.plain, dirty, { tick: core.tick }));
|
|
5318
6081
|
const presence = core.anyState.clients.get(clientId);
|
|
5319
6082
|
if (presence) presence.role = role;
|
|
6083
|
+
entry.role = role;
|
|
6084
|
+
if (catchUp) core.send(clientId, catchUp);
|
|
5320
6085
|
core.invalidateClients();
|
|
5321
6086
|
}
|
|
5322
6087
|
function checkNpcConfig(core, config) {
|
|
@@ -5367,6 +6132,7 @@ var NPC_EXAMPLE = "{ brain: { kind: 'script', script: 'chaser' } }";
|
|
|
5367
6132
|
// src/core/room.ts
|
|
5368
6133
|
var DEFAULT_PUBLIC_URL = "http://localhost/";
|
|
5369
6134
|
var CONTINUATION_FLUSH_TURNS = 8;
|
|
6135
|
+
var ADMIT_BUDGET_MS = 50;
|
|
5370
6136
|
function ownJoinCapture(plain, name, added, clientId) {
|
|
5371
6137
|
const coll = plainEntity(plain, name);
|
|
5372
6138
|
let mine;
|
|
@@ -5389,7 +6155,14 @@ var RoomCore = class _RoomCore {
|
|
|
5389
6155
|
rng;
|
|
5390
6156
|
clients = /* @__PURE__ */ new Map();
|
|
5391
6157
|
loop;
|
|
5392
|
-
/**
|
|
6158
|
+
/**
|
|
6159
|
+
* D22: the Rapier world, or `undefined` in a room whose config declares no physics.
|
|
6160
|
+
*
|
|
6161
|
+
* Not `readonly`, and the reason is M7 pf3 (a): each builder installs the runtime here *before*
|
|
6162
|
+
* it calls `runSetup`, so `room.physics*` is readable from inside `physics.setup`. That is
|
|
6163
|
+
* where an unsynced body belongs — it is static geometry with a handle — and it is the only
|
|
6164
|
+
* place a matter2d room can put one and have it come back after a wake.
|
|
6165
|
+
*/
|
|
5393
6166
|
physics;
|
|
5394
6167
|
stats = {
|
|
5395
6168
|
ticks: 0,
|
|
@@ -5406,6 +6179,8 @@ var RoomCore = class _RoomCore {
|
|
|
5406
6179
|
gridBuildMs: 0,
|
|
5407
6180
|
gridQueryMs: 0,
|
|
5408
6181
|
aoiEncodeMs: 0,
|
|
6182
|
+
physicsStepMs: 0,
|
|
6183
|
+
physicsStepMsAvg: 0,
|
|
5409
6184
|
visibleIdsTotal: 0,
|
|
5410
6185
|
membershipEnters: 0,
|
|
5411
6186
|
membershipLeaves: 0,
|
|
@@ -5431,6 +6206,11 @@ var RoomCore = class _RoomCore {
|
|
|
5431
6206
|
* end of every tick, which is the only moment in a tick where that state is settled.
|
|
5432
6207
|
*/
|
|
5433
6208
|
recorder;
|
|
6209
|
+
/**
|
|
6210
|
+
* M7 pf4: the RPC trace ring, constructed only when `RoomCoreOptions.traceRpc` asked for one.
|
|
6211
|
+
* Every cost this lane has is behind this `undefined` — see `core/rpc-trace.ts`.
|
|
6212
|
+
*/
|
|
6213
|
+
rpcTraceRing;
|
|
5434
6214
|
/**
|
|
5435
6215
|
* D65: the bandwidth ledger, present only when `RoomCoreOptions.profile` asked for one. Every
|
|
5436
6216
|
* cost the profiler has is behind this `undefined`.
|
|
@@ -5456,6 +6236,7 @@ var RoomCore = class _RoomCore {
|
|
|
5456
6236
|
this.mode = definition.config.mode;
|
|
5457
6237
|
this.ext = withBuiltins2(definition.schema);
|
|
5458
6238
|
this.ledger = options.profile === true ? new ProfileLedger(this.ext, definition.schema) : void 0;
|
|
6239
|
+
this.rpcTraceRing = options.traceRpc === true ? new RpcTraceRing() : void 0;
|
|
5459
6240
|
for (const issue of validateForDeploy(this.ext)) {
|
|
5460
6241
|
if (issue.level === "error") throw new Error(`RoomCore: ${issue.message}`);
|
|
5461
6242
|
host.log("warn", [`irtio: ${issue.message}`]);
|
|
@@ -5469,7 +6250,13 @@ var RoomCore = class _RoomCore {
|
|
|
5469
6250
|
restored = candidate;
|
|
5470
6251
|
}
|
|
5471
6252
|
if (restored) {
|
|
5472
|
-
plain = decodeSnapshot3(this.ext, restored.snapshot
|
|
6253
|
+
plain = decodeSnapshot3(this.ext, restored.snapshot, {
|
|
6254
|
+
onNonFinite: (path, value) => {
|
|
6255
|
+
host.log("warn", [
|
|
6256
|
+
`irtio: room ${options.roomId}: restored ${path} held ${value}; reset to its default`
|
|
6257
|
+
]);
|
|
6258
|
+
}
|
|
6259
|
+
}).state;
|
|
5473
6260
|
const presence = plainEntity(plain, PRESENCE_COLLECTION);
|
|
5474
6261
|
for (const id of [...presence.ids()]) presence.remove(id);
|
|
5475
6262
|
if (restored.mode !== this.mode) {
|
|
@@ -5636,6 +6423,7 @@ var RoomCore = class _RoomCore {
|
|
|
5636
6423
|
`irtio: no physics world in this snapshot (format v${restored.version}) \u2014 rebuilding it from schema state and re-running physics.setup; contact state is not restored`
|
|
5637
6424
|
]);
|
|
5638
6425
|
}
|
|
6426
|
+
this.physics = physics;
|
|
5639
6427
|
physics.runSetup(this.room);
|
|
5640
6428
|
physics.reconcile();
|
|
5641
6429
|
}
|
|
@@ -5675,6 +6463,7 @@ var RoomCore = class _RoomCore {
|
|
|
5675
6463
|
`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.`
|
|
5676
6464
|
]);
|
|
5677
6465
|
}
|
|
6466
|
+
this.physics = physics;
|
|
5678
6467
|
physics.runSetup(this.room);
|
|
5679
6468
|
physics.reconcile();
|
|
5680
6469
|
return physics;
|
|
@@ -5712,6 +6501,7 @@ var RoomCore = class _RoomCore {
|
|
|
5712
6501
|
`irtio: no rapier2d world in this snapshot (format v${restored.version}) \u2014 rebuilding it from schema state and re-running physics.setup; contact state is not restored`
|
|
5713
6502
|
]);
|
|
5714
6503
|
}
|
|
6504
|
+
this.physics = physics;
|
|
5715
6505
|
physics.runSetup(this.room);
|
|
5716
6506
|
physics.reconcile();
|
|
5717
6507
|
}
|
|
@@ -5781,6 +6571,25 @@ var RoomCore = class _RoomCore {
|
|
|
5781
6571
|
captureTimeline() {
|
|
5782
6572
|
this.recorder?.capture(this.tick, this.ext, this.plain);
|
|
5783
6573
|
}
|
|
6574
|
+
/**
|
|
6575
|
+
* M7 pf4: file one RPC on the trace. Called from `core/rpc.ts` on every exit path a call has,
|
|
6576
|
+
* and a no-op — not merely a cheap one, an untaken branch — in a room nobody armed.
|
|
6577
|
+
*/
|
|
6578
|
+
recordRpc(entry) {
|
|
6579
|
+
this.rpcTraceRing?.push(entry);
|
|
6580
|
+
}
|
|
6581
|
+
/**
|
|
6582
|
+
* M7 pf4: whether this room has a trace ring at all. Read **before** an entry is built, so an
|
|
6583
|
+
* unarmed room pays one property read per call rather than building an entry object and
|
|
6584
|
+
* serializing a result for `recordRpc` to drop on the floor.
|
|
6585
|
+
*/
|
|
6586
|
+
get rpcTraceArmed() {
|
|
6587
|
+
return this.rpcTraceRing !== void 0;
|
|
6588
|
+
}
|
|
6589
|
+
/** M7 pf4: the trace so far, or `undefined` when this room was not armed. */
|
|
6590
|
+
rpcTrace() {
|
|
6591
|
+
return this.rpcTraceRing?.dump();
|
|
6592
|
+
}
|
|
5784
6593
|
// ---- M6 lane F: rewind ----
|
|
5785
6594
|
/**
|
|
5786
6595
|
* D72: record this tick's body poses, right after `physics.sync()` — the poses the clients are
|
|
@@ -5879,9 +6688,23 @@ var RoomCore = class _RoomCore {
|
|
|
5879
6688
|
// ---- end M6 lane K ----
|
|
5880
6689
|
);
|
|
5881
6690
|
}
|
|
6691
|
+
/**
|
|
6692
|
+
* The earliest pending room timer's due time at the last `serialize()`, on `host.now()`'s
|
|
6693
|
+
* clock, or `undefined` if nothing was armed.
|
|
6694
|
+
*
|
|
6695
|
+
* `serialize()` is the departure, and it drops every room timer because a callback is a closure
|
|
6696
|
+
* and there is nothing to persist. The *deadline* is persistable, though, and this is where the
|
|
6697
|
+
* worker reads it to hand to the supervisor, which arms a wake for it. The contract room code
|
|
6698
|
+
* gets: your callback does not survive hibernation, but your room is awake again at or shortly
|
|
6699
|
+
* after the time it would have fired — so keep the deadline in state and catch up in `onWake`.
|
|
6700
|
+
*
|
|
6701
|
+
* Captured before `clearAllTimers()`, which is the only ordering that could ever have worked.
|
|
6702
|
+
*/
|
|
6703
|
+
lastSerializeTimerAt;
|
|
5882
6704
|
serialize() {
|
|
5883
6705
|
const onSleep = this.definition.config.onSleep;
|
|
5884
6706
|
if (onSleep) this.guard("onSleep", () => onSleep(this.state, this.room));
|
|
6707
|
+
this.lastSerializeTimerAt = this.loop.earliestTimerAt();
|
|
5885
6708
|
this.loop.clearAllTimers();
|
|
5886
6709
|
cancelAllAsks(this.internals);
|
|
5887
6710
|
rejectAllPending(this.internals, "room is hibernating");
|
|
@@ -5914,6 +6737,92 @@ var RoomCore = class _RoomCore {
|
|
|
5914
6737
|
);
|
|
5915
6738
|
return roles[0] ?? "";
|
|
5916
6739
|
}
|
|
6740
|
+
// ---- M8 lane 5: moderation ----
|
|
6741
|
+
/**
|
|
6742
|
+
* Subjects `room.ban` has excluded, and when each stops being excluded. Values are wall-clock
|
|
6743
|
+
* deadlines from `host.now()`; `Number.POSITIVE_INFINITY` is the default, "until this room
|
|
6744
|
+
* closes".
|
|
6745
|
+
*
|
|
6746
|
+
* In memory and nowhere else, by decision. It is not in `state`, so it is not in a snapshot, so a
|
|
6747
|
+
* hibernation drops it and a wake starts clean — the same call relay audience membership made,
|
|
6748
|
+
* and for the same reason: this is a fact about a sitting, not about a save file. Exclusion that
|
|
6749
|
+
* outlives a wake is what the control plane's `project_bans` is for, and that one is durable
|
|
6750
|
+
* because it is a row in a database rather than a `Map` in a worker.
|
|
6751
|
+
*/
|
|
6752
|
+
banned = /* @__PURE__ */ new Map();
|
|
6753
|
+
/**
|
|
6754
|
+
* The room's own refusal, before a seat exists. Throws {@link AdmissionError} or returns.
|
|
6755
|
+
*
|
|
6756
|
+
* Order matters and is deliberate: the cooldown is checked first, so a subject the room already
|
|
6757
|
+
* threw out cannot spend the room's `onAdmit` budget on every reconnect attempt.
|
|
6758
|
+
*/
|
|
6759
|
+
admit(clientId, options) {
|
|
6760
|
+
const subject = options.subject;
|
|
6761
|
+
if (subject !== void 0) {
|
|
6762
|
+
const until = this.banned.get(subject);
|
|
6763
|
+
if (until !== void 0) {
|
|
6764
|
+
if (until > this.host.now()) {
|
|
6765
|
+
throw new AdmissionError("banned from this room");
|
|
6766
|
+
}
|
|
6767
|
+
this.banned.delete(subject);
|
|
6768
|
+
}
|
|
6769
|
+
}
|
|
6770
|
+
const onAdmit = this.definition.config.onAdmit;
|
|
6771
|
+
if (!onAdmit) return;
|
|
6772
|
+
const ctx = {
|
|
6773
|
+
clientId,
|
|
6774
|
+
subject,
|
|
6775
|
+
role: this.resolveRole(options.role, void 0),
|
|
6776
|
+
name: options.name ?? "",
|
|
6777
|
+
tick: this.tick,
|
|
6778
|
+
deny: (reason) => {
|
|
6779
|
+
throw new DenyError2(reason);
|
|
6780
|
+
},
|
|
6781
|
+
require: (condition, reason) => {
|
|
6782
|
+
if (!condition) throw new DenyError2(reason);
|
|
6783
|
+
}
|
|
6784
|
+
};
|
|
6785
|
+
const startedAt = this.host.now();
|
|
6786
|
+
let denied;
|
|
6787
|
+
const ran = this.tryRun("onAdmit", () => {
|
|
6788
|
+
try {
|
|
6789
|
+
onAdmit(this.state, ctx);
|
|
6790
|
+
} catch (err) {
|
|
6791
|
+
if (err instanceof DenyError2) {
|
|
6792
|
+
denied = err.reason;
|
|
6793
|
+
return;
|
|
6794
|
+
}
|
|
6795
|
+
throw err;
|
|
6796
|
+
}
|
|
6797
|
+
});
|
|
6798
|
+
const elapsed = this.host.now() - startedAt;
|
|
6799
|
+
if (elapsed > ADMIT_BUDGET_MS) {
|
|
6800
|
+
this.log(
|
|
6801
|
+
"error",
|
|
6802
|
+
`onAdmit took ${Math.round(elapsed)}ms (budget ${ADMIT_BUDGET_MS}ms); join refused`
|
|
6803
|
+
);
|
|
6804
|
+
throw new AdmissionError("admission timed out");
|
|
6805
|
+
}
|
|
6806
|
+
if (denied !== void 0) throw new AdmissionError(denied);
|
|
6807
|
+
if (!ran.ok) throw new AdmissionError("admission check failed");
|
|
6808
|
+
}
|
|
6809
|
+
/**
|
|
6810
|
+
* `room.ban`: kick this client and keep its subject out for a while.
|
|
6811
|
+
*
|
|
6812
|
+
* Returns the subject it excluded, or `undefined` when there was nothing durable to exclude —
|
|
6813
|
+
* a key join, whose only name is a client id it can mint again. The kick happens either way,
|
|
6814
|
+
* because a kick is a real thing to do to a session even when the exclusion cannot follow it.
|
|
6815
|
+
*/
|
|
6816
|
+
banClient(clientId, reason, minutes) {
|
|
6817
|
+
this.host.kick(clientId, "E_KICKED", reason);
|
|
6818
|
+
const subject = this.clients.get(clientId)?.subject;
|
|
6819
|
+
if (subject === void 0) return void 0;
|
|
6820
|
+
const until = minutes === void 0 || !Number.isFinite(minutes) || minutes <= 0 ? Number.POSITIVE_INFINITY : this.host.now() + minutes * 6e4;
|
|
6821
|
+
const existing = this.banned.get(subject) ?? 0;
|
|
6822
|
+
this.banned.set(subject, Math.max(existing, until));
|
|
6823
|
+
return subject;
|
|
6824
|
+
}
|
|
6825
|
+
// ---- end M8 lane 5 ----
|
|
5917
6826
|
join(clientId, options = {}) {
|
|
5918
6827
|
const existing = this.clients.get(clientId);
|
|
5919
6828
|
if (this.stopped) {
|
|
@@ -5933,6 +6842,8 @@ var RoomCore = class _RoomCore {
|
|
|
5933
6842
|
}
|
|
5934
6843
|
const presence = this.presence;
|
|
5935
6844
|
const maxClients = this.definition.config.maxClients;
|
|
6845
|
+
const resuming = options.reconnecting === true || presence.has(clientId);
|
|
6846
|
+
if (!resuming) this.admit(clientId, options);
|
|
5936
6847
|
if (!presence.has(clientId) && presence.size >= maxClients) {
|
|
5937
6848
|
throw new RoomFullError(maxClients);
|
|
5938
6849
|
}
|
|
@@ -5966,20 +6877,29 @@ var RoomCore = class _RoomCore {
|
|
|
5966
6877
|
// the other tab" rather than as a line in this file.
|
|
5967
6878
|
// ---- end M6 lane K ----
|
|
5968
6879
|
playerId: options.playerId ?? clientId,
|
|
6880
|
+
// M8 lane 5: the narrower fact beside the one above. See `JoinOptions.subject`.
|
|
6881
|
+
subject: options.subject,
|
|
5969
6882
|
npc: options.npc === true,
|
|
5970
6883
|
role,
|
|
5971
6884
|
name,
|
|
5972
6885
|
connected: true,
|
|
6886
|
+
welcomed: false,
|
|
5973
6887
|
correction: void 0,
|
|
5974
6888
|
accepted: /* @__PURE__ */ new Map(),
|
|
5975
6889
|
lastClientTick: 0,
|
|
5976
6890
|
lastAppliedTick: 0,
|
|
6891
|
+
// M7 pf3 (g): the baseline `room.writerStaleness` measures from until this client's first
|
|
6892
|
+
// write lands. Without it a client that has just joined reads as `room.tick` ticks stale,
|
|
6893
|
+
// so in a room that has been up an hour every new joiner is instantly the worst offender.
|
|
6894
|
+
joinedTick: this.tick,
|
|
5977
6895
|
pendingJoinAdds: void 0,
|
|
5978
6896
|
spatialMembership: /* @__PURE__ */ new Map()
|
|
5979
6897
|
};
|
|
5980
6898
|
entry.role = role;
|
|
5981
6899
|
entry.name = name;
|
|
5982
6900
|
entry.connected = true;
|
|
6901
|
+
entry.welcomed = false;
|
|
6902
|
+
entry.joinedTick = this.tick;
|
|
5983
6903
|
this.clients.set(clientId, entry);
|
|
5984
6904
|
this.invalidateClients();
|
|
5985
6905
|
reclaimHolds(this.internals, clientId, entry.playerId);
|
|
@@ -5994,6 +6914,7 @@ var RoomCore = class _RoomCore {
|
|
|
5994
6914
|
const memberships = spatialMemberships(this.ext, this.plain, clientId, entry.role);
|
|
5995
6915
|
entry.spatialMembership = memberships;
|
|
5996
6916
|
const snapshot = encodeViewSnapshot(this.ext, this.plain, entry.role, this.tick, memberships);
|
|
6917
|
+
entry.welcomed = true;
|
|
5997
6918
|
this.ledger?.attributeSnapshot("out", snapshot);
|
|
5998
6919
|
const pending = /* @__PURE__ */ new Map();
|
|
5999
6920
|
for (const [name2, cd] of this.tracked.dirty) {
|
|
@@ -6039,6 +6960,7 @@ var RoomCore = class _RoomCore {
|
|
|
6039
6960
|
this.presence.remove(clientId);
|
|
6040
6961
|
this.recordEvent("leave", clientId, reason);
|
|
6041
6962
|
rejectPendingFor(this.internals, clientId, "client disconnected");
|
|
6963
|
+
this.rpcTraceRing?.forget(clientId);
|
|
6042
6964
|
this.loop.dropFramesFor(clientId);
|
|
6043
6965
|
this.clients.delete(clientId);
|
|
6044
6966
|
this.stats.bytesOutByClient.delete(clientId);
|
|
@@ -6071,7 +6993,15 @@ var RoomCore = class _RoomCore {
|
|
|
6071
6993
|
// other entry point (join, leave, write, ownership) has no client tick by construction.
|
|
6072
6994
|
clientTick: void 0,
|
|
6073
6995
|
reconnecting,
|
|
6074
|
-
room: this.room
|
|
6996
|
+
room: this.room,
|
|
6997
|
+
// M7 pf4 (a): on every ctx rather than only the RPC one, because the type says it is there.
|
|
6998
|
+
// `rpc.ts` is what turns the throw into an `ok: false` reply; anywhere else it is a throw.
|
|
6999
|
+
deny: (reason) => {
|
|
7000
|
+
throw new DenyError2(reason);
|
|
7001
|
+
},
|
|
7002
|
+
require: (condition, reason) => {
|
|
7003
|
+
if (!condition) throw new DenyError2(reason);
|
|
7004
|
+
}
|
|
6075
7005
|
};
|
|
6076
7006
|
}
|
|
6077
7007
|
/**
|
|
@@ -6138,7 +7068,7 @@ var RoomCore = class _RoomCore {
|
|
|
6138
7068
|
correctionFor(clientId) {
|
|
6139
7069
|
const entry = this.clients.get(clientId);
|
|
6140
7070
|
if (!entry) return void 0;
|
|
6141
|
-
if (!entry.correction) entry.correction =
|
|
7071
|
+
if (!entry.correction) entry.correction = createDirtySet3();
|
|
6142
7072
|
return entry.correction;
|
|
6143
7073
|
}
|
|
6144
7074
|
invalidateClients() {
|
|
@@ -6296,9 +7226,13 @@ var RoomCore = class _RoomCore {
|
|
|
6296
7226
|
if (crossed) (churn ??= /* @__PURE__ */ new Map()).set(name, crossed);
|
|
6297
7227
|
}
|
|
6298
7228
|
if (entry.correction) {
|
|
7229
|
+
const keep = visibleNames(this.ext, entry.role);
|
|
6299
7230
|
const payload = encodeCorrection(
|
|
6300
7231
|
this.internals,
|
|
6301
|
-
|
|
7232
|
+
filterDirty2(
|
|
7233
|
+
membershipSafeDirty(this.ext, entry.correction, memberships),
|
|
7234
|
+
(name) => keep.has(name)
|
|
7235
|
+
)
|
|
6302
7236
|
);
|
|
6303
7237
|
if (payload) {
|
|
6304
7238
|
this.send(
|
|
@@ -6398,6 +7332,8 @@ function encodeSection(physics) {
|
|
|
6398
7332
|
export {
|
|
6399
7333
|
HOST_CALL_TIMEOUT_MS,
|
|
6400
7334
|
RoomFullError,
|
|
7335
|
+
AdmissionError,
|
|
7336
|
+
PHYSICS_STEP_EMA_ALPHA,
|
|
6401
7337
|
RELAY_STATE_BYTES,
|
|
6402
7338
|
RELAY_RECORDS_PER_COLLECTION,
|
|
6403
7339
|
RELAY_WRITES_PER_SECOND,
|
|
@@ -6405,14 +7341,22 @@ export {
|
|
|
6405
7341
|
RELAY_WRITE_CAPS,
|
|
6406
7342
|
EVENT_RING_SIZE,
|
|
6407
7343
|
inspectState,
|
|
7344
|
+
HOLD_ACTIONS,
|
|
7345
|
+
RPC_TRACE_SIZE,
|
|
7346
|
+
RPC_TRACE_SUMMARY_MAX,
|
|
7347
|
+
summarize,
|
|
7348
|
+
RpcTraceRing,
|
|
7349
|
+
isVisible,
|
|
7350
|
+
visibleTo,
|
|
7351
|
+
visibleNames,
|
|
7352
|
+
viewKeyFor,
|
|
7353
|
+
encodeViewSnapshot,
|
|
7354
|
+
createVisibilityPolicy,
|
|
7355
|
+
encodeViewDelta,
|
|
7356
|
+
catchUpDirty,
|
|
6408
7357
|
RPC_TIMEOUT_MS,
|
|
6409
7358
|
MAX_CATCHUP,
|
|
6410
7359
|
CRASH_AFTER_THROWS,
|
|
6411
|
-
initMatter,
|
|
6412
|
-
loadedMatter,
|
|
6413
|
-
resetMatterForTests,
|
|
6414
|
-
encodeMatterBodies,
|
|
6415
|
-
decodeMatterBodies,
|
|
6416
7360
|
initPhysics,
|
|
6417
7361
|
loadedPhysics,
|
|
6418
7362
|
resetPhysicsForTests,
|
|
@@ -6425,21 +7369,19 @@ export {
|
|
|
6425
7369
|
encodeMatterSectionEnvelope,
|
|
6426
7370
|
decodeMatterSectionEnvelope,
|
|
6427
7371
|
decodePhysicsSection,
|
|
7372
|
+
initMatter,
|
|
7373
|
+
loadedMatter,
|
|
7374
|
+
resetMatterForTests,
|
|
7375
|
+
encodeMatterBodies,
|
|
7376
|
+
decodeMatterBodies,
|
|
6428
7377
|
Mulberry32,
|
|
6429
7378
|
initRapier2d,
|
|
6430
7379
|
loadedRapier2d,
|
|
6431
7380
|
resetRapier2dForTests,
|
|
6432
|
-
isVisible,
|
|
6433
|
-
visibleTo,
|
|
6434
|
-
visibleNames,
|
|
6435
|
-
viewKeyFor,
|
|
6436
|
-
encodeViewSnapshot,
|
|
6437
|
-
createVisibilityPolicy,
|
|
6438
|
-
encodeViewDelta,
|
|
6439
|
-
catchUpDirty,
|
|
6440
7381
|
SNAPSHOT_FORMAT_VERSION,
|
|
6441
7382
|
READABLE_SNAPSHOT_VERSIONS,
|
|
6442
7383
|
parseHibernationBlob,
|
|
7384
|
+
sameSchemaHash,
|
|
6443
7385
|
writeHibernationBlob,
|
|
6444
7386
|
decodeSave,
|
|
6445
7387
|
DEFAULT_TIMELINE_MAX_TICKS,
|