@irtio/runtime 0.10.1 → 0.11.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-WNAEE4EP.js → chunk-ACC3SDIO.js} +1 -1
- package/dist/{chunk-EENJ37KK.js → chunk-GYPUNTQN.js} +910 -77
- package/dist/{contract-C5aqs49-.d.ts → contract-Hk4SkDtW.d.ts} +101 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +12 -2
- package/dist/{room-Db3iPBKM.d.ts → room-0FZBGyke.d.ts} +9 -1
- package/dist/test/index.d.ts +26 -38
- package/dist/test/index.js +60 -4
- package/dist/worker/index.d.ts +1 -1
- package/dist/worker/index.js +2 -2
- package/package.json +4 -4
|
@@ -9,6 +9,181 @@ var RoomFullError = class extends Error {
|
|
|
9
9
|
name = "RoomFullError";
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
// src/core/caps.ts
|
|
13
|
+
import {
|
|
14
|
+
ErrorCode,
|
|
15
|
+
FrameType,
|
|
16
|
+
encodeErrorPayload,
|
|
17
|
+
encodeFrame,
|
|
18
|
+
formatError
|
|
19
|
+
} from "@irtio/protocol";
|
|
20
|
+
import { encodeSnapshot } from "@irtio/schema";
|
|
21
|
+
var RELAY_STATE_BYTES = 512 * 1024;
|
|
22
|
+
var RELAY_RECORDS_PER_COLLECTION = 1024;
|
|
23
|
+
var RELAY_WRITES_PER_SECOND = 35;
|
|
24
|
+
var DEFAULT_RECORDS_PER_COLLECTION = RELAY_RECORDS_PER_COLLECTION;
|
|
25
|
+
var RELAY_WRITE_CAPS = {
|
|
26
|
+
stateBytes: RELAY_STATE_BYTES,
|
|
27
|
+
recordsPerCollection: RELAY_RECORDS_PER_COLLECTION,
|
|
28
|
+
writesPerSecond: RELAY_WRITES_PER_SECOND
|
|
29
|
+
};
|
|
30
|
+
function refuseByName(core, clientId, code, vars) {
|
|
31
|
+
const message = formatError(code, vars);
|
|
32
|
+
core.recordEvent("write-rejected", clientId, `${code}: ${message}`);
|
|
33
|
+
core.send(
|
|
34
|
+
clientId,
|
|
35
|
+
encodeFrame(
|
|
36
|
+
FrameType.ERROR,
|
|
37
|
+
encodeErrorPayload({ code: ErrorCode[code].code, message, fatal: false })
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
function maxTypeBytes(t) {
|
|
42
|
+
const optional = t.opt ? 1 : 0;
|
|
43
|
+
switch (t.kind) {
|
|
44
|
+
case "bool":
|
|
45
|
+
case "u8":
|
|
46
|
+
return optional + 1;
|
|
47
|
+
case "u16":
|
|
48
|
+
return optional + 2;
|
|
49
|
+
case "u32":
|
|
50
|
+
case "i32":
|
|
51
|
+
case "f32":
|
|
52
|
+
return optional + 4;
|
|
53
|
+
case "f64":
|
|
54
|
+
return optional + 8;
|
|
55
|
+
case "enum":
|
|
56
|
+
return optional + 2;
|
|
57
|
+
case "str":
|
|
58
|
+
return optional + 5 + t.max * 4;
|
|
59
|
+
case "ref":
|
|
60
|
+
return optional + 5 + 64 * 4;
|
|
61
|
+
case "list":
|
|
62
|
+
return optional + 5 + t.max * maxTypeBytes(t.item);
|
|
63
|
+
case "struct": {
|
|
64
|
+
let sum = optional;
|
|
65
|
+
for (const f of Object.values(t.fields)) sum += maxTypeBytes(f);
|
|
66
|
+
return sum;
|
|
67
|
+
}
|
|
68
|
+
default:
|
|
69
|
+
return optional + 8;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function maxRecordBytes(c) {
|
|
73
|
+
let sum = 5 + 64 * 4 + 5 + 64 * 4;
|
|
74
|
+
for (const f of c.fields) sum += maxTypeBytes(f.type);
|
|
75
|
+
return sum;
|
|
76
|
+
}
|
|
77
|
+
var STALE_MEASURE_TICKS = 300;
|
|
78
|
+
var StateMeter = class {
|
|
79
|
+
constructor(core, cap) {
|
|
80
|
+
this.core = core;
|
|
81
|
+
this.cap = cap;
|
|
82
|
+
this.figure = this.measure();
|
|
83
|
+
}
|
|
84
|
+
core;
|
|
85
|
+
cap;
|
|
86
|
+
/** The running figure: the last exact measure plus every accepted write's upper bound since. */
|
|
87
|
+
figure;
|
|
88
|
+
measuredAtTick = -1;
|
|
89
|
+
measure() {
|
|
90
|
+
const bytes = encodeSnapshot(this.core.ext, this.core.plain, { tick: this.core.tick });
|
|
91
|
+
this.measuredAtTick = this.core.tick;
|
|
92
|
+
return bytes.length;
|
|
93
|
+
}
|
|
94
|
+
/** Has the figure gone long enough without an exact measure to be worth distrusting? */
|
|
95
|
+
stale() {
|
|
96
|
+
return this.core.tick - this.measuredAtTick >= STALE_MEASURE_TICKS;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The truth, if it is worth paying for: over half the cap, or stale, and at most once a tick.
|
|
100
|
+
* A small room that is being written to steadily encodes once every {@link STALE_MEASURE_TICKS}
|
|
101
|
+
* ticks and otherwise uses the running figure, which is why it costs next to nothing.
|
|
102
|
+
*/
|
|
103
|
+
refresh() {
|
|
104
|
+
if (this.measuredAtTick === this.core.tick) return;
|
|
105
|
+
if (this.figure * 2 <= this.cap && !this.stale()) return;
|
|
106
|
+
this.figure = this.measure();
|
|
107
|
+
}
|
|
108
|
+
/** `true` when a write that could add `growth` bytes must be refused. */
|
|
109
|
+
wouldExceed(growth) {
|
|
110
|
+
if (this.stale()) this.refresh();
|
|
111
|
+
if (this.figure + growth <= this.cap) return false;
|
|
112
|
+
this.refresh();
|
|
113
|
+
return this.figure + growth > this.cap;
|
|
114
|
+
}
|
|
115
|
+
/** Records an accepted write's upper bound. */
|
|
116
|
+
grew(growth) {
|
|
117
|
+
this.figure += growth;
|
|
118
|
+
}
|
|
119
|
+
/** The current figure, for the room row and `irtio rooms`. Exact when it matters. */
|
|
120
|
+
bytes() {
|
|
121
|
+
this.refresh();
|
|
122
|
+
return this.figure;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
var states = /* @__PURE__ */ new WeakMap();
|
|
126
|
+
function installCaps(core, caps) {
|
|
127
|
+
states.set(core, {
|
|
128
|
+
caps,
|
|
129
|
+
meter: caps.stateBytes !== void 0 ? new StateMeter(core, caps.stateBytes) : void 0,
|
|
130
|
+
buckets: /* @__PURE__ */ new Map()
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function stateOf(core) {
|
|
134
|
+
let s = states.get(core);
|
|
135
|
+
if (!s) {
|
|
136
|
+
s = { caps: {}, meter: void 0, buckets: /* @__PURE__ */ new Map() };
|
|
137
|
+
states.set(core, s);
|
|
138
|
+
}
|
|
139
|
+
return s;
|
|
140
|
+
}
|
|
141
|
+
function recordCap(core) {
|
|
142
|
+
return stateOf(core).caps.recordsPerCollection ?? DEFAULT_RECORDS_PER_COLLECTION;
|
|
143
|
+
}
|
|
144
|
+
function admitGrowth(core, clientId, growth) {
|
|
145
|
+
const meter = stateOf(core).meter;
|
|
146
|
+
if (!meter) return true;
|
|
147
|
+
if (meter.wouldExceed(growth)) {
|
|
148
|
+
refuseByName(core, clientId, "E_STATE_CAP", { limit: meter.cap });
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
meter.grew(growth);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
function stateBytesOf(core) {
|
|
155
|
+
return stateOf(core).meter?.bytes();
|
|
156
|
+
}
|
|
157
|
+
function admitWrite(core, clientId, nowMs, ops = 1) {
|
|
158
|
+
const s = stateOf(core);
|
|
159
|
+
const limit = s.caps.writesPerSecond;
|
|
160
|
+
if (limit === void 0) return true;
|
|
161
|
+
const cost = Math.max(1, ops);
|
|
162
|
+
let bucket = s.buckets.get(clientId);
|
|
163
|
+
if (!bucket) {
|
|
164
|
+
bucket = { tokens: limit, at: nowMs, told: false };
|
|
165
|
+
s.buckets.set(clientId, bucket);
|
|
166
|
+
}
|
|
167
|
+
const elapsed = nowMs - bucket.at;
|
|
168
|
+
if (elapsed > 0) {
|
|
169
|
+
bucket.tokens = Math.min(limit, bucket.tokens + elapsed * limit / 1e3);
|
|
170
|
+
bucket.at = nowMs;
|
|
171
|
+
}
|
|
172
|
+
if (bucket.tokens >= cost) {
|
|
173
|
+
bucket.tokens -= cost;
|
|
174
|
+
bucket.told = false;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (!bucket.told) {
|
|
178
|
+
bucket.told = true;
|
|
179
|
+
refuseByName(core, clientId, "E_WRITE_RATE", { limit });
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
function forgetWriter(core, clientId) {
|
|
184
|
+
states.get(core)?.buckets.delete(clientId);
|
|
185
|
+
}
|
|
186
|
+
|
|
12
187
|
// src/core/inspect.ts
|
|
13
188
|
import { EntityCollection } from "@irtio/schema";
|
|
14
189
|
var EVENT_RING_SIZE = 100;
|
|
@@ -39,17 +214,17 @@ function inspectState(schema, plain) {
|
|
|
39
214
|
|
|
40
215
|
// src/core/rpc.ts
|
|
41
216
|
import {
|
|
42
|
-
FrameType,
|
|
217
|
+
FrameType as FrameType2,
|
|
43
218
|
decodeCall,
|
|
44
219
|
decodeReply,
|
|
45
220
|
encodeCall,
|
|
46
|
-
encodeFrame,
|
|
221
|
+
encodeFrame as encodeFrame2,
|
|
47
222
|
encodeReply,
|
|
48
223
|
rpcByIdOf,
|
|
49
224
|
rpcTable
|
|
50
225
|
} from "@irtio/protocol";
|
|
51
226
|
import {
|
|
52
|
-
SERVER_OWNER,
|
|
227
|
+
SERVER_OWNER as SERVER_OWNER2,
|
|
53
228
|
decodeFields,
|
|
54
229
|
encodeFields,
|
|
55
230
|
validateValue
|
|
@@ -81,15 +256,251 @@ function deepEqual(a, b) {
|
|
|
81
256
|
return true;
|
|
82
257
|
}
|
|
83
258
|
|
|
259
|
+
// src/core/ownership.ts
|
|
260
|
+
import { SERVER_OWNER, defaultRecord } from "@irtio/schema";
|
|
261
|
+
var states2 = /* @__PURE__ */ new WeakMap();
|
|
262
|
+
function stateOf2(core) {
|
|
263
|
+
let s = states2.get(core);
|
|
264
|
+
if (!s) {
|
|
265
|
+
s = { holds: /* @__PURE__ */ new Map(), askWarned: false };
|
|
266
|
+
states2.set(core, s);
|
|
267
|
+
}
|
|
268
|
+
return s;
|
|
269
|
+
}
|
|
270
|
+
function keyOf(collection, id) {
|
|
271
|
+
return `${collection} ${id}`;
|
|
272
|
+
}
|
|
273
|
+
function policyCollections(core) {
|
|
274
|
+
return core.ext.collections.filter(
|
|
275
|
+
(c) => c.kind === "entity" && c.ownership !== void 0
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
function wallNow(core) {
|
|
279
|
+
return core.wallNow();
|
|
280
|
+
}
|
|
281
|
+
function holdOn(core, collection, id) {
|
|
282
|
+
return states2.get(core)?.holds.get(keyOf(collection, id));
|
|
283
|
+
}
|
|
284
|
+
function holdCount(core) {
|
|
285
|
+
return states2.get(core)?.holds.size ?? 0;
|
|
286
|
+
}
|
|
287
|
+
function placeHolds(core, clientId, playerId) {
|
|
288
|
+
const s = stateOf2(core);
|
|
289
|
+
const now = wallNow(core);
|
|
290
|
+
for (const c of policyCollections(core)) {
|
|
291
|
+
const policy = c.ownership;
|
|
292
|
+
const coll = plainEntity(core.plain, c.name);
|
|
293
|
+
for (const id of [...coll.ids()]) {
|
|
294
|
+
if (coll.ownerOf(id) !== clientId) continue;
|
|
295
|
+
const hold = {
|
|
296
|
+
collection: c.name,
|
|
297
|
+
id,
|
|
298
|
+
holder: playerId,
|
|
299
|
+
deadlineMs: now + policy.holdMs,
|
|
300
|
+
// `then` is the D77 spec's own name for the leave action; a hold is plain data nothing
|
|
301
|
+
// ever awaits.
|
|
302
|
+
// biome-ignore lint/suspicious/noThenProperty: see above
|
|
303
|
+
then: policy.then
|
|
304
|
+
};
|
|
305
|
+
if (policy.holdMs <= 0) {
|
|
306
|
+
act(core, hold);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
s.holds.set(keyOf(c.name, id), hold);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function reclaimHolds(core, clientId, playerId) {
|
|
314
|
+
const s = states2.get(core);
|
|
315
|
+
if (!s || s.holds.size === 0) return 0;
|
|
316
|
+
let reclaimed = 0;
|
|
317
|
+
for (const [key, hold] of [...s.holds]) {
|
|
318
|
+
if (hold.holder !== playerId) continue;
|
|
319
|
+
s.holds.delete(key);
|
|
320
|
+
const coll = plainEntity(core.plain, hold.collection);
|
|
321
|
+
if (!coll.has(hold.id)) continue;
|
|
322
|
+
trackedEntity(core, hold.collection).setOwner(hold.id, clientId);
|
|
323
|
+
reclaimed++;
|
|
324
|
+
}
|
|
325
|
+
return reclaimed;
|
|
326
|
+
}
|
|
327
|
+
function expireHolds(core) {
|
|
328
|
+
const s = states2.get(core);
|
|
329
|
+
if (!s || s.holds.size === 0) return;
|
|
330
|
+
const now = wallNow(core);
|
|
331
|
+
for (const [key, hold] of [...s.holds]) {
|
|
332
|
+
if (hold.deadlineMs > now) continue;
|
|
333
|
+
s.holds.delete(key);
|
|
334
|
+
act(core, hold);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function act(core, hold) {
|
|
338
|
+
const coll = plainEntity(core.plain, hold.collection);
|
|
339
|
+
if (!coll.has(hold.id)) return;
|
|
340
|
+
const tracked = trackedEntity(core, hold.collection);
|
|
341
|
+
switch (hold.then) {
|
|
342
|
+
case "remove":
|
|
343
|
+
tracked.remove(hold.id);
|
|
344
|
+
return;
|
|
345
|
+
case "release":
|
|
346
|
+
tracked.setOwner(hold.id, SERVER_OWNER);
|
|
347
|
+
return;
|
|
348
|
+
case "nearest": {
|
|
349
|
+
const to = nearestConnected(core, hold.collection, hold.id);
|
|
350
|
+
tracked.setOwner(hold.id, to ?? SERVER_OWNER);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
case "random": {
|
|
354
|
+
const candidates = connectedClients(core);
|
|
355
|
+
if (candidates.length === 0) {
|
|
356
|
+
tracked.setOwner(hold.id, SERVER_OWNER);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const pick = candidates[Math.floor(core.rng.next() * candidates.length)];
|
|
360
|
+
tracked.setOwner(hold.id, pick);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
default:
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function connectedClients(core) {
|
|
368
|
+
const out = [];
|
|
369
|
+
for (const entry of core.clients.values()) if (entry.connected) out.push(entry.clientId);
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
function numberAt(record, field) {
|
|
373
|
+
if (!record) return void 0;
|
|
374
|
+
const v = record[field];
|
|
375
|
+
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
376
|
+
}
|
|
377
|
+
function distanceTo(core, policy, object, clientId) {
|
|
378
|
+
const position = policy.position;
|
|
379
|
+
if (!position || !object) return void 0;
|
|
380
|
+
const players = plainEntity(core.plain, position.players);
|
|
381
|
+
const player = players.get(clientId);
|
|
382
|
+
const px = numberAt(player, position.x);
|
|
383
|
+
const py = numberAt(player, position.y);
|
|
384
|
+
const ox = numberAt(object, position.x);
|
|
385
|
+
const oy = numberAt(object, position.y);
|
|
386
|
+
if (px === void 0 || py === void 0 || ox === void 0 || oy === void 0)
|
|
387
|
+
return void 0;
|
|
388
|
+
return Math.hypot(px - ox, py - oy);
|
|
389
|
+
}
|
|
390
|
+
function nearestConnected(core, collection, id) {
|
|
391
|
+
const c = core.ext.collections.find((x) => x.name === collection);
|
|
392
|
+
const policy = c?.ownership;
|
|
393
|
+
if (!policy?.position) return void 0;
|
|
394
|
+
const object = plainEntity(core.plain, collection).get(id);
|
|
395
|
+
let best;
|
|
396
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
397
|
+
for (const clientId of connectedClients(core)) {
|
|
398
|
+
const d = distanceTo(core, policy, object, clientId);
|
|
399
|
+
if (d === void 0 || d >= bestDistance) continue;
|
|
400
|
+
bestDistance = d;
|
|
401
|
+
best = clientId;
|
|
402
|
+
}
|
|
403
|
+
return best;
|
|
404
|
+
}
|
|
405
|
+
function addPerPlayerRecords(core, clientId) {
|
|
406
|
+
for (const c of core.ext.collections) {
|
|
407
|
+
if (c.kind !== "entity" || !c.perPlayer) continue;
|
|
408
|
+
const coll = plainEntity(core.plain, c.name);
|
|
409
|
+
if (coll.has(clientId)) {
|
|
410
|
+
if (coll.ownerOf(clientId) !== clientId) {
|
|
411
|
+
trackedEntity(core, c.name).setOwner(clientId, clientId);
|
|
412
|
+
}
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
trackedEntity(core, c.name).add(clientId, defaultRecord(c), { owner: clientId });
|
|
417
|
+
} catch (err) {
|
|
418
|
+
core.log("error", `perPlayer add on ${c.name} failed for ${clientId}:`, err);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function resolveTransfer(core, clientId, c, id) {
|
|
423
|
+
const policy = c.ownership;
|
|
424
|
+
const coll = plainEntity(core.plain, c.name);
|
|
425
|
+
const owner = coll.ownerOf(id);
|
|
426
|
+
if (owner === clientId) return "granted";
|
|
427
|
+
const hold = holdOn(core, c.name, id);
|
|
428
|
+
if (hold) {
|
|
429
|
+
const playerId = core.clients.get(clientId)?.playerId ?? clientId;
|
|
430
|
+
if (hold.holder !== playerId) return "refused";
|
|
431
|
+
reclaimHolds(core, clientId, playerId);
|
|
432
|
+
return coll.ownerOf(id) === clientId ? "granted" : "refused";
|
|
433
|
+
}
|
|
434
|
+
switch (policy.transfer) {
|
|
435
|
+
case "never":
|
|
436
|
+
return "refused";
|
|
437
|
+
case "free":
|
|
438
|
+
trackedEntity(core, c.name).setOwner(id, clientId);
|
|
439
|
+
return "granted";
|
|
440
|
+
case "closer": {
|
|
441
|
+
if (owner === void 0 || owner === SERVER_OWNER) {
|
|
442
|
+
trackedEntity(core, c.name).setOwner(id, clientId);
|
|
443
|
+
return "granted";
|
|
444
|
+
}
|
|
445
|
+
const object = coll.get(id);
|
|
446
|
+
const ownerDistance = distanceTo(core, policy, object, owner);
|
|
447
|
+
if (ownerDistance === void 0) {
|
|
448
|
+
trackedEntity(core, c.name).setOwner(id, clientId);
|
|
449
|
+
return "granted";
|
|
450
|
+
}
|
|
451
|
+
const mine = distanceTo(core, policy, object, clientId);
|
|
452
|
+
if (mine === void 0) return "refused";
|
|
453
|
+
if (mine + policy.by >= ownerDistance) return "refused";
|
|
454
|
+
trackedEntity(core, c.name).setOwner(id, clientId);
|
|
455
|
+
return "granted";
|
|
456
|
+
}
|
|
457
|
+
case "ask": {
|
|
458
|
+
const s = stateOf2(core);
|
|
459
|
+
if (!s.askWarned) {
|
|
460
|
+
s.askWarned = true;
|
|
461
|
+
core.log(
|
|
462
|
+
"warn",
|
|
463
|
+
`${c.name}: ownership transfer 'ask' is not yet forwarded to owners; requests are refused (the same answer an owner who does not reply gives). Use 'closer' or 'free' for now.`
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return "refused";
|
|
467
|
+
}
|
|
468
|
+
default:
|
|
469
|
+
return "refused";
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function serializeHolds(core) {
|
|
473
|
+
const s = states2.get(core);
|
|
474
|
+
if (!s || s.holds.size === 0) return void 0;
|
|
475
|
+
const json = JSON.stringify([...s.holds.values()]);
|
|
476
|
+
return new TextEncoder().encode(json);
|
|
477
|
+
}
|
|
478
|
+
function restoreHolds(core, bytes) {
|
|
479
|
+
const s = stateOf2(core);
|
|
480
|
+
s.holds.clear();
|
|
481
|
+
if (!bytes || bytes.length === 0) return;
|
|
482
|
+
try {
|
|
483
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
484
|
+
for (const h of parsed) {
|
|
485
|
+
if (typeof h?.collection !== "string" || typeof h.id !== "string" || typeof h.holder !== "string" || typeof h.deadlineMs !== "number") {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
s.holds.set(keyOf(h.collection, h.id), h);
|
|
489
|
+
}
|
|
490
|
+
} catch (err) {
|
|
491
|
+
core.log("warn", "hold deadlines did not decode; the room wakes holding nothing:", err);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
84
495
|
// src/core/rpc.ts
|
|
85
496
|
var RPC_TIMEOUT_MS = 5e3;
|
|
86
497
|
var REQUEST_OWNERSHIP = "requestOwnership";
|
|
87
|
-
var
|
|
88
|
-
function
|
|
89
|
-
let s =
|
|
498
|
+
var states3 = /* @__PURE__ */ new WeakMap();
|
|
499
|
+
function stateOf3(core) {
|
|
500
|
+
let s = states3.get(core);
|
|
90
501
|
if (!s) {
|
|
91
502
|
s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
|
|
92
|
-
|
|
503
|
+
states3.set(core, s);
|
|
93
504
|
}
|
|
94
505
|
return s;
|
|
95
506
|
}
|
|
@@ -101,7 +512,7 @@ function errorText(code, detail) {
|
|
|
101
512
|
}
|
|
102
513
|
function sendReply(core, clientId, reqId, result, error) {
|
|
103
514
|
const payload = result === null ? encodeReply({ reqId, ok: false, error: error ?? errorText("E_INTERNAL") }) : encodeReply({ reqId, ok: true, result });
|
|
104
|
-
core.send(clientId,
|
|
515
|
+
core.send(clientId, encodeFrame2(FrameType2.REPLY, payload));
|
|
105
516
|
}
|
|
106
517
|
function handleCall(core, clientId, payload) {
|
|
107
518
|
let call;
|
|
@@ -184,6 +595,9 @@ function runRequestOwnership(core, clientId, ctx, params) {
|
|
|
184
595
|
if (!plain.has(id)) return { granted: false };
|
|
185
596
|
const handler = core.definition.config.onOwnershipRequest;
|
|
186
597
|
const tracked = trackedEntity(core, name);
|
|
598
|
+
if (!handler && c.ownership) {
|
|
599
|
+
return { granted: resolveTransfer(core, clientId, c, id) === "granted" };
|
|
600
|
+
}
|
|
187
601
|
if (handler) {
|
|
188
602
|
const ran = core.tryRun("onOwnershipRequest", () => handler(core.anyState, name, id, ctx));
|
|
189
603
|
if (ran.ok && ran.value === true && plain.ownerOf(id) !== clientId) {
|
|
@@ -193,7 +607,7 @@ function runRequestOwnership(core, clientId, ctx, params) {
|
|
|
193
607
|
}
|
|
194
608
|
const owner = plain.ownerOf(id);
|
|
195
609
|
if (owner === clientId) return { granted: true };
|
|
196
|
-
if (owner !==
|
|
610
|
+
if (owner !== SERVER_OWNER2) return { granted: false };
|
|
197
611
|
tracked.setOwner(id, clientId);
|
|
198
612
|
return { granted: true };
|
|
199
613
|
}
|
|
@@ -206,13 +620,13 @@ function descFor(core, name) {
|
|
|
206
620
|
return found;
|
|
207
621
|
}
|
|
208
622
|
function sendCall(core, clientId, desc, params) {
|
|
209
|
-
const s =
|
|
623
|
+
const s = stateOf3(core);
|
|
210
624
|
const reqId = s.nextReqId;
|
|
211
625
|
s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
|
|
212
626
|
const bytes = encodeFields(desc.params, params);
|
|
213
627
|
core.send(
|
|
214
628
|
clientId,
|
|
215
|
-
|
|
629
|
+
encodeFrame2(FrameType2.CALL, encodeCall({ reqId, rpcId: desc.index, params: bytes }))
|
|
216
630
|
);
|
|
217
631
|
return reqId;
|
|
218
632
|
}
|
|
@@ -232,7 +646,7 @@ function createCallProxy(core, clientId) {
|
|
|
232
646
|
return;
|
|
233
647
|
}
|
|
234
648
|
const reqId = sendCall(core, clientId, desc, params ?? {});
|
|
235
|
-
const s =
|
|
649
|
+
const s = stateOf3(core);
|
|
236
650
|
s.pending.set(pendingKey(clientId, reqId), {
|
|
237
651
|
clientId,
|
|
238
652
|
name: desc.name,
|
|
@@ -280,7 +694,7 @@ function handleReply(core, clientId, payload) {
|
|
|
280
694
|
core.log("warn", `REPLY from ${clientId} failed to decode:`, err);
|
|
281
695
|
return false;
|
|
282
696
|
}
|
|
283
|
-
const s =
|
|
697
|
+
const s = stateOf3(core);
|
|
284
698
|
const key = pendingKey(clientId, reply.reqId);
|
|
285
699
|
const pending = s.pending.get(key);
|
|
286
700
|
if (!pending) {
|
|
@@ -304,7 +718,7 @@ function handleReply(core, clientId, payload) {
|
|
|
304
718
|
return true;
|
|
305
719
|
}
|
|
306
720
|
function rejectPendingFor(core, clientId, reason) {
|
|
307
|
-
const s =
|
|
721
|
+
const s = stateOf3(core);
|
|
308
722
|
for (const [key, p] of [...s.pending]) {
|
|
309
723
|
if (p.clientId !== clientId) continue;
|
|
310
724
|
s.pending.delete(key);
|
|
@@ -312,7 +726,7 @@ function rejectPendingFor(core, clientId, reason) {
|
|
|
312
726
|
}
|
|
313
727
|
}
|
|
314
728
|
function checkTimeouts(core) {
|
|
315
|
-
const s =
|
|
729
|
+
const s = stateOf3(core);
|
|
316
730
|
if (s.pending.size === 0) return;
|
|
317
731
|
const now = core.host.now();
|
|
318
732
|
for (const [key, p] of [...s.pending]) {
|
|
@@ -322,7 +736,7 @@ function checkTimeouts(core) {
|
|
|
322
736
|
}
|
|
323
737
|
}
|
|
324
738
|
function rejectAllPending(core, reason) {
|
|
325
|
-
const s =
|
|
739
|
+
const s = stateOf3(core);
|
|
326
740
|
for (const [key, p] of [...s.pending]) {
|
|
327
741
|
s.pending.delete(key);
|
|
328
742
|
p.reject(new Error(reason));
|
|
@@ -330,15 +744,15 @@ function rejectAllPending(core, reason) {
|
|
|
330
744
|
}
|
|
331
745
|
|
|
332
746
|
// src/core/loop.ts
|
|
333
|
-
import { FrameType as
|
|
747
|
+
import { FrameType as FrameType3 } from "@irtio/protocol";
|
|
334
748
|
|
|
335
749
|
// src/core/host-calls.ts
|
|
336
|
-
var
|
|
337
|
-
function
|
|
338
|
-
let s =
|
|
750
|
+
var states4 = /* @__PURE__ */ new WeakMap();
|
|
751
|
+
function stateOf4(core) {
|
|
752
|
+
let s = states4.get(core);
|
|
339
753
|
if (!s) {
|
|
340
754
|
s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
|
|
341
|
-
|
|
755
|
+
states4.set(core, s);
|
|
342
756
|
}
|
|
343
757
|
return s;
|
|
344
758
|
}
|
|
@@ -351,7 +765,7 @@ function startHostCall(core, call, map) {
|
|
|
351
765
|
return rejectAsEvent(core, new Error(`room.${call.kind}: the room is stopped`));
|
|
352
766
|
}
|
|
353
767
|
return new Promise((resolve, reject2) => {
|
|
354
|
-
const s =
|
|
768
|
+
const s = stateOf4(core);
|
|
355
769
|
const reqId = s.nextReqId;
|
|
356
770
|
s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
|
|
357
771
|
s.pending.set(reqId, {
|
|
@@ -375,7 +789,7 @@ function startHostCall(core, call, map) {
|
|
|
375
789
|
});
|
|
376
790
|
}
|
|
377
791
|
function completeHostCall(core, reqId, result) {
|
|
378
|
-
const s =
|
|
792
|
+
const s = stateOf4(core);
|
|
379
793
|
const pending = s.pending.get(reqId);
|
|
380
794
|
if (!pending) return false;
|
|
381
795
|
s.pending.delete(reqId);
|
|
@@ -384,7 +798,7 @@ function completeHostCall(core, reqId, result) {
|
|
|
384
798
|
return true;
|
|
385
799
|
}
|
|
386
800
|
function checkHostCallTimeouts(core) {
|
|
387
|
-
const s =
|
|
801
|
+
const s = stateOf4(core);
|
|
388
802
|
if (s.pending.size === 0) return;
|
|
389
803
|
const now = core.host.now();
|
|
390
804
|
for (const [reqId, p] of [...s.pending]) {
|
|
@@ -394,7 +808,7 @@ function checkHostCallTimeouts(core) {
|
|
|
394
808
|
}
|
|
395
809
|
}
|
|
396
810
|
function rejectAllHostCalls(core, reason) {
|
|
397
|
-
const s =
|
|
811
|
+
const s = stateOf4(core);
|
|
398
812
|
for (const [reqId, p] of [...s.pending]) {
|
|
399
813
|
s.pending.delete(reqId);
|
|
400
814
|
p.reject(new Error(reason));
|
|
@@ -403,7 +817,7 @@ function rejectAllHostCalls(core, reason) {
|
|
|
403
817
|
|
|
404
818
|
// src/core/writes.ts
|
|
405
819
|
import {
|
|
406
|
-
SERVER_OWNER as
|
|
820
|
+
SERVER_OWNER as SERVER_OWNER3,
|
|
407
821
|
cloneValue,
|
|
408
822
|
decodeDelta,
|
|
409
823
|
encodeDelta,
|
|
@@ -493,6 +907,9 @@ function applyWrite(core, clientId, payload) {
|
|
|
493
907
|
core.log("warn", `WRITE from ${clientId} failed to decode:`, err);
|
|
494
908
|
return { ok: false };
|
|
495
909
|
}
|
|
910
|
+
let ops = 0;
|
|
911
|
+
for (const dc of delta.collections) ops += dc.ops.length;
|
|
912
|
+
if (!admitWrite(core, clientId, core.host.now(), ops)) return { ok: true };
|
|
496
913
|
const judged = core.clients.get(clientId);
|
|
497
914
|
if (judged && delta.tick > judged.lastClientTick) {
|
|
498
915
|
judged.lastClientTick = delta.tick;
|
|
@@ -513,8 +930,19 @@ function reject(core, clientId, c, op, reason) {
|
|
|
513
930
|
correctLeaves(core, clientId, c.name, op.id, leafPaths(fieldList(c), op.mask));
|
|
514
931
|
}
|
|
515
932
|
function applyOp(core, clientId, c, op) {
|
|
516
|
-
if (op.op
|
|
517
|
-
|
|
933
|
+
if (op.op === "add" || op.op === "remove") {
|
|
934
|
+
if (c.kind !== "entity" || c.serverOwned || !c.clientCreate) {
|
|
935
|
+
reject(
|
|
936
|
+
core,
|
|
937
|
+
clientId,
|
|
938
|
+
c,
|
|
939
|
+
op,
|
|
940
|
+
`op '${op.op}' is not allowed in a WRITE (${c.name} does not declare clientCreate)`
|
|
941
|
+
);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (op.op === "add") applyAdd(core, clientId, c, op);
|
|
945
|
+
else applyRemove(core, clientId, c, op);
|
|
518
946
|
return;
|
|
519
947
|
}
|
|
520
948
|
if (c.kind !== "entity") {
|
|
@@ -537,6 +965,8 @@ function applyOp(core, clientId, c, op) {
|
|
|
537
965
|
return;
|
|
538
966
|
}
|
|
539
967
|
const fields = fieldList(c);
|
|
968
|
+
const growth = updateGrowth(c, leafPaths(fields, op.mask));
|
|
969
|
+
if (growth > 0 && !admitGrowth(core, clientId, growth)) return;
|
|
540
970
|
const prev = cloneValue(current);
|
|
541
971
|
const next = cloneValue(current);
|
|
542
972
|
applyMaskedPatch(fields, next, op.patch, op.mask);
|
|
@@ -594,6 +1024,66 @@ function applyOp(core, clientId, c, op) {
|
|
|
594
1024
|
);
|
|
595
1025
|
correctLeaves(core, clientId, c.name, op.id, corrected);
|
|
596
1026
|
}
|
|
1027
|
+
function applyAdd(core, clientId, c, op) {
|
|
1028
|
+
const plainColl = plainEntity(core.plain, c.name);
|
|
1029
|
+
if (plainColl.has(op.id)) {
|
|
1030
|
+
reject(core, clientId, c, op, `id ${JSON.stringify(op.id)} already exists`);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const cap = recordCap(core);
|
|
1034
|
+
if (plainColl.size >= cap) {
|
|
1035
|
+
refuseByName(core, clientId, "E_RECORD_CAP", { entity: c.name, limit: cap });
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (!admitGrowth(core, clientId, maxRecordBytes(c))) return;
|
|
1039
|
+
try {
|
|
1040
|
+
trackedEntity(core, c.name).add(op.id, op.value, { owner: clientId });
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
reject(core, clientId, c, op, `add rejected: ${String(err)}`);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
core.recordEvent("write", clientId, `${c.name}: added ${op.id}`);
|
|
1046
|
+
}
|
|
1047
|
+
function applyRemove(core, clientId, c, op) {
|
|
1048
|
+
const plainColl = plainEntity(core.plain, c.name);
|
|
1049
|
+
if (!plainColl.has(op.id)) return;
|
|
1050
|
+
if (plainColl.ownerOf(op.id) !== clientId) {
|
|
1051
|
+
reject(core, clientId, c, op, "not the owner");
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
trackedEntity(core, c.name).remove(op.id);
|
|
1055
|
+
core.recordEvent("write", clientId, `${c.name}: removed ${op.id}`);
|
|
1056
|
+
}
|
|
1057
|
+
function updateGrowth(c, leaves) {
|
|
1058
|
+
let growth = 0;
|
|
1059
|
+
for (const leaf of leaves) {
|
|
1060
|
+
const first = leaf.path[0];
|
|
1061
|
+
if (first === void 0) continue;
|
|
1062
|
+
const field = c.fields[first];
|
|
1063
|
+
if (!field) continue;
|
|
1064
|
+
const bound = maxTypeBytes(field.type);
|
|
1065
|
+
if (isFixedWidth(field.type)) continue;
|
|
1066
|
+
growth += bound;
|
|
1067
|
+
}
|
|
1068
|
+
return growth;
|
|
1069
|
+
}
|
|
1070
|
+
function isFixedWidth(t) {
|
|
1071
|
+
switch (t.kind) {
|
|
1072
|
+
case "bool":
|
|
1073
|
+
case "u8":
|
|
1074
|
+
case "u16":
|
|
1075
|
+
case "u32":
|
|
1076
|
+
case "i32":
|
|
1077
|
+
case "f32":
|
|
1078
|
+
case "f64":
|
|
1079
|
+
case "enum":
|
|
1080
|
+
return t.opt !== true;
|
|
1081
|
+
case "struct":
|
|
1082
|
+
return Object.values(t.fields).every(isFixedWidth) && t.opt !== true;
|
|
1083
|
+
default:
|
|
1084
|
+
return false;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
597
1087
|
function serverWinsCorrections(core, dirty) {
|
|
598
1088
|
const ext = core.ext;
|
|
599
1089
|
for (const [name, cd] of dirty) {
|
|
@@ -604,7 +1094,7 @@ function serverWinsCorrections(core, dirty) {
|
|
|
604
1094
|
const fields = fieldList(c);
|
|
605
1095
|
for (const [id, rd] of cd.updated) {
|
|
606
1096
|
const owner = coll.ownerOf(id);
|
|
607
|
-
if (owner === void 0 || owner ===
|
|
1097
|
+
if (owner === void 0 || owner === SERVER_OWNER3) continue;
|
|
608
1098
|
const entry = core.clients.get(owner);
|
|
609
1099
|
if (!entry || !entry.connected) continue;
|
|
610
1100
|
const value = coll.get(id);
|
|
@@ -700,8 +1190,8 @@ var Loop = class {
|
|
|
700
1190
|
}
|
|
701
1191
|
/** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
|
|
702
1192
|
applyFrame(f) {
|
|
703
|
-
if (f.type ===
|
|
704
|
-
if (f.type ===
|
|
1193
|
+
if (f.type === FrameType3.WRITE) return applyWrite(this.core, f.clientId, f.payload).ok;
|
|
1194
|
+
if (f.type === FrameType3.CALL) return handleCall(this.core, f.clientId, f.payload);
|
|
705
1195
|
return true;
|
|
706
1196
|
}
|
|
707
1197
|
// -------------------------------------------------------------------------
|
|
@@ -2088,7 +2578,7 @@ import {
|
|
|
2088
2578
|
createDirtySet,
|
|
2089
2579
|
createFieldMask,
|
|
2090
2580
|
encodeDelta as encodeDelta2,
|
|
2091
|
-
encodeSnapshot,
|
|
2581
|
+
encodeSnapshot as encodeSnapshot2,
|
|
2092
2582
|
filterDirty,
|
|
2093
2583
|
isDirtyEmpty,
|
|
2094
2584
|
markAdd,
|
|
@@ -2195,7 +2685,7 @@ function viewKeyFor(ext, role) {
|
|
|
2195
2685
|
return infoOf(ext).scoped.has(role) ? role : "all";
|
|
2196
2686
|
}
|
|
2197
2687
|
function encodeViewSnapshot(ext, plain, role, tick, memberships) {
|
|
2198
|
-
return
|
|
2688
|
+
return encodeSnapshot2(
|
|
2199
2689
|
ext,
|
|
2200
2690
|
plain,
|
|
2201
2691
|
{ tick },
|
|
@@ -2325,7 +2815,7 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
|
|
|
2325
2815
|
import { withBuiltins } from "@irtio/protocol";
|
|
2326
2816
|
import { ByteReader as ByteReader3, ByteWriter as ByteWriter3, decodeSnapshot } from "@irtio/schema";
|
|
2327
2817
|
var SNAPSHOT_FORMAT_VERSION = 3;
|
|
2328
|
-
var READABLE_SNAPSHOT_VERSIONS = [1, 2, 3];
|
|
2818
|
+
var READABLE_SNAPSHOT_VERSIONS = [1, 2, 3, 4];
|
|
2329
2819
|
function parseHibernationBlob(bytes) {
|
|
2330
2820
|
const r = new ByteReader3(bytes);
|
|
2331
2821
|
const version = r.u8();
|
|
@@ -2342,7 +2832,12 @@ function parseHibernationBlob(bytes) {
|
|
|
2342
2832
|
const section = r.blob();
|
|
2343
2833
|
if (section.length > 0) physics = section;
|
|
2344
2834
|
}
|
|
2345
|
-
|
|
2835
|
+
let holds;
|
|
2836
|
+
if (version >= 4) {
|
|
2837
|
+
const section = r.blob();
|
|
2838
|
+
if (section.length > 0) holds = section;
|
|
2839
|
+
}
|
|
2840
|
+
return { version, seed, rngState, tick, mode, schemaHash8, physics, holds, snapshot: r.rest() };
|
|
2346
2841
|
}
|
|
2347
2842
|
function sameSchemaHash(a, b) {
|
|
2348
2843
|
if (!a || !b) return true;
|
|
@@ -2350,10 +2845,10 @@ function sameSchemaHash(a, b) {
|
|
|
2350
2845
|
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
2351
2846
|
return true;
|
|
2352
2847
|
}
|
|
2353
|
-
function writeHibernationBlob(header, snapshot, physics) {
|
|
2848
|
+
function writeHibernationBlob(header, snapshot, physics, holds) {
|
|
2354
2849
|
const hash = header.schemaHash8;
|
|
2355
|
-
const version = hash ? 3 : physics ? 2 : 1;
|
|
2356
|
-
const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) +
|
|
2850
|
+
const version = holds && holds.length > 0 ? 4 : hash ? 3 : physics ? 2 : 1;
|
|
2851
|
+
const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + (holds?.length ?? 0) + 40);
|
|
2357
2852
|
w.u8(version);
|
|
2358
2853
|
w.u32(header.seed >>> 0);
|
|
2359
2854
|
w.u32(header.rngState >>> 0);
|
|
@@ -2361,6 +2856,7 @@ function writeHibernationBlob(header, snapshot, physics) {
|
|
|
2361
2856
|
w.u8(header.mode === "event" ? 1 : 0);
|
|
2362
2857
|
if (hash) w.bytes(hash);
|
|
2363
2858
|
if (version >= 2) w.blob(physics ?? new Uint8Array(0));
|
|
2859
|
+
if (version >= 4) w.blob(holds ?? new Uint8Array(0));
|
|
2364
2860
|
w.bytes(snapshot);
|
|
2365
2861
|
return w.finish();
|
|
2366
2862
|
}
|
|
@@ -2434,13 +2930,13 @@ var TimelineRecorder = class {
|
|
|
2434
2930
|
|
|
2435
2931
|
// src/core/room.ts
|
|
2436
2932
|
import {
|
|
2437
|
-
FrameType as
|
|
2933
|
+
FrameType as FrameType6,
|
|
2438
2934
|
PRESENCE_COLLECTION,
|
|
2439
2935
|
ProfileLedger,
|
|
2440
2936
|
busChannelProblem as busChannelProblem2,
|
|
2441
2937
|
decodeFrame,
|
|
2442
2938
|
encodeCorrectFrame,
|
|
2443
|
-
encodeFrame as
|
|
2939
|
+
encodeFrame as encodeFrame5,
|
|
2444
2940
|
withBuiltins as withBuiltins2
|
|
2445
2941
|
} from "@irtio/protocol";
|
|
2446
2942
|
import {
|
|
@@ -2448,9 +2944,9 @@ import {
|
|
|
2448
2944
|
createDirtySet as createDirtySet2,
|
|
2449
2945
|
createState,
|
|
2450
2946
|
decodeSnapshot as decodeSnapshot2,
|
|
2451
|
-
encodeDelta as
|
|
2452
|
-
encodeSnapshot as
|
|
2453
|
-
isDirtyEmpty as
|
|
2947
|
+
encodeDelta as encodeDelta5,
|
|
2948
|
+
encodeSnapshot as encodeSnapshot4,
|
|
2949
|
+
isDirtyEmpty as isDirtyEmpty4,
|
|
2454
2950
|
track,
|
|
2455
2951
|
validateForDeploy
|
|
2456
2952
|
} from "@irtio/schema";
|
|
@@ -2709,7 +3205,7 @@ function historyOf(config) {
|
|
|
2709
3205
|
}
|
|
2710
3206
|
return { depth, channels };
|
|
2711
3207
|
}
|
|
2712
|
-
function
|
|
3208
|
+
function keyOf2(collection, id) {
|
|
2713
3209
|
return `${collection} ${id}`;
|
|
2714
3210
|
}
|
|
2715
3211
|
function angleOf(qz, qw) {
|
|
@@ -2734,7 +3230,7 @@ var BaseScratch = class {
|
|
|
2734
3230
|
this.sync();
|
|
2735
3231
|
this.present.clear();
|
|
2736
3232
|
for (let i = 0; i < entry.count; i++) {
|
|
2737
|
-
const key =
|
|
3233
|
+
const key = keyOf2(entry.collections[i], entry.ids[i]);
|
|
2738
3234
|
const e = this.bodies.get(key);
|
|
2739
3235
|
if (!e) continue;
|
|
2740
3236
|
poseAt(entry, i, this.pose);
|
|
@@ -2760,7 +3256,7 @@ var BaseScratch = class {
|
|
|
2760
3256
|
const now = this.tickNow();
|
|
2761
3257
|
const seen = /* @__PURE__ */ new Set();
|
|
2762
3258
|
this.physics.eachTrackedBody((collection, id, body) => {
|
|
2763
|
-
const key =
|
|
3259
|
+
const key = keyOf2(collection, id);
|
|
2764
3260
|
seen.add(key);
|
|
2765
3261
|
const signature = this.signatureOf(body);
|
|
2766
3262
|
const existing = this.bodies.get(key);
|
|
@@ -3205,13 +3701,13 @@ function lobbyConfigOf(core) {
|
|
|
3205
3701
|
if (!schemaHasLobby(core.definition.schema)) return void 0;
|
|
3206
3702
|
return declared;
|
|
3207
3703
|
}
|
|
3208
|
-
function
|
|
3704
|
+
function stateOf5(core) {
|
|
3209
3705
|
return core.anyState[LOBBY_STATE];
|
|
3210
3706
|
}
|
|
3211
3707
|
function evaluateLobby(core) {
|
|
3212
3708
|
const config = lobbyConfigOf(core);
|
|
3213
3709
|
if (config === void 0) return;
|
|
3214
|
-
const state =
|
|
3710
|
+
const state = stateOf5(core);
|
|
3215
3711
|
if (state === void 0) return;
|
|
3216
3712
|
const members = trackedEntity(core, LOBBY_MEMBERS);
|
|
3217
3713
|
const policy = config.start ?? "when-full";
|
|
@@ -3250,7 +3746,7 @@ function evaluateLobby(core) {
|
|
|
3250
3746
|
function startLobby(core, why) {
|
|
3251
3747
|
const runtime = runtimeOf(core);
|
|
3252
3748
|
if (runtime.started) return;
|
|
3253
|
-
const state =
|
|
3749
|
+
const state = stateOf5(core);
|
|
3254
3750
|
if (state === void 0 || state.phase === "started") {
|
|
3255
3751
|
runtime.started = true;
|
|
3256
3752
|
return;
|
|
@@ -3273,7 +3769,7 @@ function makeLobby(core) {
|
|
|
3273
3769
|
"room.lobby: this room has no lobby. Spread lobbyCollections into the schema and declare lobby: {} in defineRoom \u2014 both are needed, and defineRoom refuses either one alone"
|
|
3274
3770
|
);
|
|
3275
3771
|
}
|
|
3276
|
-
const state =
|
|
3772
|
+
const state = stateOf5(core);
|
|
3277
3773
|
const members = trackedEntity(core, LOBBY_MEMBERS);
|
|
3278
3774
|
return {
|
|
3279
3775
|
get phase() {
|
|
@@ -3311,7 +3807,7 @@ function makeLobby(core) {
|
|
|
3311
3807
|
function setLobbyPublic(core, value) {
|
|
3312
3808
|
const config = lobbyConfigOf(core);
|
|
3313
3809
|
if (config === void 0) return;
|
|
3314
|
-
const state =
|
|
3810
|
+
const state = stateOf5(core);
|
|
3315
3811
|
if (state === void 0) return;
|
|
3316
3812
|
if (state.phase === "started") {
|
|
3317
3813
|
core.log(
|
|
@@ -3330,7 +3826,7 @@ function setLobbyPublic(core, value) {
|
|
|
3330
3826
|
}
|
|
3331
3827
|
|
|
3332
3828
|
// src/core/messages.ts
|
|
3333
|
-
import { FrameType as
|
|
3829
|
+
import { FrameType as FrameType4, decodeMsg, encodeFrame as encodeFrame3, encodeMsg } from "@irtio/protocol";
|
|
3334
3830
|
import { decodeFields as decodeFields2 } from "@irtio/schema";
|
|
3335
3831
|
function toRoomTarget(target) {
|
|
3336
3832
|
switch (target.kind) {
|
|
@@ -3410,8 +3906,8 @@ function handleMsg(core, clientId, payload) {
|
|
|
3410
3906
|
if (!ran.ok || ran.value === false) return true;
|
|
3411
3907
|
}
|
|
3412
3908
|
if (msg.target.kind === "server") return true;
|
|
3413
|
-
const frame =
|
|
3414
|
-
|
|
3909
|
+
const frame = encodeFrame3(
|
|
3910
|
+
FrameType4.MSG,
|
|
3415
3911
|
encodeMsg({
|
|
3416
3912
|
target: { kind: "client", clientId },
|
|
3417
3913
|
payload: msg.payload,
|
|
@@ -3425,28 +3921,306 @@ function wireTargetOf(target) {
|
|
|
3425
3921
|
return target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
|
|
3426
3922
|
}
|
|
3427
3923
|
function sendMessage(core, target, bytes) {
|
|
3428
|
-
const frame =
|
|
3429
|
-
|
|
3924
|
+
const frame = encodeFrame3(
|
|
3925
|
+
FrameType4.MSG,
|
|
3430
3926
|
encodeMsg({ target: { kind: "server" }, payload: bytes })
|
|
3431
3927
|
);
|
|
3432
3928
|
deliver(core, wireTargetOf(target), frame);
|
|
3433
3929
|
}
|
|
3434
3930
|
function sendTypedMessage(core, index, target, payload) {
|
|
3435
|
-
const frame =
|
|
3436
|
-
|
|
3931
|
+
const frame = encodeFrame3(
|
|
3932
|
+
FrameType4.MSG,
|
|
3437
3933
|
encodeMsg({ target: { kind: "server" }, payload, typed: { index } })
|
|
3438
3934
|
);
|
|
3439
3935
|
deliver(core, wireTargetOf(target), frame);
|
|
3440
3936
|
}
|
|
3441
3937
|
|
|
3938
|
+
// src/core/replay.ts
|
|
3939
|
+
import {
|
|
3940
|
+
CLIP_MAX_BYTES,
|
|
3941
|
+
encodeClip
|
|
3942
|
+
} from "@irtio/protocol";
|
|
3943
|
+
import {
|
|
3944
|
+
encodeDelta as encodeDelta3,
|
|
3945
|
+
encodeSnapshot as encodeSnapshot3,
|
|
3946
|
+
isDirtyEmpty as isDirtyEmpty2
|
|
3947
|
+
} from "@irtio/schema";
|
|
3948
|
+
var REPLAY_DEFAULT_SECONDS = 60;
|
|
3949
|
+
var REPLAY_MAX_SECONDS = 300;
|
|
3950
|
+
var REPLAY_RING_MAX_BYTES = 8 * 1024 * 1024;
|
|
3951
|
+
var REPLAY_KEYFRAME_SECONDS = 2;
|
|
3952
|
+
var CLIP_COOLDOWN_MS = 1e3;
|
|
3953
|
+
var ReplayRing = class {
|
|
3954
|
+
constructor(bounds) {
|
|
3955
|
+
this.bounds = bounds;
|
|
3956
|
+
}
|
|
3957
|
+
bounds;
|
|
3958
|
+
items = [];
|
|
3959
|
+
total = 0;
|
|
3960
|
+
/** How many times an oversized segment has emptied the ring. Read through `append`'s return. */
|
|
3961
|
+
wipes = 0;
|
|
3962
|
+
/** The tick the newest keyframe went in at; `-1` while the ring is empty. */
|
|
3963
|
+
lastKeyframeTick = -1;
|
|
3964
|
+
get frames() {
|
|
3965
|
+
return this.items;
|
|
3966
|
+
}
|
|
3967
|
+
get bytes() {
|
|
3968
|
+
return this.total;
|
|
3969
|
+
}
|
|
3970
|
+
get empty() {
|
|
3971
|
+
return this.items.length === 0;
|
|
3972
|
+
}
|
|
3973
|
+
/** How many oversized segments have emptied the ring. A test's handle on the degenerate case. */
|
|
3974
|
+
get wipeCount() {
|
|
3975
|
+
return this.wipes;
|
|
3976
|
+
}
|
|
3977
|
+
/**
|
|
3978
|
+
* Appends a frame and trims. Returns `true` when the trim had to throw the *whole* ring away
|
|
3979
|
+
* (an oversized segment with nowhere to cut), which the caller has to know about: the ring is
|
|
3980
|
+
* now empty, so anything it was about to append next would head the ring without a keyframe.
|
|
3981
|
+
*/
|
|
3982
|
+
append(frame) {
|
|
3983
|
+
this.items.push(frame);
|
|
3984
|
+
this.total += frame.bytes.length;
|
|
3985
|
+
if (frame.keyframe) this.lastKeyframeTick = frame.tick;
|
|
3986
|
+
const before = this.wipes;
|
|
3987
|
+
this.trim();
|
|
3988
|
+
return this.wipes !== before;
|
|
3989
|
+
}
|
|
3990
|
+
/** Is a keyframe due at `tick`? Always true while the ring is empty. */
|
|
3991
|
+
keyframeDue(tick) {
|
|
3992
|
+
return this.empty || tick - this.lastKeyframeTick >= this.bounds.keyframeEvery;
|
|
3993
|
+
}
|
|
3994
|
+
/** The frames from the newest keyframe at or before `fromTick` to the end. */
|
|
3995
|
+
tail(fromTick) {
|
|
3996
|
+
let start = 0;
|
|
3997
|
+
for (let i = 0; i < this.items.length; i++) {
|
|
3998
|
+
const f = this.items[i];
|
|
3999
|
+
if (f.keyframe && f.tick <= fromTick) start = i;
|
|
4000
|
+
}
|
|
4001
|
+
return this.items.slice(start);
|
|
4002
|
+
}
|
|
4003
|
+
trim() {
|
|
4004
|
+
const last = this.items[this.items.length - 1];
|
|
4005
|
+
if (!last) return;
|
|
4006
|
+
for (; ; ) {
|
|
4007
|
+
const head = this.items[0];
|
|
4008
|
+
if (!head) return;
|
|
4009
|
+
const overBytes = this.total > this.bounds.maxBytes;
|
|
4010
|
+
const overWindow = last.tick - head.tick > this.bounds.windowTicks;
|
|
4011
|
+
if (!overBytes && !overWindow) return;
|
|
4012
|
+
let next = -1;
|
|
4013
|
+
for (let i = 1; i < this.items.length; i++) {
|
|
4014
|
+
if (this.items[i]?.keyframe === true) {
|
|
4015
|
+
next = i;
|
|
4016
|
+
break;
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
if (next === -1) {
|
|
4020
|
+
if (!overBytes) return;
|
|
4021
|
+
this.items = [];
|
|
4022
|
+
this.total = 0;
|
|
4023
|
+
this.lastKeyframeTick = -1;
|
|
4024
|
+
this.wipes++;
|
|
4025
|
+
return;
|
|
4026
|
+
}
|
|
4027
|
+
for (let i = 0; i < next; i++) this.total -= this.items[i].bytes.length;
|
|
4028
|
+
this.items = this.items.slice(next);
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
};
|
|
4032
|
+
var states5 = /* @__PURE__ */ new WeakMap();
|
|
4033
|
+
function configOf(core) {
|
|
4034
|
+
const config = core.definition.config;
|
|
4035
|
+
return config.replay;
|
|
4036
|
+
}
|
|
4037
|
+
function replayArmed(core) {
|
|
4038
|
+
return configOf(core) !== void 0;
|
|
4039
|
+
}
|
|
4040
|
+
function stateOf6(core) {
|
|
4041
|
+
const declared = configOf(core);
|
|
4042
|
+
if (declared === void 0) return void 0;
|
|
4043
|
+
let s = states5.get(core);
|
|
4044
|
+
if (!s) {
|
|
4045
|
+
const tickRate = core.definition.config.tickRate ?? 20;
|
|
4046
|
+
const seconds = Math.min(
|
|
4047
|
+
REPLAY_MAX_SECONDS,
|
|
4048
|
+
Math.max(1, Math.floor(declared.seconds ?? REPLAY_DEFAULT_SECONDS))
|
|
4049
|
+
);
|
|
4050
|
+
s = {
|
|
4051
|
+
seconds,
|
|
4052
|
+
view: declared.view ?? "",
|
|
4053
|
+
ring: new ReplayRing({
|
|
4054
|
+
windowTicks: Math.max(1, Math.ceil(seconds * tickRate)),
|
|
4055
|
+
keyframeEvery: Math.max(1, Math.round(REPLAY_KEYFRAME_SECONDS * tickRate)),
|
|
4056
|
+
maxBytes: REPLAY_RING_MAX_BYTES
|
|
4057
|
+
}),
|
|
4058
|
+
lastClipAt: Number.NEGATIVE_INFINITY,
|
|
4059
|
+
clipsTaken: 0,
|
|
4060
|
+
disarmed: false
|
|
4061
|
+
};
|
|
4062
|
+
states5.set(core, s);
|
|
4063
|
+
}
|
|
4064
|
+
return s;
|
|
4065
|
+
}
|
|
4066
|
+
function omniscientMemberships(core) {
|
|
4067
|
+
const out = /* @__PURE__ */ new Map();
|
|
4068
|
+
for (const desc of core.ext.collections) {
|
|
4069
|
+
if (desc.visibility !== "spatial-grid") continue;
|
|
4070
|
+
out.set(desc.name, new Set(plainEntity(core.plain, desc.name).ids()));
|
|
4071
|
+
}
|
|
4072
|
+
return out;
|
|
4073
|
+
}
|
|
4074
|
+
function hasRoleScoped(core) {
|
|
4075
|
+
return core.ext.collections.some((c) => c.visibility === "role");
|
|
4076
|
+
}
|
|
4077
|
+
function hasSpatial(core) {
|
|
4078
|
+
return core.ext.collections.some(
|
|
4079
|
+
(c) => c.visibility === "spatial-grid"
|
|
4080
|
+
);
|
|
4081
|
+
}
|
|
4082
|
+
function recorderSnapshot(core, state) {
|
|
4083
|
+
if (state.view === "") {
|
|
4084
|
+
return encodeSnapshot3(core.ext, core.plain, { tick: core.tick });
|
|
4085
|
+
}
|
|
4086
|
+
return encodeViewSnapshot(
|
|
4087
|
+
core.ext,
|
|
4088
|
+
core.plain,
|
|
4089
|
+
state.view,
|
|
4090
|
+
core.tick,
|
|
4091
|
+
omniscientMemberships(core)
|
|
4092
|
+
);
|
|
4093
|
+
}
|
|
4094
|
+
function recorderDelta(core, state, dirty) {
|
|
4095
|
+
if (state.view === "") {
|
|
4096
|
+
if (isDirtyEmpty2(dirty)) return null;
|
|
4097
|
+
return encodeDelta3(core.ext, core.plain, dirty, { tick: core.tick });
|
|
4098
|
+
}
|
|
4099
|
+
return encodeViewDelta(core.ext, core.plain, dirty, state.view, core.tick);
|
|
4100
|
+
}
|
|
4101
|
+
function cacheKeyFor(core, state) {
|
|
4102
|
+
if (hasSpatial(core)) return void 0;
|
|
4103
|
+
if (state.view === "") return hasRoleScoped(core) ? void 0 : "all";
|
|
4104
|
+
return viewKeyFor(core.ext, state.view);
|
|
4105
|
+
}
|
|
4106
|
+
function recordFlush(core, dirty, byView) {
|
|
4107
|
+
const state = stateOf6(core);
|
|
4108
|
+
if (!state || state.disarmed) return;
|
|
4109
|
+
const ring = state.ring;
|
|
4110
|
+
if (!ring.empty && isDirtyEmpty2(dirty)) return;
|
|
4111
|
+
if (ring.keyframeDue(core.tick)) {
|
|
4112
|
+
const wiped = ring.append({
|
|
4113
|
+
tick: core.tick,
|
|
4114
|
+
keyframe: true,
|
|
4115
|
+
bytes: recorderSnapshot(core, state)
|
|
4116
|
+
});
|
|
4117
|
+
if (wiped) {
|
|
4118
|
+
disarm(core, state);
|
|
4119
|
+
return;
|
|
4120
|
+
}
|
|
4121
|
+
}
|
|
4122
|
+
const key = cacheKeyFor(core, state);
|
|
4123
|
+
const cached = key === void 0 ? void 0 : byView.get(key);
|
|
4124
|
+
const delta = cached !== void 0 ? cached : recorderDelta(core, state, dirty);
|
|
4125
|
+
if (!delta) return;
|
|
4126
|
+
ring.append({ tick: core.tick, keyframe: false, bytes: delta });
|
|
4127
|
+
}
|
|
4128
|
+
function disarm(core, state) {
|
|
4129
|
+
state.disarmed = true;
|
|
4130
|
+
core.host.log("warn", [
|
|
4131
|
+
`irtio: room ${core.roomId}: a single replay keyframe is larger than the replay ring's ${REPLAY_RING_MAX_BYTES}-byte bound, so nothing could ever be held. Recording is off for the life of this room and room.replay.clip() will refuse. Record a narrower view (\`replay: { view: <role> }\`) or hold less state in the recorded collections.`
|
|
4132
|
+
]);
|
|
4133
|
+
}
|
|
4134
|
+
function assembleClip(core, seconds) {
|
|
4135
|
+
const state = stateOf6(core);
|
|
4136
|
+
if (!state || state.disarmed || state.ring.empty) return void 0;
|
|
4137
|
+
const tickRate = core.definition.config.tickRate ?? 20;
|
|
4138
|
+
const want = Math.min(state.seconds, Math.max(1, Math.floor(seconds ?? state.seconds)));
|
|
4139
|
+
const ringFrames = state.ring.frames;
|
|
4140
|
+
const last = ringFrames[ringFrames.length - 1];
|
|
4141
|
+
const frames = state.ring.tail(last.tick - Math.ceil(want * tickRate)).map((f) => ({
|
|
4142
|
+
tick: f.tick,
|
|
4143
|
+
kind: f.keyframe ? "keyframe" : "delta",
|
|
4144
|
+
bytes: f.bytes
|
|
4145
|
+
}));
|
|
4146
|
+
const head = frames[0];
|
|
4147
|
+
if (head === void 0 || head.kind !== "keyframe") return void 0;
|
|
4148
|
+
const clip = {
|
|
4149
|
+
// The GAME schema's hash, not the extended one's. That is what a client puts in its HELLO,
|
|
4150
|
+
// so it is the value playback can actually compare against — and it is the hash a deploy
|
|
4151
|
+
// changes, which is the whole thing the pin exists to catch.
|
|
4152
|
+
schemaHash8: core.definition.schema.hash8,
|
|
4153
|
+
tickRate,
|
|
4154
|
+
startTick: head.tick,
|
|
4155
|
+
durationTicks: last.tick - head.tick,
|
|
4156
|
+
view: state.view,
|
|
4157
|
+
frames
|
|
4158
|
+
};
|
|
4159
|
+
return encodeClip(clip);
|
|
4160
|
+
}
|
|
4161
|
+
function takeClip(core, seconds) {
|
|
4162
|
+
if (!replayArmed(core)) {
|
|
4163
|
+
return rejectAsEvent(
|
|
4164
|
+
core,
|
|
4165
|
+
new Error(
|
|
4166
|
+
"room.replay.clip: this room type did not declare `replay:`, so nothing is being recorded. Add `replay: { seconds: 60 }` to the room config to arm the ring."
|
|
4167
|
+
)
|
|
4168
|
+
);
|
|
4169
|
+
}
|
|
4170
|
+
const state = stateOf6(core);
|
|
4171
|
+
const now = core.host.now();
|
|
4172
|
+
if (now - state.lastClipAt < CLIP_COOLDOWN_MS) {
|
|
4173
|
+
return rejectAsEvent(
|
|
4174
|
+
core,
|
|
4175
|
+
new Error(
|
|
4176
|
+
`E_CLIP_COOLDOWN: room.replay.clip may be called once every ${CLIP_COOLDOWN_MS}ms per room; this call came too soon after the last one`
|
|
4177
|
+
)
|
|
4178
|
+
);
|
|
4179
|
+
}
|
|
4180
|
+
if (state.disarmed) {
|
|
4181
|
+
return rejectAsEvent(
|
|
4182
|
+
core,
|
|
4183
|
+
new Error(
|
|
4184
|
+
`E_CLIP_DISARMED: recording stopped in this room because a single replay keyframe was larger than the ${REPLAY_RING_MAX_BYTES}-byte replay ring; see the warning on this room's log`
|
|
4185
|
+
)
|
|
4186
|
+
);
|
|
4187
|
+
}
|
|
4188
|
+
let bytes;
|
|
4189
|
+
try {
|
|
4190
|
+
bytes = assembleClip(core, seconds);
|
|
4191
|
+
} catch (err) {
|
|
4192
|
+
return rejectAsEvent(
|
|
4193
|
+
core,
|
|
4194
|
+
new Error(`E_CLIP_ENCODE: the replay ring could not be encoded: ${String(err)}`)
|
|
4195
|
+
);
|
|
4196
|
+
}
|
|
4197
|
+
if (bytes === void 0) {
|
|
4198
|
+
return rejectAsEvent(
|
|
4199
|
+
core,
|
|
4200
|
+
new Error("E_CLIP_EMPTY: the replay ring is empty \u2014 this room has not flushed a change yet")
|
|
4201
|
+
);
|
|
4202
|
+
}
|
|
4203
|
+
if (bytes.length > CLIP_MAX_BYTES) {
|
|
4204
|
+
return rejectAsEvent(
|
|
4205
|
+
core,
|
|
4206
|
+
new Error(
|
|
4207
|
+
`E_CLIP_TOO_LARGE: this clip is ${bytes.length} bytes and the limit is ${CLIP_MAX_BYTES}`
|
|
4208
|
+
)
|
|
4209
|
+
);
|
|
4210
|
+
}
|
|
4211
|
+
state.lastClipAt = now;
|
|
4212
|
+
state.clipsTaken++;
|
|
4213
|
+
return startHostCall(core, { kind: "clip", bytes }, (value) => ({ id: value ?? "" }));
|
|
4214
|
+
}
|
|
4215
|
+
|
|
3442
4216
|
// src/core/room-api.ts
|
|
3443
4217
|
import {
|
|
3444
|
-
FrameType as
|
|
4218
|
+
FrameType as FrameType5,
|
|
3445
4219
|
busChannelProblem,
|
|
3446
4220
|
busPayloadProblem,
|
|
3447
|
-
encodeFrame as
|
|
4221
|
+
encodeFrame as encodeFrame4
|
|
3448
4222
|
} from "@irtio/protocol";
|
|
3449
|
-
import { encodeDelta as
|
|
4223
|
+
import { encodeDelta as encodeDelta4, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty3 } from "@irtio/schema";
|
|
3450
4224
|
function makePhysicsApi(p) {
|
|
3451
4225
|
return {
|
|
3452
4226
|
get rapier() {
|
|
@@ -3796,6 +4570,20 @@ function createRoomApi(core, publicUrl) {
|
|
|
3796
4570
|
get lobby() {
|
|
3797
4571
|
return makeLobby(core);
|
|
3798
4572
|
},
|
|
4573
|
+
// ---- M6 lane N: replays ----
|
|
4574
|
+
// D81: one getter, and deliberately only one. Everything replays do — the ring, the
|
|
4575
|
+
// eviction invariant, the recorder view, the clip assembly, the cooldown — lives in
|
|
4576
|
+
// `core/replay.ts` behind these two calls, so this file stays what it says it is: a facade
|
|
4577
|
+
// that reads through.
|
|
4578
|
+
get replay() {
|
|
4579
|
+
return {
|
|
4580
|
+
get armed() {
|
|
4581
|
+
return replayArmed(core);
|
|
4582
|
+
},
|
|
4583
|
+
clip: (seconds) => takeClip(core, seconds)
|
|
4584
|
+
};
|
|
4585
|
+
},
|
|
4586
|
+
// ---- end M6 lane N ----
|
|
3799
4587
|
alarm(name, atMs) {
|
|
3800
4588
|
if (!isAlarmName(core, name, "room.alarm")) return;
|
|
3801
4589
|
if (!Number.isFinite(atMs)) {
|
|
@@ -3878,10 +4666,10 @@ function setRole(core, clientId, role) {
|
|
|
3878
4666
|
}
|
|
3879
4667
|
if (entry.role === role) return;
|
|
3880
4668
|
const dirty = catchUpDirty(core.ext, core.plain, entry.role, role);
|
|
3881
|
-
if (!
|
|
4669
|
+
if (!isDirtyEmpty3(dirty)) {
|
|
3882
4670
|
core.send(
|
|
3883
4671
|
clientId,
|
|
3884
|
-
|
|
4672
|
+
encodeFrame4(FrameType5.DELTA, encodeDelta4(core.ext, core.plain, dirty, { tick: core.tick }))
|
|
3885
4673
|
);
|
|
3886
4674
|
}
|
|
3887
4675
|
entry.role = role;
|
|
@@ -3984,6 +4772,10 @@ var RoomCore = class _RoomCore {
|
|
|
3984
4772
|
};
|
|
3985
4773
|
tick = 0;
|
|
3986
4774
|
stopped = false;
|
|
4775
|
+
// ---- M6 lane K: stateful relay ----
|
|
4776
|
+
/** D77: the wall clock hold deadlines are measured against. See `RoomCoreOptions.wallNow`. */
|
|
4777
|
+
wallNow;
|
|
4778
|
+
// ---- end M6 lane K ----
|
|
3987
4779
|
/**
|
|
3988
4780
|
* Called for every handler throw `tryRun` swallows, before it is logged. A test harness sets
|
|
3989
4781
|
* this so a room that breaks fails the test that broke it (bug 2): the runtime's job is to
|
|
@@ -4060,6 +4852,9 @@ var RoomCore = class _RoomCore {
|
|
|
4060
4852
|
const historySetup = this.physics ? historyOf(definition.config.physics) : void 0;
|
|
4061
4853
|
this.rewindState = historySetup === void 0 ? void 0 : new RewindState(historySetup);
|
|
4062
4854
|
this.subscribeDeclaredChannels();
|
|
4855
|
+
installCaps(self, options.caps ?? {});
|
|
4856
|
+
this.wallNow = options.wallNow ?? (() => Date.now());
|
|
4857
|
+
restoreHolds(self, restored?.holds);
|
|
4063
4858
|
if (restored) {
|
|
4064
4859
|
const onWake = definition.config.onWake;
|
|
4065
4860
|
if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
|
|
@@ -4400,8 +5195,13 @@ var RoomCore = class _RoomCore {
|
|
|
4400
5195
|
mode: this.mode,
|
|
4401
5196
|
schemaHash8: this.ext.hash8
|
|
4402
5197
|
},
|
|
4403
|
-
|
|
4404
|
-
physics
|
|
5198
|
+
encodeSnapshot4(this.ext, this.plain, { tick: this.tick }),
|
|
5199
|
+
physics,
|
|
5200
|
+
// ---- M6 lane K: stateful relay ----
|
|
5201
|
+
// D77: hold deadlines are state, so they go where the state goes. `undefined` for a room
|
|
5202
|
+
// holding nothing, which keeps its blob byte-identical to the one it wrote before D77.
|
|
5203
|
+
serializeHolds(this.internals)
|
|
5204
|
+
// ---- end M6 lane K ----
|
|
4405
5205
|
);
|
|
4406
5206
|
}
|
|
4407
5207
|
serialize() {
|
|
@@ -4476,6 +5276,18 @@ var RoomCore = class _RoomCore {
|
|
|
4476
5276
|
// a key join `ctx.playerId` is exactly as durable as the resume token and no more. A JWT
|
|
4477
5277
|
// join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
|
|
4478
5278
|
// and nothing else here changes.
|
|
5279
|
+
//
|
|
5280
|
+
// ---- M6 lane K: stateful relay ----
|
|
5281
|
+
// An existing entry keeps the `playerId` it was created with: this is `existing ?? {...}`,
|
|
5282
|
+
// so a rejoin never overwrites it. That is deliberate and it is the safe direction — a
|
|
5283
|
+
// session's identity cannot be changed by anything a later HELLO on the same seat says. It
|
|
5284
|
+
// has one visible consequence, and D77's holds are where it shows: a client that joins
|
|
5285
|
+
// anonymously and presents an assertion later in the same session keeps the resume token's
|
|
5286
|
+
// client id as its `playerId`, so its holds stay bound to the token rather than moving to
|
|
5287
|
+
// the verified subject. Its next fresh join binds them to the subject. Said in
|
|
5288
|
+
// `authority-models.md` too, because a developer meets it as "my holds did not follow me to
|
|
5289
|
+
// the other tab" rather than as a line in this file.
|
|
5290
|
+
// ---- end M6 lane K ----
|
|
4479
5291
|
playerId: options.playerId ?? clientId,
|
|
4480
5292
|
npc: options.npc === true,
|
|
4481
5293
|
role,
|
|
@@ -4493,6 +5305,8 @@ var RoomCore = class _RoomCore {
|
|
|
4493
5305
|
entry.connected = true;
|
|
4494
5306
|
this.clients.set(clientId, entry);
|
|
4495
5307
|
this.invalidateClients();
|
|
5308
|
+
reclaimHolds(this.internals, clientId, entry.playerId);
|
|
5309
|
+
addPerPlayerRecords(this.internals, clientId);
|
|
4496
5310
|
const onJoin = this.definition.config.onJoin;
|
|
4497
5311
|
if (onJoin) {
|
|
4498
5312
|
const ctx = this.ctxFor(clientId, reconnecting);
|
|
@@ -4526,7 +5340,7 @@ var RoomCore = class _RoomCore {
|
|
|
4526
5340
|
/** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
|
|
4527
5341
|
eventFlush() {
|
|
4528
5342
|
if (this.mode !== "event" || this.stopped) return;
|
|
4529
|
-
if (
|
|
5343
|
+
if (isDirtyEmpty4(this.tracked.dirty)) return;
|
|
4530
5344
|
this.tick++;
|
|
4531
5345
|
this.flush();
|
|
4532
5346
|
this.captureTimeline();
|
|
@@ -4542,6 +5356,8 @@ var RoomCore = class _RoomCore {
|
|
|
4542
5356
|
const ctx = this.ctxFor(clientId);
|
|
4543
5357
|
this.guard("onLeave", () => onLeave(this.state, ctx, reason));
|
|
4544
5358
|
}
|
|
5359
|
+
placeHolds(this.internals, clientId, entry.playerId);
|
|
5360
|
+
forgetWriter(this.internals, clientId);
|
|
4545
5361
|
this.presence.remove(clientId);
|
|
4546
5362
|
this.recordEvent("leave", clientId, reason);
|
|
4547
5363
|
rejectPendingFor(this.internals, clientId, "client disconnected");
|
|
@@ -4675,6 +5491,16 @@ var RoomCore = class _RoomCore {
|
|
|
4675
5491
|
* `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
|
|
4676
5492
|
* `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
|
|
4677
5493
|
*/
|
|
5494
|
+
// ---- M6 lane K: stateful relay ----
|
|
5495
|
+
/** D77: the encoded state size, when a host set the state cap. See `caps.ts`. */
|
|
5496
|
+
stateBytes() {
|
|
5497
|
+
return stateBytesOf(this.internals);
|
|
5498
|
+
}
|
|
5499
|
+
/** D77: how many records are held for players who have left. */
|
|
5500
|
+
holdCount() {
|
|
5501
|
+
return holdCount(this.internals);
|
|
5502
|
+
}
|
|
5503
|
+
// ---- end M6 lane K ----
|
|
4678
5504
|
profile() {
|
|
4679
5505
|
return this.ledger?.snapshot();
|
|
4680
5506
|
}
|
|
@@ -4705,20 +5531,20 @@ var RoomCore = class _RoomCore {
|
|
|
4705
5531
|
return;
|
|
4706
5532
|
}
|
|
4707
5533
|
switch (type) {
|
|
4708
|
-
case
|
|
4709
|
-
case
|
|
4710
|
-
if (type ===
|
|
5534
|
+
case FrameType6.WRITE:
|
|
5535
|
+
case FrameType6.CALL: {
|
|
5536
|
+
if (type === FrameType6.WRITE) this.recordEvent("write", clientId);
|
|
4711
5537
|
if (this.mode === "tick") {
|
|
4712
5538
|
this.loop.enqueue({ clientId, type, payload: payload.slice() });
|
|
4713
5539
|
} else if (!this.loop.applyEvent({ clientId, type, payload })) {
|
|
4714
5540
|
this.badFrame(
|
|
4715
5541
|
clientId,
|
|
4716
|
-
`malformed ${type ===
|
|
5542
|
+
`malformed ${type === FrameType6.WRITE ? "WRITE" : "CALL"} payload`
|
|
4717
5543
|
);
|
|
4718
5544
|
}
|
|
4719
5545
|
return;
|
|
4720
5546
|
}
|
|
4721
|
-
case
|
|
5547
|
+
case FrameType6.REPLY: {
|
|
4722
5548
|
if (!handleReply(this.internals, clientId, payload)) {
|
|
4723
5549
|
this.badFrame(clientId, "malformed REPLY payload");
|
|
4724
5550
|
} else {
|
|
@@ -4726,7 +5552,7 @@ var RoomCore = class _RoomCore {
|
|
|
4726
5552
|
}
|
|
4727
5553
|
return;
|
|
4728
5554
|
}
|
|
4729
|
-
case
|
|
5555
|
+
case FrameType6.MSG: {
|
|
4730
5556
|
if (this.mode === "event") this.tick++;
|
|
4731
5557
|
const ok = handleMsg(this.internals, clientId, payload);
|
|
4732
5558
|
if (this.mode === "event") this.flush();
|
|
@@ -4744,6 +5570,7 @@ var RoomCore = class _RoomCore {
|
|
|
4744
5570
|
*/
|
|
4745
5571
|
flush() {
|
|
4746
5572
|
evaluateLobby(this.internals);
|
|
5573
|
+
expireHolds(this.internals);
|
|
4747
5574
|
const dirty = this.tracked.flush();
|
|
4748
5575
|
serverWinsCorrections(this.internals, dirty);
|
|
4749
5576
|
this.ledger?.newFlush();
|
|
@@ -4801,14 +5628,14 @@ var RoomCore = class _RoomCore {
|
|
|
4801
5628
|
let shared;
|
|
4802
5629
|
if (entry.pendingJoinAdds) {
|
|
4803
5630
|
const sharedDirty = sharedViewDirty(this.ext, sourceDirty, entry.role);
|
|
4804
|
-
shared =
|
|
5631
|
+
shared = isDirtyEmpty4(sharedDirty) ? null : encodeDelta5(this.ext, this.plain, sharedDirty, { tick: this.tick });
|
|
4805
5632
|
if (shared) this.stats.encodesLastFlush++;
|
|
4806
5633
|
} else {
|
|
4807
5634
|
const key = viewKeyFor(this.ext, entry.role);
|
|
4808
5635
|
let cached = byView.get(key);
|
|
4809
5636
|
if (cached === void 0) {
|
|
4810
5637
|
const sharedDirty = sharedViewDirty(this.ext, dirty, entry.role);
|
|
4811
|
-
cached =
|
|
5638
|
+
cached = isDirtyEmpty4(sharedDirty) ? null : encodeDelta5(this.ext, this.plain, sharedDirty, { tick: this.tick });
|
|
4812
5639
|
if (cached) this.stats.encodesLastFlush++;
|
|
4813
5640
|
byView.set(key, cached);
|
|
4814
5641
|
}
|
|
@@ -4817,7 +5644,7 @@ var RoomCore = class _RoomCore {
|
|
|
4817
5644
|
if (shared) {
|
|
4818
5645
|
this.send(
|
|
4819
5646
|
entry.clientId,
|
|
4820
|
-
|
|
5647
|
+
encodeFrame5(FrameType6.DELTA, shared),
|
|
4821
5648
|
this.ledger ? { shared } : void 0
|
|
4822
5649
|
);
|
|
4823
5650
|
}
|
|
@@ -4828,7 +5655,7 @@ var RoomCore = class _RoomCore {
|
|
|
4828
5655
|
memberships
|
|
4829
5656
|
);
|
|
4830
5657
|
const encodeStarted = performance.now();
|
|
4831
|
-
delta =
|
|
5658
|
+
delta = isDirtyEmpty4(aoiDirty) ? null : encodeDelta5(this.ext, this.plain, aoiDirty, { tick: this.tick });
|
|
4832
5659
|
this.stats.aoiEncodeMs += performance.now() - encodeStarted;
|
|
4833
5660
|
if (delta) {
|
|
4834
5661
|
this.stats.encodesLastFlush++;
|
|
@@ -4859,7 +5686,7 @@ var RoomCore = class _RoomCore {
|
|
|
4859
5686
|
if (delta) {
|
|
4860
5687
|
this.send(
|
|
4861
5688
|
entry.clientId,
|
|
4862
|
-
|
|
5689
|
+
encodeFrame5(FrameType6.DELTA, delta),
|
|
4863
5690
|
this.ledger ? {
|
|
4864
5691
|
...churn ? { churn } : {},
|
|
4865
5692
|
...spatialDescs.length > 0 ? {} : { shared: delta }
|
|
@@ -4870,6 +5697,7 @@ var RoomCore = class _RoomCore {
|
|
|
4870
5697
|
entry.correction = void 0;
|
|
4871
5698
|
entry.accepted.clear();
|
|
4872
5699
|
}
|
|
5700
|
+
recordFlush(this.internals, dirty, byView);
|
|
4873
5701
|
}
|
|
4874
5702
|
};
|
|
4875
5703
|
function encodeSection(physics) {
|
|
@@ -4885,6 +5713,11 @@ function encodeSection(physics) {
|
|
|
4885
5713
|
export {
|
|
4886
5714
|
HOST_CALL_TIMEOUT_MS,
|
|
4887
5715
|
RoomFullError,
|
|
5716
|
+
RELAY_STATE_BYTES,
|
|
5717
|
+
RELAY_RECORDS_PER_COLLECTION,
|
|
5718
|
+
RELAY_WRITES_PER_SECOND,
|
|
5719
|
+
DEFAULT_RECORDS_PER_COLLECTION,
|
|
5720
|
+
RELAY_WRITE_CAPS,
|
|
4888
5721
|
EVENT_RING_SIZE,
|
|
4889
5722
|
inspectState,
|
|
4890
5723
|
RPC_TIMEOUT_MS,
|