@irtio/runtime 0.10.1 → 1.0.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-EENJ37KK.js → chunk-LT46QFLW.js} +1626 -108
- package/dist/{chunk-WNAEE4EP.js → chunk-YWDRE77H.js} +1 -1
- package/dist/{contract-C5aqs49-.d.ts → contract-UEKTud1Z.d.ts} +145 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +12 -2
- package/dist/{room-Db3iPBKM.d.ts → room-uhj3b-8E.d.ts} +32 -2
- package/dist/test/index.d.ts +32 -38
- package/dist/test/index.js +77 -4
- package/dist/worker/index.d.ts +37 -3
- package/dist/worker/index.js +34 -3
- 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,20 @@ function inspectState(schema, plain) {
|
|
|
39
214
|
|
|
40
215
|
// src/core/rpc.ts
|
|
41
216
|
import {
|
|
42
|
-
|
|
217
|
+
ErrorCode as ErrorCode2,
|
|
218
|
+
FrameType as FrameType2,
|
|
219
|
+
OWNERSHIP_ASK,
|
|
43
220
|
decodeCall,
|
|
44
221
|
decodeReply,
|
|
45
222
|
encodeCall,
|
|
46
|
-
encodeFrame,
|
|
223
|
+
encodeFrame as encodeFrame2,
|
|
47
224
|
encodeReply,
|
|
225
|
+
isReservedRpcName,
|
|
48
226
|
rpcByIdOf,
|
|
49
227
|
rpcTable
|
|
50
228
|
} from "@irtio/protocol";
|
|
51
229
|
import {
|
|
52
|
-
SERVER_OWNER,
|
|
230
|
+
SERVER_OWNER as SERVER_OWNER2,
|
|
53
231
|
decodeFields,
|
|
54
232
|
encodeFields,
|
|
55
233
|
validateValue
|
|
@@ -81,15 +259,279 @@ function deepEqual(a, b) {
|
|
|
81
259
|
return true;
|
|
82
260
|
}
|
|
83
261
|
|
|
262
|
+
// src/core/ownership.ts
|
|
263
|
+
import { SERVER_OWNER, defaultRecord } from "@irtio/schema";
|
|
264
|
+
var ASK_WINDOW_MS = 5e3;
|
|
265
|
+
var states2 = /* @__PURE__ */ new WeakMap();
|
|
266
|
+
function stateOf2(core) {
|
|
267
|
+
let s = states2.get(core);
|
|
268
|
+
if (!s) {
|
|
269
|
+
s = { holds: /* @__PURE__ */ new Map(), asks: /* @__PURE__ */ new Map(), askUnknownWarned: false };
|
|
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
|
+
);
|
|
281
|
+
}
|
|
282
|
+
function wallNow(core) {
|
|
283
|
+
return core.wallNow();
|
|
284
|
+
}
|
|
285
|
+
function holdOn(core, collection, id) {
|
|
286
|
+
return states2.get(core)?.holds.get(keyOf(collection, id));
|
|
287
|
+
}
|
|
288
|
+
function holdCount(core) {
|
|
289
|
+
return states2.get(core)?.holds.size ?? 0;
|
|
290
|
+
}
|
|
291
|
+
function placeHolds(core, clientId, playerId) {
|
|
292
|
+
const s = stateOf2(core);
|
|
293
|
+
const now = wallNow(core);
|
|
294
|
+
for (const c of policyCollections(core)) {
|
|
295
|
+
const policy = c.ownership;
|
|
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);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function reclaimHolds(core, clientId, playerId) {
|
|
318
|
+
const s = states2.get(core);
|
|
319
|
+
if (!s || s.holds.size === 0) return 0;
|
|
320
|
+
let reclaimed = 0;
|
|
321
|
+
for (const [key, hold] of [...s.holds]) {
|
|
322
|
+
if (hold.holder !== playerId) continue;
|
|
323
|
+
s.holds.delete(key);
|
|
324
|
+
const coll = plainEntity(core.plain, hold.collection);
|
|
325
|
+
if (!coll.has(hold.id)) continue;
|
|
326
|
+
trackedEntity(core, hold.collection).setOwner(hold.id, clientId);
|
|
327
|
+
reclaimed++;
|
|
328
|
+
}
|
|
329
|
+
return reclaimed;
|
|
330
|
+
}
|
|
331
|
+
function expireHolds(core) {
|
|
332
|
+
const s = states2.get(core);
|
|
333
|
+
if (!s || s.holds.size === 0) return;
|
|
334
|
+
const now = wallNow(core);
|
|
335
|
+
for (const [key, hold] of [...s.holds]) {
|
|
336
|
+
if (hold.deadlineMs > now) continue;
|
|
337
|
+
s.holds.delete(key);
|
|
338
|
+
act(core, hold);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function act(core, hold) {
|
|
342
|
+
const coll = plainEntity(core.plain, hold.collection);
|
|
343
|
+
if (!coll.has(hold.id)) return;
|
|
344
|
+
const tracked = trackedEntity(core, hold.collection);
|
|
345
|
+
switch (hold.then) {
|
|
346
|
+
case "remove":
|
|
347
|
+
tracked.remove(hold.id);
|
|
348
|
+
return;
|
|
349
|
+
case "release":
|
|
350
|
+
tracked.setOwner(hold.id, SERVER_OWNER);
|
|
351
|
+
return;
|
|
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;
|
|
366
|
+
}
|
|
367
|
+
default:
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function connectedClients(core) {
|
|
372
|
+
const out = [];
|
|
373
|
+
for (const entry of core.clients.values()) if (entry.connected) out.push(entry.clientId);
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
function numberAt(record, field) {
|
|
377
|
+
if (!record) return void 0;
|
|
378
|
+
const v = record[field];
|
|
379
|
+
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
380
|
+
}
|
|
381
|
+
function distanceTo(core, policy, object, clientId) {
|
|
382
|
+
const position = policy.position;
|
|
383
|
+
if (!position || !object) return void 0;
|
|
384
|
+
const players = plainEntity(core.plain, position.players);
|
|
385
|
+
const player = players.get(clientId);
|
|
386
|
+
const px = numberAt(player, position.x);
|
|
387
|
+
const py = numberAt(player, position.y);
|
|
388
|
+
const ox = numberAt(object, position.x);
|
|
389
|
+
const oy = numberAt(object, position.y);
|
|
390
|
+
if (px === void 0 || py === void 0 || ox === void 0 || oy === void 0)
|
|
391
|
+
return void 0;
|
|
392
|
+
return Math.hypot(px - ox, py - oy);
|
|
393
|
+
}
|
|
394
|
+
function nearestConnected(core, collection, id) {
|
|
395
|
+
const c = core.ext.collections.find((x) => x.name === collection);
|
|
396
|
+
const policy = c?.ownership;
|
|
397
|
+
if (!policy?.position) return void 0;
|
|
398
|
+
const object = plainEntity(core.plain, collection).get(id);
|
|
399
|
+
let best;
|
|
400
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
401
|
+
for (const clientId of connectedClients(core)) {
|
|
402
|
+
const d = distanceTo(core, policy, object, clientId);
|
|
403
|
+
if (d === void 0 || d >= bestDistance) continue;
|
|
404
|
+
bestDistance = d;
|
|
405
|
+
best = clientId;
|
|
406
|
+
}
|
|
407
|
+
return best;
|
|
408
|
+
}
|
|
409
|
+
function addPerPlayerRecords(core, clientId) {
|
|
410
|
+
for (const c of core.ext.collections) {
|
|
411
|
+
if (c.kind !== "entity" || !c.perPlayer) continue;
|
|
412
|
+
const coll = plainEntity(core.plain, c.name);
|
|
413
|
+
if (coll.has(clientId)) {
|
|
414
|
+
if (coll.ownerOf(clientId) !== clientId) {
|
|
415
|
+
trackedEntity(core, c.name).setOwner(clientId, clientId);
|
|
416
|
+
}
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
trackedEntity(core, c.name).add(clientId, defaultRecord(c), { owner: clientId });
|
|
421
|
+
} catch (err) {
|
|
422
|
+
core.log("error", `perPlayer add on ${c.name} failed for ${clientId}:`, err);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function askOn(core, collection, id) {
|
|
427
|
+
return states2.get(core)?.asks.get(keyOf(collection, id));
|
|
428
|
+
}
|
|
429
|
+
function trackAsk(core, ask) {
|
|
430
|
+
stateOf2(core).asks.set(keyOf(ask.collection, ask.id), ask);
|
|
431
|
+
}
|
|
432
|
+
function clearAsk(core, collection, id) {
|
|
433
|
+
states2.get(core)?.asks.delete(keyOf(collection, id));
|
|
434
|
+
}
|
|
435
|
+
function asksInFlight(core) {
|
|
436
|
+
const s = states2.get(core);
|
|
437
|
+
return s ? [...s.asks.values()] : [];
|
|
438
|
+
}
|
|
439
|
+
function askCount(core) {
|
|
440
|
+
return states2.get(core)?.asks.size ?? 0;
|
|
441
|
+
}
|
|
442
|
+
function warnSdkPredatesAsk(core, clientId) {
|
|
443
|
+
const s = stateOf2(core);
|
|
444
|
+
if (s.askUnknownWarned) return;
|
|
445
|
+
s.askUnknownWarned = true;
|
|
446
|
+
core.log("warn", `client ${clientId}'s SDK predates $ownership.ask; ask auto-declined`);
|
|
447
|
+
}
|
|
448
|
+
function grantOwnership(core, collection, id, clientId) {
|
|
449
|
+
trackedEntity(core, collection).setOwner(id, clientId);
|
|
450
|
+
}
|
|
451
|
+
function resolveTransfer(core, clientId, c, id) {
|
|
452
|
+
const policy = c.ownership;
|
|
453
|
+
const coll = plainEntity(core.plain, c.name);
|
|
454
|
+
const owner = coll.ownerOf(id);
|
|
455
|
+
if (owner === clientId) return "granted";
|
|
456
|
+
const hold = holdOn(core, c.name, id);
|
|
457
|
+
if (hold) {
|
|
458
|
+
const playerId = core.clients.get(clientId)?.playerId ?? clientId;
|
|
459
|
+
if (hold.holder !== playerId) return "refused";
|
|
460
|
+
reclaimHolds(core, clientId, playerId);
|
|
461
|
+
return coll.ownerOf(id) === clientId ? "granted" : "refused";
|
|
462
|
+
}
|
|
463
|
+
switch (policy.transfer) {
|
|
464
|
+
case "never":
|
|
465
|
+
return "refused";
|
|
466
|
+
case "free":
|
|
467
|
+
grantOwnership(core, c.name, id, clientId);
|
|
468
|
+
return "granted";
|
|
469
|
+
case "closer": {
|
|
470
|
+
if (owner === void 0 || owner === SERVER_OWNER) {
|
|
471
|
+
grantOwnership(core, c.name, id, clientId);
|
|
472
|
+
return "granted";
|
|
473
|
+
}
|
|
474
|
+
const object = coll.get(id);
|
|
475
|
+
const ownerDistance = distanceTo(core, policy, object, owner);
|
|
476
|
+
if (ownerDistance === void 0) {
|
|
477
|
+
grantOwnership(core, c.name, id, clientId);
|
|
478
|
+
return "granted";
|
|
479
|
+
}
|
|
480
|
+
const mine = distanceTo(core, policy, object, clientId);
|
|
481
|
+
if (mine === void 0) return "refused";
|
|
482
|
+
if (mine + policy.by >= ownerDistance) return "refused";
|
|
483
|
+
grantOwnership(core, c.name, id, clientId);
|
|
484
|
+
return "granted";
|
|
485
|
+
}
|
|
486
|
+
// ---- M6 ask packet ----
|
|
487
|
+
case "ask": {
|
|
488
|
+
if (owner === void 0 || owner === SERVER_OWNER) {
|
|
489
|
+
grantOwnership(core, c.name, id, clientId);
|
|
490
|
+
return "granted";
|
|
491
|
+
}
|
|
492
|
+
if (core.clients.get(owner)?.connected !== true) return "gone";
|
|
493
|
+
return "pending";
|
|
494
|
+
}
|
|
495
|
+
// ---- end M6 ask packet ----
|
|
496
|
+
default:
|
|
497
|
+
return "refused";
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function serializeHolds(core) {
|
|
501
|
+
const s = states2.get(core);
|
|
502
|
+
if (!s || s.holds.size === 0) return void 0;
|
|
503
|
+
const json = JSON.stringify([...s.holds.values()]);
|
|
504
|
+
return new TextEncoder().encode(json);
|
|
505
|
+
}
|
|
506
|
+
function restoreHolds(core, bytes) {
|
|
507
|
+
const s = stateOf2(core);
|
|
508
|
+
s.holds.clear();
|
|
509
|
+
if (!bytes || bytes.length === 0) return;
|
|
510
|
+
try {
|
|
511
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
512
|
+
for (const h of parsed) {
|
|
513
|
+
if (typeof h?.collection !== "string" || typeof h.id !== "string" || typeof h.holder !== "string" || typeof h.deadlineMs !== "number") {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
s.holds.set(keyOf(h.collection, h.id), h);
|
|
517
|
+
}
|
|
518
|
+
} catch (err) {
|
|
519
|
+
core.log("warn", "hold deadlines did not decode; the room wakes holding nothing:", err);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
84
523
|
// src/core/rpc.ts
|
|
85
524
|
var RPC_TIMEOUT_MS = 5e3;
|
|
86
525
|
var REQUEST_OWNERSHIP = "requestOwnership";
|
|
87
|
-
var
|
|
88
|
-
|
|
89
|
-
|
|
526
|
+
var RpcTimeoutError = class extends Error {
|
|
527
|
+
};
|
|
528
|
+
var DEFERRED = /* @__PURE__ */ Symbol("deferred reply");
|
|
529
|
+
var states3 = /* @__PURE__ */ new WeakMap();
|
|
530
|
+
function stateOf3(core) {
|
|
531
|
+
let s = states3.get(core);
|
|
90
532
|
if (!s) {
|
|
91
533
|
s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
|
|
92
|
-
|
|
534
|
+
states3.set(core, s);
|
|
93
535
|
}
|
|
94
536
|
return s;
|
|
95
537
|
}
|
|
@@ -101,7 +543,7 @@ function errorText(code, detail) {
|
|
|
101
543
|
}
|
|
102
544
|
function sendReply(core, clientId, reqId, result, error) {
|
|
103
545
|
const payload = result === null ? encodeReply({ reqId, ok: false, error: error ?? errorText("E_INTERNAL") }) : encodeReply({ reqId, ok: true, result });
|
|
104
|
-
core.send(clientId,
|
|
546
|
+
core.send(clientId, encodeFrame2(FrameType2.REPLY, payload));
|
|
105
547
|
}
|
|
106
548
|
function handleCall(core, clientId, payload) {
|
|
107
549
|
let call;
|
|
@@ -119,6 +561,16 @@ function handleCall(core, clientId, payload) {
|
|
|
119
561
|
sendReply(core, clientId, call.reqId, null, errorText("E_RPC_UNKNOWN", `rpcId ${call.rpcId}`));
|
|
120
562
|
return true;
|
|
121
563
|
}
|
|
564
|
+
if (isReservedRpcName(desc.name)) {
|
|
565
|
+
sendReply(
|
|
566
|
+
core,
|
|
567
|
+
clientId,
|
|
568
|
+
call.reqId,
|
|
569
|
+
null,
|
|
570
|
+
errorText("E_RPC_REJECTED", `${desc.name} is reserved by irtio`)
|
|
571
|
+
);
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
122
574
|
if (desc.direction !== "server") {
|
|
123
575
|
sendReply(
|
|
124
576
|
core,
|
|
@@ -146,7 +598,8 @@ function handleCall(core, clientId, payload) {
|
|
|
146
598
|
const ctx = { ...core.ctxFor(clientId), clientTick: call.clientTick || void 0 };
|
|
147
599
|
let result;
|
|
148
600
|
if (desc.name === REQUEST_OWNERSHIP) {
|
|
149
|
-
result = runRequestOwnership(core, clientId, ctx, params);
|
|
601
|
+
result = runRequestOwnership(core, clientId, ctx, params, call.reqId, desc);
|
|
602
|
+
if (result === DEFERRED) return true;
|
|
150
603
|
} else {
|
|
151
604
|
const impl = core.definition.config.rpc?.[desc.name];
|
|
152
605
|
if (!impl) {
|
|
@@ -175,29 +628,126 @@ function handleCall(core, clientId, payload) {
|
|
|
175
628
|
}
|
|
176
629
|
return true;
|
|
177
630
|
}
|
|
178
|
-
function runRequestOwnership(core, clientId, ctx, params) {
|
|
631
|
+
function runRequestOwnership(core, clientId, ctx, params, reqId, desc) {
|
|
179
632
|
const name = String(params.entity ?? "");
|
|
180
633
|
const id = String(params.id ?? "");
|
|
181
634
|
const c = collectionDescOf(core.ext, name);
|
|
182
|
-
if (!c || c.kind !== "entity" || c.serverOwned) return
|
|
635
|
+
if (!c || c.kind !== "entity" || c.serverOwned) return refused("gone");
|
|
183
636
|
const plain = plainEntity(core.plain, name);
|
|
184
|
-
if (!plain.has(id)) return
|
|
637
|
+
if (!plain.has(id)) return refused("gone");
|
|
185
638
|
const handler = core.definition.config.onOwnershipRequest;
|
|
186
639
|
const tracked = trackedEntity(core, name);
|
|
640
|
+
if (!handler && c.ownership) {
|
|
641
|
+
if (askOn(core, name, id)) return refused("pending");
|
|
642
|
+
const verdict = resolveTransfer(core, clientId, c, id);
|
|
643
|
+
if (verdict === "pending") return beginAsk(core, clientId, name, id, reqId, desc);
|
|
644
|
+
if (verdict === "gone") return refused("gone");
|
|
645
|
+
return verdict === "granted" ? granted() : refused("");
|
|
646
|
+
}
|
|
187
647
|
if (handler) {
|
|
188
648
|
const ran = core.tryRun("onOwnershipRequest", () => handler(core.anyState, name, id, ctx));
|
|
189
649
|
if (ran.ok && ran.value === true && plain.ownerOf(id) !== clientId) {
|
|
190
650
|
tracked.setOwner(id, clientId);
|
|
191
651
|
}
|
|
192
|
-
return
|
|
652
|
+
return plain.ownerOf(id) === clientId ? granted() : refused("");
|
|
193
653
|
}
|
|
194
654
|
const owner = plain.ownerOf(id);
|
|
195
|
-
if (owner === clientId) return
|
|
196
|
-
if (owner !==
|
|
655
|
+
if (owner === clientId) return granted();
|
|
656
|
+
if (owner !== SERVER_OWNER2) return refused("");
|
|
197
657
|
tracked.setOwner(id, clientId);
|
|
198
|
-
return
|
|
658
|
+
return granted();
|
|
659
|
+
}
|
|
660
|
+
function granted() {
|
|
661
|
+
return { granted: true, reason: "" };
|
|
662
|
+
}
|
|
663
|
+
function refused(reason) {
|
|
664
|
+
return { granted: false, reason };
|
|
665
|
+
}
|
|
666
|
+
var UNKNOWN_RPC_PREFIX = ErrorCode2.E_RPC_UNKNOWN.message.split("{")[0] ?? "";
|
|
667
|
+
function isUnknownRpcReply(err) {
|
|
668
|
+
return err.message.startsWith(UNKNOWN_RPC_PREFIX) || err.message.startsWith("E_RPC_UNKNOWN");
|
|
669
|
+
}
|
|
670
|
+
function replyToAsker(core, clientId, reqId, desc, result) {
|
|
671
|
+
if (!desc.returns) {
|
|
672
|
+
sendReply(core, clientId, reqId, new Uint8Array(0));
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
sendReply(core, clientId, reqId, encodeFields(desc.returns, result));
|
|
676
|
+
}
|
|
677
|
+
function beginAsk(core, asker, collection, id, askerReqId, askerDesc) {
|
|
678
|
+
const owner = plainEntity(core.plain, collection).ownerOf(id);
|
|
679
|
+
if (owner === void 0 || owner === SERVER_OWNER2) return refused("gone");
|
|
680
|
+
const askDesc = rpcTable(core.definition.schema).find((r) => r.name === OWNERSHIP_ASK);
|
|
681
|
+
if (!askDesc) {
|
|
682
|
+
core.log("error", `${OWNERSHIP_ASK} is missing from the rpc table; the ask cannot be sent`);
|
|
683
|
+
return refused("declined");
|
|
684
|
+
}
|
|
685
|
+
let settled = false;
|
|
686
|
+
const answer = (grant, reason) => {
|
|
687
|
+
if (settled) return;
|
|
688
|
+
settled = true;
|
|
689
|
+
clearAsk(core, collection, id);
|
|
690
|
+
if (grant) grantOwnership(core, collection, id, asker);
|
|
691
|
+
replyToAsker(core, asker, askerReqId, askerDesc, grant ? granted() : refused(reason));
|
|
692
|
+
};
|
|
693
|
+
const ownerReqId = callClient(
|
|
694
|
+
core,
|
|
695
|
+
owner,
|
|
696
|
+
askDesc,
|
|
697
|
+
{ entity: collection, id, from: asker },
|
|
698
|
+
{
|
|
699
|
+
timeoutMs: ASK_WINDOW_MS,
|
|
700
|
+
resolve: (value) => {
|
|
701
|
+
const say = value?.granted === true;
|
|
702
|
+
if (!say) {
|
|
703
|
+
answer(false, "declined");
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const coll = plainEntity(core.plain, collection);
|
|
707
|
+
const stillTheirs = coll.has(id) && coll.ownerOf(id) === owner;
|
|
708
|
+
const askerHere = core.clients.get(asker)?.connected === true;
|
|
709
|
+
if (!stillTheirs || !askerHere) {
|
|
710
|
+
answer(false, "gone");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
answer(true, "");
|
|
714
|
+
},
|
|
715
|
+
reject: (err) => {
|
|
716
|
+
if (isUnknownRpcReply(err)) warnSdkPredatesAsk(core, owner);
|
|
717
|
+
answer(false, err instanceof RpcTimeoutError ? "timeout" : "declined");
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
);
|
|
721
|
+
if (settled) return DEFERRED;
|
|
722
|
+
const ask = {
|
|
723
|
+
collection,
|
|
724
|
+
id,
|
|
725
|
+
asker,
|
|
726
|
+
owner,
|
|
727
|
+
ownerReqId,
|
|
728
|
+
deadlineMs: core.host.now() + ASK_WINDOW_MS,
|
|
729
|
+
answer
|
|
730
|
+
};
|
|
731
|
+
trackAsk(core, ask);
|
|
732
|
+
return DEFERRED;
|
|
733
|
+
}
|
|
734
|
+
function cancelAsksFor(core, clientId) {
|
|
735
|
+
for (const ask of asksInFlight(core)) {
|
|
736
|
+
if (ask.asker !== clientId && ask.owner !== clientId) continue;
|
|
737
|
+
stateOf3(core).pending.delete(pendingKey(ask.owner, ask.ownerReqId));
|
|
738
|
+
ask.answer(false, "declined");
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function cancelAllAsks(core) {
|
|
742
|
+
for (const ask of asksInFlight(core)) {
|
|
743
|
+
stateOf3(core).pending.delete(pendingKey(ask.owner, ask.ownerReqId));
|
|
744
|
+
ask.answer(false, "declined");
|
|
745
|
+
}
|
|
199
746
|
}
|
|
200
747
|
function descFor(core, name) {
|
|
748
|
+
if (isReservedRpcName(name)) {
|
|
749
|
+
throw new Error(`room.call: ${name} is reserved by irtio and cannot be called from room code`);
|
|
750
|
+
}
|
|
201
751
|
const found = rpcTable(core.definition.schema).find((r) => r.name === name);
|
|
202
752
|
if (!found) throw new Error(`room.call: unknown rpc ${JSON.stringify(name)}`);
|
|
203
753
|
if (found.direction !== "client") {
|
|
@@ -206,16 +756,38 @@ function descFor(core, name) {
|
|
|
206
756
|
return found;
|
|
207
757
|
}
|
|
208
758
|
function sendCall(core, clientId, desc, params) {
|
|
209
|
-
const s =
|
|
759
|
+
const s = stateOf3(core);
|
|
210
760
|
const reqId = s.nextReqId;
|
|
211
761
|
s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
|
|
212
762
|
const bytes = encodeFields(desc.params, params);
|
|
213
763
|
core.send(
|
|
214
764
|
clientId,
|
|
215
|
-
|
|
765
|
+
encodeFrame2(FrameType2.CALL, encodeCall({ reqId, rpcId: desc.index, params: bytes }))
|
|
216
766
|
);
|
|
217
767
|
return reqId;
|
|
218
768
|
}
|
|
769
|
+
function callClient(core, clientId, desc, params, settle) {
|
|
770
|
+
const entry = core.clients.get(clientId);
|
|
771
|
+
if (!entry || !entry.connected) {
|
|
772
|
+
core.scheduleContinuationFlush();
|
|
773
|
+
settle.reject(new Error(`room.call: ${clientId} is not connected`));
|
|
774
|
+
return 0;
|
|
775
|
+
}
|
|
776
|
+
const reqId = sendCall(core, clientId, desc, params);
|
|
777
|
+
const s = stateOf3(core);
|
|
778
|
+
s.pending.set(pendingKey(clientId, reqId), {
|
|
779
|
+
clientId,
|
|
780
|
+
name: desc.name,
|
|
781
|
+
returns: desc.returns,
|
|
782
|
+
deadline: core.host.now() + settle.timeoutMs,
|
|
783
|
+
resolve: settle.resolve,
|
|
784
|
+
reject: settle.reject
|
|
785
|
+
});
|
|
786
|
+
if (core.mode === "event") {
|
|
787
|
+
core.loop.after(settle.timeoutMs, () => checkTimeouts(core));
|
|
788
|
+
}
|
|
789
|
+
return reqId;
|
|
790
|
+
}
|
|
219
791
|
function createCallProxy(core, clientId) {
|
|
220
792
|
return new Proxy(
|
|
221
793
|
{},
|
|
@@ -225,25 +797,11 @@ function createCallProxy(core, clientId) {
|
|
|
225
797
|
return (params) => {
|
|
226
798
|
const desc = descFor(core, prop);
|
|
227
799
|
return new Promise((resolve, reject2) => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
core.scheduleContinuationFlush();
|
|
231
|
-
reject2(new Error(`room.call: ${clientId} is not connected`));
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
const reqId = sendCall(core, clientId, desc, params ?? {});
|
|
235
|
-
const s = stateOf(core);
|
|
236
|
-
s.pending.set(pendingKey(clientId, reqId), {
|
|
237
|
-
clientId,
|
|
238
|
-
name: desc.name,
|
|
239
|
-
returns: desc.returns,
|
|
240
|
-
deadline: core.host.now() + RPC_TIMEOUT_MS,
|
|
800
|
+
callClient(core, clientId, desc, params ?? {}, {
|
|
801
|
+
timeoutMs: RPC_TIMEOUT_MS,
|
|
241
802
|
resolve,
|
|
242
803
|
reject: reject2
|
|
243
804
|
});
|
|
244
|
-
if (core.mode === "event") {
|
|
245
|
-
core.loop.after(RPC_TIMEOUT_MS, () => checkTimeouts(core));
|
|
246
|
-
}
|
|
247
805
|
});
|
|
248
806
|
};
|
|
249
807
|
}
|
|
@@ -280,7 +838,7 @@ function handleReply(core, clientId, payload) {
|
|
|
280
838
|
core.log("warn", `REPLY from ${clientId} failed to decode:`, err);
|
|
281
839
|
return false;
|
|
282
840
|
}
|
|
283
|
-
const s =
|
|
841
|
+
const s = stateOf3(core);
|
|
284
842
|
const key = pendingKey(clientId, reply.reqId);
|
|
285
843
|
const pending = s.pending.get(key);
|
|
286
844
|
if (!pending) {
|
|
@@ -304,7 +862,7 @@ function handleReply(core, clientId, payload) {
|
|
|
304
862
|
return true;
|
|
305
863
|
}
|
|
306
864
|
function rejectPendingFor(core, clientId, reason) {
|
|
307
|
-
const s =
|
|
865
|
+
const s = stateOf3(core);
|
|
308
866
|
for (const [key, p] of [...s.pending]) {
|
|
309
867
|
if (p.clientId !== clientId) continue;
|
|
310
868
|
s.pending.delete(key);
|
|
@@ -312,17 +870,17 @@ function rejectPendingFor(core, clientId, reason) {
|
|
|
312
870
|
}
|
|
313
871
|
}
|
|
314
872
|
function checkTimeouts(core) {
|
|
315
|
-
const s =
|
|
873
|
+
const s = stateOf3(core);
|
|
316
874
|
if (s.pending.size === 0) return;
|
|
317
875
|
const now = core.host.now();
|
|
318
876
|
for (const [key, p] of [...s.pending]) {
|
|
319
877
|
if (p.deadline > now) continue;
|
|
320
878
|
s.pending.delete(key);
|
|
321
|
-
p.reject(new
|
|
879
|
+
p.reject(new RpcTimeoutError(errorText("E_RPC_TIMEOUT", `rpc ${p.name} timed out`)));
|
|
322
880
|
}
|
|
323
881
|
}
|
|
324
882
|
function rejectAllPending(core, reason) {
|
|
325
|
-
const s =
|
|
883
|
+
const s = stateOf3(core);
|
|
326
884
|
for (const [key, p] of [...s.pending]) {
|
|
327
885
|
s.pending.delete(key);
|
|
328
886
|
p.reject(new Error(reason));
|
|
@@ -330,15 +888,15 @@ function rejectAllPending(core, reason) {
|
|
|
330
888
|
}
|
|
331
889
|
|
|
332
890
|
// src/core/loop.ts
|
|
333
|
-
import { FrameType as
|
|
891
|
+
import { FrameType as FrameType3 } from "@irtio/protocol";
|
|
334
892
|
|
|
335
893
|
// src/core/host-calls.ts
|
|
336
|
-
var
|
|
337
|
-
function
|
|
338
|
-
let s =
|
|
894
|
+
var states4 = /* @__PURE__ */ new WeakMap();
|
|
895
|
+
function stateOf4(core) {
|
|
896
|
+
let s = states4.get(core);
|
|
339
897
|
if (!s) {
|
|
340
898
|
s = { nextReqId: 1, pending: /* @__PURE__ */ new Map() };
|
|
341
|
-
|
|
899
|
+
states4.set(core, s);
|
|
342
900
|
}
|
|
343
901
|
return s;
|
|
344
902
|
}
|
|
@@ -346,12 +904,16 @@ function rejectAsEvent(core, err) {
|
|
|
346
904
|
core.scheduleContinuationFlush();
|
|
347
905
|
return Promise.reject(err);
|
|
348
906
|
}
|
|
907
|
+
function resolveAsEvent(core, value) {
|
|
908
|
+
core.scheduleContinuationFlush();
|
|
909
|
+
return Promise.resolve(value);
|
|
910
|
+
}
|
|
349
911
|
function startHostCall(core, call, map) {
|
|
350
912
|
if (core.stopped) {
|
|
351
913
|
return rejectAsEvent(core, new Error(`room.${call.kind}: the room is stopped`));
|
|
352
914
|
}
|
|
353
915
|
return new Promise((resolve, reject2) => {
|
|
354
|
-
const s =
|
|
916
|
+
const s = stateOf4(core);
|
|
355
917
|
const reqId = s.nextReqId;
|
|
356
918
|
s.nextReqId = s.nextReqId + 1 >>> 0 || 1;
|
|
357
919
|
s.pending.set(reqId, {
|
|
@@ -375,7 +937,7 @@ function startHostCall(core, call, map) {
|
|
|
375
937
|
});
|
|
376
938
|
}
|
|
377
939
|
function completeHostCall(core, reqId, result) {
|
|
378
|
-
const s =
|
|
940
|
+
const s = stateOf4(core);
|
|
379
941
|
const pending = s.pending.get(reqId);
|
|
380
942
|
if (!pending) return false;
|
|
381
943
|
s.pending.delete(reqId);
|
|
@@ -384,7 +946,7 @@ function completeHostCall(core, reqId, result) {
|
|
|
384
946
|
return true;
|
|
385
947
|
}
|
|
386
948
|
function checkHostCallTimeouts(core) {
|
|
387
|
-
const s =
|
|
949
|
+
const s = stateOf4(core);
|
|
388
950
|
if (s.pending.size === 0) return;
|
|
389
951
|
const now = core.host.now();
|
|
390
952
|
for (const [reqId, p] of [...s.pending]) {
|
|
@@ -394,7 +956,7 @@ function checkHostCallTimeouts(core) {
|
|
|
394
956
|
}
|
|
395
957
|
}
|
|
396
958
|
function rejectAllHostCalls(core, reason) {
|
|
397
|
-
const s =
|
|
959
|
+
const s = stateOf4(core);
|
|
398
960
|
for (const [reqId, p] of [...s.pending]) {
|
|
399
961
|
s.pending.delete(reqId);
|
|
400
962
|
p.reject(new Error(reason));
|
|
@@ -403,7 +965,7 @@ function rejectAllHostCalls(core, reason) {
|
|
|
403
965
|
|
|
404
966
|
// src/core/writes.ts
|
|
405
967
|
import {
|
|
406
|
-
SERVER_OWNER as
|
|
968
|
+
SERVER_OWNER as SERVER_OWNER3,
|
|
407
969
|
cloneValue,
|
|
408
970
|
decodeDelta,
|
|
409
971
|
encodeDelta,
|
|
@@ -493,6 +1055,9 @@ function applyWrite(core, clientId, payload) {
|
|
|
493
1055
|
core.log("warn", `WRITE from ${clientId} failed to decode:`, err);
|
|
494
1056
|
return { ok: false };
|
|
495
1057
|
}
|
|
1058
|
+
let ops = 0;
|
|
1059
|
+
for (const dc of delta.collections) ops += dc.ops.length;
|
|
1060
|
+
if (!admitWrite(core, clientId, core.host.now(), ops)) return { ok: true };
|
|
496
1061
|
const judged = core.clients.get(clientId);
|
|
497
1062
|
if (judged && delta.tick > judged.lastClientTick) {
|
|
498
1063
|
judged.lastClientTick = delta.tick;
|
|
@@ -513,8 +1078,19 @@ function reject(core, clientId, c, op, reason) {
|
|
|
513
1078
|
correctLeaves(core, clientId, c.name, op.id, leafPaths(fieldList(c), op.mask));
|
|
514
1079
|
}
|
|
515
1080
|
function applyOp(core, clientId, c, op) {
|
|
516
|
-
if (op.op
|
|
517
|
-
|
|
1081
|
+
if (op.op === "add" || op.op === "remove") {
|
|
1082
|
+
if (c.kind !== "entity" || c.serverOwned || !c.clientCreate) {
|
|
1083
|
+
reject(
|
|
1084
|
+
core,
|
|
1085
|
+
clientId,
|
|
1086
|
+
c,
|
|
1087
|
+
op,
|
|
1088
|
+
`op '${op.op}' is not allowed in a WRITE (${c.name} does not declare clientCreate)`
|
|
1089
|
+
);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
if (op.op === "add") applyAdd(core, clientId, c, op);
|
|
1093
|
+
else applyRemove(core, clientId, c, op);
|
|
518
1094
|
return;
|
|
519
1095
|
}
|
|
520
1096
|
if (c.kind !== "entity") {
|
|
@@ -537,26 +1113,28 @@ function applyOp(core, clientId, c, op) {
|
|
|
537
1113
|
return;
|
|
538
1114
|
}
|
|
539
1115
|
const fields = fieldList(c);
|
|
1116
|
+
const growth = updateGrowth(c, leafPaths(fields, op.mask));
|
|
1117
|
+
if (growth > 0 && !admitGrowth(core, clientId, growth)) return;
|
|
540
1118
|
const prev = cloneValue(current);
|
|
541
1119
|
const next = cloneValue(current);
|
|
542
1120
|
applyMaskedPatch(fields, next, op.patch, op.mask);
|
|
543
1121
|
const requested = cloneValue(next);
|
|
544
1122
|
const simulated = c.physics;
|
|
545
1123
|
if (simulated) {
|
|
546
|
-
const
|
|
1124
|
+
const refused2 = [];
|
|
547
1125
|
for (const leaf of leafPaths(fields, op.mask)) {
|
|
548
1126
|
const field = leaf.names[0];
|
|
549
1127
|
if (simulated.intents.includes(field)) continue;
|
|
550
1128
|
next[field] = cloneValue(prev[field]);
|
|
551
|
-
|
|
1129
|
+
refused2.push(
|
|
552
1130
|
simulated.bodyFields.has(field) ? `${field} (simulated)` : `${field} (not an intent)`
|
|
553
1131
|
);
|
|
554
1132
|
}
|
|
555
|
-
if (
|
|
556
|
-
core.recordEvent("write-rejected", clientId, `${c.name}: ${
|
|
1133
|
+
if (refused2.length > 0) {
|
|
1134
|
+
core.recordEvent("write-rejected", clientId, `${c.name}: ${refused2.join(", ")}`);
|
|
557
1135
|
core.log(
|
|
558
1136
|
"warn",
|
|
559
|
-
`WRITE to ${c.name}.${op.id} from ${clientId} touched fields the owner does not write: ${
|
|
1137
|
+
`WRITE to ${c.name}.${op.id} from ${clientId} touched fields the owner does not write: ${refused2.join(", ")}`
|
|
560
1138
|
);
|
|
561
1139
|
}
|
|
562
1140
|
}
|
|
@@ -594,6 +1172,66 @@ function applyOp(core, clientId, c, op) {
|
|
|
594
1172
|
);
|
|
595
1173
|
correctLeaves(core, clientId, c.name, op.id, corrected);
|
|
596
1174
|
}
|
|
1175
|
+
function applyAdd(core, clientId, c, op) {
|
|
1176
|
+
const plainColl = plainEntity(core.plain, c.name);
|
|
1177
|
+
if (plainColl.has(op.id)) {
|
|
1178
|
+
reject(core, clientId, c, op, `id ${JSON.stringify(op.id)} already exists`);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const cap = recordCap(core);
|
|
1182
|
+
if (plainColl.size >= cap) {
|
|
1183
|
+
refuseByName(core, clientId, "E_RECORD_CAP", { entity: c.name, limit: cap });
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (!admitGrowth(core, clientId, maxRecordBytes(c))) return;
|
|
1187
|
+
try {
|
|
1188
|
+
trackedEntity(core, c.name).add(op.id, op.value, { owner: clientId });
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
reject(core, clientId, c, op, `add rejected: ${String(err)}`);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
core.recordEvent("write", clientId, `${c.name}: added ${op.id}`);
|
|
1194
|
+
}
|
|
1195
|
+
function applyRemove(core, clientId, c, op) {
|
|
1196
|
+
const plainColl = plainEntity(core.plain, c.name);
|
|
1197
|
+
if (!plainColl.has(op.id)) return;
|
|
1198
|
+
if (plainColl.ownerOf(op.id) !== clientId) {
|
|
1199
|
+
reject(core, clientId, c, op, "not the owner");
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
trackedEntity(core, c.name).remove(op.id);
|
|
1203
|
+
core.recordEvent("write", clientId, `${c.name}: removed ${op.id}`);
|
|
1204
|
+
}
|
|
1205
|
+
function updateGrowth(c, leaves) {
|
|
1206
|
+
let growth = 0;
|
|
1207
|
+
for (const leaf of leaves) {
|
|
1208
|
+
const first = leaf.path[0];
|
|
1209
|
+
if (first === void 0) continue;
|
|
1210
|
+
const field = c.fields[first];
|
|
1211
|
+
if (!field) continue;
|
|
1212
|
+
const bound = maxTypeBytes(field.type);
|
|
1213
|
+
if (isFixedWidth(field.type)) continue;
|
|
1214
|
+
growth += bound;
|
|
1215
|
+
}
|
|
1216
|
+
return growth;
|
|
1217
|
+
}
|
|
1218
|
+
function isFixedWidth(t) {
|
|
1219
|
+
switch (t.kind) {
|
|
1220
|
+
case "bool":
|
|
1221
|
+
case "u8":
|
|
1222
|
+
case "u16":
|
|
1223
|
+
case "u32":
|
|
1224
|
+
case "i32":
|
|
1225
|
+
case "f32":
|
|
1226
|
+
case "f64":
|
|
1227
|
+
case "enum":
|
|
1228
|
+
return t.opt !== true;
|
|
1229
|
+
case "struct":
|
|
1230
|
+
return Object.values(t.fields).every(isFixedWidth) && t.opt !== true;
|
|
1231
|
+
default:
|
|
1232
|
+
return false;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
597
1235
|
function serverWinsCorrections(core, dirty) {
|
|
598
1236
|
const ext = core.ext;
|
|
599
1237
|
for (const [name, cd] of dirty) {
|
|
@@ -604,7 +1242,7 @@ function serverWinsCorrections(core, dirty) {
|
|
|
604
1242
|
const fields = fieldList(c);
|
|
605
1243
|
for (const [id, rd] of cd.updated) {
|
|
606
1244
|
const owner = coll.ownerOf(id);
|
|
607
|
-
if (owner === void 0 || owner ===
|
|
1245
|
+
if (owner === void 0 || owner === SERVER_OWNER3) continue;
|
|
608
1246
|
const entry = core.clients.get(owner);
|
|
609
1247
|
if (!entry || !entry.connected) continue;
|
|
610
1248
|
const value = coll.get(id);
|
|
@@ -700,8 +1338,8 @@ var Loop = class {
|
|
|
700
1338
|
}
|
|
701
1339
|
/** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
|
|
702
1340
|
applyFrame(f) {
|
|
703
|
-
if (f.type ===
|
|
704
|
-
if (f.type ===
|
|
1341
|
+
if (f.type === FrameType3.WRITE) return applyWrite(this.core, f.clientId, f.payload).ok;
|
|
1342
|
+
if (f.type === FrameType3.CALL) return handleCall(this.core, f.clientId, f.payload);
|
|
705
1343
|
return true;
|
|
706
1344
|
}
|
|
707
1345
|
// -------------------------------------------------------------------------
|
|
@@ -2088,7 +2726,7 @@ import {
|
|
|
2088
2726
|
createDirtySet,
|
|
2089
2727
|
createFieldMask,
|
|
2090
2728
|
encodeDelta as encodeDelta2,
|
|
2091
|
-
encodeSnapshot,
|
|
2729
|
+
encodeSnapshot as encodeSnapshot2,
|
|
2092
2730
|
filterDirty,
|
|
2093
2731
|
isDirtyEmpty,
|
|
2094
2732
|
markAdd,
|
|
@@ -2195,7 +2833,7 @@ function viewKeyFor(ext, role) {
|
|
|
2195
2833
|
return infoOf(ext).scoped.has(role) ? role : "all";
|
|
2196
2834
|
}
|
|
2197
2835
|
function encodeViewSnapshot(ext, plain, role, tick, memberships) {
|
|
2198
|
-
return
|
|
2836
|
+
return encodeSnapshot2(
|
|
2199
2837
|
ext,
|
|
2200
2838
|
plain,
|
|
2201
2839
|
{ tick },
|
|
@@ -2325,7 +2963,7 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
|
|
|
2325
2963
|
import { withBuiltins } from "@irtio/protocol";
|
|
2326
2964
|
import { ByteReader as ByteReader3, ByteWriter as ByteWriter3, decodeSnapshot } from "@irtio/schema";
|
|
2327
2965
|
var SNAPSHOT_FORMAT_VERSION = 3;
|
|
2328
|
-
var READABLE_SNAPSHOT_VERSIONS = [1, 2, 3];
|
|
2966
|
+
var READABLE_SNAPSHOT_VERSIONS = [1, 2, 3, 4];
|
|
2329
2967
|
function parseHibernationBlob(bytes) {
|
|
2330
2968
|
const r = new ByteReader3(bytes);
|
|
2331
2969
|
const version = r.u8();
|
|
@@ -2342,7 +2980,12 @@ function parseHibernationBlob(bytes) {
|
|
|
2342
2980
|
const section = r.blob();
|
|
2343
2981
|
if (section.length > 0) physics = section;
|
|
2344
2982
|
}
|
|
2345
|
-
|
|
2983
|
+
let holds;
|
|
2984
|
+
if (version >= 4) {
|
|
2985
|
+
const section = r.blob();
|
|
2986
|
+
if (section.length > 0) holds = section;
|
|
2987
|
+
}
|
|
2988
|
+
return { version, seed, rngState, tick, mode, schemaHash8, physics, holds, snapshot: r.rest() };
|
|
2346
2989
|
}
|
|
2347
2990
|
function sameSchemaHash(a, b) {
|
|
2348
2991
|
if (!a || !b) return true;
|
|
@@ -2350,10 +2993,10 @@ function sameSchemaHash(a, b) {
|
|
|
2350
2993
|
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
2351
2994
|
return true;
|
|
2352
2995
|
}
|
|
2353
|
-
function writeHibernationBlob(header, snapshot, physics) {
|
|
2996
|
+
function writeHibernationBlob(header, snapshot, physics, holds) {
|
|
2354
2997
|
const hash = header.schemaHash8;
|
|
2355
|
-
const version = hash ? 3 : physics ? 2 : 1;
|
|
2356
|
-
const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) +
|
|
2998
|
+
const version = holds && holds.length > 0 ? 4 : hash ? 3 : physics ? 2 : 1;
|
|
2999
|
+
const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + (holds?.length ?? 0) + 40);
|
|
2357
3000
|
w.u8(version);
|
|
2358
3001
|
w.u32(header.seed >>> 0);
|
|
2359
3002
|
w.u32(header.rngState >>> 0);
|
|
@@ -2361,6 +3004,7 @@ function writeHibernationBlob(header, snapshot, physics) {
|
|
|
2361
3004
|
w.u8(header.mode === "event" ? 1 : 0);
|
|
2362
3005
|
if (hash) w.bytes(hash);
|
|
2363
3006
|
if (version >= 2) w.blob(physics ?? new Uint8Array(0));
|
|
3007
|
+
if (version >= 4) w.blob(holds ?? new Uint8Array(0));
|
|
2364
3008
|
w.bytes(snapshot);
|
|
2365
3009
|
return w.finish();
|
|
2366
3010
|
}
|
|
@@ -2434,23 +3078,23 @@ var TimelineRecorder = class {
|
|
|
2434
3078
|
|
|
2435
3079
|
// src/core/room.ts
|
|
2436
3080
|
import {
|
|
2437
|
-
FrameType as
|
|
3081
|
+
FrameType as FrameType6,
|
|
2438
3082
|
PRESENCE_COLLECTION,
|
|
2439
3083
|
ProfileLedger,
|
|
2440
3084
|
busChannelProblem as busChannelProblem2,
|
|
2441
3085
|
decodeFrame,
|
|
2442
3086
|
encodeCorrectFrame,
|
|
2443
|
-
encodeFrame as
|
|
3087
|
+
encodeFrame as encodeFrame5,
|
|
2444
3088
|
withBuiltins as withBuiltins2
|
|
2445
3089
|
} from "@irtio/protocol";
|
|
2446
3090
|
import {
|
|
2447
3091
|
cloneValue as cloneValue2,
|
|
2448
3092
|
createDirtySet as createDirtySet2,
|
|
2449
3093
|
createState,
|
|
2450
|
-
decodeSnapshot as
|
|
2451
|
-
encodeDelta as
|
|
2452
|
-
encodeSnapshot as
|
|
2453
|
-
isDirtyEmpty as
|
|
3094
|
+
decodeSnapshot as decodeSnapshot3,
|
|
3095
|
+
encodeDelta as encodeDelta5,
|
|
3096
|
+
encodeSnapshot as encodeSnapshot4,
|
|
3097
|
+
isDirtyEmpty as isDirtyEmpty4,
|
|
2454
3098
|
track,
|
|
2455
3099
|
validateForDeploy
|
|
2456
3100
|
} from "@irtio/schema";
|
|
@@ -2555,7 +3199,7 @@ var PoseHistory = class {
|
|
|
2555
3199
|
const scratch = spec.scratch;
|
|
2556
3200
|
scratch.fill(0);
|
|
2557
3201
|
try {
|
|
2558
|
-
spec.read(instance, scratch);
|
|
3202
|
+
spec.read(instance, scratch, id);
|
|
2559
3203
|
entry.channels.set(scratch, o2);
|
|
2560
3204
|
} catch (err) {
|
|
2561
3205
|
if (!this.hookFailed.has(collection)) {
|
|
@@ -2709,7 +3353,7 @@ function historyOf(config) {
|
|
|
2709
3353
|
}
|
|
2710
3354
|
return { depth, channels };
|
|
2711
3355
|
}
|
|
2712
|
-
function
|
|
3356
|
+
function keyOf2(collection, id) {
|
|
2713
3357
|
return `${collection} ${id}`;
|
|
2714
3358
|
}
|
|
2715
3359
|
function angleOf(qz, qw) {
|
|
@@ -2734,7 +3378,7 @@ var BaseScratch = class {
|
|
|
2734
3378
|
this.sync();
|
|
2735
3379
|
this.present.clear();
|
|
2736
3380
|
for (let i = 0; i < entry.count; i++) {
|
|
2737
|
-
const key =
|
|
3381
|
+
const key = keyOf2(entry.collections[i], entry.ids[i]);
|
|
2738
3382
|
const e = this.bodies.get(key);
|
|
2739
3383
|
if (!e) continue;
|
|
2740
3384
|
poseAt(entry, i, this.pose);
|
|
@@ -2760,7 +3404,7 @@ var BaseScratch = class {
|
|
|
2760
3404
|
const now = this.tickNow();
|
|
2761
3405
|
const seen = /* @__PURE__ */ new Set();
|
|
2762
3406
|
this.physics.eachTrackedBody((collection, id, body) => {
|
|
2763
|
-
const key =
|
|
3407
|
+
const key = keyOf2(collection, id);
|
|
2764
3408
|
seen.add(key);
|
|
2765
3409
|
const signature = this.signatureOf(body);
|
|
2766
3410
|
const existing = this.bodies.get(key);
|
|
@@ -3205,13 +3849,13 @@ function lobbyConfigOf(core) {
|
|
|
3205
3849
|
if (!schemaHasLobby(core.definition.schema)) return void 0;
|
|
3206
3850
|
return declared;
|
|
3207
3851
|
}
|
|
3208
|
-
function
|
|
3852
|
+
function stateOf5(core) {
|
|
3209
3853
|
return core.anyState[LOBBY_STATE];
|
|
3210
3854
|
}
|
|
3211
3855
|
function evaluateLobby(core) {
|
|
3212
3856
|
const config = lobbyConfigOf(core);
|
|
3213
3857
|
if (config === void 0) return;
|
|
3214
|
-
const state =
|
|
3858
|
+
const state = stateOf5(core);
|
|
3215
3859
|
if (state === void 0) return;
|
|
3216
3860
|
const members = trackedEntity(core, LOBBY_MEMBERS);
|
|
3217
3861
|
const policy = config.start ?? "when-full";
|
|
@@ -3250,7 +3894,7 @@ function evaluateLobby(core) {
|
|
|
3250
3894
|
function startLobby(core, why) {
|
|
3251
3895
|
const runtime = runtimeOf(core);
|
|
3252
3896
|
if (runtime.started) return;
|
|
3253
|
-
const state =
|
|
3897
|
+
const state = stateOf5(core);
|
|
3254
3898
|
if (state === void 0 || state.phase === "started") {
|
|
3255
3899
|
runtime.started = true;
|
|
3256
3900
|
return;
|
|
@@ -3273,7 +3917,7 @@ function makeLobby(core) {
|
|
|
3273
3917
|
"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
3918
|
);
|
|
3275
3919
|
}
|
|
3276
|
-
const state =
|
|
3920
|
+
const state = stateOf5(core);
|
|
3277
3921
|
const members = trackedEntity(core, LOBBY_MEMBERS);
|
|
3278
3922
|
return {
|
|
3279
3923
|
get phase() {
|
|
@@ -3311,7 +3955,7 @@ function makeLobby(core) {
|
|
|
3311
3955
|
function setLobbyPublic(core, value) {
|
|
3312
3956
|
const config = lobbyConfigOf(core);
|
|
3313
3957
|
if (config === void 0) return;
|
|
3314
|
-
const state =
|
|
3958
|
+
const state = stateOf5(core);
|
|
3315
3959
|
if (state === void 0) return;
|
|
3316
3960
|
if (state.phase === "started") {
|
|
3317
3961
|
core.log(
|
|
@@ -3330,7 +3974,15 @@ function setLobbyPublic(core, value) {
|
|
|
3330
3974
|
}
|
|
3331
3975
|
|
|
3332
3976
|
// src/core/messages.ts
|
|
3333
|
-
import {
|
|
3977
|
+
import {
|
|
3978
|
+
FrameType as FrameType4,
|
|
3979
|
+
decodeChatMsg,
|
|
3980
|
+
decodeMsg,
|
|
3981
|
+
encodeChatMsg,
|
|
3982
|
+
encodeFrame as encodeFrame3,
|
|
3983
|
+
encodeMsg,
|
|
3984
|
+
isChatMsg
|
|
3985
|
+
} from "@irtio/protocol";
|
|
3334
3986
|
import { decodeFields as decodeFields2 } from "@irtio/schema";
|
|
3335
3987
|
function toRoomTarget(target) {
|
|
3336
3988
|
switch (target.kind) {
|
|
@@ -3378,6 +4030,7 @@ function decodeTyped(core, clientId, index, payload) {
|
|
|
3378
4030
|
}
|
|
3379
4031
|
function handleMsg(core, clientId, payload) {
|
|
3380
4032
|
core.recordEvent("msg", clientId);
|
|
4033
|
+
if (isChatMsg(payload)) return handleChat(core, clientId, payload);
|
|
3381
4034
|
let msg;
|
|
3382
4035
|
try {
|
|
3383
4036
|
msg = decodeMsg(payload);
|
|
@@ -3410,8 +4063,8 @@ function handleMsg(core, clientId, payload) {
|
|
|
3410
4063
|
if (!ran.ok || ran.value === false) return true;
|
|
3411
4064
|
}
|
|
3412
4065
|
if (msg.target.kind === "server") return true;
|
|
3413
|
-
const frame =
|
|
3414
|
-
|
|
4066
|
+
const frame = encodeFrame3(
|
|
4067
|
+
FrameType4.MSG,
|
|
3415
4068
|
encodeMsg({
|
|
3416
4069
|
target: { kind: "client", clientId },
|
|
3417
4070
|
payload: msg.payload,
|
|
@@ -3421,32 +4074,785 @@ function handleMsg(core, clientId, payload) {
|
|
|
3421
4074
|
deliver(core, msg.target, frame, clientId);
|
|
3422
4075
|
return true;
|
|
3423
4076
|
}
|
|
4077
|
+
function handleChat(core, clientId, payload) {
|
|
4078
|
+
let chat;
|
|
4079
|
+
try {
|
|
4080
|
+
chat = decodeChatMsg(payload);
|
|
4081
|
+
} catch (err) {
|
|
4082
|
+
core.log("warn", `chat from ${clientId} failed to decode:`, err);
|
|
4083
|
+
return false;
|
|
4084
|
+
}
|
|
4085
|
+
if (chat.target.kind !== "all" && chat.target.kind !== "client") {
|
|
4086
|
+
core.stats.messagesDropped++;
|
|
4087
|
+
return true;
|
|
4088
|
+
}
|
|
4089
|
+
const onChat = core.definition.config.onChat;
|
|
4090
|
+
if (onChat) {
|
|
4091
|
+
const line = {
|
|
4092
|
+
from: clientId,
|
|
4093
|
+
text: chat.text,
|
|
4094
|
+
target: chat.target.kind === "all" ? "all" : chat.target.clientId
|
|
4095
|
+
};
|
|
4096
|
+
const ran = core.tryRun("onChat", () => onChat(core.anyState, line, core.ctxFor(clientId)));
|
|
4097
|
+
if (!ran.ok || ran.value === false) return true;
|
|
4098
|
+
}
|
|
4099
|
+
const frame = encodeFrame3(
|
|
4100
|
+
FrameType4.MSG,
|
|
4101
|
+
encodeChatMsg({ target: { kind: "client", clientId }, text: chat.text })
|
|
4102
|
+
);
|
|
4103
|
+
deliver(core, chat.target, frame, clientId);
|
|
4104
|
+
return true;
|
|
4105
|
+
}
|
|
3424
4106
|
function wireTargetOf(target) {
|
|
3425
4107
|
return target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
|
|
3426
4108
|
}
|
|
3427
4109
|
function sendMessage(core, target, bytes) {
|
|
3428
|
-
const frame =
|
|
3429
|
-
|
|
4110
|
+
const frame = encodeFrame3(
|
|
4111
|
+
FrameType4.MSG,
|
|
3430
4112
|
encodeMsg({ target: { kind: "server" }, payload: bytes })
|
|
3431
4113
|
);
|
|
3432
4114
|
deliver(core, wireTargetOf(target), frame);
|
|
3433
4115
|
}
|
|
3434
4116
|
function sendTypedMessage(core, index, target, payload) {
|
|
3435
|
-
const frame =
|
|
3436
|
-
|
|
4117
|
+
const frame = encodeFrame3(
|
|
4118
|
+
FrameType4.MSG,
|
|
3437
4119
|
encodeMsg({ target: { kind: "server" }, payload, typed: { index } })
|
|
3438
4120
|
);
|
|
3439
4121
|
deliver(core, wireTargetOf(target), frame);
|
|
3440
4122
|
}
|
|
3441
4123
|
|
|
4124
|
+
// src/core/replay.ts
|
|
4125
|
+
import {
|
|
4126
|
+
CLIP_MAX_BYTES,
|
|
4127
|
+
CLIP_TTL_DEFAULT_MS,
|
|
4128
|
+
RECORDING_MAX_BYTES,
|
|
4129
|
+
clipExpiryEpochS,
|
|
4130
|
+
encodeClip,
|
|
4131
|
+
encodeRecordingChunk,
|
|
4132
|
+
parseRetention
|
|
4133
|
+
} from "@irtio/protocol";
|
|
4134
|
+
import {
|
|
4135
|
+
encodeDelta as encodeDelta3,
|
|
4136
|
+
encodeSnapshot as encodeSnapshot3,
|
|
4137
|
+
isDirtyEmpty as isDirtyEmpty2
|
|
4138
|
+
} from "@irtio/schema";
|
|
4139
|
+
|
|
4140
|
+
// src/core/replay-excerpt.ts
|
|
4141
|
+
import {
|
|
4142
|
+
applyDelta,
|
|
4143
|
+
decodeDelta as decodeDelta2,
|
|
4144
|
+
decodeSnapshot as decodeSnapshot2
|
|
4145
|
+
} from "@irtio/schema";
|
|
4146
|
+
var EXCERPT_MAX_SECONDS = 30;
|
|
4147
|
+
var EXCERPT_BURST = 4;
|
|
4148
|
+
var EXCERPT_COOLDOWN_MS = 1e3;
|
|
4149
|
+
var EXCERPT_MAX_BYTES = 4 * 1024 * 1024;
|
|
4150
|
+
var ExcerptTooLargeError = class extends Error {
|
|
4151
|
+
constructor(bytes) {
|
|
4152
|
+
super(`excerpt output passed ${EXCERPT_MAX_BYTES} bytes`);
|
|
4153
|
+
this.bytes = bytes;
|
|
4154
|
+
this.name = "ExcerptTooLargeError";
|
|
4155
|
+
}
|
|
4156
|
+
bytes;
|
|
4157
|
+
};
|
|
4158
|
+
function estimateValue(value) {
|
|
4159
|
+
if (typeof value === "string") return value.length + 2;
|
|
4160
|
+
if (Array.isArray(value)) {
|
|
4161
|
+
let n = 2;
|
|
4162
|
+
for (const v of value) n += estimateValue(v) + 1;
|
|
4163
|
+
return n;
|
|
4164
|
+
}
|
|
4165
|
+
if (typeof value === "object" && value !== null) {
|
|
4166
|
+
let n = 2;
|
|
4167
|
+
for (const [k, v] of Object.entries(value)) {
|
|
4168
|
+
n += k.length + 3 + estimateValue(v);
|
|
4169
|
+
}
|
|
4170
|
+
return n;
|
|
4171
|
+
}
|
|
4172
|
+
return 8;
|
|
4173
|
+
}
|
|
4174
|
+
function copyValue(value) {
|
|
4175
|
+
if (Array.isArray(value)) return value.map(copyValue);
|
|
4176
|
+
if (typeof value === "object" && value !== null) {
|
|
4177
|
+
const out = {};
|
|
4178
|
+
for (const [k, v] of Object.entries(value)) out[k] = copyValue(v);
|
|
4179
|
+
return out;
|
|
4180
|
+
}
|
|
4181
|
+
return value;
|
|
4182
|
+
}
|
|
4183
|
+
function projectTick(state, wanted, ids) {
|
|
4184
|
+
const entities = {};
|
|
4185
|
+
let bytes = 0;
|
|
4186
|
+
for (const desc of wanted) {
|
|
4187
|
+
const coll = state[desc.name];
|
|
4188
|
+
if (coll === void 0) continue;
|
|
4189
|
+
const rows = {};
|
|
4190
|
+
let any = false;
|
|
4191
|
+
for (const id of [...coll.ids()]) {
|
|
4192
|
+
if (ids !== void 0 && !ids.has(id)) continue;
|
|
4193
|
+
const record = coll.get(id);
|
|
4194
|
+
if (record === void 0) continue;
|
|
4195
|
+
const row = {};
|
|
4196
|
+
for (const field of desc.fields) {
|
|
4197
|
+
const v = record[field.name];
|
|
4198
|
+
if (v === void 0) continue;
|
|
4199
|
+
row[field.name] = copyValue(v);
|
|
4200
|
+
bytes += field.name.length + 3 + estimateValue(v);
|
|
4201
|
+
}
|
|
4202
|
+
bytes += id.length + 4;
|
|
4203
|
+
rows[id] = row;
|
|
4204
|
+
any = true;
|
|
4205
|
+
}
|
|
4206
|
+
if (any) {
|
|
4207
|
+
bytes += desc.name.length + 4;
|
|
4208
|
+
entities[desc.name] = rows;
|
|
4209
|
+
}
|
|
4210
|
+
}
|
|
4211
|
+
return { entities, bytes };
|
|
4212
|
+
}
|
|
4213
|
+
function wantedCollections(ext, names) {
|
|
4214
|
+
const all = ext.collections.filter((c) => c.kind === "entity");
|
|
4215
|
+
if (names === void 0) return [...all];
|
|
4216
|
+
const wanted = new Set(names);
|
|
4217
|
+
return all.filter((c) => wanted.has(c.name));
|
|
4218
|
+
}
|
|
4219
|
+
function unknownCollection(ext, options) {
|
|
4220
|
+
const names = options?.collections;
|
|
4221
|
+
if (names === void 0) return void 0;
|
|
4222
|
+
const known = new Set(
|
|
4223
|
+
ext.collections.filter((c) => c.kind === "entity").map((c) => c.name)
|
|
4224
|
+
);
|
|
4225
|
+
for (const name of names) {
|
|
4226
|
+
if (!known.has(name)) return String(name);
|
|
4227
|
+
}
|
|
4228
|
+
return void 0;
|
|
4229
|
+
}
|
|
4230
|
+
function walkExcerpt(ext, ring, tickRate, seconds, options) {
|
|
4231
|
+
const ringFrames = ring.frames;
|
|
4232
|
+
const last = ringFrames[ringFrames.length - 1];
|
|
4233
|
+
if (last === void 0) return { startTick: 0, endTick: 0, tickRate, frames: [] };
|
|
4234
|
+
const from = last.tick - Math.ceil(seconds * tickRate);
|
|
4235
|
+
const window = ring.tail(from);
|
|
4236
|
+
const head = window[0];
|
|
4237
|
+
if (head === void 0 || !head.keyframe) {
|
|
4238
|
+
return { startTick: 0, endTick: 0, tickRate, frames: [] };
|
|
4239
|
+
}
|
|
4240
|
+
const wanted = wantedCollections(ext, options?.collections);
|
|
4241
|
+
const ids = options?.ids === void 0 ? void 0 : new Set(options.ids);
|
|
4242
|
+
const state = decodeSnapshot2(ext, head.bytes).state;
|
|
4243
|
+
const frames = [];
|
|
4244
|
+
let bytes = 0;
|
|
4245
|
+
const emit = (tick) => {
|
|
4246
|
+
if (tick < from) return;
|
|
4247
|
+
const { entities, bytes: n } = projectTick(state, wanted, ids);
|
|
4248
|
+
bytes += n;
|
|
4249
|
+
if (bytes > EXCERPT_MAX_BYTES) throw new ExcerptTooLargeError(bytes);
|
|
4250
|
+
frames.push({ tick, entities });
|
|
4251
|
+
};
|
|
4252
|
+
if (window[1]?.tick !== head.tick) emit(head.tick);
|
|
4253
|
+
for (let i = 1; i < window.length; i++) {
|
|
4254
|
+
const f = window[i];
|
|
4255
|
+
if (f.keyframe) continue;
|
|
4256
|
+
applyDelta(ext, state, decodeDelta2(ext, f.bytes));
|
|
4257
|
+
emit(f.tick);
|
|
4258
|
+
}
|
|
4259
|
+
const first = frames[0];
|
|
4260
|
+
return {
|
|
4261
|
+
startTick: first?.tick ?? head.tick,
|
|
4262
|
+
endTick: frames[frames.length - 1]?.tick ?? head.tick,
|
|
4263
|
+
tickRate,
|
|
4264
|
+
frames
|
|
4265
|
+
};
|
|
4266
|
+
}
|
|
4267
|
+
|
|
4268
|
+
// src/core/replay.ts
|
|
4269
|
+
var REPLAY_DEFAULT_SECONDS = 60;
|
|
4270
|
+
var REPLAY_MAX_SECONDS = 300;
|
|
4271
|
+
var REPLAY_RING_MAX_BYTES = 8 * 1024 * 1024;
|
|
4272
|
+
var REPLAY_KEYFRAME_SECONDS = 2;
|
|
4273
|
+
var CLIP_COOLDOWN_MS = 1e3;
|
|
4274
|
+
var CLIP_BURST = 8;
|
|
4275
|
+
var ReplayRing = class {
|
|
4276
|
+
/**
|
|
4277
|
+
* M6 record packet: `onEvict` is handed every segment the ring drops, oldest first, at the
|
|
4278
|
+
* moment it drops it — which is the whole of what a recording is. It is optional and unset in
|
|
4279
|
+
* every room that did not ask to record, so the ring a clip-only room runs is the ring it ran
|
|
4280
|
+
* before this packet, allocation for allocation.
|
|
4281
|
+
*
|
|
4282
|
+
* A *wipe* deliberately does not call it. A wipe is one segment that outgrew the entire ring, so
|
|
4283
|
+
* emitting it would mean writing a chunk bigger than the bound the ring exists to keep. For a
|
|
4284
|
+
* clip that is "holds less footage than it asked for"; for a recording it is not, because the
|
|
4285
|
+
* frames after the wipe are deltas whose keyframe was never written, and playback skips
|
|
4286
|
+
* mid-stream keyframes rather than re-basing on them. So a wipe is not silently less footage: it
|
|
4287
|
+
* ENDS the recording (`append` returns `true` and the flush tap stops it), which turns wrong
|
|
4288
|
+
* footage into short footage.
|
|
4289
|
+
*/
|
|
4290
|
+
constructor(bounds, onEvict) {
|
|
4291
|
+
this.bounds = bounds;
|
|
4292
|
+
this.onEvict = onEvict;
|
|
4293
|
+
}
|
|
4294
|
+
bounds;
|
|
4295
|
+
onEvict;
|
|
4296
|
+
items = [];
|
|
4297
|
+
total = 0;
|
|
4298
|
+
/** How many times an oversized segment has emptied the ring. Read through `append`'s return. */
|
|
4299
|
+
wipes = 0;
|
|
4300
|
+
/** The tick the newest keyframe went in at; `-1` while the ring is empty. */
|
|
4301
|
+
lastKeyframeTick = -1;
|
|
4302
|
+
get frames() {
|
|
4303
|
+
return this.items;
|
|
4304
|
+
}
|
|
4305
|
+
get bytes() {
|
|
4306
|
+
return this.total;
|
|
4307
|
+
}
|
|
4308
|
+
get empty() {
|
|
4309
|
+
return this.items.length === 0;
|
|
4310
|
+
}
|
|
4311
|
+
/** How many oversized segments have emptied the ring. A test's handle on the degenerate case. */
|
|
4312
|
+
get wipeCount() {
|
|
4313
|
+
return this.wipes;
|
|
4314
|
+
}
|
|
4315
|
+
/**
|
|
4316
|
+
* Appends a frame and trims. Returns `true` when the trim had to throw the *whole* ring away
|
|
4317
|
+
* (an oversized segment with nowhere to cut), which the caller has to know about: the ring is
|
|
4318
|
+
* now empty, so anything it was about to append next would head the ring without a keyframe.
|
|
4319
|
+
*/
|
|
4320
|
+
append(frame) {
|
|
4321
|
+
this.items.push(frame);
|
|
4322
|
+
this.total += frame.bytes.length;
|
|
4323
|
+
if (frame.keyframe) this.lastKeyframeTick = frame.tick;
|
|
4324
|
+
const before = this.wipes;
|
|
4325
|
+
this.trim();
|
|
4326
|
+
return this.wipes !== before;
|
|
4327
|
+
}
|
|
4328
|
+
/** Is a keyframe due at `tick`? Always true while the ring is empty. */
|
|
4329
|
+
keyframeDue(tick) {
|
|
4330
|
+
return this.empty || tick - this.lastKeyframeTick >= this.bounds.keyframeEvery;
|
|
4331
|
+
}
|
|
4332
|
+
/** The frames from the newest keyframe at or before `fromTick` to the end. */
|
|
4333
|
+
tail(fromTick) {
|
|
4334
|
+
let start = 0;
|
|
4335
|
+
for (let i = 0; i < this.items.length; i++) {
|
|
4336
|
+
const f = this.items[i];
|
|
4337
|
+
if (f.keyframe && f.tick <= fromTick) start = i;
|
|
4338
|
+
}
|
|
4339
|
+
return this.items.slice(start);
|
|
4340
|
+
}
|
|
4341
|
+
trim() {
|
|
4342
|
+
const last = this.items[this.items.length - 1];
|
|
4343
|
+
if (!last) return;
|
|
4344
|
+
for (; ; ) {
|
|
4345
|
+
const head = this.items[0];
|
|
4346
|
+
if (!head) return;
|
|
4347
|
+
const overBytes = this.total > this.bounds.maxBytes;
|
|
4348
|
+
const overWindow = last.tick - head.tick > this.bounds.windowTicks;
|
|
4349
|
+
if (!overBytes && !overWindow) return;
|
|
4350
|
+
let next = -1;
|
|
4351
|
+
for (let i = 1; i < this.items.length; i++) {
|
|
4352
|
+
if (this.items[i]?.keyframe === true) {
|
|
4353
|
+
next = i;
|
|
4354
|
+
break;
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
if (next === -1) {
|
|
4358
|
+
if (!overBytes) return;
|
|
4359
|
+
this.items = [];
|
|
4360
|
+
this.total = 0;
|
|
4361
|
+
this.lastKeyframeTick = -1;
|
|
4362
|
+
this.wipes++;
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
for (let i = 0; i < next; i++) this.total -= this.items[i].bytes.length;
|
|
4366
|
+
const evicted = this.items.slice(0, next);
|
|
4367
|
+
this.items = this.items.slice(next);
|
|
4368
|
+
this.onEvict?.(evicted);
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
};
|
|
4372
|
+
var RECORD_QUEUE_MAX = 2;
|
|
4373
|
+
var states5 = /* @__PURE__ */ new WeakMap();
|
|
4374
|
+
function configOf(core) {
|
|
4375
|
+
const config = core.definition.config;
|
|
4376
|
+
return config.replay;
|
|
4377
|
+
}
|
|
4378
|
+
function replayArmed(core) {
|
|
4379
|
+
return configOf(core) !== void 0;
|
|
4380
|
+
}
|
|
4381
|
+
function stateOf6(core) {
|
|
4382
|
+
const declared = configOf(core);
|
|
4383
|
+
if (declared === void 0) return void 0;
|
|
4384
|
+
let s = states5.get(core);
|
|
4385
|
+
if (!s) {
|
|
4386
|
+
const tickRate = core.definition.config.tickRate ?? 20;
|
|
4387
|
+
const seconds = Math.min(
|
|
4388
|
+
REPLAY_MAX_SECONDS,
|
|
4389
|
+
Math.max(1, Math.floor(declared.seconds ?? REPLAY_DEFAULT_SECONDS))
|
|
4390
|
+
);
|
|
4391
|
+
const record = declared.record === true;
|
|
4392
|
+
const outbox = [];
|
|
4393
|
+
const ttlMs = parseRetention(declared.ttl) ?? CLIP_TTL_DEFAULT_MS;
|
|
4394
|
+
s = {
|
|
4395
|
+
seconds,
|
|
4396
|
+
ttlMs,
|
|
4397
|
+
recExpiresAtEpochS: void 0,
|
|
4398
|
+
view: declared.view ?? "",
|
|
4399
|
+
ring: new ReplayRing(
|
|
4400
|
+
{
|
|
4401
|
+
windowTicks: Math.max(1, Math.ceil(seconds * tickRate)),
|
|
4402
|
+
keyframeEvery: Math.max(1, Math.round(REPLAY_KEYFRAME_SECONDS * tickRate)),
|
|
4403
|
+
maxBytes: REPLAY_RING_MAX_BYTES
|
|
4404
|
+
},
|
|
4405
|
+
record ? (frames) => outbox.push(...frames) : void 0
|
|
4406
|
+
),
|
|
4407
|
+
clipTokens: CLIP_BURST,
|
|
4408
|
+
lastRefillAt: Number.NEGATIVE_INFINITY,
|
|
4409
|
+
clipsTaken: 0,
|
|
4410
|
+
excerptTokens: EXCERPT_BURST,
|
|
4411
|
+
lastExcerptRefillAt: Number.NEGATIVE_INFINITY,
|
|
4412
|
+
excerptsTaken: 0,
|
|
4413
|
+
disarmed: false,
|
|
4414
|
+
record,
|
|
4415
|
+
outbox,
|
|
4416
|
+
queued: [],
|
|
4417
|
+
recId: void 0,
|
|
4418
|
+
nextSeq: 0,
|
|
4419
|
+
encoded: 0,
|
|
4420
|
+
recBytes: 0,
|
|
4421
|
+
inFlight: false,
|
|
4422
|
+
recStopped: false,
|
|
4423
|
+
recWarned: false,
|
|
4424
|
+
recExpiryWarned: false,
|
|
4425
|
+
finalDrained: false,
|
|
4426
|
+
dropInFlight: false,
|
|
4427
|
+
chunksOut: 0
|
|
4428
|
+
};
|
|
4429
|
+
states5.set(core, s);
|
|
4430
|
+
}
|
|
4431
|
+
return s;
|
|
4432
|
+
}
|
|
4433
|
+
function omniscientMemberships(core) {
|
|
4434
|
+
const out = /* @__PURE__ */ new Map();
|
|
4435
|
+
for (const desc of core.ext.collections) {
|
|
4436
|
+
if (desc.visibility !== "spatial-grid") continue;
|
|
4437
|
+
out.set(desc.name, new Set(plainEntity(core.plain, desc.name).ids()));
|
|
4438
|
+
}
|
|
4439
|
+
return out;
|
|
4440
|
+
}
|
|
4441
|
+
function hasRoleScoped(core) {
|
|
4442
|
+
return core.ext.collections.some((c) => c.visibility === "role");
|
|
4443
|
+
}
|
|
4444
|
+
function hasSpatial(core) {
|
|
4445
|
+
return core.ext.collections.some(
|
|
4446
|
+
(c) => c.visibility === "spatial-grid"
|
|
4447
|
+
);
|
|
4448
|
+
}
|
|
4449
|
+
function recorderSnapshot(core, state) {
|
|
4450
|
+
if (state.view === "") {
|
|
4451
|
+
return encodeSnapshot3(core.ext, core.plain, { tick: core.tick });
|
|
4452
|
+
}
|
|
4453
|
+
return encodeViewSnapshot(
|
|
4454
|
+
core.ext,
|
|
4455
|
+
core.plain,
|
|
4456
|
+
state.view,
|
|
4457
|
+
core.tick,
|
|
4458
|
+
omniscientMemberships(core)
|
|
4459
|
+
);
|
|
4460
|
+
}
|
|
4461
|
+
function recorderDelta(core, state, dirty) {
|
|
4462
|
+
if (state.view === "") {
|
|
4463
|
+
if (isDirtyEmpty2(dirty)) return null;
|
|
4464
|
+
return encodeDelta3(core.ext, core.plain, dirty, { tick: core.tick });
|
|
4465
|
+
}
|
|
4466
|
+
return encodeViewDelta(core.ext, core.plain, dirty, state.view, core.tick);
|
|
4467
|
+
}
|
|
4468
|
+
function cacheKeyFor(core, state) {
|
|
4469
|
+
if (hasSpatial(core)) return void 0;
|
|
4470
|
+
if (state.view === "") return hasRoleScoped(core) ? void 0 : "all";
|
|
4471
|
+
return viewKeyFor(core.ext, state.view);
|
|
4472
|
+
}
|
|
4473
|
+
function recordFlush(core, dirty, byView) {
|
|
4474
|
+
const state = stateOf6(core);
|
|
4475
|
+
if (!state || state.disarmed) return;
|
|
4476
|
+
appendFlush(core, state, dirty, byView);
|
|
4477
|
+
if (state.record) pumpDrain(core, state);
|
|
4478
|
+
}
|
|
4479
|
+
function appendFlush(core, state, dirty, byView) {
|
|
4480
|
+
const ring = state.ring;
|
|
4481
|
+
if (!ring.empty && isDirtyEmpty2(dirty)) return;
|
|
4482
|
+
if (ring.keyframeDue(core.tick)) {
|
|
4483
|
+
const wiped2 = ring.append({
|
|
4484
|
+
tick: core.tick,
|
|
4485
|
+
keyframe: true,
|
|
4486
|
+
bytes: recorderSnapshot(core, state)
|
|
4487
|
+
});
|
|
4488
|
+
if (wiped2) {
|
|
4489
|
+
disarm(core, state);
|
|
4490
|
+
return;
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
4493
|
+
const key = cacheKeyFor(core, state);
|
|
4494
|
+
const cached = key === void 0 ? void 0 : byView.get(key);
|
|
4495
|
+
const delta = cached ?? recorderDelta(core, state, dirty);
|
|
4496
|
+
if (!delta) return;
|
|
4497
|
+
const wiped = ring.append({ tick: core.tick, keyframe: false, bytes: delta });
|
|
4498
|
+
if (wiped && state.record) {
|
|
4499
|
+
stopRecording(
|
|
4500
|
+
core,
|
|
4501
|
+
state,
|
|
4502
|
+
`one replay segment was larger than the whole ${REPLAY_RING_MAX_BYTES}-byte ring, so the ring was emptied and the footage after it could not be re-based`
|
|
4503
|
+
);
|
|
4504
|
+
}
|
|
4505
|
+
}
|
|
4506
|
+
function disarm(core, state) {
|
|
4507
|
+
state.disarmed = true;
|
|
4508
|
+
if (state.record) stopRecording(core, state, "the replay ring disarmed");
|
|
4509
|
+
core.host.log("warn", [
|
|
4510
|
+
`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.`
|
|
4511
|
+
]);
|
|
4512
|
+
}
|
|
4513
|
+
function assembleClip(core, seconds) {
|
|
4514
|
+
const state = stateOf6(core);
|
|
4515
|
+
if (!state || state.disarmed || state.ring.empty) return void 0;
|
|
4516
|
+
const tickRate = core.definition.config.tickRate ?? 20;
|
|
4517
|
+
const want = Math.min(state.seconds, Math.max(1, Math.floor(seconds ?? state.seconds)));
|
|
4518
|
+
const ringFrames = state.ring.frames;
|
|
4519
|
+
const last = ringFrames[ringFrames.length - 1];
|
|
4520
|
+
const frames = state.ring.tail(last.tick - Math.ceil(want * tickRate)).map((f) => ({
|
|
4521
|
+
tick: f.tick,
|
|
4522
|
+
kind: f.keyframe ? "keyframe" : "delta",
|
|
4523
|
+
bytes: f.bytes
|
|
4524
|
+
}));
|
|
4525
|
+
const head = frames[0];
|
|
4526
|
+
if (head === void 0 || head.kind !== "keyframe") return void 0;
|
|
4527
|
+
const clip = {
|
|
4528
|
+
// The GAME schema's hash, not the extended one's. That is what a client puts in its HELLO,
|
|
4529
|
+
// so it is the value playback can actually compare against — and it is the hash a deploy
|
|
4530
|
+
// changes, which is the whole thing the pin exists to catch.
|
|
4531
|
+
schemaHash8: core.definition.schema.hash8,
|
|
4532
|
+
tickRate,
|
|
4533
|
+
startTick: head.tick,
|
|
4534
|
+
durationTicks: last.tick - head.tick,
|
|
4535
|
+
view: state.view,
|
|
4536
|
+
frames
|
|
4537
|
+
};
|
|
4538
|
+
return encodeClip(clip);
|
|
4539
|
+
}
|
|
4540
|
+
function takeClip(core, seconds) {
|
|
4541
|
+
if (!replayArmed(core)) {
|
|
4542
|
+
return rejectAsEvent(
|
|
4543
|
+
core,
|
|
4544
|
+
new Error(
|
|
4545
|
+
"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."
|
|
4546
|
+
)
|
|
4547
|
+
);
|
|
4548
|
+
}
|
|
4549
|
+
const state = stateOf6(core);
|
|
4550
|
+
const now = core.host.now();
|
|
4551
|
+
if (state.lastRefillAt === Number.NEGATIVE_INFINITY) state.lastRefillAt = now;
|
|
4552
|
+
const refilled = Math.floor((now - state.lastRefillAt) / CLIP_COOLDOWN_MS);
|
|
4553
|
+
if (refilled > 0) {
|
|
4554
|
+
state.clipTokens = Math.min(CLIP_BURST, state.clipTokens + refilled);
|
|
4555
|
+
state.lastRefillAt += refilled * CLIP_COOLDOWN_MS;
|
|
4556
|
+
}
|
|
4557
|
+
if (state.clipTokens <= 0) {
|
|
4558
|
+
return rejectAsEvent(
|
|
4559
|
+
core,
|
|
4560
|
+
new Error(
|
|
4561
|
+
`E_CLIP_COOLDOWN: room.replay.clip allows bursts of ${CLIP_BURST}, refilled one every ${CLIP_COOLDOWN_MS}ms per room; this room is out of clip tokens right now`
|
|
4562
|
+
)
|
|
4563
|
+
);
|
|
4564
|
+
}
|
|
4565
|
+
if (state.disarmed) {
|
|
4566
|
+
return rejectAsEvent(
|
|
4567
|
+
core,
|
|
4568
|
+
new Error(
|
|
4569
|
+
`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`
|
|
4570
|
+
)
|
|
4571
|
+
);
|
|
4572
|
+
}
|
|
4573
|
+
let bytes;
|
|
4574
|
+
try {
|
|
4575
|
+
bytes = assembleClip(core, seconds);
|
|
4576
|
+
} catch (err) {
|
|
4577
|
+
return rejectAsEvent(
|
|
4578
|
+
core,
|
|
4579
|
+
new Error(`E_CLIP_ENCODE: the replay ring could not be encoded: ${String(err)}`)
|
|
4580
|
+
);
|
|
4581
|
+
}
|
|
4582
|
+
if (bytes === void 0) {
|
|
4583
|
+
return rejectAsEvent(
|
|
4584
|
+
core,
|
|
4585
|
+
new Error("E_CLIP_EMPTY: the replay ring is empty \u2014 this room has not flushed a change yet")
|
|
4586
|
+
);
|
|
4587
|
+
}
|
|
4588
|
+
if (bytes.length > CLIP_MAX_BYTES) {
|
|
4589
|
+
return rejectAsEvent(
|
|
4590
|
+
core,
|
|
4591
|
+
new Error(
|
|
4592
|
+
`E_CLIP_TOO_LARGE: this clip is ${bytes.length} bytes and the limit is ${CLIP_MAX_BYTES}`
|
|
4593
|
+
)
|
|
4594
|
+
);
|
|
4595
|
+
}
|
|
4596
|
+
state.clipTokens--;
|
|
4597
|
+
state.clipsTaken++;
|
|
4598
|
+
return startHostCall(
|
|
4599
|
+
core,
|
|
4600
|
+
{ kind: "clip", bytes, expiresAtEpochS: clipExpiryEpochS(core.wallNow(), state.ttlMs) },
|
|
4601
|
+
(value) => ({ id: value ?? "" })
|
|
4602
|
+
);
|
|
4603
|
+
}
|
|
4604
|
+
function takeExcerpt(core, options) {
|
|
4605
|
+
if (!replayArmed(core)) {
|
|
4606
|
+
return rejectAsEvent(
|
|
4607
|
+
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
|
+
)
|
|
4611
|
+
);
|
|
4612
|
+
}
|
|
4613
|
+
const seconds = options?.seconds;
|
|
4614
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) {
|
|
4615
|
+
return rejectAsEvent(
|
|
4616
|
+
core,
|
|
4617
|
+
new Error(
|
|
4618
|
+
"E_EXCERPT_BAD_ARGS: room.replay.excerpt needs { seconds } as a positive number, got " + JSON.stringify(seconds)
|
|
4619
|
+
)
|
|
4620
|
+
);
|
|
4621
|
+
}
|
|
4622
|
+
if (seconds > EXCERPT_MAX_SECONDS) {
|
|
4623
|
+
return rejectAsEvent(
|
|
4624
|
+
core,
|
|
4625
|
+
new Error(
|
|
4626
|
+
`E_EXCERPT_TOO_LONG: room.replay.excerpt decodes at most ${EXCERPT_MAX_SECONDS} seconds at a time and was asked for ${seconds}. An excerpt is a killcam, not a match; for a whole match use \`replay: { record: true }\`.`
|
|
4627
|
+
)
|
|
4628
|
+
);
|
|
4629
|
+
}
|
|
4630
|
+
const badName = unknownCollection(core.ext, options);
|
|
4631
|
+
if (badName !== void 0) {
|
|
4632
|
+
return rejectAsEvent(
|
|
4633
|
+
core,
|
|
4634
|
+
new Error(
|
|
4635
|
+
`E_EXCERPT_BAD_ARGS: room.replay.excerpt was asked for the collection ${JSON.stringify(
|
|
4636
|
+
badName
|
|
4637
|
+
)}, which is not an entity collection in this room's schema. Singleton collections are not excerptable.`
|
|
4638
|
+
)
|
|
4639
|
+
);
|
|
4640
|
+
}
|
|
4641
|
+
const state = stateOf6(core);
|
|
4642
|
+
if (state.disarmed) {
|
|
4643
|
+
return rejectAsEvent(
|
|
4644
|
+
core,
|
|
4645
|
+
new Error(
|
|
4646
|
+
`E_EXCERPT_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`
|
|
4647
|
+
)
|
|
4648
|
+
);
|
|
4649
|
+
}
|
|
4650
|
+
const now = core.host.now();
|
|
4651
|
+
if (state.lastExcerptRefillAt === Number.NEGATIVE_INFINITY) state.lastExcerptRefillAt = now;
|
|
4652
|
+
const refilled = Math.floor((now - state.lastExcerptRefillAt) / EXCERPT_COOLDOWN_MS);
|
|
4653
|
+
if (refilled > 0) {
|
|
4654
|
+
state.excerptTokens = Math.min(EXCERPT_BURST, state.excerptTokens + refilled);
|
|
4655
|
+
state.lastExcerptRefillAt += refilled * EXCERPT_COOLDOWN_MS;
|
|
4656
|
+
}
|
|
4657
|
+
if (state.excerptTokens <= 0) {
|
|
4658
|
+
return rejectAsEvent(
|
|
4659
|
+
core,
|
|
4660
|
+
new Error(
|
|
4661
|
+
`E_EXCERPT_COOLDOWN: room.replay.excerpt allows bursts of ${EXCERPT_BURST}, refilled one every ${EXCERPT_COOLDOWN_MS}ms per room; this room is out of excerpt tokens right now`
|
|
4662
|
+
)
|
|
4663
|
+
);
|
|
4664
|
+
}
|
|
4665
|
+
state.excerptTokens--;
|
|
4666
|
+
const tickRate = core.definition.config.tickRate ?? 20;
|
|
4667
|
+
let out;
|
|
4668
|
+
try {
|
|
4669
|
+
out = walkExcerpt(core.ext, state.ring, tickRate, seconds, options);
|
|
4670
|
+
} catch (err) {
|
|
4671
|
+
if (err instanceof ExcerptTooLargeError) {
|
|
4672
|
+
return rejectAsEvent(
|
|
4673
|
+
core,
|
|
4674
|
+
new Error(
|
|
4675
|
+
`E_EXCERPT_TOO_LARGE: this excerpt would materialize about ${err.bytes} bytes of frames and the limit is ${EXCERPT_MAX_BYTES}. Ask for fewer seconds, or name the collections (and ids) you actually need in \`collections\`/\`ids\`.`
|
|
4676
|
+
)
|
|
4677
|
+
);
|
|
4678
|
+
}
|
|
4679
|
+
return rejectAsEvent(
|
|
4680
|
+
core,
|
|
4681
|
+
new Error(`E_EXCERPT_DECODE: the replay ring could not be decoded: ${String(err)}`)
|
|
4682
|
+
);
|
|
4683
|
+
}
|
|
4684
|
+
state.excerptsTaken++;
|
|
4685
|
+
return resolveAsEvent(core, out);
|
|
4686
|
+
}
|
|
4687
|
+
var recordingCap = RECORDING_MAX_BYTES;
|
|
4688
|
+
function stopRecording(core, state, why) {
|
|
4689
|
+
if (state.recStopped) return;
|
|
4690
|
+
state.recStopped = true;
|
|
4691
|
+
state.outbox.length = 0;
|
|
4692
|
+
state.queued.length = 0;
|
|
4693
|
+
if (state.recWarned) return;
|
|
4694
|
+
state.recWarned = true;
|
|
4695
|
+
core.host.log("warn", [
|
|
4696
|
+
`irtio: room ${core.roomId}: full-match recording stopped (${why}). The chunks already written stay playable and room.replay.clip() still works; nothing further is recorded for the life of this room.`
|
|
4697
|
+
]);
|
|
4698
|
+
}
|
|
4699
|
+
function pumpDrain(core, state) {
|
|
4700
|
+
if (state.recStopped) {
|
|
4701
|
+
state.outbox.length = 0;
|
|
4702
|
+
return;
|
|
4703
|
+
}
|
|
4704
|
+
if (state.outbox.length > 0) {
|
|
4705
|
+
const frames = state.outbox.splice(0, state.outbox.length);
|
|
4706
|
+
encodeAndQueue(core, state, frames);
|
|
4707
|
+
}
|
|
4708
|
+
sendNext(core, state);
|
|
4709
|
+
}
|
|
4710
|
+
function encodeAndQueue(core, state, frames) {
|
|
4711
|
+
if (frames.length === 0) return;
|
|
4712
|
+
if (state.recExpiresAtEpochS === void 0) {
|
|
4713
|
+
state.recExpiresAtEpochS = clipExpiryEpochS(core.wallNow(), state.ttlMs);
|
|
4714
|
+
}
|
|
4715
|
+
const first = frames[0];
|
|
4716
|
+
const header = state.encoded === 0 ? {
|
|
4717
|
+
// The GAME schema's hash, for `assembleClip`'s reason: it is what a client's HELLO
|
|
4718
|
+
// carries and what a deploy changes.
|
|
4719
|
+
schemaHash8: core.definition.schema.hash8,
|
|
4720
|
+
tickRate: core.definition.config.tickRate ?? 20,
|
|
4721
|
+
startTick: first.tick,
|
|
4722
|
+
view: state.view
|
|
4723
|
+
} : void 0;
|
|
4724
|
+
if (header !== void 0 && first.keyframe !== true) {
|
|
4725
|
+
stopRecording(core, state, "the first chunk would not have begun with a keyframe");
|
|
4726
|
+
return;
|
|
4727
|
+
}
|
|
4728
|
+
let bytes;
|
|
4729
|
+
try {
|
|
4730
|
+
bytes = encodeRecordingChunk(
|
|
4731
|
+
frames.map((f) => ({
|
|
4732
|
+
tick: f.tick,
|
|
4733
|
+
kind: f.keyframe ? "keyframe" : "delta",
|
|
4734
|
+
bytes: f.bytes
|
|
4735
|
+
})),
|
|
4736
|
+
header
|
|
4737
|
+
);
|
|
4738
|
+
} catch (err) {
|
|
4739
|
+
stopRecording(core, state, `the chunk could not be encoded: ${String(err)}`);
|
|
4740
|
+
return;
|
|
4741
|
+
}
|
|
4742
|
+
if (bytes.length === 0) return;
|
|
4743
|
+
if (state.recBytes + bytes.length > recordingCap) {
|
|
4744
|
+
stopRecording(
|
|
4745
|
+
core,
|
|
4746
|
+
state,
|
|
4747
|
+
`it reached the ${recordingCap}-byte per-recording limit after ${state.chunksOut} chunk(s)`
|
|
4748
|
+
);
|
|
4749
|
+
return;
|
|
4750
|
+
}
|
|
4751
|
+
state.encoded++;
|
|
4752
|
+
state.queued.push(bytes);
|
|
4753
|
+
if (state.queued.length > RECORD_QUEUE_MAX) {
|
|
4754
|
+
stopRecording(
|
|
4755
|
+
core,
|
|
4756
|
+
state,
|
|
4757
|
+
`the store is answering slower than the ring evicts: ${RECORD_QUEUE_MAX} chunk(s) were already waiting behind the one in flight, and dropping one would have left a gap nothing downstream could re-base on`
|
|
4758
|
+
);
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
function sendNext(core, state, all = false) {
|
|
4762
|
+
if (state.recStopped && !state.finalDrained) return;
|
|
4763
|
+
const parallel = all && state.recId !== void 0;
|
|
4764
|
+
for (; ; ) {
|
|
4765
|
+
if (state.inFlight && !parallel) return;
|
|
4766
|
+
const bytes = state.queued.shift();
|
|
4767
|
+
if (bytes === void 0) return;
|
|
4768
|
+
sendChunk(core, state, bytes);
|
|
4769
|
+
if (!parallel) return;
|
|
4770
|
+
}
|
|
4771
|
+
}
|
|
4772
|
+
function warnRecordingOutlivedTtl(core, state) {
|
|
4773
|
+
if (state.recExpiryWarned) return;
|
|
4774
|
+
const expiresAtEpochS = state.recExpiresAtEpochS;
|
|
4775
|
+
if (expiresAtEpochS === void 0 || expiresAtEpochS * 1e3 > core.wallNow()) return;
|
|
4776
|
+
state.recExpiryWarned = true;
|
|
4777
|
+
core.host.log("warn", [
|
|
4778
|
+
`irtio: room ${core.roomId}: this full-match recording expired at ${expiresAtEpochS} (unix seconds) while it was still being written. A recording is kept for replay.ttl from its first stored chunk, so a ttl shorter than the match deletes the recording while the match is still running and its link answers 404. Set replay.ttl to at least the length of a match.`
|
|
4779
|
+
]);
|
|
4780
|
+
}
|
|
4781
|
+
function sendChunk(core, state, bytes) {
|
|
4782
|
+
warnRecordingOutlivedTtl(core, state);
|
|
4783
|
+
const seq = state.nextSeq;
|
|
4784
|
+
state.nextSeq++;
|
|
4785
|
+
state.chunksOut++;
|
|
4786
|
+
state.recBytes += bytes.length;
|
|
4787
|
+
state.inFlight = true;
|
|
4788
|
+
startHostCall(
|
|
4789
|
+
core,
|
|
4790
|
+
{
|
|
4791
|
+
kind: "record-chunk",
|
|
4792
|
+
recId: state.recId,
|
|
4793
|
+
seq,
|
|
4794
|
+
bytes,
|
|
4795
|
+
// The first chunk's expiry, on every chunk. `encodeAndQueue` set it before anything could
|
|
4796
|
+
// reach here; the fallback is a value, not a second policy.
|
|
4797
|
+
expiresAtEpochS: state.recExpiresAtEpochS ?? clipExpiryEpochS(core.wallNow(), state.ttlMs)
|
|
4798
|
+
},
|
|
4799
|
+
(value) => value ?? ""
|
|
4800
|
+
).then((id) => {
|
|
4801
|
+
state.inFlight = false;
|
|
4802
|
+
state.dropInFlight = false;
|
|
4803
|
+
if (state.recId === void 0 && id !== "") state.recId = id;
|
|
4804
|
+
sendNext(core, state, state.finalDrained);
|
|
4805
|
+
}).catch((err) => {
|
|
4806
|
+
state.inFlight = false;
|
|
4807
|
+
if (state.dropInFlight) {
|
|
4808
|
+
state.dropInFlight = false;
|
|
4809
|
+
if (state.recId === void 0) {
|
|
4810
|
+
stopRecording(
|
|
4811
|
+
core,
|
|
4812
|
+
state,
|
|
4813
|
+
"the reply carrying the recording's id was dropped before it arrived, and a second chunk would have been stored as a second recording"
|
|
4814
|
+
);
|
|
4815
|
+
return;
|
|
4816
|
+
}
|
|
4817
|
+
sendNext(core, state, state.finalDrained);
|
|
4818
|
+
return;
|
|
4819
|
+
}
|
|
4820
|
+
if (state.queued.length > 0) {
|
|
4821
|
+
const bytes2 = state.queued.reduce((n, b) => n + b.length, 0);
|
|
4822
|
+
core.host.log("warn", [
|
|
4823
|
+
`irtio: room ${core.roomId}: the full-match recording lost its last ${state.queued.length} chunk(s) (${bytes2} bytes). The room ended before the store answered the first chunk, so the recording's id was never known here and the tail had nowhere to go. Everything already stored plays.`
|
|
4824
|
+
]);
|
|
4825
|
+
state.queued.length = 0;
|
|
4826
|
+
}
|
|
4827
|
+
stopRecording(core, state, `a chunk was not stored: ${String(err)}`);
|
|
4828
|
+
});
|
|
4829
|
+
}
|
|
4830
|
+
function drainRecordingFinal(core) {
|
|
4831
|
+
const state = states5.get(core);
|
|
4832
|
+
if (!state || !state.record || state.recStopped || state.finalDrained) return;
|
|
4833
|
+
state.finalDrained = true;
|
|
4834
|
+
const remaining = state.ring.frames;
|
|
4835
|
+
const frames = [...state.outbox.splice(0, state.outbox.length), ...remaining];
|
|
4836
|
+
if (frames.length > 0) encodeAndQueue(core, state, frames);
|
|
4837
|
+
sendNext(core, state, true);
|
|
4838
|
+
state.recStopped = true;
|
|
4839
|
+
}
|
|
4840
|
+
function recordingId(core) {
|
|
4841
|
+
return states5.get(core)?.recId;
|
|
4842
|
+
}
|
|
4843
|
+
function noteRecordingRepliesDropped(core) {
|
|
4844
|
+
const state = states5.get(core);
|
|
4845
|
+
if (state?.inFlight === true) state.dropInFlight = true;
|
|
4846
|
+
}
|
|
4847
|
+
|
|
3442
4848
|
// src/core/room-api.ts
|
|
3443
4849
|
import {
|
|
3444
|
-
FrameType as
|
|
4850
|
+
FrameType as FrameType5,
|
|
3445
4851
|
busChannelProblem,
|
|
3446
4852
|
busPayloadProblem,
|
|
3447
|
-
encodeFrame as
|
|
4853
|
+
encodeFrame as encodeFrame4
|
|
3448
4854
|
} from "@irtio/protocol";
|
|
3449
|
-
import { encodeDelta as
|
|
4855
|
+
import { encodeDelta as encodeDelta4, encodeFields as encodeFields2, isDirtyEmpty as isDirtyEmpty3 } from "@irtio/schema";
|
|
3450
4856
|
function makePhysicsApi(p) {
|
|
3451
4857
|
return {
|
|
3452
4858
|
get rapier() {
|
|
@@ -3796,6 +5202,30 @@ function createRoomApi(core, publicUrl) {
|
|
|
3796
5202
|
get lobby() {
|
|
3797
5203
|
return makeLobby(core);
|
|
3798
5204
|
},
|
|
5205
|
+
// ---- M6 lane N: replays ----
|
|
5206
|
+
// D81: one getter, and deliberately only one. Everything replays do — the ring, the
|
|
5207
|
+
// eviction invariant, the recorder view, the clip assembly, the cooldown — lives in
|
|
5208
|
+
// `core/replay.ts` behind these two calls, so this file stays what it says it is: a facade
|
|
5209
|
+
// that reads through.
|
|
5210
|
+
get replay() {
|
|
5211
|
+
return {
|
|
5212
|
+
get armed() {
|
|
5213
|
+
return replayArmed(core);
|
|
5214
|
+
},
|
|
5215
|
+
clip: (seconds) => takeClip(core, seconds),
|
|
5216
|
+
// M7 planned feature 5: the ring read back as plain frames. Same read-through shape as
|
|
5217
|
+
// `clip` — the walk, the projection and the whole budget live in `core/replay-excerpt.ts`
|
|
5218
|
+
// and `core/replay.ts`.
|
|
5219
|
+
excerpt: (options) => takeExcerpt(core, options),
|
|
5220
|
+
// M6 record packet: the id of the recording being written, once the first chunk has been
|
|
5221
|
+
// stored. A getter, like `armed`, so a game reading it late in a match sees the id rather
|
|
5222
|
+
// than the `undefined` it would have captured at the start of one.
|
|
5223
|
+
get recording() {
|
|
5224
|
+
return recordingId(core);
|
|
5225
|
+
}
|
|
5226
|
+
};
|
|
5227
|
+
},
|
|
5228
|
+
// ---- end M6 lane N ----
|
|
3799
5229
|
alarm(name, atMs) {
|
|
3800
5230
|
if (!isAlarmName(core, name, "room.alarm")) return;
|
|
3801
5231
|
if (!Number.isFinite(atMs)) {
|
|
@@ -3878,10 +5308,10 @@ function setRole(core, clientId, role) {
|
|
|
3878
5308
|
}
|
|
3879
5309
|
if (entry.role === role) return;
|
|
3880
5310
|
const dirty = catchUpDirty(core.ext, core.plain, entry.role, role);
|
|
3881
|
-
if (!
|
|
5311
|
+
if (!isDirtyEmpty3(dirty)) {
|
|
3882
5312
|
core.send(
|
|
3883
5313
|
clientId,
|
|
3884
|
-
|
|
5314
|
+
encodeFrame4(FrameType5.DELTA, encodeDelta4(core.ext, core.plain, dirty, { tick: core.tick }))
|
|
3885
5315
|
);
|
|
3886
5316
|
}
|
|
3887
5317
|
entry.role = role;
|
|
@@ -3984,6 +5414,10 @@ var RoomCore = class _RoomCore {
|
|
|
3984
5414
|
};
|
|
3985
5415
|
tick = 0;
|
|
3986
5416
|
stopped = false;
|
|
5417
|
+
// ---- M6 lane K: stateful relay ----
|
|
5418
|
+
/** D77: the wall clock hold deadlines are measured against. See `RoomCoreOptions.wallNow`. */
|
|
5419
|
+
wallNow;
|
|
5420
|
+
// ---- end M6 lane K ----
|
|
3987
5421
|
/**
|
|
3988
5422
|
* Called for every handler throw `tryRun` swallows, before it is logged. A test harness sets
|
|
3989
5423
|
* this so a room that breaks fails the test that broke it (bug 2): the runtime's job is to
|
|
@@ -4035,7 +5469,7 @@ var RoomCore = class _RoomCore {
|
|
|
4035
5469
|
restored = candidate;
|
|
4036
5470
|
}
|
|
4037
5471
|
if (restored) {
|
|
4038
|
-
plain =
|
|
5472
|
+
plain = decodeSnapshot3(this.ext, restored.snapshot).state;
|
|
4039
5473
|
const presence = plainEntity(plain, PRESENCE_COLLECTION);
|
|
4040
5474
|
for (const id of [...presence.ids()]) presence.remove(id);
|
|
4041
5475
|
if (restored.mode !== this.mode) {
|
|
@@ -4060,6 +5494,9 @@ var RoomCore = class _RoomCore {
|
|
|
4060
5494
|
const historySetup = this.physics ? historyOf(definition.config.physics) : void 0;
|
|
4061
5495
|
this.rewindState = historySetup === void 0 ? void 0 : new RewindState(historySetup);
|
|
4062
5496
|
this.subscribeDeclaredChannels();
|
|
5497
|
+
installCaps(self, options.caps ?? {});
|
|
5498
|
+
this.wallNow = options.wallNow ?? (() => Date.now());
|
|
5499
|
+
restoreHolds(self, restored?.holds);
|
|
4063
5500
|
if (restored) {
|
|
4064
5501
|
const onWake = definition.config.onWake;
|
|
4065
5502
|
if (onWake) this.guard("onWake", () => onWake(this.state, this.room));
|
|
@@ -4083,6 +5520,37 @@ var RoomCore = class _RoomCore {
|
|
|
4083
5520
|
get busConfig() {
|
|
4084
5521
|
return this.definition.config.bus;
|
|
4085
5522
|
}
|
|
5523
|
+
/**
|
|
5524
|
+
* M7 D-P2: the platform notice, and the room's veto.
|
|
5525
|
+
*
|
|
5526
|
+
* Answers whether the supervisor should broadcast. `false` — the veto — comes back for exactly
|
|
5527
|
+
* one input: a handler that ran and returned `false`. Every other path answers `true`, and each
|
|
5528
|
+
* one is a different way of the room having said nothing:
|
|
5529
|
+
*
|
|
5530
|
+
* - **No handler.** A room that never wrote this hook. The design's whole point is that its
|
|
5531
|
+
* players still hear the warning; a code-less room does not even reach this file.
|
|
5532
|
+
* - **A handler that threw.** `tryRun` and not `guard`, so the throw counts toward the crash
|
|
5533
|
+
* threshold like any other, and it still does not become a veto. This is the one place the
|
|
5534
|
+
* convention differs from `onChat`, where a throw refuses, and the reason is the asymmetry of
|
|
5535
|
+
* the two losses: a chat line nobody sees is small; a save-loss warning nobody sees is the
|
|
5536
|
+
* feature not existing.
|
|
5537
|
+
* - **A stopped room.** Nobody to ask, and for `closing` nobody left to veto for.
|
|
5538
|
+
*
|
|
5539
|
+
* The handler may also have written state, posted chat or started an end sequence. All of that
|
|
5540
|
+
* is ordinary handler work and is flushed here the way an alarm's is.
|
|
5541
|
+
*/
|
|
5542
|
+
deliverPlatformNotice(notice) {
|
|
5543
|
+
if (this.stopped) return true;
|
|
5544
|
+
const handler = this.definition.config.onPlatformNotice;
|
|
5545
|
+
if (!handler) return true;
|
|
5546
|
+
this.recordEvent("notice", void 0, notice.phase);
|
|
5547
|
+
const ran = this.tryRun(
|
|
5548
|
+
"onPlatformNotice",
|
|
5549
|
+
() => handler(this.anyState, notice, this.room)
|
|
5550
|
+
);
|
|
5551
|
+
this.eventFlush();
|
|
5552
|
+
return !(ran.ok && ran.value === false);
|
|
5553
|
+
}
|
|
4086
5554
|
/**
|
|
4087
5555
|
* D59: one published message arriving on a channel this room is subscribed to.
|
|
4088
5556
|
*
|
|
@@ -4371,6 +5839,8 @@ var RoomCore = class _RoomCore {
|
|
|
4371
5839
|
}
|
|
4372
5840
|
stop() {
|
|
4373
5841
|
if (this.stopped) return;
|
|
5842
|
+
drainRecordingFinal(this.internals);
|
|
5843
|
+
cancelAllAsks(this.internals);
|
|
4374
5844
|
this.stopped = true;
|
|
4375
5845
|
this.started = false;
|
|
4376
5846
|
this.loop.stop();
|
|
@@ -4400,15 +5870,22 @@ var RoomCore = class _RoomCore {
|
|
|
4400
5870
|
mode: this.mode,
|
|
4401
5871
|
schemaHash8: this.ext.hash8
|
|
4402
5872
|
},
|
|
4403
|
-
|
|
4404
|
-
physics
|
|
5873
|
+
encodeSnapshot4(this.ext, this.plain, { tick: this.tick }),
|
|
5874
|
+
physics,
|
|
5875
|
+
// ---- M6 lane K: stateful relay ----
|
|
5876
|
+
// D77: hold deadlines are state, so they go where the state goes. `undefined` for a room
|
|
5877
|
+
// holding nothing, which keeps its blob byte-identical to the one it wrote before D77.
|
|
5878
|
+
serializeHolds(this.internals)
|
|
5879
|
+
// ---- end M6 lane K ----
|
|
4405
5880
|
);
|
|
4406
5881
|
}
|
|
4407
5882
|
serialize() {
|
|
4408
5883
|
const onSleep = this.definition.config.onSleep;
|
|
4409
5884
|
if (onSleep) this.guard("onSleep", () => onSleep(this.state, this.room));
|
|
4410
5885
|
this.loop.clearAllTimers();
|
|
5886
|
+
cancelAllAsks(this.internals);
|
|
4411
5887
|
rejectAllPending(this.internals, "room is hibernating");
|
|
5888
|
+
noteRecordingRepliesDropped(this.internals);
|
|
4412
5889
|
rejectAllHostCalls(this.internals, "room is hibernating");
|
|
4413
5890
|
return this.snapshot();
|
|
4414
5891
|
}
|
|
@@ -4476,6 +5953,18 @@ var RoomCore = class _RoomCore {
|
|
|
4476
5953
|
// a key join `ctx.playerId` is exactly as durable as the resume token and no more. A JWT
|
|
4477
5954
|
// join (D27, week 13) passes the verified `<iss>:<sub>` in `JoinOptions.playerId` instead,
|
|
4478
5955
|
// and nothing else here changes.
|
|
5956
|
+
//
|
|
5957
|
+
// ---- M6 lane K: stateful relay ----
|
|
5958
|
+
// An existing entry keeps the `playerId` it was created with: this is `existing ?? {...}`,
|
|
5959
|
+
// so a rejoin never overwrites it. That is deliberate and it is the safe direction — a
|
|
5960
|
+
// session's identity cannot be changed by anything a later HELLO on the same seat says. It
|
|
5961
|
+
// has one visible consequence, and D77's holds are where it shows: a client that joins
|
|
5962
|
+
// anonymously and presents an assertion later in the same session keeps the resume token's
|
|
5963
|
+
// client id as its `playerId`, so its holds stay bound to the token rather than moving to
|
|
5964
|
+
// the verified subject. Its next fresh join binds them to the subject. Said in
|
|
5965
|
+
// `authority-models.md` too, because a developer meets it as "my holds did not follow me to
|
|
5966
|
+
// the other tab" rather than as a line in this file.
|
|
5967
|
+
// ---- end M6 lane K ----
|
|
4479
5968
|
playerId: options.playerId ?? clientId,
|
|
4480
5969
|
npc: options.npc === true,
|
|
4481
5970
|
role,
|
|
@@ -4493,6 +5982,8 @@ var RoomCore = class _RoomCore {
|
|
|
4493
5982
|
entry.connected = true;
|
|
4494
5983
|
this.clients.set(clientId, entry);
|
|
4495
5984
|
this.invalidateClients();
|
|
5985
|
+
reclaimHolds(this.internals, clientId, entry.playerId);
|
|
5986
|
+
addPerPlayerRecords(this.internals, clientId);
|
|
4496
5987
|
const onJoin = this.definition.config.onJoin;
|
|
4497
5988
|
if (onJoin) {
|
|
4498
5989
|
const ctx = this.ctxFor(clientId, reconnecting);
|
|
@@ -4526,7 +6017,7 @@ var RoomCore = class _RoomCore {
|
|
|
4526
6017
|
/** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
|
|
4527
6018
|
eventFlush() {
|
|
4528
6019
|
if (this.mode !== "event" || this.stopped) return;
|
|
4529
|
-
if (
|
|
6020
|
+
if (isDirtyEmpty4(this.tracked.dirty)) return;
|
|
4530
6021
|
this.tick++;
|
|
4531
6022
|
this.flush();
|
|
4532
6023
|
this.captureTimeline();
|
|
@@ -4542,6 +6033,9 @@ var RoomCore = class _RoomCore {
|
|
|
4542
6033
|
const ctx = this.ctxFor(clientId);
|
|
4543
6034
|
this.guard("onLeave", () => onLeave(this.state, ctx, reason));
|
|
4544
6035
|
}
|
|
6036
|
+
cancelAsksFor(this.internals, clientId);
|
|
6037
|
+
placeHolds(this.internals, clientId, entry.playerId);
|
|
6038
|
+
forgetWriter(this.internals, clientId);
|
|
4545
6039
|
this.presence.remove(clientId);
|
|
4546
6040
|
this.recordEvent("leave", clientId, reason);
|
|
4547
6041
|
rejectPendingFor(this.internals, clientId, "client disconnected");
|
|
@@ -4559,6 +6053,7 @@ var RoomCore = class _RoomCore {
|
|
|
4559
6053
|
}
|
|
4560
6054
|
entry.connected = false;
|
|
4561
6055
|
entry.correction = void 0;
|
|
6056
|
+
cancelAsksFor(this.internals, clientId);
|
|
4562
6057
|
const record = this.presence.get(clientId);
|
|
4563
6058
|
if (record && record.connected !== false) record.connected = false;
|
|
4564
6059
|
this.invalidateClients();
|
|
@@ -4675,6 +6170,22 @@ var RoomCore = class _RoomCore {
|
|
|
4675
6170
|
* `receive()` accepted, plus the join snapshots this room handed the host to wrap in a
|
|
4676
6171
|
* `WELCOME` (the host builds that frame, so the room never sees it — see the docs page).
|
|
4677
6172
|
*/
|
|
6173
|
+
// ---- M6 lane K: stateful relay ----
|
|
6174
|
+
/** D77: the encoded state size, when a host set the state cap. See `caps.ts`. */
|
|
6175
|
+
stateBytes() {
|
|
6176
|
+
return stateBytesOf(this.internals);
|
|
6177
|
+
}
|
|
6178
|
+
/** D77: how many records are held for players who have left. */
|
|
6179
|
+
holdCount() {
|
|
6180
|
+
return holdCount(this.internals);
|
|
6181
|
+
}
|
|
6182
|
+
// ---- end M6 lane K ----
|
|
6183
|
+
// ---- M6 ask packet ----
|
|
6184
|
+
/** How many `transfer: 'ask'` requests are waiting on an owner's answer right now. */
|
|
6185
|
+
askCount() {
|
|
6186
|
+
return askCount(this.internals);
|
|
6187
|
+
}
|
|
6188
|
+
// ---- end M6 ask packet ----
|
|
4678
6189
|
profile() {
|
|
4679
6190
|
return this.ledger?.snapshot();
|
|
4680
6191
|
}
|
|
@@ -4705,20 +6216,20 @@ var RoomCore = class _RoomCore {
|
|
|
4705
6216
|
return;
|
|
4706
6217
|
}
|
|
4707
6218
|
switch (type) {
|
|
4708
|
-
case
|
|
4709
|
-
case
|
|
4710
|
-
if (type ===
|
|
6219
|
+
case FrameType6.WRITE:
|
|
6220
|
+
case FrameType6.CALL: {
|
|
6221
|
+
if (type === FrameType6.WRITE) this.recordEvent("write", clientId);
|
|
4711
6222
|
if (this.mode === "tick") {
|
|
4712
6223
|
this.loop.enqueue({ clientId, type, payload: payload.slice() });
|
|
4713
6224
|
} else if (!this.loop.applyEvent({ clientId, type, payload })) {
|
|
4714
6225
|
this.badFrame(
|
|
4715
6226
|
clientId,
|
|
4716
|
-
`malformed ${type ===
|
|
6227
|
+
`malformed ${type === FrameType6.WRITE ? "WRITE" : "CALL"} payload`
|
|
4717
6228
|
);
|
|
4718
6229
|
}
|
|
4719
6230
|
return;
|
|
4720
6231
|
}
|
|
4721
|
-
case
|
|
6232
|
+
case FrameType6.REPLY: {
|
|
4722
6233
|
if (!handleReply(this.internals, clientId, payload)) {
|
|
4723
6234
|
this.badFrame(clientId, "malformed REPLY payload");
|
|
4724
6235
|
} else {
|
|
@@ -4726,7 +6237,7 @@ var RoomCore = class _RoomCore {
|
|
|
4726
6237
|
}
|
|
4727
6238
|
return;
|
|
4728
6239
|
}
|
|
4729
|
-
case
|
|
6240
|
+
case FrameType6.MSG: {
|
|
4730
6241
|
if (this.mode === "event") this.tick++;
|
|
4731
6242
|
const ok = handleMsg(this.internals, clientId, payload);
|
|
4732
6243
|
if (this.mode === "event") this.flush();
|
|
@@ -4744,6 +6255,7 @@ var RoomCore = class _RoomCore {
|
|
|
4744
6255
|
*/
|
|
4745
6256
|
flush() {
|
|
4746
6257
|
evaluateLobby(this.internals);
|
|
6258
|
+
expireHolds(this.internals);
|
|
4747
6259
|
const dirty = this.tracked.flush();
|
|
4748
6260
|
serverWinsCorrections(this.internals, dirty);
|
|
4749
6261
|
this.ledger?.newFlush();
|
|
@@ -4801,14 +6313,14 @@ var RoomCore = class _RoomCore {
|
|
|
4801
6313
|
let shared;
|
|
4802
6314
|
if (entry.pendingJoinAdds) {
|
|
4803
6315
|
const sharedDirty = sharedViewDirty(this.ext, sourceDirty, entry.role);
|
|
4804
|
-
shared =
|
|
6316
|
+
shared = isDirtyEmpty4(sharedDirty) ? null : encodeDelta5(this.ext, this.plain, sharedDirty, { tick: this.tick });
|
|
4805
6317
|
if (shared) this.stats.encodesLastFlush++;
|
|
4806
6318
|
} else {
|
|
4807
6319
|
const key = viewKeyFor(this.ext, entry.role);
|
|
4808
6320
|
let cached = byView.get(key);
|
|
4809
6321
|
if (cached === void 0) {
|
|
4810
6322
|
const sharedDirty = sharedViewDirty(this.ext, dirty, entry.role);
|
|
4811
|
-
cached =
|
|
6323
|
+
cached = isDirtyEmpty4(sharedDirty) ? null : encodeDelta5(this.ext, this.plain, sharedDirty, { tick: this.tick });
|
|
4812
6324
|
if (cached) this.stats.encodesLastFlush++;
|
|
4813
6325
|
byView.set(key, cached);
|
|
4814
6326
|
}
|
|
@@ -4817,7 +6329,7 @@ var RoomCore = class _RoomCore {
|
|
|
4817
6329
|
if (shared) {
|
|
4818
6330
|
this.send(
|
|
4819
6331
|
entry.clientId,
|
|
4820
|
-
|
|
6332
|
+
encodeFrame5(FrameType6.DELTA, shared),
|
|
4821
6333
|
this.ledger ? { shared } : void 0
|
|
4822
6334
|
);
|
|
4823
6335
|
}
|
|
@@ -4828,7 +6340,7 @@ var RoomCore = class _RoomCore {
|
|
|
4828
6340
|
memberships
|
|
4829
6341
|
);
|
|
4830
6342
|
const encodeStarted = performance.now();
|
|
4831
|
-
delta =
|
|
6343
|
+
delta = isDirtyEmpty4(aoiDirty) ? null : encodeDelta5(this.ext, this.plain, aoiDirty, { tick: this.tick });
|
|
4832
6344
|
this.stats.aoiEncodeMs += performance.now() - encodeStarted;
|
|
4833
6345
|
if (delta) {
|
|
4834
6346
|
this.stats.encodesLastFlush++;
|
|
@@ -4859,7 +6371,7 @@ var RoomCore = class _RoomCore {
|
|
|
4859
6371
|
if (delta) {
|
|
4860
6372
|
this.send(
|
|
4861
6373
|
entry.clientId,
|
|
4862
|
-
|
|
6374
|
+
encodeFrame5(FrameType6.DELTA, delta),
|
|
4863
6375
|
this.ledger ? {
|
|
4864
6376
|
...churn ? { churn } : {},
|
|
4865
6377
|
...spatialDescs.length > 0 ? {} : { shared: delta }
|
|
@@ -4870,6 +6382,7 @@ var RoomCore = class _RoomCore {
|
|
|
4870
6382
|
entry.correction = void 0;
|
|
4871
6383
|
entry.accepted.clear();
|
|
4872
6384
|
}
|
|
6385
|
+
recordFlush(this.internals, dirty, byView);
|
|
4873
6386
|
}
|
|
4874
6387
|
};
|
|
4875
6388
|
function encodeSection(physics) {
|
|
@@ -4885,6 +6398,11 @@ function encodeSection(physics) {
|
|
|
4885
6398
|
export {
|
|
4886
6399
|
HOST_CALL_TIMEOUT_MS,
|
|
4887
6400
|
RoomFullError,
|
|
6401
|
+
RELAY_STATE_BYTES,
|
|
6402
|
+
RELAY_RECORDS_PER_COLLECTION,
|
|
6403
|
+
RELAY_WRITES_PER_SECOND,
|
|
6404
|
+
DEFAULT_RECORDS_PER_COLLECTION,
|
|
6405
|
+
RELAY_WRITE_CAPS,
|
|
4888
6406
|
EVENT_RING_SIZE,
|
|
4889
6407
|
inspectState,
|
|
4890
6408
|
RPC_TIMEOUT_MS,
|