@irtio/bots 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +917 -63
- package/dist/index.js +1114 -144
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -14,7 +14,14 @@ var INVARIANT_NAMES = [
|
|
|
14
14
|
"misprediction",
|
|
15
15
|
"snaps",
|
|
16
16
|
"disconnects",
|
|
17
|
-
"tick-health"
|
|
17
|
+
"tick-health",
|
|
18
|
+
// D70 (M6 lane C): typed peer messages a bot could not read. A shape mismatch between a bot
|
|
19
|
+
// script and the schema was previously a silent nothing-happened; this makes it a red run.
|
|
20
|
+
"typed-message-drops",
|
|
21
|
+
// M6 lane E (D73-c): every party's members share a room, and no room holds more bots than its
|
|
22
|
+
// ticket said it seats. Listed only on a run that queued — a run that built its own room has no
|
|
23
|
+
// parties to be intact, so the row is absent rather than unread. See `partyIntegrityResult`.
|
|
24
|
+
"party-integrity"
|
|
18
25
|
];
|
|
19
26
|
var DEFAULT_THRESHOLDS = {
|
|
20
27
|
budgetBytesPerSec: 128e3,
|
|
@@ -22,7 +29,8 @@ var DEFAULT_THRESHOLDS = {
|
|
|
22
29
|
handlerErrorsMax: 0,
|
|
23
30
|
mispredictionMagnitudeMax: Number.POSITIVE_INFINITY,
|
|
24
31
|
snapsMax: Number.POSITIVE_INFINITY,
|
|
25
|
-
overrunsMax: 0
|
|
32
|
+
overrunsMax: 0,
|
|
33
|
+
typedMessageDropsMax: 0
|
|
26
34
|
};
|
|
27
35
|
var widenedSchemas = /* @__PURE__ */ new WeakMap();
|
|
28
36
|
function widenGrids(ext, slack) {
|
|
@@ -110,29 +118,390 @@ function frameVisibilityLeaks(ext, role, type, payload, spatial) {
|
|
|
110
118
|
return deltaVisibilityLeaks(ext, role, decodeDelta(ext, payload), spatial);
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
// src/conditions.ts
|
|
122
|
+
import { FrameType as FrameType2 } from "@irtio/protocol";
|
|
123
|
+
var STATE_FRAMES = /* @__PURE__ */ new Set([
|
|
124
|
+
FrameType2.WRITE,
|
|
125
|
+
FrameType2.DELTA,
|
|
126
|
+
FrameType2.CORRECT,
|
|
127
|
+
FrameType2.MSG
|
|
128
|
+
]);
|
|
129
|
+
var DUPLICATE_GAP_MS = 1;
|
|
130
|
+
var DEFAULT_REORDER_MS = 50;
|
|
131
|
+
function newConditionCounters() {
|
|
132
|
+
return {
|
|
133
|
+
droppedOut: 0,
|
|
134
|
+
droppedIn: 0,
|
|
135
|
+
duplicatedOut: 0,
|
|
136
|
+
duplicatedIn: 0,
|
|
137
|
+
reorderedOut: 0,
|
|
138
|
+
reorderedIn: 0,
|
|
139
|
+
delayed: 0
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function hasConditions(c) {
|
|
143
|
+
if (!c) return false;
|
|
144
|
+
return (c.rttMs ?? 0) > 0 || (c.jitterMs ?? 0) > 0 || (c.loss ?? 0) > 0 || (c.duplicate ?? 0) > 0 || (c.reorder ?? 0) > 0;
|
|
145
|
+
}
|
|
146
|
+
function describeConditions(c) {
|
|
147
|
+
const parts = [];
|
|
148
|
+
if ((c.rttMs ?? 0) > 0) parts.push(`rtt ${c.rttMs} ms`);
|
|
149
|
+
if ((c.jitterMs ?? 0) > 0) parts.push(`jitter ${c.jitterMs} ms`);
|
|
150
|
+
if ((c.loss ?? 0) > 0) parts.push(`loss ${pct(c.loss ?? 0)}`);
|
|
151
|
+
if ((c.duplicate ?? 0) > 0) parts.push(`duplicate ${pct(c.duplicate ?? 0)}`);
|
|
152
|
+
if ((c.reorder ?? 0) > 0) {
|
|
153
|
+
parts.push(`reorder ${pct(c.reorder ?? 0)} within ${c.reorderMs ?? DEFAULT_REORDER_MS} ms`);
|
|
154
|
+
}
|
|
155
|
+
return parts.length === 0 ? "none" : parts.join(", ");
|
|
156
|
+
}
|
|
157
|
+
function pct(v) {
|
|
158
|
+
return `${Math.round(v * 1e3) / 10}%`;
|
|
159
|
+
}
|
|
160
|
+
var realTimers = {
|
|
161
|
+
now: () => Date.now(),
|
|
162
|
+
setTimeout(fn, ms) {
|
|
163
|
+
const timer = setTimeout(fn, ms);
|
|
164
|
+
timer.unref?.();
|
|
165
|
+
return () => clearTimeout(timer);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
function conditionedTransport(base, conditions, rng, options = {}) {
|
|
169
|
+
const counters = options.counters ?? newConditionCounters();
|
|
170
|
+
const timers = options.timers ?? realTimers;
|
|
171
|
+
const rttMs = Math.max(0, conditions.rttMs ?? 0);
|
|
172
|
+
const jitterMs = Math.max(0, conditions.jitterMs ?? 0);
|
|
173
|
+
const loss = clamp01(conditions.loss ?? 0);
|
|
174
|
+
const duplicate = clamp01(conditions.duplicate ?? 0);
|
|
175
|
+
const reorder = clamp01(conditions.reorder ?? 0);
|
|
176
|
+
const reorderMs = Math.max(0, conditions.reorderMs ?? DEFAULT_REORDER_MS);
|
|
177
|
+
return {
|
|
178
|
+
connect(url) {
|
|
179
|
+
const socket = base.connect(url);
|
|
180
|
+
const cancels = /* @__PURE__ */ new Set();
|
|
181
|
+
let closed = false;
|
|
182
|
+
let nextIn = 0;
|
|
183
|
+
let nextOut = 0;
|
|
184
|
+
function schedule(delayMs, run) {
|
|
185
|
+
if (closed) return;
|
|
186
|
+
counters.delayed++;
|
|
187
|
+
let cancel = () => void 0;
|
|
188
|
+
const fire = () => {
|
|
189
|
+
cancels.delete(cancel);
|
|
190
|
+
if (!closed) run();
|
|
191
|
+
};
|
|
192
|
+
if (delayMs <= 0) {
|
|
193
|
+
cancel = timers.setTimeout(fire, 0);
|
|
194
|
+
} else {
|
|
195
|
+
cancel = timers.setTimeout(fire, delayMs);
|
|
196
|
+
}
|
|
197
|
+
cancels.add(cancel);
|
|
198
|
+
}
|
|
199
|
+
function deliver(dir, bytes, run) {
|
|
200
|
+
const type = bytes.length > 0 ? bytes[0] ?? -1 : -1;
|
|
201
|
+
const stateful = STATE_FRAMES.has(type);
|
|
202
|
+
if (stateful && loss > 0 && rng.next() < loss) {
|
|
203
|
+
if (dir === "in") counters.droppedIn++;
|
|
204
|
+
else counters.droppedOut++;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const now = timers.now();
|
|
208
|
+
let at = now + rttMs / 2 + (jitterMs > 0 ? rng.next() * jitterMs : 0);
|
|
209
|
+
const reordered = stateful && reorder > 0 && rng.next() < reorder;
|
|
210
|
+
if (reordered) {
|
|
211
|
+
at += reorderMs > 0 ? rng.next() * reorderMs : 0;
|
|
212
|
+
if (dir === "in") counters.reorderedIn++;
|
|
213
|
+
else counters.reorderedOut++;
|
|
214
|
+
} else {
|
|
215
|
+
const floor = dir === "in" ? nextIn : nextOut;
|
|
216
|
+
at = Math.max(at, floor);
|
|
217
|
+
if (dir === "in") nextIn = at;
|
|
218
|
+
else nextOut = at;
|
|
219
|
+
}
|
|
220
|
+
schedule(at - now, run);
|
|
221
|
+
if (stateful && duplicate > 0 && rng.next() < duplicate) {
|
|
222
|
+
if (dir === "in") counters.duplicatedIn++;
|
|
223
|
+
else counters.duplicatedOut++;
|
|
224
|
+
schedule(at - now + DUPLICATE_GAP_MS, run);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const wrapper = {
|
|
228
|
+
onopen: null,
|
|
229
|
+
onmessage: null,
|
|
230
|
+
onclose: null,
|
|
231
|
+
onerror: null,
|
|
232
|
+
send(bytes) {
|
|
233
|
+
const copy = bytes.slice();
|
|
234
|
+
deliver("out", copy, () => socket.send(copy));
|
|
235
|
+
},
|
|
236
|
+
close() {
|
|
237
|
+
closed = true;
|
|
238
|
+
for (const cancel of cancels) cancel();
|
|
239
|
+
cancels.clear();
|
|
240
|
+
socket.close();
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
socket.onopen = () => wrapper.onopen?.();
|
|
244
|
+
socket.onmessage = (bytes) => {
|
|
245
|
+
const copy = bytes.slice();
|
|
246
|
+
deliver("in", copy, () => wrapper.onmessage?.(copy));
|
|
247
|
+
};
|
|
248
|
+
socket.onclose = (info) => {
|
|
249
|
+
closed = true;
|
|
250
|
+
for (const cancel of cancels) cancel();
|
|
251
|
+
cancels.clear();
|
|
252
|
+
wrapper.onclose?.(info);
|
|
253
|
+
};
|
|
254
|
+
socket.onerror = (error) => wrapper.onerror?.(error);
|
|
255
|
+
return wrapper;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function clamp01(v) {
|
|
260
|
+
return Math.min(1, Math.max(0, v));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/hits.ts
|
|
264
|
+
function entityValue(frameState, collection, id) {
|
|
265
|
+
const table = frameState[collection];
|
|
266
|
+
if (typeof table !== "object" || table === null) return void 0;
|
|
267
|
+
const record = table[id];
|
|
268
|
+
if (typeof record !== "object" || record === null) return void 0;
|
|
269
|
+
const value = record.value;
|
|
270
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
271
|
+
}
|
|
272
|
+
function correlateShots(shots, dump) {
|
|
273
|
+
const frames = dump.frames;
|
|
274
|
+
return shots.map((shot) => {
|
|
275
|
+
const exact = shot.serverTick !== void 0;
|
|
276
|
+
const frame = exact ? frames.find((f) => f.tick === shot.serverTick) : frames.find((f) => f.at >= shot.sentAt + shot.uplinkMs);
|
|
277
|
+
const estimated = !exact && shot.uplinkMs > 0;
|
|
278
|
+
const rewound = shot.rewound;
|
|
279
|
+
if (frame === void 0) {
|
|
280
|
+
return {
|
|
281
|
+
...shot,
|
|
282
|
+
serverTick: exact ? shot.serverTick : void 0,
|
|
283
|
+
estimated,
|
|
284
|
+
rewound,
|
|
285
|
+
authoritative: void 0,
|
|
286
|
+
missDistance: void 0,
|
|
287
|
+
unresolved: frames.length === 0 ? "nothing was recorded, so there is no tick to judge this shot against" : exact ? `the room judged this shot at tick ${shot.serverTick}, which is not in the recording` : `the shot lands after the last recorded tick (${frames[frames.length - 1]?.tick})`
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const value = entityValue(frame.state, shot.collection, shot.target);
|
|
291
|
+
if (value === void 0) {
|
|
292
|
+
return {
|
|
293
|
+
...shot,
|
|
294
|
+
serverTick: frame.tick,
|
|
295
|
+
estimated,
|
|
296
|
+
rewound,
|
|
297
|
+
authoritative: void 0,
|
|
298
|
+
missDistance: void 0,
|
|
299
|
+
unresolved: `${shot.collection}.${shot.target} is not in the recording at tick ${frame.tick}`
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const authoritative = {};
|
|
303
|
+
let sum = 0;
|
|
304
|
+
let missing;
|
|
305
|
+
for (const [field, aimed] of Object.entries(shot.aim)) {
|
|
306
|
+
const actual = value[field];
|
|
307
|
+
if (typeof actual !== "number") {
|
|
308
|
+
missing ??= `${shot.collection}.${shot.target}.${field} is not a number on the timeline`;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
authoritative[field] = actual;
|
|
312
|
+
sum += (actual - aimed) ** 2;
|
|
313
|
+
}
|
|
314
|
+
if (missing !== void 0) {
|
|
315
|
+
return {
|
|
316
|
+
...shot,
|
|
317
|
+
serverTick: frame.tick,
|
|
318
|
+
estimated,
|
|
319
|
+
rewound,
|
|
320
|
+
authoritative,
|
|
321
|
+
missDistance: void 0,
|
|
322
|
+
unresolved: missing
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
...shot,
|
|
327
|
+
serverTick: frame.tick,
|
|
328
|
+
estimated,
|
|
329
|
+
rewound,
|
|
330
|
+
authoritative,
|
|
331
|
+
missDistance: Math.sqrt(sum)
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// src/truth.ts
|
|
337
|
+
function captureClientState(schema, state) {
|
|
338
|
+
const view = state ?? {};
|
|
339
|
+
const out = {};
|
|
340
|
+
for (const desc of schema.collections) {
|
|
341
|
+
const raw = view[desc.name];
|
|
342
|
+
if (raw === void 0) continue;
|
|
343
|
+
const collection = asCollection(raw);
|
|
344
|
+
if (collection) {
|
|
345
|
+
const records = {};
|
|
346
|
+
for (const id of collection.ids()) {
|
|
347
|
+
const value = collection.get(id);
|
|
348
|
+
if (value === void 0) continue;
|
|
349
|
+
records[id] = { owner: collection.ownerOf(id), value: plain(value) };
|
|
350
|
+
}
|
|
351
|
+
out[desc.name] = records;
|
|
352
|
+
} else {
|
|
353
|
+
out[desc.name] = plain(raw);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
function asCollection(value) {
|
|
359
|
+
return value !== null && typeof value === "object" && typeof value.ownerOf === "function" ? value : void 0;
|
|
360
|
+
}
|
|
361
|
+
function plain(value) {
|
|
362
|
+
if (value === null || typeof value !== "object") return value;
|
|
363
|
+
const out = {};
|
|
364
|
+
for (const [k, v] of Object.entries(value)) out[k] = plain(v);
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
function diffAgainstSave(clients, saveState, meta) {
|
|
368
|
+
const bots = [];
|
|
369
|
+
const all = [];
|
|
370
|
+
let compared = 0;
|
|
371
|
+
for (const client of clients) {
|
|
372
|
+
const differences = [];
|
|
373
|
+
let seen = 0;
|
|
374
|
+
for (const [collection, held] of Object.entries(client.state)) {
|
|
375
|
+
const authoritative = saveState[collection];
|
|
376
|
+
if (isTable(held)) {
|
|
377
|
+
const table = isTable(authoritative) ? authoritative : {};
|
|
378
|
+
for (const [id, record] of Object.entries(held)) {
|
|
379
|
+
seen++;
|
|
380
|
+
const mine = record;
|
|
381
|
+
const theirs = table[id];
|
|
382
|
+
if (theirs === void 0) {
|
|
383
|
+
differences.push({
|
|
384
|
+
bot: client.bot,
|
|
385
|
+
collection,
|
|
386
|
+
id,
|
|
387
|
+
client: mine.value,
|
|
388
|
+
save: void 0,
|
|
389
|
+
detail: `bot ${client.bot} holds ${collection}.${id}, which the save does not have. A client holding an entity authority does not is a desync, not a visibility gap.`
|
|
390
|
+
});
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (mine.owner !== theirs.owner) {
|
|
394
|
+
differences.push({
|
|
395
|
+
bot: client.bot,
|
|
396
|
+
collection,
|
|
397
|
+
id,
|
|
398
|
+
field: "owner",
|
|
399
|
+
client: mine.owner,
|
|
400
|
+
save: theirs.owner,
|
|
401
|
+
detail: `bot ${client.bot} has ${collection}.${id} owned by ${String(mine.owner)}, the save says ${String(theirs.owner)}`
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
compareValues(client.bot, collection, id, mine.value, theirs.value, differences);
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
seen++;
|
|
408
|
+
compareValues(client.bot, collection, void 0, held, authoritative, differences);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
compared += seen;
|
|
412
|
+
bots.push({ bot: client.bot, compared: seen, differences, ok: differences.length === 0 });
|
|
413
|
+
all.push(...differences);
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
ok: all.length === 0,
|
|
417
|
+
bots,
|
|
418
|
+
compared,
|
|
419
|
+
differences: all,
|
|
420
|
+
saveTick: meta.saveTick,
|
|
421
|
+
saveVersion: meta.saveVersion
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
function isTable(value) {
|
|
425
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
426
|
+
}
|
|
427
|
+
function compareValues(bot, collection, id, client, save, out) {
|
|
428
|
+
const where = id === void 0 ? collection : `${collection}.${id}`;
|
|
429
|
+
if (!isTable(client)) {
|
|
430
|
+
if (!same(client, save)) {
|
|
431
|
+
out.push({
|
|
432
|
+
bot,
|
|
433
|
+
collection,
|
|
434
|
+
...id !== void 0 ? { id } : {},
|
|
435
|
+
client,
|
|
436
|
+
save,
|
|
437
|
+
detail: `bot ${bot} has ${where} = ${show(client)}, the save has ${show(save)}`
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const theirs = isTable(save) ? save : void 0;
|
|
443
|
+
for (const [field, value] of Object.entries(client)) {
|
|
444
|
+
const actual = theirs?.[field];
|
|
445
|
+
if (same(value, actual)) continue;
|
|
446
|
+
out.push({
|
|
447
|
+
bot,
|
|
448
|
+
collection,
|
|
449
|
+
...id !== void 0 ? { id } : {},
|
|
450
|
+
field,
|
|
451
|
+
client: value,
|
|
452
|
+
save: actual,
|
|
453
|
+
detail: `bot ${bot} has ${where}.${field} = ${show(value)}, the save has ${show(actual)}`
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function same(a, b) {
|
|
458
|
+
if (a === b) return true;
|
|
459
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
460
|
+
return Number.isNaN(a) && Number.isNaN(b);
|
|
461
|
+
}
|
|
462
|
+
if (isTable(a) && isTable(b)) {
|
|
463
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
464
|
+
for (const k of keys) if (!same(a[k], b[k])) return false;
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
function show(value) {
|
|
470
|
+
if (value === void 0) return "nothing";
|
|
471
|
+
if (typeof value === "number") return String(Math.round(value * 1e3) / 1e3);
|
|
472
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
473
|
+
if (isTable(value)) return JSON.stringify(value);
|
|
474
|
+
return String(value);
|
|
475
|
+
}
|
|
476
|
+
|
|
113
477
|
// src/observer.ts
|
|
114
478
|
import {
|
|
115
|
-
FrameType as
|
|
479
|
+
FrameType as FrameType4,
|
|
116
480
|
decodeErrorPayload,
|
|
117
481
|
decodeFrame,
|
|
482
|
+
decodeMsg,
|
|
118
483
|
decodeReply,
|
|
119
484
|
decodeWelcome,
|
|
120
485
|
errorByCode,
|
|
121
|
-
readCorrectClientTick
|
|
486
|
+
readCorrectClientTick,
|
|
487
|
+
readSchemaPayload,
|
|
488
|
+
withBuiltins
|
|
122
489
|
} from "@irtio/protocol";
|
|
123
490
|
import {
|
|
124
491
|
ByteReader,
|
|
125
492
|
applyDelta,
|
|
126
493
|
decodeDelta as decodeDelta2,
|
|
127
494
|
decodeDeltaFrom,
|
|
128
|
-
|
|
495
|
+
decodeFields,
|
|
496
|
+
decodeSnapshot,
|
|
497
|
+
schemaFromCanonical
|
|
129
498
|
} from "@irtio/schema";
|
|
130
499
|
|
|
131
500
|
// src/trace.ts
|
|
132
501
|
import { writeFile } from "fs/promises";
|
|
133
|
-
import { FrameType as
|
|
502
|
+
import { FrameType as FrameType3 } from "@irtio/protocol";
|
|
134
503
|
var FRAME_NAMES = new Map(
|
|
135
|
-
Object.entries(
|
|
504
|
+
Object.entries(FrameType3).map(([name, value]) => [value, name])
|
|
136
505
|
);
|
|
137
506
|
function frameName(type) {
|
|
138
507
|
return FRAME_NAMES.get(type) ?? `UNKNOWN(${type})`;
|
|
@@ -192,27 +561,38 @@ function makeTrace(startedAt, rings) {
|
|
|
192
561
|
}
|
|
193
562
|
|
|
194
563
|
// src/observer.ts
|
|
195
|
-
function simulationOnly(ext, delta,
|
|
564
|
+
function simulationOnly(ext, delta, kindOf) {
|
|
196
565
|
let sawSync = false;
|
|
197
566
|
let sawPredicted = false;
|
|
567
|
+
let sawProxied = false;
|
|
198
568
|
for (const dc of delta.collections) {
|
|
199
569
|
const desc = ext.collections.find((c) => c.name === dc.name);
|
|
200
570
|
const physics = desc?.physics;
|
|
201
571
|
if (!desc || !physics) return "mixed";
|
|
202
572
|
for (const op of dc.ops) {
|
|
203
573
|
if (op.op !== "update") return "mixed";
|
|
204
|
-
const
|
|
574
|
+
const kind = kindOf(dc.name, op.id);
|
|
575
|
+
const predicted = kind === "predicted";
|
|
205
576
|
for (const index of op.mask.fields) {
|
|
206
577
|
const field = desc.fields[index];
|
|
207
|
-
if (!field
|
|
208
|
-
if (
|
|
209
|
-
|
|
578
|
+
if (!field) return "mixed";
|
|
579
|
+
if (physics.bodyFields.has(field.name)) {
|
|
580
|
+
if (predicted) sawPredicted = true;
|
|
581
|
+
else if (kind === "proxied") sawProxied = true;
|
|
582
|
+
else sawSync = true;
|
|
583
|
+
} else if (!predicted && !physics.intents.includes(field.name)) {
|
|
584
|
+
sawSync = true;
|
|
585
|
+
} else {
|
|
586
|
+
return "mixed";
|
|
587
|
+
}
|
|
210
588
|
}
|
|
211
589
|
}
|
|
212
590
|
}
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
return "
|
|
591
|
+
const kinds = Number(sawPredicted) + Number(sawSync) + Number(sawProxied);
|
|
592
|
+
if (kinds !== 1) return "mixed";
|
|
593
|
+
if (sawPredicted) return "predicted";
|
|
594
|
+
if (sawProxied) return "proxied";
|
|
595
|
+
return "sync";
|
|
216
596
|
}
|
|
217
597
|
var WriteLog = class {
|
|
218
598
|
constructor(limit = 4096) {
|
|
@@ -262,11 +642,22 @@ var BotObserver = class {
|
|
|
262
642
|
constructor(options) {
|
|
263
643
|
this.options = options;
|
|
264
644
|
this.ring = new TraceRing(options.traceLimit);
|
|
645
|
+
this.ext = options.ext;
|
|
265
646
|
}
|
|
266
647
|
options;
|
|
267
648
|
ring;
|
|
268
649
|
violations = /* @__PURE__ */ new Map();
|
|
269
650
|
counts = /* @__PURE__ */ new Map();
|
|
651
|
+
/**
|
|
652
|
+
* Bug #28: the decode extension, *mutable*. It starts as `options.ext` (the schema the run was
|
|
653
|
+
* spawned with) and is rebuilt in place when an inbound `SCHEMA` frame (13) lands — a D50
|
|
654
|
+
* additive `migrate` swaps the wire mid-session, the real client rebuilds its own decoders
|
|
655
|
+
* (`session.swapSchema`), and an observer still holding the v1 descriptor underruns on the v2
|
|
656
|
+
* resync WELCOME and fails `schema-validity` on a swap that was perfect.
|
|
657
|
+
*/
|
|
658
|
+
ext;
|
|
659
|
+
/** How many `SCHEMA` frames rebuilt {@link ext}. For tests and the trace, like the client's. */
|
|
660
|
+
schemaSwaps = 0;
|
|
270
661
|
id = "";
|
|
271
662
|
role = "";
|
|
272
663
|
roomId = "";
|
|
@@ -284,14 +675,29 @@ var BotObserver = class {
|
|
|
284
675
|
bytesOut = 0;
|
|
285
676
|
corrections = 0;
|
|
286
677
|
syncCorrections = 0;
|
|
678
|
+
proxiedCorrections = 0;
|
|
287
679
|
suppressedCorrections = 0;
|
|
288
680
|
mispredictions = 0;
|
|
289
681
|
/**
|
|
290
|
-
*
|
|
291
|
-
* `spawnBots` once the room exists (`room.prediction`);
|
|
292
|
-
* it cannot be derived from the bytes the observer otherwise sticks to.
|
|
682
|
+
* M6 lane E (D73-b): what this bot's client's local world holds for `collection[id]` right now.
|
|
683
|
+
* Assigned by `spawnBots` once the room exists (`room.prediction`); the local world's shape is
|
|
684
|
+
* client-local, so it cannot be derived from the bytes the observer otherwise sticks to.
|
|
685
|
+
*
|
|
686
|
+
* Unset means "no local world at all", which answers `absent` for everything — the behaviour a
|
|
687
|
+
* bot without physics has always had.
|
|
688
|
+
*/
|
|
689
|
+
bodyKind;
|
|
690
|
+
/**
|
|
691
|
+
* M6 lane E (D73-a): the client's own `room.prediction`, when this bot joined with `physics` or
|
|
692
|
+
* `physics2d`. Held rather than copied because `active` flips once the engine has loaded, which
|
|
693
|
+
* happens off the join path — a boolean read at join time would say `false` on every run.
|
|
293
694
|
*/
|
|
294
|
-
|
|
695
|
+
prediction;
|
|
696
|
+
/** D73-a: latched true the first time {@link prediction} reported an active local world. */
|
|
697
|
+
predicted = false;
|
|
698
|
+
/** D73-b: the high-water marks of `prediction.stats.proxies` / `.absent` across the run. */
|
|
699
|
+
proxies = 0;
|
|
700
|
+
absent = 0;
|
|
295
701
|
mispredictionMagnitude = 0;
|
|
296
702
|
mispredictionMax = 0;
|
|
297
703
|
snaps = 0;
|
|
@@ -300,6 +706,10 @@ var BotObserver = class {
|
|
|
300
706
|
disconnects = 0;
|
|
301
707
|
peakBytesInPerSec = 0;
|
|
302
708
|
peakCorrectionsPerSec = 0;
|
|
709
|
+
/** D70: peer-message counters, filled by `inspect`'s MSG case in both directions. */
|
|
710
|
+
messagesSent = 0;
|
|
711
|
+
messagesReceived = 0;
|
|
712
|
+
messagesDropped = 0;
|
|
303
713
|
/**
|
|
304
714
|
* Spatial-grid ops actually put to the AOI policy. Zero on a run whose room has no spatial
|
|
305
715
|
* collection; zero on a run that *does* and would mean the invariant passed vacuously, which is
|
|
@@ -335,6 +745,7 @@ var BotObserver = class {
|
|
|
335
745
|
bytesOut: this.bytesOut,
|
|
336
746
|
corrections: this.corrections,
|
|
337
747
|
syncCorrections: this.syncCorrections,
|
|
748
|
+
proxiedCorrections: this.proxiedCorrections,
|
|
338
749
|
suppressedCorrections: this.suppressedCorrections,
|
|
339
750
|
mispredictions: this.mispredictions,
|
|
340
751
|
mispredictionMagnitude: this.mispredictionMagnitude,
|
|
@@ -344,7 +755,13 @@ var BotObserver = class {
|
|
|
344
755
|
errors: this.errors,
|
|
345
756
|
disconnects: this.disconnects,
|
|
346
757
|
peakBytesInPerSec: this.peakBytesInPerSec,
|
|
347
|
-
peakCorrectionsPerSec: this.peakCorrectionsPerSec
|
|
758
|
+
peakCorrectionsPerSec: this.peakCorrectionsPerSec,
|
|
759
|
+
messagesSent: this.messagesSent,
|
|
760
|
+
messagesReceived: this.messagesReceived,
|
|
761
|
+
messagesDropped: this.messagesDropped,
|
|
762
|
+
predicting: this.predicted,
|
|
763
|
+
proxies: this.proxies,
|
|
764
|
+
absent: this.absent
|
|
348
765
|
};
|
|
349
766
|
}
|
|
350
767
|
/** Counts a violation always; keeps an example, rate-limited per invariant when asked. */
|
|
@@ -370,6 +787,12 @@ var BotObserver = class {
|
|
|
370
787
|
/** The `onFrame` hook. `bytes` is the whole frame, envelope byte included. */
|
|
371
788
|
onFrame(dir, type, bytes) {
|
|
372
789
|
const now = Date.now();
|
|
790
|
+
const prediction = this.prediction;
|
|
791
|
+
if (prediction?.active === true) {
|
|
792
|
+
this.predicted = true;
|
|
793
|
+
if (prediction.stats.proxies > this.proxies) this.proxies = prediction.stats.proxies;
|
|
794
|
+
if (prediction.stats.absent > this.absent) this.absent = prediction.stats.absent;
|
|
795
|
+
}
|
|
373
796
|
let note;
|
|
374
797
|
if (dir === "in") {
|
|
375
798
|
this.framesIn++;
|
|
@@ -403,44 +826,101 @@ var BotObserver = class {
|
|
|
403
826
|
...note !== void 0 ? { note } : {}
|
|
404
827
|
};
|
|
405
828
|
this.ring.push(entry);
|
|
406
|
-
if (dir === "in" && type ===
|
|
829
|
+
if (dir === "in" && type === FrameType4.CORRECT) this.lastCorrectEntry = entry;
|
|
407
830
|
}
|
|
408
831
|
/** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
|
|
409
832
|
inspect(dir, type, bytes, now) {
|
|
410
833
|
const payload = decodeFrame(bytes).payload;
|
|
411
834
|
if (dir === "out") {
|
|
412
|
-
if (type ===
|
|
413
|
-
if (type ===
|
|
835
|
+
if (type === FrameType4.CALL) this.calls++;
|
|
836
|
+
if (type === FrameType4.WRITE) this.recordWrites(decodeDelta2(this.ext, payload), now);
|
|
837
|
+
if (type === FrameType4.MSG) return this.onMsg("out", payload);
|
|
414
838
|
return void 0;
|
|
415
839
|
}
|
|
416
840
|
switch (type) {
|
|
417
|
-
case
|
|
841
|
+
case FrameType4.WELCOME:
|
|
418
842
|
return this.onWelcome(decodeWelcome(payload));
|
|
419
|
-
case
|
|
420
|
-
return this.onDelta(decodeDelta2(this.
|
|
421
|
-
case
|
|
843
|
+
case FrameType4.DELTA:
|
|
844
|
+
return this.onDelta(decodeDelta2(this.ext, payload), now);
|
|
845
|
+
case FrameType4.CORRECT: {
|
|
422
846
|
const r = new ByteReader(payload);
|
|
423
|
-
const delta = decodeDeltaFrom(this.
|
|
847
|
+
const delta = decodeDeltaFrom(this.ext, r);
|
|
424
848
|
return this.onCorrect(delta, readCorrectClientTick(r), now);
|
|
425
849
|
}
|
|
426
|
-
case
|
|
850
|
+
case FrameType4.ERROR:
|
|
427
851
|
return this.onError(payload);
|
|
428
|
-
case
|
|
852
|
+
case FrameType4.REPLY:
|
|
429
853
|
return this.onReply(payload);
|
|
854
|
+
case FrameType4.SCHEMA:
|
|
855
|
+
return this.onSchema(payload);
|
|
856
|
+
case FrameType4.MSG:
|
|
857
|
+
return this.onMsg("in", payload);
|
|
430
858
|
default:
|
|
431
859
|
return void 0;
|
|
432
860
|
}
|
|
433
861
|
}
|
|
862
|
+
/**
|
|
863
|
+
* D70: one peer message, in either direction, as a trace note.
|
|
864
|
+
*
|
|
865
|
+
* Before this, `MSG` fell through to `undefined` and a trace showed a frame with a byte count
|
|
866
|
+
* and nothing else — which was tolerable while every message was opaque bytes and is not now
|
|
867
|
+
* that some of them have declared shapes. A raw message notes its target; a typed one notes its
|
|
868
|
+
* name and its value when this bot holds the schema, and its index when it does not.
|
|
869
|
+
*
|
|
870
|
+
* A typed message this bot cannot read is counted as a **drop**, which is what the
|
|
871
|
+
* `typed-message-drops` invariant fails a run on. That is the whole point of the counter: a
|
|
872
|
+
* scenario sending a shape the schema does not describe used to be a message that silently
|
|
873
|
+
* never arrived.
|
|
874
|
+
*/
|
|
875
|
+
onMsg(dir, payload) {
|
|
876
|
+
let msg;
|
|
877
|
+
try {
|
|
878
|
+
msg = decodeMsg(payload);
|
|
879
|
+
} catch (err) {
|
|
880
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
881
|
+
if (dir === "in") this.dropTyped(`a MSG frame did not decode: ${why}`);
|
|
882
|
+
return `msg undecodable (${why})`;
|
|
883
|
+
}
|
|
884
|
+
const who = msg.target.kind === "client" ? msg.target.clientId : msg.target.kind === "role" ? `role:${msg.target.role}` : msg.target.kind;
|
|
885
|
+
if (!msg.typed) {
|
|
886
|
+
if (dir === "out") this.messagesSent++;
|
|
887
|
+
else this.messagesReceived++;
|
|
888
|
+
return `msg raw ${who} (${msg.payload.length}B)`;
|
|
889
|
+
}
|
|
890
|
+
const desc = (this.ext.messages ?? [])[msg.typed.index];
|
|
891
|
+
if (!desc) {
|
|
892
|
+
if (dir === "out") this.messagesSent++;
|
|
893
|
+
else this.dropTyped(`no message with index ${msg.typed.index} in this schema`);
|
|
894
|
+
return `msg typed #${msg.typed.index} (undecodable)`;
|
|
895
|
+
}
|
|
896
|
+
let value;
|
|
897
|
+
try {
|
|
898
|
+
value = decodeFields(desc.fields, msg.payload);
|
|
899
|
+
} catch {
|
|
900
|
+
if (dir === "out") this.messagesSent++;
|
|
901
|
+
else this.dropTyped(`${desc.name} did not decode against this schema`);
|
|
902
|
+
return `msg ${desc.name} (undecodable)`;
|
|
903
|
+
}
|
|
904
|
+
if (dir === "out") this.messagesSent++;
|
|
905
|
+
else this.messagesReceived++;
|
|
906
|
+
const arrow = dir === "out" ? "->" : "<-";
|
|
907
|
+
return `msg ${desc.name} ${arrow} ${who} ${JSON.stringify(value)}`;
|
|
908
|
+
}
|
|
909
|
+
/** D70: one typed message this bot could not read — a counter and an invariant violation. */
|
|
910
|
+
dropTyped(why) {
|
|
911
|
+
this.messagesDropped++;
|
|
912
|
+
this.violate("typed-message-drops", why, true);
|
|
913
|
+
}
|
|
434
914
|
onWelcome(welcome) {
|
|
435
915
|
this.id = welcome.clientId;
|
|
436
916
|
this.role = welcome.role;
|
|
437
917
|
this.roomId = welcome.roomId;
|
|
438
|
-
const snapshot = decodeSnapshot(this.
|
|
918
|
+
const snapshot = decodeSnapshot(this.ext, welcome.snapshot);
|
|
439
919
|
this.view = snapshot.state;
|
|
440
920
|
this.rememberSnapshot(snapshot.state);
|
|
441
921
|
this.countSpatialSnapshot(snapshot.state);
|
|
442
922
|
for (const leak of snapshotVisibilityLeaks(
|
|
443
|
-
this.
|
|
923
|
+
this.ext,
|
|
444
924
|
this.role,
|
|
445
925
|
snapshot.state,
|
|
446
926
|
this.spatialContext()
|
|
@@ -449,6 +929,22 @@ var BotObserver = class {
|
|
|
449
929
|
}
|
|
450
930
|
return `joined ${welcome.roomId} as ${welcome.clientId}/${welcome.role || "default"}`;
|
|
451
931
|
}
|
|
932
|
+
/**
|
|
933
|
+
* Bug #28: a `SCHEMA` frame (D50 additive migrate). Rebuild the decode extension from the
|
|
934
|
+
* descriptor the frame carries, exactly as the real client's `swapSchema` does, so the resync
|
|
935
|
+
* WELCOME about to arrive decodes under the schema it was encoded with. The old view and
|
|
936
|
+
* seen-ids are dropped — they were laid out by descriptors that no longer exist, and the resync
|
|
937
|
+
* WELCOME re-seeds both (`onWelcome`).
|
|
938
|
+
*/
|
|
939
|
+
onSchema(payload) {
|
|
940
|
+
const canonical = readSchemaPayload(payload);
|
|
941
|
+
const next = schemaFromCanonical(canonical);
|
|
942
|
+
this.ext = withBuiltins(next);
|
|
943
|
+
this.schemaSwaps++;
|
|
944
|
+
this.view = void 0;
|
|
945
|
+
this.seen.clear();
|
|
946
|
+
return `schema swapped (hash ${next.hash}) \u2014 decode extension rebuilt`;
|
|
947
|
+
}
|
|
452
948
|
onDelta(delta, now) {
|
|
453
949
|
this.checkVisibility(delta);
|
|
454
950
|
this.matchWrites(delta, now);
|
|
@@ -458,14 +954,18 @@ var BotObserver = class {
|
|
|
458
954
|
this.checkVisibility(delta);
|
|
459
955
|
const names = delta.collections.map((c) => c.name).join(",");
|
|
460
956
|
const kind = simulationOnly(
|
|
461
|
-
this.
|
|
957
|
+
this.ext,
|
|
462
958
|
delta,
|
|
463
|
-
(c, id) => this.
|
|
959
|
+
(c, id) => this.bodyKind ? this.bodyKind(c, id) : "absent"
|
|
464
960
|
);
|
|
465
961
|
if (kind === "sync") {
|
|
466
962
|
this.syncCorrections++;
|
|
467
963
|
return `synced ${names}`;
|
|
468
964
|
}
|
|
965
|
+
if (kind === "proxied") {
|
|
966
|
+
this.proxiedCorrections++;
|
|
967
|
+
return `proxied ${names}`;
|
|
968
|
+
}
|
|
469
969
|
if (kind === "predicted") {
|
|
470
970
|
return `judged ${names}${clientTick !== void 0 ? ` (clientTick ${clientTick})` : ""}`;
|
|
471
971
|
}
|
|
@@ -479,7 +979,20 @@ var BotObserver = class {
|
|
|
479
979
|
true
|
|
480
980
|
);
|
|
481
981
|
}
|
|
482
|
-
|
|
982
|
+
const fields = /* @__PURE__ */ new Set();
|
|
983
|
+
for (const dc of delta.collections) {
|
|
984
|
+
const desc = this.ext.collections.find(
|
|
985
|
+
(c) => c.name === dc.name
|
|
986
|
+
);
|
|
987
|
+
for (const op of dc.ops) {
|
|
988
|
+
if (op.op !== "update") {
|
|
989
|
+
fields.add(`${op.op} ${op.id}`);
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
for (const index of op.mask.fields) fields.add(desc?.fields[index]?.name ?? `#${index}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return `corrected ${names} [${[...fields].join(" ")}]${clientTick !== void 0 ? ` (clientTick ${clientTick})` : ""}`;
|
|
483
996
|
}
|
|
484
997
|
/**
|
|
485
998
|
* The room's `correct` event, one per correction op — the only source of `previous` (the local
|
|
@@ -492,7 +1005,7 @@ var BotObserver = class {
|
|
|
492
1005
|
this.suppressedCorrections++;
|
|
493
1006
|
return;
|
|
494
1007
|
}
|
|
495
|
-
const desc = this.
|
|
1008
|
+
const desc = this.ext.collections.find(
|
|
496
1009
|
(c) => c.name === correction.collection
|
|
497
1010
|
);
|
|
498
1011
|
const predictedBody = desc?.physics !== void 0 && correction.fields.length > 0 && correction.fields.every((f) => desc.physics?.bodyFields.has(f));
|
|
@@ -560,14 +1073,9 @@ var BotObserver = class {
|
|
|
560
1073
|
* frame — a departing neighbour, not a leak.
|
|
561
1074
|
*/
|
|
562
1075
|
checkVisibility(delta) {
|
|
563
|
-
if (this.view) applyDelta(this.
|
|
1076
|
+
if (this.view) applyDelta(this.ext, this.view, delta);
|
|
564
1077
|
this.countSpatialDelta(delta);
|
|
565
|
-
for (const leak of deltaVisibilityLeaks(
|
|
566
|
-
this.options.ext,
|
|
567
|
-
this.role,
|
|
568
|
-
delta,
|
|
569
|
-
this.spatialContext()
|
|
570
|
-
)) {
|
|
1078
|
+
for (const leak of deltaVisibilityLeaks(this.ext, this.role, delta, this.spatialContext())) {
|
|
571
1079
|
this.violate("visibility-leak", leak);
|
|
572
1080
|
}
|
|
573
1081
|
this.remember(delta);
|
|
@@ -575,12 +1083,12 @@ var BotObserver = class {
|
|
|
575
1083
|
countSpatialDelta(delta) {
|
|
576
1084
|
if (!this.view || this.id === "") return;
|
|
577
1085
|
for (const collection of delta.collections) {
|
|
578
|
-
const desc = this.
|
|
1086
|
+
const desc = this.ext.collection(collection.name);
|
|
579
1087
|
if (desc.visibility === "spatial-grid") this.spatialOpsJudged += collection.ops.length;
|
|
580
1088
|
}
|
|
581
1089
|
}
|
|
582
1090
|
countSpatialSnapshot(state) {
|
|
583
|
-
for (const desc of this.
|
|
1091
|
+
for (const desc of this.ext.collections) {
|
|
584
1092
|
if (desc.visibility !== "spatial-grid") continue;
|
|
585
1093
|
this.spatialOpsJudged += state[desc.name]?.size ?? 0;
|
|
586
1094
|
}
|
|
@@ -594,7 +1102,7 @@ var BotObserver = class {
|
|
|
594
1102
|
return ids;
|
|
595
1103
|
}
|
|
596
1104
|
rememberSnapshot(state) {
|
|
597
|
-
for (const desc of this.
|
|
1105
|
+
for (const desc of this.ext.collections) {
|
|
598
1106
|
if (desc.kind !== "entity") continue;
|
|
599
1107
|
const collection = state[desc.name];
|
|
600
1108
|
if (!collection) continue;
|
|
@@ -619,6 +1127,7 @@ var BotObserver = class {
|
|
|
619
1127
|
} catch {
|
|
620
1128
|
name = `code ${error.code}`;
|
|
621
1129
|
}
|
|
1130
|
+
if (name === "E_STARTING") return `${name} (room starting \u2014 not counted)`;
|
|
622
1131
|
if (!error.fatal) {
|
|
623
1132
|
this.errors++;
|
|
624
1133
|
this.violate("handler-error", `${name}: ${error.message}`);
|
|
@@ -747,6 +1256,59 @@ function freshValue(rng, desc, ctx) {
|
|
|
747
1256
|
}
|
|
748
1257
|
|
|
749
1258
|
// src/report.ts
|
|
1259
|
+
import { mergeProfiles } from "@irtio/protocol";
|
|
1260
|
+
function matchmakingSummary(reading) {
|
|
1261
|
+
const waits = [...reading.tickets.map((t) => t.waitedMs)].sort((a, b) => a - b);
|
|
1262
|
+
const rooms = /* @__PURE__ */ new Map();
|
|
1263
|
+
for (const t of reading.tickets) {
|
|
1264
|
+
const room = rooms.get(t.room) ?? { bots: 0, size: t.size };
|
|
1265
|
+
room.bots += 1;
|
|
1266
|
+
room.size = Math.max(room.size, t.size);
|
|
1267
|
+
rooms.set(t.room, room);
|
|
1268
|
+
}
|
|
1269
|
+
const byParty = /* @__PURE__ */ new Map();
|
|
1270
|
+
for (const t of reading.tickets) {
|
|
1271
|
+
if (t.party === void 0) continue;
|
|
1272
|
+
const seen = byParty.get(t.party) ?? /* @__PURE__ */ new Set();
|
|
1273
|
+
seen.add(t.room);
|
|
1274
|
+
byParty.set(t.party, seen);
|
|
1275
|
+
}
|
|
1276
|
+
const splitParties = [...byParty].filter(([, seen]) => seen.size > 1).map(([party, seen]) => ({ party, rooms: [...seen] }));
|
|
1277
|
+
const overfullRooms = [...rooms].filter(([, r]) => r.size > 0 && r.bots > r.size).map(([room, r]) => ({ room, bots: r.bots, size: r.size }));
|
|
1278
|
+
return {
|
|
1279
|
+
tickets: reading.tickets.length,
|
|
1280
|
+
rooms: rooms.size,
|
|
1281
|
+
waitP50Ms: percentile(waits, 50),
|
|
1282
|
+
waitP95Ms: percentile(waits, 95),
|
|
1283
|
+
waitMaxMs: waits[waits.length - 1] ?? 0,
|
|
1284
|
+
timeouts: reading.failures.filter((f) => f.code === "E_NO_MATCH").length,
|
|
1285
|
+
failures: reading.failures.filter((f) => f.code !== "E_NO_MATCH"),
|
|
1286
|
+
backfills: reading.tickets.filter((t) => t.backfill).length,
|
|
1287
|
+
partiesIntact: byParty.size - splitParties.length,
|
|
1288
|
+
parties: byParty.size,
|
|
1289
|
+
overfullRooms,
|
|
1290
|
+
splitParties
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
function partyIntegrityResult(reading) {
|
|
1294
|
+
const summary = matchmakingSummary(reading);
|
|
1295
|
+
const violations = summary.splitParties.length + summary.overfullRooms.length;
|
|
1296
|
+
const detail = violations === 0 ? `${summary.parties} part${summary.parties === 1 ? "y" : "ies"} landed whole across ${summary.rooms} room(s), and no room held more bots than its ticket seats` : [
|
|
1297
|
+
...summary.splitParties.map(
|
|
1298
|
+
(p) => `party ${p.party} was split across ${p.rooms.join(", ")}`
|
|
1299
|
+
),
|
|
1300
|
+
...summary.overfullRooms.map(
|
|
1301
|
+
(r) => `room ${r.room} holds ${r.bots} bots on a ticket that seats ${r.size}`
|
|
1302
|
+
)
|
|
1303
|
+
].join("; ");
|
|
1304
|
+
return {
|
|
1305
|
+
name: "party-integrity",
|
|
1306
|
+
ok: violations === 0,
|
|
1307
|
+
state: violations === 0 ? "ok" : "violation",
|
|
1308
|
+
violations,
|
|
1309
|
+
detail
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
750
1312
|
function percentile(sorted, p) {
|
|
751
1313
|
if (sorted.length === 0) return 0;
|
|
752
1314
|
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1));
|
|
@@ -796,9 +1358,12 @@ function detailFor(name, observers, violations, thresholds) {
|
|
|
796
1358
|
case "correction-storm": {
|
|
797
1359
|
const corrections = observers.reduce((sum, o) => sum + o.corrections, 0);
|
|
798
1360
|
const synced = observers.reduce((sum, o) => sum + o.syncCorrections, 0);
|
|
1361
|
+
const proxied = observers.reduce((sum, o) => sum + o.proxiedCorrections, 0);
|
|
799
1362
|
const suppressed = observers.reduce((sum, o) => sum + o.suppressedCorrections, 0);
|
|
800
1363
|
const worst = peak(observers, (o) => o.peakCorrectionsPerSec);
|
|
801
|
-
return `${corrections} correction(s), peak ${worst}/s per bot, threshold ${thresholds.correctionsPerSecMax}/s${synced > 0 ? ` (+${synced} body-sync)` : ""}${
|
|
1364
|
+
return `${corrections} correction(s), peak ${worst}/s per bot, threshold ${thresholds.correctionsPerSecMax}/s${synced > 0 ? ` (+${synced} body-sync)` : ""}${// D73-b: named apart from body-sync, because a proxy correction says something else —
|
|
1365
|
+
// the server disagreeing with what this client drew.
|
|
1366
|
+
proxied > 0 ? ` (+${proxied} proxied)` : ""}${suppressed > 0 ? ` (+${suppressed} within-epsilon)` : ""}${violations === 0 ? "" : examples(observers, name)}`;
|
|
802
1367
|
}
|
|
803
1368
|
case "misprediction": {
|
|
804
1369
|
const count = observers.reduce((sum, o) => sum + o.mispredictions, 0);
|
|
@@ -818,7 +1383,14 @@ function detailFor(name, observers, violations, thresholds) {
|
|
|
818
1383
|
const count = observers.reduce((sum, o) => sum + o.disconnects, 0);
|
|
819
1384
|
return count === 0 ? "every bot stayed connected" : `${count} disconnect(s)${examples(observers, name)}`;
|
|
820
1385
|
}
|
|
1386
|
+
case "typed-message-drops": {
|
|
1387
|
+
const dropped = observers.reduce((sum, o) => sum + o.messagesDropped, 0);
|
|
1388
|
+
const received = observers.reduce((sum, o) => sum + o.messagesReceived, 0);
|
|
1389
|
+
const sent = observers.reduce((sum, o) => sum + o.messagesSent, 0);
|
|
1390
|
+
return `${sent} message(s) sent, ${received} received, ${dropped} dropped, tolerated ${thresholds.typedMessageDropsMax}` + (violations === 0 ? "" : `${examples(observers, name)} \u2014 a dropped typed message means the sender's shape is not one this schema declares`);
|
|
1391
|
+
}
|
|
821
1392
|
case "tick-health":
|
|
1393
|
+
case "party-integrity":
|
|
822
1394
|
return "";
|
|
823
1395
|
}
|
|
824
1396
|
}
|
|
@@ -857,10 +1429,19 @@ function buildReport(options) {
|
|
|
857
1429
|
const thresholds = options.thresholds ?? DEFAULT_THRESHOLDS;
|
|
858
1430
|
const durationMs = Math.max(1, options.durationMs);
|
|
859
1431
|
const seconds = durationMs / 1e3;
|
|
860
|
-
const
|
|
1432
|
+
const matchmaking = options.matchmaking;
|
|
1433
|
+
const invariants = INVARIANT_NAMES.filter(
|
|
1434
|
+
// D73-c: a run that did not queue has no parties, so the row is absent rather than unread.
|
|
1435
|
+
(name) => name !== "party-integrity" || matchmaking !== void 0
|
|
1436
|
+
).map((name) => {
|
|
861
1437
|
if (name === "tick-health") return tickHealthResult(options.tickHealth, thresholds);
|
|
1438
|
+
if (name === "party-integrity") return partyIntegrityResult(matchmaking);
|
|
862
1439
|
const violations = total(observers, name);
|
|
863
|
-
const ok = name === "handler-error" ? violations <= thresholds.handlerErrorsMax :
|
|
1440
|
+
const ok = name === "handler-error" ? violations <= thresholds.handlerErrorsMax : (
|
|
1441
|
+
// D70: a budget too, for the same reason `handler-error` has one — a scenario that
|
|
1442
|
+
// deliberately sends a shape the room does not declare says so with `--typed-drops-max`.
|
|
1443
|
+
name === "typed-message-drops" ? violations <= thresholds.typedMessageDropsMax : violations === 0
|
|
1444
|
+
);
|
|
864
1445
|
const state = ok ? "ok" : "violation";
|
|
865
1446
|
return {
|
|
866
1447
|
name,
|
|
@@ -878,16 +1459,26 @@ function buildReport(options) {
|
|
|
878
1459
|
bytesOut: perBot.reduce((sum, b) => sum + b.bytesOut, 0),
|
|
879
1460
|
corrections: perBot.reduce((sum, b) => sum + b.corrections, 0),
|
|
880
1461
|
syncCorrections: perBot.reduce((sum, b) => sum + b.syncCorrections, 0),
|
|
1462
|
+
proxiedCorrections: perBot.reduce((sum, b) => sum + b.proxiedCorrections, 0),
|
|
881
1463
|
suppressedCorrections: perBot.reduce((sum, b) => sum + b.suppressedCorrections, 0),
|
|
882
1464
|
mispredictions: perBot.reduce((sum, b) => sum + b.mispredictions, 0),
|
|
883
1465
|
mispredictionMagnitude: perBot.reduce((sum, b) => sum + b.mispredictionMagnitude, 0),
|
|
884
1466
|
mispredictionMax: perBot.reduce((max, b) => Math.max(max, b.mispredictionMax), 0),
|
|
885
1467
|
snaps: perBot.reduce((sum, b) => sum + b.snaps, 0),
|
|
886
1468
|
calls: perBot.reduce((sum, b) => sum + b.calls, 0),
|
|
887
|
-
errors: perBot.reduce((sum, b) => sum + b.errors, 0)
|
|
1469
|
+
errors: perBot.reduce((sum, b) => sum + b.errors, 0),
|
|
1470
|
+
messagesSent: perBot.reduce((sum, b) => sum + b.messagesSent, 0),
|
|
1471
|
+
messagesReceived: perBot.reduce((sum, b) => sum + b.messagesReceived, 0),
|
|
1472
|
+
messagesDropped: perBot.reduce((sum, b) => sum + b.messagesDropped, 0)
|
|
888
1473
|
};
|
|
889
1474
|
const bots = Math.max(1, perBot.length);
|
|
890
1475
|
const convergence = convergenceStats(lags);
|
|
1476
|
+
const predicting = perBot.filter((b) => b.predicting);
|
|
1477
|
+
const prediction = predicting.length === 0 ? void 0 : {
|
|
1478
|
+
bots: predicting.length,
|
|
1479
|
+
proxies: predicting.reduce((sum, b) => sum + b.proxies, 0),
|
|
1480
|
+
absent: predicting.reduce((sum, b) => sum + b.absent, 0)
|
|
1481
|
+
};
|
|
891
1482
|
return {
|
|
892
1483
|
ok: invariants.every((i) => i.ok),
|
|
893
1484
|
bots: perBot.length,
|
|
@@ -901,10 +1492,125 @@ function buildReport(options) {
|
|
|
901
1492
|
bytesOutPerSecondPerBot: round(totals.bytesOut / seconds / bots),
|
|
902
1493
|
convergence,
|
|
903
1494
|
convergenceLagMs: convergence?.p50Ms,
|
|
904
|
-
tracePath: options.tracePath
|
|
1495
|
+
tracePath: options.tracePath,
|
|
1496
|
+
...options.profiles !== void 0 && options.profiles.length > 0 ? { profile: options.profiles.reduce(mergeProfiles) } : {},
|
|
1497
|
+
...prediction !== void 0 ? { prediction } : {},
|
|
1498
|
+
...options.matchmaking !== void 0 ? { matchmaking: matchmakingSummary(options.matchmaking) } : {}
|
|
905
1499
|
};
|
|
906
1500
|
}
|
|
907
1501
|
|
|
1502
|
+
// src/match.ts
|
|
1503
|
+
import { MatchError, createParty, findMatch } from "@irtio/client";
|
|
1504
|
+
var DEFAULT_MATCH_TIMEOUT_MS = 2e4;
|
|
1505
|
+
function describe(err) {
|
|
1506
|
+
if (err instanceof MatchError) return { code: err.code, message: err.message };
|
|
1507
|
+
return { code: "E_MATCH_FAILED", message: err instanceof Error ? err.message : String(err) };
|
|
1508
|
+
}
|
|
1509
|
+
async function matchBots(n, options) {
|
|
1510
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
1511
|
+
throw new Error(`irtio bots: matchBots needs at least one bot, got ${String(n)}`);
|
|
1512
|
+
}
|
|
1513
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1514
|
+
for (let i = 0; i < n; i++) {
|
|
1515
|
+
const key = options.party?.(i);
|
|
1516
|
+
if (key === void 0) continue;
|
|
1517
|
+
const members = groups.get(key) ?? [];
|
|
1518
|
+
members.push(i);
|
|
1519
|
+
groups.set(key, members);
|
|
1520
|
+
}
|
|
1521
|
+
const parties = /* @__PURE__ */ new Map();
|
|
1522
|
+
const partyErrors = /* @__PURE__ */ new Map();
|
|
1523
|
+
for (const [key, members] of groups) {
|
|
1524
|
+
if (members.length < 2) continue;
|
|
1525
|
+
try {
|
|
1526
|
+
const minted = await createParty(options.project, {
|
|
1527
|
+
size: members.length,
|
|
1528
|
+
controlUrl: options.controlUrl,
|
|
1529
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {}
|
|
1530
|
+
});
|
|
1531
|
+
parties.set(key, minted.party);
|
|
1532
|
+
} catch (err) {
|
|
1533
|
+
partyErrors.set(key, describe(err));
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_MATCH_TIMEOUT_MS;
|
|
1537
|
+
const attempts = await Promise.all(
|
|
1538
|
+
Array.from({ length: n }, async (_unused, index) => {
|
|
1539
|
+
const key = options.party?.(index);
|
|
1540
|
+
const party = key !== void 0 && parties.has(key) ? parties.get(key) : void 0;
|
|
1541
|
+
const startedAt = Date.now();
|
|
1542
|
+
const failed = key !== void 0 ? partyErrors.get(key) : void 0;
|
|
1543
|
+
if (failed !== void 0) {
|
|
1544
|
+
return { index, waitedMs: 0, error: failed, ...key !== void 0 ? { party: key } : {} };
|
|
1545
|
+
}
|
|
1546
|
+
const identity = options.identity?.(index);
|
|
1547
|
+
try {
|
|
1548
|
+
const ticket = await findMatch(options.project, {
|
|
1549
|
+
controlUrl: options.controlUrl,
|
|
1550
|
+
timeoutMs,
|
|
1551
|
+
...options.queue !== void 0 ? { queue: options.queue } : {},
|
|
1552
|
+
...party !== void 0 ? { party } : {},
|
|
1553
|
+
...identity !== void 0 ? { identity } : {},
|
|
1554
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {}
|
|
1555
|
+
});
|
|
1556
|
+
return {
|
|
1557
|
+
index,
|
|
1558
|
+
ticket,
|
|
1559
|
+
waitedMs: Date.now() - startedAt,
|
|
1560
|
+
...key !== void 0 ? { party: key } : {}
|
|
1561
|
+
};
|
|
1562
|
+
} catch (err) {
|
|
1563
|
+
return {
|
|
1564
|
+
index,
|
|
1565
|
+
waitedMs: Date.now() - startedAt,
|
|
1566
|
+
error: describe(err),
|
|
1567
|
+
...key !== void 0 ? { party: key } : {}
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
})
|
|
1571
|
+
);
|
|
1572
|
+
const tickets = [];
|
|
1573
|
+
const failures = [];
|
|
1574
|
+
for (const attempt of attempts) {
|
|
1575
|
+
if (attempt.ticket === void 0) {
|
|
1576
|
+
failures.push({
|
|
1577
|
+
requested: attempt.index,
|
|
1578
|
+
code: attempt.error?.code ?? "E_MATCH_FAILED",
|
|
1579
|
+
message: attempt.error?.message ?? "no ticket and no error",
|
|
1580
|
+
waitedMs: attempt.waitedMs,
|
|
1581
|
+
...attempt.party !== void 0 ? { party: attempt.party } : {}
|
|
1582
|
+
});
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
tickets.push({
|
|
1586
|
+
bot: tickets.length,
|
|
1587
|
+
requested: attempt.index,
|
|
1588
|
+
ticket: attempt.ticket,
|
|
1589
|
+
waitedMs: attempt.waitedMs,
|
|
1590
|
+
...attempt.party !== void 0 ? { party: attempt.party } : {}
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
return { tickets, failures, parties };
|
|
1594
|
+
}
|
|
1595
|
+
function roomsOf(tickets) {
|
|
1596
|
+
const rooms = /* @__PURE__ */ new Map();
|
|
1597
|
+
for (const t of tickets) {
|
|
1598
|
+
const bots = rooms.get(t.ticket.room) ?? [];
|
|
1599
|
+
bots.push(t.bot);
|
|
1600
|
+
rooms.set(t.ticket.room, bots);
|
|
1601
|
+
}
|
|
1602
|
+
return rooms;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// src/scenario.ts
|
|
1606
|
+
function defineScenario(scenario) {
|
|
1607
|
+
return scenario;
|
|
1608
|
+
}
|
|
1609
|
+
function looksLikeScenario(value) {
|
|
1610
|
+
const s = value;
|
|
1611
|
+
return typeof s === "object" && s !== null && Number.isInteger(s.bots) && s.bots > 0 && typeof s.script === "function" && typeof s.assert === "function";
|
|
1612
|
+
}
|
|
1613
|
+
|
|
908
1614
|
// src/script.ts
|
|
909
1615
|
import { rpcTable } from "@irtio/protocol";
|
|
910
1616
|
function ownableCollections(schema) {
|
|
@@ -920,11 +1626,16 @@ function writableFields(desc, cheat) {
|
|
|
920
1626
|
function voidServerRpcs(schema) {
|
|
921
1627
|
return rpcTable(schema).filter((r) => r.direction === "server" && r.returns === void 0);
|
|
922
1628
|
}
|
|
923
|
-
function
|
|
1629
|
+
function asCollection2(value) {
|
|
924
1630
|
return value && typeof value.ownerOf === "function" ? value : void 0;
|
|
925
1631
|
}
|
|
1632
|
+
function cheatPredicate(cheat) {
|
|
1633
|
+
if (cheat === void 0) return () => false;
|
|
1634
|
+
if (typeof cheat === "function") return (index) => cheat(index) === true;
|
|
1635
|
+
return () => cheat;
|
|
1636
|
+
}
|
|
926
1637
|
function randomScript(schema, options = {}) {
|
|
927
|
-
const
|
|
1638
|
+
const cheats = cheatPredicate(options.cheat);
|
|
928
1639
|
const stepMs = options.stepMs ?? 50;
|
|
929
1640
|
const step = options.step ?? 8;
|
|
930
1641
|
const rpcChance = options.rpcChance ?? 0.05;
|
|
@@ -932,24 +1643,25 @@ function randomScript(schema, options = {}) {
|
|
|
932
1643
|
const collections = ownableCollections(schema);
|
|
933
1644
|
const rpcs = voidServerRpcs(schema);
|
|
934
1645
|
return async (bot) => {
|
|
1646
|
+
const cheat = cheats(bot.index);
|
|
935
1647
|
const room = bot.room;
|
|
936
1648
|
const origins = /* @__PURE__ */ new Map();
|
|
937
1649
|
while (!bot.stopped) {
|
|
938
1650
|
for (const desc of collections) {
|
|
939
|
-
const collection =
|
|
1651
|
+
const collection = asCollection2(room.state[desc.name]);
|
|
940
1652
|
if (!collection) continue;
|
|
941
1653
|
for (const id of [...collection.ids()]) {
|
|
942
1654
|
if (collection.ownerOf(id) !== room.me) continue;
|
|
943
1655
|
const instance = collection.get(id);
|
|
944
1656
|
if (!instance) continue;
|
|
945
|
-
writeFields(bot, desc, id, instance, origins);
|
|
1657
|
+
writeFields(bot, desc, id, instance, origins, cheat);
|
|
946
1658
|
}
|
|
947
1659
|
}
|
|
948
1660
|
if (rpcs.length > 0 && bot.rng.chance(rpcChance)) await callRandomRpc(bot, room.call, rpcs);
|
|
949
1661
|
await bot.wait(stepMs);
|
|
950
1662
|
}
|
|
951
1663
|
};
|
|
952
|
-
function writeFields(bot, desc, id, instance, origins) {
|
|
1664
|
+
function writeFields(bot, desc, id, instance, origins, cheat) {
|
|
953
1665
|
const fields = writableFields(desc, cheat);
|
|
954
1666
|
if (fields.length === 0) return;
|
|
955
1667
|
const count = Math.min(fieldsPerStep, fields.length);
|
|
@@ -999,7 +1711,7 @@ import {
|
|
|
999
1711
|
joinRoom,
|
|
1000
1712
|
webSocketTransport
|
|
1001
1713
|
} from "@irtio/client";
|
|
1002
|
-
import { relaySchema, withBuiltins } from "@irtio/protocol";
|
|
1714
|
+
import { relaySchema, withBuiltins as withBuiltins2 } from "@irtio/protocol";
|
|
1003
1715
|
var DEFAULT_SEED = 96016;
|
|
1004
1716
|
var DEFAULT_TRACE_LIMIT = 4096;
|
|
1005
1717
|
var DEFAULT_JOIN_TIMEOUT_MS = 2e4;
|
|
@@ -1010,20 +1722,34 @@ function per(value, index) {
|
|
|
1010
1722
|
return typeof value === "function" ? value(index) : value;
|
|
1011
1723
|
}
|
|
1012
1724
|
var BotImpl = class {
|
|
1013
|
-
constructor(index, room, observer, seed, startedAt) {
|
|
1725
|
+
constructor(index, room, observer, seed, startedAt, conditions, shots) {
|
|
1014
1726
|
this.index = index;
|
|
1015
1727
|
this.room = room;
|
|
1016
1728
|
this.observer = observer;
|
|
1017
1729
|
this.startedAt = startedAt;
|
|
1730
|
+
this.conditions = conditions;
|
|
1731
|
+
this.shots = shots;
|
|
1018
1732
|
this.rng = makeRng(seed);
|
|
1019
1733
|
}
|
|
1020
1734
|
index;
|
|
1021
1735
|
room;
|
|
1022
1736
|
observer;
|
|
1023
1737
|
startedAt;
|
|
1738
|
+
conditions;
|
|
1739
|
+
shots;
|
|
1024
1740
|
stopped = false;
|
|
1025
1741
|
rng;
|
|
1026
1742
|
stopListeners = /* @__PURE__ */ new Set();
|
|
1743
|
+
shot(request) {
|
|
1744
|
+
const tick = this.room.tick;
|
|
1745
|
+
this.shots.push({
|
|
1746
|
+
...request,
|
|
1747
|
+
bot: this.index,
|
|
1748
|
+
sentAt: Date.now(),
|
|
1749
|
+
clientTick: typeof tick === "number" ? tick : void 0,
|
|
1750
|
+
uplinkMs: (this.conditions?.rttMs ?? 0) / 2
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1027
1753
|
get id() {
|
|
1028
1754
|
return this.observer.id;
|
|
1029
1755
|
}
|
|
@@ -1114,10 +1840,16 @@ async function withJoinTimeout(joining, index, timeoutMs, observer, sockets) {
|
|
|
1114
1840
|
if (timer !== void 0) clearTimeout(timer);
|
|
1115
1841
|
}
|
|
1116
1842
|
}
|
|
1843
|
+
var CONDITIONS_SEED_OFFSET = 377022;
|
|
1117
1844
|
async function spawnBots(n, options = {}) {
|
|
1118
1845
|
if (!Number.isInteger(n) || n < 1) {
|
|
1119
1846
|
throw new Error(`irtio bots: spawnBots needs at least one bot, got ${String(n)}`);
|
|
1120
1847
|
}
|
|
1848
|
+
if (options.physics !== void 0 && options.physics2d !== void 0) {
|
|
1849
|
+
throw new Error(
|
|
1850
|
+
"irtio: joinRoom was given both { physics } and { physics2d }. A room runs one engine and the client predicts with that one, so pass the option matching the engine your room config declares."
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1121
1853
|
const startedAt = Date.now();
|
|
1122
1854
|
const thresholds = {
|
|
1123
1855
|
budgetBytesPerSec: options.budgetBytesPerSec ?? DEFAULT_THRESHOLDS.budgetBytesPerSec,
|
|
@@ -1125,15 +1857,18 @@ async function spawnBots(n, options = {}) {
|
|
|
1125
1857
|
handlerErrorsMax: options.handlerErrorsMax ?? DEFAULT_THRESHOLDS.handlerErrorsMax,
|
|
1126
1858
|
mispredictionMagnitudeMax: options.mispredictionMagnitudeMax ?? DEFAULT_THRESHOLDS.mispredictionMagnitudeMax,
|
|
1127
1859
|
snapsMax: options.snapsMax ?? DEFAULT_THRESHOLDS.snapsMax,
|
|
1128
|
-
overrunsMax: options.overrunsMax ?? DEFAULT_THRESHOLDS.overrunsMax
|
|
1860
|
+
overrunsMax: options.overrunsMax ?? DEFAULT_THRESHOLDS.overrunsMax,
|
|
1861
|
+
typedMessageDropsMax: options.typedMessageDropsMax ?? DEFAULT_THRESHOLDS.typedMessageDropsMax
|
|
1129
1862
|
};
|
|
1130
|
-
const ext = options.schema ?
|
|
1863
|
+
const ext = options.schema ? withBuiltins2(options.schema) : relaySchema;
|
|
1131
1864
|
const seed = options.seed ?? DEFAULT_SEED;
|
|
1132
1865
|
const traceLimit = options.traceLimit ?? DEFAULT_TRACE_LIMIT;
|
|
1133
1866
|
const writeLog = new WriteLog();
|
|
1134
1867
|
const lags = [];
|
|
1135
1868
|
const observers = [];
|
|
1136
1869
|
const bots = [];
|
|
1870
|
+
const shots = [];
|
|
1871
|
+
const conditions = [];
|
|
1137
1872
|
const joinTimeoutMs = options.joinTimeoutMs ?? DEFAULT_JOIN_TIMEOUT_MS;
|
|
1138
1873
|
const roomGoneGraceMs = options.roomGoneGraceMs ?? DEFAULT_ROOM_GONE_GRACE_MS;
|
|
1139
1874
|
const baseTransport = options.transport ?? webSocketTransport;
|
|
@@ -1150,13 +1885,25 @@ async function spawnBots(n, options = {}) {
|
|
|
1150
1885
|
const role = per(options.role, index);
|
|
1151
1886
|
const name = per(options.name, index);
|
|
1152
1887
|
const sockets = [];
|
|
1153
|
-
const
|
|
1888
|
+
const asked = per(options.conditions, index);
|
|
1889
|
+
const injected = hasConditions(asked) ? asked : void 0;
|
|
1890
|
+
const counters = newConditionCounters();
|
|
1891
|
+
conditions[index] = { bot: index, conditions: injected, counters };
|
|
1892
|
+
const recording = {
|
|
1154
1893
|
connect(url) {
|
|
1155
1894
|
const socket = baseTransport.connect(url);
|
|
1156
1895
|
sockets.push(socket);
|
|
1157
1896
|
return socket;
|
|
1158
1897
|
}
|
|
1159
1898
|
};
|
|
1899
|
+
const transport = injected === void 0 ? recording : conditionedTransport(
|
|
1900
|
+
recording,
|
|
1901
|
+
injected,
|
|
1902
|
+
makeRng(seed + index + CONDITIONS_SEED_OFFSET),
|
|
1903
|
+
{
|
|
1904
|
+
counters
|
|
1905
|
+
}
|
|
1906
|
+
);
|
|
1160
1907
|
const common = {
|
|
1161
1908
|
room: roomId2,
|
|
1162
1909
|
...options.url !== void 0 ? { url: options.url } : {},
|
|
@@ -1171,14 +1918,67 @@ async function spawnBots(n, options = {}) {
|
|
|
1171
1918
|
...common,
|
|
1172
1919
|
...options.flushMs !== void 0 ? { writeIntervalMs: options.flushMs } : {},
|
|
1173
1920
|
...options.rpc !== void 0 ? { rpc: options.rpc } : {},
|
|
1174
|
-
...options.physics !== void 0 ? { physics: options.physics } : {}
|
|
1921
|
+
...options.physics !== void 0 ? { physics: options.physics } : {},
|
|
1922
|
+
...options.physics2d !== void 0 ? { physics2d: options.physics2d } : {},
|
|
1923
|
+
...options.profile === true ? { profile: true } : {}
|
|
1175
1924
|
}) : joinRelay(common);
|
|
1176
1925
|
const room = await withJoinTimeout(joining, index, joinTimeoutMs, observer, sockets);
|
|
1177
1926
|
room.on("correct", (correction) => observer.onCorrection(correction));
|
|
1178
1927
|
const prediction = room.prediction;
|
|
1179
|
-
if (prediction)
|
|
1928
|
+
if (prediction) {
|
|
1929
|
+
observer.bodyKind = (c, id) => prediction.predicts(c, id) ? "predicted" : prediction.proxied(c, id) ? "proxied" : "absent";
|
|
1930
|
+
observer.prediction = prediction;
|
|
1931
|
+
}
|
|
1180
1932
|
observers[index] = observer;
|
|
1181
|
-
return new BotImpl(
|
|
1933
|
+
return new BotImpl(
|
|
1934
|
+
index,
|
|
1935
|
+
room,
|
|
1936
|
+
observer,
|
|
1937
|
+
seed + index,
|
|
1938
|
+
startedAt,
|
|
1939
|
+
injected,
|
|
1940
|
+
shots
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
if (options.match !== void 0) {
|
|
1944
|
+
const matched = await matchBots(n, options.match);
|
|
1945
|
+
if (matched.tickets.length === 0) {
|
|
1946
|
+
const first2 = matched.failures[0];
|
|
1947
|
+
throw new Error(
|
|
1948
|
+
`irtio bots: no bot got a ticket from the ${options.match.queue ?? "default"} queue at ${options.match.controlUrl}, so there is no room to run in` + (first2 !== void 0 ? ` (${first2.code}: ${first2.message})` : "")
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
const joined = await Promise.allSettled(
|
|
1952
|
+
matched.tickets.map((t) => joinOne(t.bot, t.ticket.room))
|
|
1953
|
+
);
|
|
1954
|
+
const failure = joined.find((r) => r.status === "rejected");
|
|
1955
|
+
if (failure !== void 0) {
|
|
1956
|
+
for (const settled of joined) {
|
|
1957
|
+
if (settled.status === "fulfilled") settled.value.room.leave();
|
|
1958
|
+
}
|
|
1959
|
+
throw failure.reason;
|
|
1960
|
+
}
|
|
1961
|
+
for (const settled of joined) {
|
|
1962
|
+
if (settled.status === "fulfilled") bots.push(settled.value);
|
|
1963
|
+
}
|
|
1964
|
+
return finishRun(bots, {
|
|
1965
|
+
roomId: matched.tickets[0]?.ticket.room ?? "",
|
|
1966
|
+
matches: matched.tickets,
|
|
1967
|
+
rooms: roomsOf(matched.tickets),
|
|
1968
|
+
matchFailures: matched.failures,
|
|
1969
|
+
matchmaking: {
|
|
1970
|
+
tickets: matched.tickets.map((t) => ({
|
|
1971
|
+
bot: t.bot,
|
|
1972
|
+
room: t.ticket.room,
|
|
1973
|
+
queue: t.ticket.queue,
|
|
1974
|
+
size: t.ticket.size,
|
|
1975
|
+
backfill: t.ticket.backfill,
|
|
1976
|
+
waitedMs: t.waitedMs,
|
|
1977
|
+
...t.party !== void 0 ? { party: t.party } : {}
|
|
1978
|
+
})),
|
|
1979
|
+
failures: matched.failures.map((f) => ({ requested: f.requested, code: f.code }))
|
|
1980
|
+
}
|
|
1981
|
+
});
|
|
1182
1982
|
}
|
|
1183
1983
|
const first = await joinOne(0, options.room ?? "");
|
|
1184
1984
|
bots.push(first);
|
|
@@ -1199,117 +1999,287 @@ async function spawnBots(n, options = {}) {
|
|
|
1199
1999
|
if (settled.status === "fulfilled") bots.push(settled.value);
|
|
1200
2000
|
}
|
|
1201
2001
|
}
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
await bot.until(() => bot.stopped, { label: "the run to end", timeoutMs: 24 * 36e5 });
|
|
1208
|
-
return;
|
|
1209
|
-
}
|
|
1210
|
-
try {
|
|
1211
|
-
await script(bot);
|
|
1212
|
-
} catch (error) {
|
|
1213
|
-
scriptErrors.push({ bot: bot.index, error });
|
|
1214
|
-
firstError ??= error;
|
|
1215
|
-
}
|
|
2002
|
+
return finishRun(bots, {
|
|
2003
|
+
roomId,
|
|
2004
|
+
matches: [],
|
|
2005
|
+
rooms: /* @__PURE__ */ new Map([[roomId, bots.map((b) => b.index)]]),
|
|
2006
|
+
matchFailures: []
|
|
1216
2007
|
});
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
}
|
|
1226
|
-
let goneSince = 0;
|
|
1227
|
-
let goneTimer;
|
|
1228
|
-
if (roomGoneGraceMs > 0) {
|
|
1229
|
-
goneTimer = setInterval(() => {
|
|
1230
|
-
const down = observers.every(
|
|
1231
|
-
(o) => o.lastStatus === "reconnecting" || o.lastStatus === "closed"
|
|
1232
|
-
);
|
|
1233
|
-
if (!down) {
|
|
1234
|
-
goneSince = 0;
|
|
2008
|
+
function finishRun(bots2, placement) {
|
|
2009
|
+
const roomId2 = placement.roomId;
|
|
2010
|
+
const scriptErrors = [];
|
|
2011
|
+
let firstError;
|
|
2012
|
+
const script = options.script;
|
|
2013
|
+
const scripts = bots2.map(async (bot) => {
|
|
2014
|
+
if (!script) {
|
|
2015
|
+
await bot.until(() => bot.stopped, { label: "the run to end", timeoutMs: 24 * 36e5 });
|
|
1235
2016
|
return;
|
|
1236
2017
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
endedBy
|
|
1246
|
-
|
|
1247
|
-
if (
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
2018
|
+
try {
|
|
2019
|
+
await script(bot);
|
|
2020
|
+
} catch (error) {
|
|
2021
|
+
scriptErrors.push({ bot: bot.index, error });
|
|
2022
|
+
firstError ??= error;
|
|
2023
|
+
}
|
|
2024
|
+
});
|
|
2025
|
+
const finished = Promise.all(scripts).then(() => void 0);
|
|
2026
|
+
let endedBy;
|
|
2027
|
+
let deadline;
|
|
2028
|
+
if (options.durationMs !== void 0) {
|
|
2029
|
+
deadline = setTimeout(() => {
|
|
2030
|
+
endedBy ??= "duration";
|
|
2031
|
+
for (const bot of bots2) bot.stop();
|
|
2032
|
+
}, options.durationMs);
|
|
2033
|
+
}
|
|
2034
|
+
let goneSince = 0;
|
|
2035
|
+
let goneTimer;
|
|
2036
|
+
if (roomGoneGraceMs > 0) {
|
|
2037
|
+
goneTimer = setInterval(() => {
|
|
2038
|
+
const down = observers.every(
|
|
2039
|
+
(o) => o.lastStatus === "reconnecting" || o.lastStatus === "closed"
|
|
2040
|
+
);
|
|
2041
|
+
if (!down) {
|
|
2042
|
+
goneSince = 0;
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
goneSince ||= Date.now();
|
|
2046
|
+
if (Date.now() - goneSince < roomGoneGraceMs) return;
|
|
2047
|
+
endedBy ??= "room-gone";
|
|
2048
|
+
for (const bot of bots2) bot.stop();
|
|
2049
|
+
}, ROOM_GONE_POLL_MS);
|
|
2050
|
+
goneTimer.unref?.();
|
|
2051
|
+
}
|
|
2052
|
+
void finished.then(() => {
|
|
2053
|
+
endedBy ??= "scripts";
|
|
2054
|
+
if (deadline) clearTimeout(deadline);
|
|
2055
|
+
if (goneTimer) clearInterval(goneTimer);
|
|
2056
|
+
});
|
|
2057
|
+
let stoppedAt;
|
|
2058
|
+
let tickHealth;
|
|
2059
|
+
const rings = () => observers.map((o) => o.ring);
|
|
2060
|
+
let profiles;
|
|
2061
|
+
const runner = {
|
|
2062
|
+
bots: bots2,
|
|
2063
|
+
roomId: roomId2,
|
|
2064
|
+
matches: placement.matches,
|
|
2065
|
+
rooms: placement.rooms,
|
|
2066
|
+
matchFailures: placement.matchFailures,
|
|
2067
|
+
trace: makeTrace(startedAt, rings),
|
|
2068
|
+
scriptErrors,
|
|
2069
|
+
shots,
|
|
2070
|
+
conditions,
|
|
2071
|
+
get endedBy() {
|
|
2072
|
+
return endedBy;
|
|
2073
|
+
},
|
|
2074
|
+
[Symbol.iterator]: () => bots2[Symbol.iterator](),
|
|
2075
|
+
async done() {
|
|
2076
|
+
await finished;
|
|
2077
|
+
if (firstError !== void 0) throw firstError;
|
|
2078
|
+
},
|
|
2079
|
+
recordTickHealth(reading) {
|
|
2080
|
+
tickHealth = reading;
|
|
2081
|
+
},
|
|
2082
|
+
report() {
|
|
2083
|
+
if (options.profile === true) {
|
|
2084
|
+
const live = bots2.map((bot) => bot.room.profile?.total()).filter((p) => p !== void 0);
|
|
2085
|
+
if (live.length > 0) profiles = live;
|
|
2086
|
+
}
|
|
2087
|
+
return buildReport({
|
|
2088
|
+
observers,
|
|
2089
|
+
roomId: roomId2,
|
|
2090
|
+
durationMs: (stoppedAt ?? Date.now()) - startedAt,
|
|
2091
|
+
lags,
|
|
2092
|
+
thresholds,
|
|
2093
|
+
tickHealth,
|
|
2094
|
+
...placement.matchmaking !== void 0 ? { matchmaking: placement.matchmaking } : {},
|
|
2095
|
+
...profiles !== void 0 ? { profiles } : {}
|
|
2096
|
+
});
|
|
2097
|
+
},
|
|
2098
|
+
async stop() {
|
|
2099
|
+
if (deadline) clearTimeout(deadline);
|
|
2100
|
+
if (goneTimer) clearInterval(goneTimer);
|
|
2101
|
+
for (const bot of bots2) bot.stop();
|
|
2102
|
+
await finished;
|
|
2103
|
+
stoppedAt ??= Date.now();
|
|
2104
|
+
for (const observer of observers) observer.stopping = true;
|
|
2105
|
+
for (const bot of bots2) bot.room.leave();
|
|
2106
|
+
return this.report();
|
|
2107
|
+
}
|
|
2108
|
+
};
|
|
2109
|
+
return runner;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// src/timeline.ts
|
|
2114
|
+
var Collection = class {
|
|
2115
|
+
constructor(raw, entity) {
|
|
2116
|
+
this.raw = raw;
|
|
2117
|
+
this.entity = entity;
|
|
2118
|
+
}
|
|
2119
|
+
raw;
|
|
2120
|
+
entity;
|
|
2121
|
+
entry(id) {
|
|
2122
|
+
if (!this.entity) return void 0;
|
|
2123
|
+
const row = this.raw[id];
|
|
2124
|
+
return typeof row === "object" && row !== null ? row : void 0;
|
|
2125
|
+
}
|
|
2126
|
+
get(id) {
|
|
2127
|
+
const value = this.entry(id)?.value;
|
|
2128
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
2129
|
+
}
|
|
2130
|
+
has(id) {
|
|
2131
|
+
return this.entity && Object.hasOwn(this.raw, id);
|
|
2132
|
+
}
|
|
2133
|
+
ids() {
|
|
2134
|
+
return this.entity ? Object.keys(this.raw) : [];
|
|
2135
|
+
}
|
|
2136
|
+
get size() {
|
|
2137
|
+
return this.entity ? Object.keys(this.raw).length : 0;
|
|
2138
|
+
}
|
|
2139
|
+
owner(id) {
|
|
2140
|
+
const owner = this.entry(id)?.owner;
|
|
2141
|
+
return typeof owner === "string" && owner !== "" ? owner : void 0;
|
|
2142
|
+
}
|
|
2143
|
+
get value() {
|
|
2144
|
+
return this.entity ? void 0 : this.raw;
|
|
2145
|
+
}
|
|
2146
|
+
};
|
|
2147
|
+
var UnknownCollectionError = class extends Error {
|
|
2148
|
+
name = "UnknownCollectionError";
|
|
2149
|
+
constructor(collection, known) {
|
|
2150
|
+
super(
|
|
2151
|
+
`irtio scenario: this room has no collection named "${collection}". It has: ${known.length > 0 ? known.join(", ") : "none"}.`
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
};
|
|
2155
|
+
var TickNotRecordedError = class extends Error {
|
|
2156
|
+
constructor(tick, first, last, dropped) {
|
|
2157
|
+
super(
|
|
2158
|
+
first === void 0 ? `irtio scenario: nothing was recorded, so tick ${tick} cannot be read.` : `irtio scenario: tick ${tick} is not in the recording, which holds ticks ${first} to ${last}` + (dropped > 0 ? `. ${dropped} earlier tick(s) were dropped by the recorder's cap, so raise it or shorten the run.` : ".")
|
|
2159
|
+
);
|
|
2160
|
+
this.tick = tick;
|
|
2161
|
+
}
|
|
2162
|
+
tick;
|
|
2163
|
+
name = "TickNotRecordedError";
|
|
2164
|
+
};
|
|
2165
|
+
function momentOf(dump, index, read) {
|
|
2166
|
+
const frame = dump.frames[index];
|
|
2167
|
+
read(frame.tick);
|
|
2168
|
+
const known = Object.keys(dump.collections);
|
|
2169
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2170
|
+
return new Proxy({}, {
|
|
2171
|
+
get(_target, property) {
|
|
2172
|
+
if (typeof property !== "string") return void 0;
|
|
2173
|
+
const cached = cache.get(property);
|
|
2174
|
+
if (cached) return cached;
|
|
2175
|
+
const kind = dump.collections[property];
|
|
2176
|
+
if (kind === void 0) throw new UnknownCollectionError(property, known);
|
|
2177
|
+
const raw = frame.state[property];
|
|
2178
|
+
const view = new Collection(
|
|
2179
|
+
typeof raw === "object" && raw !== null ? raw : {},
|
|
2180
|
+
kind === "entity"
|
|
2181
|
+
);
|
|
2182
|
+
cache.set(property, view);
|
|
2183
|
+
return view;
|
|
1264
2184
|
},
|
|
1265
|
-
|
|
1266
|
-
|
|
2185
|
+
has: (_target, property) => typeof property === "string" && property in dump.collections,
|
|
2186
|
+
ownKeys: () => [...known],
|
|
2187
|
+
getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true })
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
function makeTimeline(dump) {
|
|
2191
|
+
const index = /* @__PURE__ */ new Map();
|
|
2192
|
+
dump.frames.forEach((f, i) => index.set(f.tick, i));
|
|
2193
|
+
const ticks = dump.frames.map((f) => f.tick);
|
|
2194
|
+
const results = [];
|
|
2195
|
+
let readTicks = [];
|
|
2196
|
+
const read = (tick) => {
|
|
2197
|
+
readTicks.push(tick);
|
|
2198
|
+
};
|
|
2199
|
+
const at = (tick) => {
|
|
2200
|
+
const i = index.get(tick);
|
|
2201
|
+
if (i === void 0) {
|
|
2202
|
+
throw new TickNotRecordedError(tick, ticks[0], ticks[ticks.length - 1], dump.dropped);
|
|
2203
|
+
}
|
|
2204
|
+
return momentOf(dump, i, read);
|
|
2205
|
+
};
|
|
2206
|
+
return {
|
|
2207
|
+
roomId: dump.roomId,
|
|
2208
|
+
ticks,
|
|
2209
|
+
dropped: dump.dropped,
|
|
2210
|
+
at,
|
|
2211
|
+
find(match) {
|
|
2212
|
+
for (let i = 0; i < dump.frames.length; i++) {
|
|
2213
|
+
const tick = dump.frames[i].tick;
|
|
2214
|
+
const state = momentOf(dump, i, read);
|
|
2215
|
+
if (match(state, tick)) return { tick, state };
|
|
2216
|
+
}
|
|
2217
|
+
return void 0;
|
|
1267
2218
|
},
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
2219
|
+
check(name, assertion) {
|
|
2220
|
+
readTicks = [];
|
|
2221
|
+
try {
|
|
2222
|
+
assertion();
|
|
2223
|
+
results.push({
|
|
2224
|
+
name,
|
|
2225
|
+
ok: true,
|
|
2226
|
+
...readTicks.length > 0 ? { tick: readTicks[readTicks.length - 1] } : {},
|
|
2227
|
+
detail: readTicks.length === 0 ? "held (read no tick)" : `held, reading tick ${readTicks[readTicks.length - 1]}` + (readTicks.length > 1 ? ` (${readTicks.length} ticks read)` : "")
|
|
2228
|
+
});
|
|
2229
|
+
} catch (err) {
|
|
2230
|
+
results.push({
|
|
2231
|
+
name,
|
|
2232
|
+
ok: false,
|
|
2233
|
+
...readTicks.length > 0 ? { tick: readTicks[readTicks.length - 1] } : {},
|
|
2234
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
1277
2237
|
},
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
if (goneTimer) clearInterval(goneTimer);
|
|
1281
|
-
for (const bot of bots) bot.stop();
|
|
1282
|
-
await finished;
|
|
1283
|
-
stoppedAt ??= Date.now();
|
|
1284
|
-
for (const observer of observers) observer.stopping = true;
|
|
1285
|
-
for (const bot of bots) bot.room.leave();
|
|
1286
|
-
return this.report();
|
|
2238
|
+
get results() {
|
|
2239
|
+
return results;
|
|
1287
2240
|
}
|
|
1288
2241
|
};
|
|
1289
|
-
return runner;
|
|
1290
2242
|
}
|
|
1291
2243
|
export {
|
|
1292
2244
|
BotObserver,
|
|
1293
2245
|
DEFAULT_JOIN_TIMEOUT_MS,
|
|
2246
|
+
DEFAULT_MATCH_TIMEOUT_MS,
|
|
2247
|
+
DEFAULT_REORDER_MS,
|
|
1294
2248
|
DEFAULT_ROOM_GONE_GRACE_MS,
|
|
1295
2249
|
DEFAULT_SEED,
|
|
1296
2250
|
DEFAULT_THRESHOLDS,
|
|
1297
2251
|
DEFAULT_TRACE_LIMIT,
|
|
1298
2252
|
INVARIANT_NAMES,
|
|
1299
2253
|
JoinTimeoutError,
|
|
2254
|
+
TickNotRecordedError,
|
|
1300
2255
|
TraceRing,
|
|
2256
|
+
UnknownCollectionError,
|
|
1301
2257
|
WriteLog,
|
|
1302
2258
|
buildReport,
|
|
2259
|
+
captureClientState,
|
|
2260
|
+
cheatPredicate,
|
|
2261
|
+
conditionedTransport,
|
|
1303
2262
|
convergenceStats,
|
|
2263
|
+
correlateShots,
|
|
2264
|
+
defineScenario,
|
|
1304
2265
|
deltaVisibilityLeaks,
|
|
2266
|
+
describeConditions,
|
|
2267
|
+
diffAgainstSave,
|
|
1305
2268
|
frameName,
|
|
1306
2269
|
frameVisibilityLeaks,
|
|
1307
2270
|
freshValue,
|
|
2271
|
+
hasConditions,
|
|
2272
|
+
looksLikeScenario,
|
|
1308
2273
|
makeRng,
|
|
2274
|
+
makeTimeline,
|
|
1309
2275
|
makeTrace,
|
|
2276
|
+
matchBots,
|
|
2277
|
+
matchmakingSummary,
|
|
2278
|
+
newConditionCounters,
|
|
1310
2279
|
nextValue,
|
|
1311
2280
|
randomScript,
|
|
1312
2281
|
relayEchoScript,
|
|
2282
|
+
roomsOf,
|
|
1313
2283
|
snapshotVisibilityLeaks,
|
|
1314
2284
|
spawnBots
|
|
1315
2285
|
};
|