@irtio/bots 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +652 -58
- package/dist/index.js +811 -56
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -13,14 +13,16 @@ var INVARIANT_NAMES = [
|
|
|
13
13
|
"correction-storm",
|
|
14
14
|
"misprediction",
|
|
15
15
|
"snaps",
|
|
16
|
-
"disconnects"
|
|
16
|
+
"disconnects",
|
|
17
|
+
"tick-health"
|
|
17
18
|
];
|
|
18
19
|
var DEFAULT_THRESHOLDS = {
|
|
19
20
|
budgetBytesPerSec: 128e3,
|
|
20
21
|
correctionsPerSecMax: 5,
|
|
21
22
|
handlerErrorsMax: 0,
|
|
22
23
|
mispredictionMagnitudeMax: Number.POSITIVE_INFINITY,
|
|
23
|
-
snapsMax: Number.POSITIVE_INFINITY
|
|
24
|
+
snapsMax: Number.POSITIVE_INFINITY,
|
|
25
|
+
overrunsMax: 0
|
|
24
26
|
};
|
|
25
27
|
var widenedSchemas = /* @__PURE__ */ new WeakMap();
|
|
26
28
|
function widenGrids(ext, slack) {
|
|
@@ -108,29 +110,382 @@ function frameVisibilityLeaks(ext, role, type, payload, spatial) {
|
|
|
108
110
|
return deltaVisibilityLeaks(ext, role, decodeDelta(ext, payload), spatial);
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
// src/conditions.ts
|
|
114
|
+
import { FrameType as FrameType2 } from "@irtio/protocol";
|
|
115
|
+
var STATE_FRAMES = /* @__PURE__ */ new Set([
|
|
116
|
+
FrameType2.WRITE,
|
|
117
|
+
FrameType2.DELTA,
|
|
118
|
+
FrameType2.CORRECT,
|
|
119
|
+
FrameType2.MSG
|
|
120
|
+
]);
|
|
121
|
+
var DUPLICATE_GAP_MS = 1;
|
|
122
|
+
var DEFAULT_REORDER_MS = 50;
|
|
123
|
+
function newConditionCounters() {
|
|
124
|
+
return {
|
|
125
|
+
droppedOut: 0,
|
|
126
|
+
droppedIn: 0,
|
|
127
|
+
duplicatedOut: 0,
|
|
128
|
+
duplicatedIn: 0,
|
|
129
|
+
reorderedOut: 0,
|
|
130
|
+
reorderedIn: 0,
|
|
131
|
+
delayed: 0
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function hasConditions(c) {
|
|
135
|
+
if (!c) return false;
|
|
136
|
+
return (c.rttMs ?? 0) > 0 || (c.jitterMs ?? 0) > 0 || (c.loss ?? 0) > 0 || (c.duplicate ?? 0) > 0 || (c.reorder ?? 0) > 0;
|
|
137
|
+
}
|
|
138
|
+
function describeConditions(c) {
|
|
139
|
+
const parts = [];
|
|
140
|
+
if ((c.rttMs ?? 0) > 0) parts.push(`rtt ${c.rttMs} ms`);
|
|
141
|
+
if ((c.jitterMs ?? 0) > 0) parts.push(`jitter ${c.jitterMs} ms`);
|
|
142
|
+
if ((c.loss ?? 0) > 0) parts.push(`loss ${pct(c.loss ?? 0)}`);
|
|
143
|
+
if ((c.duplicate ?? 0) > 0) parts.push(`duplicate ${pct(c.duplicate ?? 0)}`);
|
|
144
|
+
if ((c.reorder ?? 0) > 0) {
|
|
145
|
+
parts.push(`reorder ${pct(c.reorder ?? 0)} within ${c.reorderMs ?? DEFAULT_REORDER_MS} ms`);
|
|
146
|
+
}
|
|
147
|
+
return parts.length === 0 ? "none" : parts.join(", ");
|
|
148
|
+
}
|
|
149
|
+
function pct(v) {
|
|
150
|
+
return `${Math.round(v * 1e3) / 10}%`;
|
|
151
|
+
}
|
|
152
|
+
var realTimers = {
|
|
153
|
+
now: () => Date.now(),
|
|
154
|
+
setTimeout(fn, ms) {
|
|
155
|
+
const timer = setTimeout(fn, ms);
|
|
156
|
+
timer.unref?.();
|
|
157
|
+
return () => clearTimeout(timer);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
function conditionedTransport(base, conditions, rng, options = {}) {
|
|
161
|
+
const counters = options.counters ?? newConditionCounters();
|
|
162
|
+
const timers = options.timers ?? realTimers;
|
|
163
|
+
const rttMs = Math.max(0, conditions.rttMs ?? 0);
|
|
164
|
+
const jitterMs = Math.max(0, conditions.jitterMs ?? 0);
|
|
165
|
+
const loss = clamp01(conditions.loss ?? 0);
|
|
166
|
+
const duplicate = clamp01(conditions.duplicate ?? 0);
|
|
167
|
+
const reorder = clamp01(conditions.reorder ?? 0);
|
|
168
|
+
const reorderMs = Math.max(0, conditions.reorderMs ?? DEFAULT_REORDER_MS);
|
|
169
|
+
return {
|
|
170
|
+
connect(url) {
|
|
171
|
+
const socket = base.connect(url);
|
|
172
|
+
const cancels = /* @__PURE__ */ new Set();
|
|
173
|
+
let closed = false;
|
|
174
|
+
let nextIn = 0;
|
|
175
|
+
let nextOut = 0;
|
|
176
|
+
function schedule(delayMs, run) {
|
|
177
|
+
if (closed) return;
|
|
178
|
+
counters.delayed++;
|
|
179
|
+
let cancel = () => void 0;
|
|
180
|
+
const fire = () => {
|
|
181
|
+
cancels.delete(cancel);
|
|
182
|
+
if (!closed) run();
|
|
183
|
+
};
|
|
184
|
+
if (delayMs <= 0) {
|
|
185
|
+
cancel = timers.setTimeout(fire, 0);
|
|
186
|
+
} else {
|
|
187
|
+
cancel = timers.setTimeout(fire, delayMs);
|
|
188
|
+
}
|
|
189
|
+
cancels.add(cancel);
|
|
190
|
+
}
|
|
191
|
+
function deliver(dir, bytes, run) {
|
|
192
|
+
const type = bytes.length > 0 ? bytes[0] ?? -1 : -1;
|
|
193
|
+
const stateful = STATE_FRAMES.has(type);
|
|
194
|
+
if (stateful && loss > 0 && rng.next() < loss) {
|
|
195
|
+
if (dir === "in") counters.droppedIn++;
|
|
196
|
+
else counters.droppedOut++;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const now = timers.now();
|
|
200
|
+
let at = now + rttMs / 2 + (jitterMs > 0 ? rng.next() * jitterMs : 0);
|
|
201
|
+
const reordered = stateful && reorder > 0 && rng.next() < reorder;
|
|
202
|
+
if (reordered) {
|
|
203
|
+
at += reorderMs > 0 ? rng.next() * reorderMs : 0;
|
|
204
|
+
if (dir === "in") counters.reorderedIn++;
|
|
205
|
+
else counters.reorderedOut++;
|
|
206
|
+
} else {
|
|
207
|
+
const floor = dir === "in" ? nextIn : nextOut;
|
|
208
|
+
at = Math.max(at, floor);
|
|
209
|
+
if (dir === "in") nextIn = at;
|
|
210
|
+
else nextOut = at;
|
|
211
|
+
}
|
|
212
|
+
schedule(at - now, run);
|
|
213
|
+
if (stateful && duplicate > 0 && rng.next() < duplicate) {
|
|
214
|
+
if (dir === "in") counters.duplicatedIn++;
|
|
215
|
+
else counters.duplicatedOut++;
|
|
216
|
+
schedule(at - now + DUPLICATE_GAP_MS, run);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const wrapper = {
|
|
220
|
+
onopen: null,
|
|
221
|
+
onmessage: null,
|
|
222
|
+
onclose: null,
|
|
223
|
+
onerror: null,
|
|
224
|
+
send(bytes) {
|
|
225
|
+
const copy = bytes.slice();
|
|
226
|
+
deliver("out", copy, () => socket.send(copy));
|
|
227
|
+
},
|
|
228
|
+
close() {
|
|
229
|
+
closed = true;
|
|
230
|
+
for (const cancel of cancels) cancel();
|
|
231
|
+
cancels.clear();
|
|
232
|
+
socket.close();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
socket.onopen = () => wrapper.onopen?.();
|
|
236
|
+
socket.onmessage = (bytes) => {
|
|
237
|
+
const copy = bytes.slice();
|
|
238
|
+
deliver("in", copy, () => wrapper.onmessage?.(copy));
|
|
239
|
+
};
|
|
240
|
+
socket.onclose = (info) => {
|
|
241
|
+
closed = true;
|
|
242
|
+
for (const cancel of cancels) cancel();
|
|
243
|
+
cancels.clear();
|
|
244
|
+
wrapper.onclose?.(info);
|
|
245
|
+
};
|
|
246
|
+
socket.onerror = (error) => wrapper.onerror?.(error);
|
|
247
|
+
return wrapper;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function clamp01(v) {
|
|
252
|
+
return Math.min(1, Math.max(0, v));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/hits.ts
|
|
256
|
+
function entityValue(frameState, collection, id) {
|
|
257
|
+
const table = frameState[collection];
|
|
258
|
+
if (typeof table !== "object" || table === null) return void 0;
|
|
259
|
+
const record = table[id];
|
|
260
|
+
if (typeof record !== "object" || record === null) return void 0;
|
|
261
|
+
const value = record.value;
|
|
262
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
263
|
+
}
|
|
264
|
+
function correlateShots(shots, dump) {
|
|
265
|
+
const frames = dump.frames;
|
|
266
|
+
return shots.map((shot) => {
|
|
267
|
+
const arrivedAt = shot.sentAt + shot.uplinkMs;
|
|
268
|
+
const frame = frames.find((f) => f.at >= arrivedAt);
|
|
269
|
+
if (frame === void 0) {
|
|
270
|
+
return {
|
|
271
|
+
...shot,
|
|
272
|
+
serverTick: void 0,
|
|
273
|
+
estimated: shot.uplinkMs > 0,
|
|
274
|
+
authoritative: void 0,
|
|
275
|
+
missDistance: void 0,
|
|
276
|
+
unresolved: frames.length === 0 ? "nothing was recorded, so there is no tick to judge this shot against" : `the shot lands after the last recorded tick (${frames[frames.length - 1]?.tick})`
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const value = entityValue(frame.state, shot.collection, shot.target);
|
|
280
|
+
if (value === void 0) {
|
|
281
|
+
return {
|
|
282
|
+
...shot,
|
|
283
|
+
serverTick: frame.tick,
|
|
284
|
+
estimated: shot.uplinkMs > 0,
|
|
285
|
+
authoritative: void 0,
|
|
286
|
+
missDistance: void 0,
|
|
287
|
+
unresolved: `${shot.collection}.${shot.target} is not in the recording at tick ${frame.tick}`
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const authoritative = {};
|
|
291
|
+
let sum = 0;
|
|
292
|
+
let missing;
|
|
293
|
+
for (const [field, aimed] of Object.entries(shot.aim)) {
|
|
294
|
+
const actual = value[field];
|
|
295
|
+
if (typeof actual !== "number") {
|
|
296
|
+
missing ??= `${shot.collection}.${shot.target}.${field} is not a number on the timeline`;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
authoritative[field] = actual;
|
|
300
|
+
sum += (actual - aimed) ** 2;
|
|
301
|
+
}
|
|
302
|
+
if (missing !== void 0) {
|
|
303
|
+
return {
|
|
304
|
+
...shot,
|
|
305
|
+
serverTick: frame.tick,
|
|
306
|
+
estimated: shot.uplinkMs > 0,
|
|
307
|
+
authoritative,
|
|
308
|
+
missDistance: void 0,
|
|
309
|
+
unresolved: missing
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
...shot,
|
|
314
|
+
serverTick: frame.tick,
|
|
315
|
+
estimated: shot.uplinkMs > 0,
|
|
316
|
+
authoritative,
|
|
317
|
+
missDistance: Math.sqrt(sum)
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/truth.ts
|
|
323
|
+
function captureClientState(schema, state) {
|
|
324
|
+
const view = state ?? {};
|
|
325
|
+
const out = {};
|
|
326
|
+
for (const desc of schema.collections) {
|
|
327
|
+
const raw = view[desc.name];
|
|
328
|
+
if (raw === void 0) continue;
|
|
329
|
+
const collection = asCollection(raw);
|
|
330
|
+
if (collection) {
|
|
331
|
+
const records = {};
|
|
332
|
+
for (const id of collection.ids()) {
|
|
333
|
+
const value = collection.get(id);
|
|
334
|
+
if (value === void 0) continue;
|
|
335
|
+
records[id] = { owner: collection.ownerOf(id), value: plain(value) };
|
|
336
|
+
}
|
|
337
|
+
out[desc.name] = records;
|
|
338
|
+
} else {
|
|
339
|
+
out[desc.name] = plain(raw);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
function asCollection(value) {
|
|
345
|
+
return value !== null && typeof value === "object" && typeof value.ownerOf === "function" ? value : void 0;
|
|
346
|
+
}
|
|
347
|
+
function plain(value) {
|
|
348
|
+
if (value === null || typeof value !== "object") return value;
|
|
349
|
+
const out = {};
|
|
350
|
+
for (const [k, v] of Object.entries(value)) out[k] = plain(v);
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
function diffAgainstSave(clients, saveState, meta) {
|
|
354
|
+
const bots = [];
|
|
355
|
+
const all = [];
|
|
356
|
+
let compared = 0;
|
|
357
|
+
for (const client of clients) {
|
|
358
|
+
const differences = [];
|
|
359
|
+
let seen = 0;
|
|
360
|
+
for (const [collection, held] of Object.entries(client.state)) {
|
|
361
|
+
const authoritative = saveState[collection];
|
|
362
|
+
if (isTable(held)) {
|
|
363
|
+
const table = isTable(authoritative) ? authoritative : {};
|
|
364
|
+
for (const [id, record] of Object.entries(held)) {
|
|
365
|
+
seen++;
|
|
366
|
+
const mine = record;
|
|
367
|
+
const theirs = table[id];
|
|
368
|
+
if (theirs === void 0) {
|
|
369
|
+
differences.push({
|
|
370
|
+
bot: client.bot,
|
|
371
|
+
collection,
|
|
372
|
+
id,
|
|
373
|
+
client: mine.value,
|
|
374
|
+
save: void 0,
|
|
375
|
+
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.`
|
|
376
|
+
});
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (mine.owner !== theirs.owner) {
|
|
380
|
+
differences.push({
|
|
381
|
+
bot: client.bot,
|
|
382
|
+
collection,
|
|
383
|
+
id,
|
|
384
|
+
field: "owner",
|
|
385
|
+
client: mine.owner,
|
|
386
|
+
save: theirs.owner,
|
|
387
|
+
detail: `bot ${client.bot} has ${collection}.${id} owned by ${String(mine.owner)}, the save says ${String(theirs.owner)}`
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
compareValues(client.bot, collection, id, mine.value, theirs.value, differences);
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
seen++;
|
|
394
|
+
compareValues(client.bot, collection, void 0, held, authoritative, differences);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
compared += seen;
|
|
398
|
+
bots.push({ bot: client.bot, compared: seen, differences, ok: differences.length === 0 });
|
|
399
|
+
all.push(...differences);
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
ok: all.length === 0,
|
|
403
|
+
bots,
|
|
404
|
+
compared,
|
|
405
|
+
differences: all,
|
|
406
|
+
saveTick: meta.saveTick,
|
|
407
|
+
saveVersion: meta.saveVersion
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function isTable(value) {
|
|
411
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
412
|
+
}
|
|
413
|
+
function compareValues(bot, collection, id, client, save, out) {
|
|
414
|
+
const where = id === void 0 ? collection : `${collection}.${id}`;
|
|
415
|
+
if (!isTable(client)) {
|
|
416
|
+
if (!same(client, save)) {
|
|
417
|
+
out.push({
|
|
418
|
+
bot,
|
|
419
|
+
collection,
|
|
420
|
+
...id !== void 0 ? { id } : {},
|
|
421
|
+
client,
|
|
422
|
+
save,
|
|
423
|
+
detail: `bot ${bot} has ${where} = ${show(client)}, the save has ${show(save)}`
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const theirs = isTable(save) ? save : void 0;
|
|
429
|
+
for (const [field, value] of Object.entries(client)) {
|
|
430
|
+
const actual = theirs?.[field];
|
|
431
|
+
if (same(value, actual)) continue;
|
|
432
|
+
out.push({
|
|
433
|
+
bot,
|
|
434
|
+
collection,
|
|
435
|
+
...id !== void 0 ? { id } : {},
|
|
436
|
+
field,
|
|
437
|
+
client: value,
|
|
438
|
+
save: actual,
|
|
439
|
+
detail: `bot ${bot} has ${where}.${field} = ${show(value)}, the save has ${show(actual)}`
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function same(a, b) {
|
|
444
|
+
if (a === b) return true;
|
|
445
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
446
|
+
return Number.isNaN(a) && Number.isNaN(b);
|
|
447
|
+
}
|
|
448
|
+
if (isTable(a) && isTable(b)) {
|
|
449
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
450
|
+
for (const k of keys) if (!same(a[k], b[k])) return false;
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
function show(value) {
|
|
456
|
+
if (value === void 0) return "nothing";
|
|
457
|
+
if (typeof value === "number") return String(Math.round(value * 1e3) / 1e3);
|
|
458
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
459
|
+
if (isTable(value)) return JSON.stringify(value);
|
|
460
|
+
return String(value);
|
|
461
|
+
}
|
|
462
|
+
|
|
111
463
|
// src/observer.ts
|
|
112
464
|
import {
|
|
113
|
-
FrameType as
|
|
465
|
+
FrameType as FrameType4,
|
|
114
466
|
decodeErrorPayload,
|
|
115
467
|
decodeFrame,
|
|
116
468
|
decodeReply,
|
|
117
469
|
decodeWelcome,
|
|
118
470
|
errorByCode,
|
|
119
|
-
readCorrectClientTick
|
|
471
|
+
readCorrectClientTick,
|
|
472
|
+
readSchemaPayload,
|
|
473
|
+
withBuiltins
|
|
120
474
|
} from "@irtio/protocol";
|
|
121
475
|
import {
|
|
122
476
|
ByteReader,
|
|
123
477
|
applyDelta,
|
|
124
478
|
decodeDelta as decodeDelta2,
|
|
125
479
|
decodeDeltaFrom,
|
|
126
|
-
decodeSnapshot
|
|
480
|
+
decodeSnapshot,
|
|
481
|
+
schemaFromCanonical
|
|
127
482
|
} from "@irtio/schema";
|
|
128
483
|
|
|
129
484
|
// src/trace.ts
|
|
130
485
|
import { writeFile } from "fs/promises";
|
|
131
|
-
import { FrameType as
|
|
486
|
+
import { FrameType as FrameType3 } from "@irtio/protocol";
|
|
132
487
|
var FRAME_NAMES = new Map(
|
|
133
|
-
Object.entries(
|
|
488
|
+
Object.entries(FrameType3).map(([name, value]) => [value, name])
|
|
134
489
|
);
|
|
135
490
|
function frameName(type) {
|
|
136
491
|
return FRAME_NAMES.get(type) ?? `UNKNOWN(${type})`;
|
|
@@ -202,9 +557,15 @@ function simulationOnly(ext, delta, predicts) {
|
|
|
202
557
|
const predicted = predicts(dc.name, op.id);
|
|
203
558
|
for (const index of op.mask.fields) {
|
|
204
559
|
const field = desc.fields[index];
|
|
205
|
-
if (!field
|
|
206
|
-
if (
|
|
207
|
-
|
|
560
|
+
if (!field) return "mixed";
|
|
561
|
+
if (physics.bodyFields.has(field.name)) {
|
|
562
|
+
if (predicted) sawPredicted = true;
|
|
563
|
+
else sawSync = true;
|
|
564
|
+
} else if (!predicted && !physics.intents.includes(field.name)) {
|
|
565
|
+
sawSync = true;
|
|
566
|
+
} else {
|
|
567
|
+
return "mixed";
|
|
568
|
+
}
|
|
208
569
|
}
|
|
209
570
|
}
|
|
210
571
|
}
|
|
@@ -260,16 +621,33 @@ var BotObserver = class {
|
|
|
260
621
|
constructor(options) {
|
|
261
622
|
this.options = options;
|
|
262
623
|
this.ring = new TraceRing(options.traceLimit);
|
|
624
|
+
this.ext = options.ext;
|
|
263
625
|
}
|
|
264
626
|
options;
|
|
265
627
|
ring;
|
|
266
628
|
violations = /* @__PURE__ */ new Map();
|
|
267
629
|
counts = /* @__PURE__ */ new Map();
|
|
630
|
+
/**
|
|
631
|
+
* Bug #28: the decode extension, *mutable*. It starts as `options.ext` (the schema the run was
|
|
632
|
+
* spawned with) and is rebuilt in place when an inbound `SCHEMA` frame (13) lands — a D50
|
|
633
|
+
* additive `migrate` swaps the wire mid-session, the real client rebuilds its own decoders
|
|
634
|
+
* (`session.swapSchema`), and an observer still holding the v1 descriptor underruns on the v2
|
|
635
|
+
* resync WELCOME and fails `schema-validity` on a swap that was perfect.
|
|
636
|
+
*/
|
|
637
|
+
ext;
|
|
638
|
+
/** How many `SCHEMA` frames rebuilt {@link ext}. For tests and the trace, like the client's. */
|
|
639
|
+
schemaSwaps = 0;
|
|
268
640
|
id = "";
|
|
269
641
|
role = "";
|
|
270
642
|
roomId = "";
|
|
271
643
|
/** Set before `leave()`, so a deliberate teardown is not reported as a disconnect. */
|
|
272
644
|
stopping = false;
|
|
645
|
+
/**
|
|
646
|
+
* D36: the last connection status this bot's client reported, recorded whatever it was. This
|
|
647
|
+
* is the *fact* of where the connection is — the room-gone watcher and the join-timeout
|
|
648
|
+
* message read it — while `disconnects` below stays the judgement, with its old rules.
|
|
649
|
+
*/
|
|
650
|
+
lastStatus = "connecting";
|
|
273
651
|
framesIn = 0;
|
|
274
652
|
framesOut = 0;
|
|
275
653
|
bytesIn = 0;
|
|
@@ -353,6 +731,7 @@ var BotObserver = class {
|
|
|
353
731
|
}
|
|
354
732
|
/** `room.on('status')`: transitions away from a healthy connection, ignored during teardown. */
|
|
355
733
|
onStatus(status) {
|
|
734
|
+
this.lastStatus = status;
|
|
356
735
|
if (this.stopping) return;
|
|
357
736
|
if (status !== "reconnecting" && status !== "closed") return;
|
|
358
737
|
this.disconnects++;
|
|
@@ -394,30 +773,32 @@ var BotObserver = class {
|
|
|
394
773
|
...note !== void 0 ? { note } : {}
|
|
395
774
|
};
|
|
396
775
|
this.ring.push(entry);
|
|
397
|
-
if (dir === "in" && type ===
|
|
776
|
+
if (dir === "in" && type === FrameType4.CORRECT) this.lastCorrectEntry = entry;
|
|
398
777
|
}
|
|
399
778
|
/** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
|
|
400
779
|
inspect(dir, type, bytes, now) {
|
|
401
780
|
const payload = decodeFrame(bytes).payload;
|
|
402
781
|
if (dir === "out") {
|
|
403
|
-
if (type ===
|
|
404
|
-
if (type ===
|
|
782
|
+
if (type === FrameType4.CALL) this.calls++;
|
|
783
|
+
if (type === FrameType4.WRITE) this.recordWrites(decodeDelta2(this.ext, payload), now);
|
|
405
784
|
return void 0;
|
|
406
785
|
}
|
|
407
786
|
switch (type) {
|
|
408
|
-
case
|
|
787
|
+
case FrameType4.WELCOME:
|
|
409
788
|
return this.onWelcome(decodeWelcome(payload));
|
|
410
|
-
case
|
|
411
|
-
return this.onDelta(decodeDelta2(this.
|
|
412
|
-
case
|
|
789
|
+
case FrameType4.DELTA:
|
|
790
|
+
return this.onDelta(decodeDelta2(this.ext, payload), now);
|
|
791
|
+
case FrameType4.CORRECT: {
|
|
413
792
|
const r = new ByteReader(payload);
|
|
414
|
-
const delta = decodeDeltaFrom(this.
|
|
793
|
+
const delta = decodeDeltaFrom(this.ext, r);
|
|
415
794
|
return this.onCorrect(delta, readCorrectClientTick(r), now);
|
|
416
795
|
}
|
|
417
|
-
case
|
|
796
|
+
case FrameType4.ERROR:
|
|
418
797
|
return this.onError(payload);
|
|
419
|
-
case
|
|
798
|
+
case FrameType4.REPLY:
|
|
420
799
|
return this.onReply(payload);
|
|
800
|
+
case FrameType4.SCHEMA:
|
|
801
|
+
return this.onSchema(payload);
|
|
421
802
|
default:
|
|
422
803
|
return void 0;
|
|
423
804
|
}
|
|
@@ -426,12 +807,12 @@ var BotObserver = class {
|
|
|
426
807
|
this.id = welcome.clientId;
|
|
427
808
|
this.role = welcome.role;
|
|
428
809
|
this.roomId = welcome.roomId;
|
|
429
|
-
const snapshot = decodeSnapshot(this.
|
|
810
|
+
const snapshot = decodeSnapshot(this.ext, welcome.snapshot);
|
|
430
811
|
this.view = snapshot.state;
|
|
431
812
|
this.rememberSnapshot(snapshot.state);
|
|
432
813
|
this.countSpatialSnapshot(snapshot.state);
|
|
433
814
|
for (const leak of snapshotVisibilityLeaks(
|
|
434
|
-
this.
|
|
815
|
+
this.ext,
|
|
435
816
|
this.role,
|
|
436
817
|
snapshot.state,
|
|
437
818
|
this.spatialContext()
|
|
@@ -440,6 +821,22 @@ var BotObserver = class {
|
|
|
440
821
|
}
|
|
441
822
|
return `joined ${welcome.roomId} as ${welcome.clientId}/${welcome.role || "default"}`;
|
|
442
823
|
}
|
|
824
|
+
/**
|
|
825
|
+
* Bug #28: a `SCHEMA` frame (D50 additive migrate). Rebuild the decode extension from the
|
|
826
|
+
* descriptor the frame carries, exactly as the real client's `swapSchema` does, so the resync
|
|
827
|
+
* WELCOME about to arrive decodes under the schema it was encoded with. The old view and
|
|
828
|
+
* seen-ids are dropped — they were laid out by descriptors that no longer exist, and the resync
|
|
829
|
+
* WELCOME re-seeds both (`onWelcome`).
|
|
830
|
+
*/
|
|
831
|
+
onSchema(payload) {
|
|
832
|
+
const canonical = readSchemaPayload(payload);
|
|
833
|
+
const next = schemaFromCanonical(canonical);
|
|
834
|
+
this.ext = withBuiltins(next);
|
|
835
|
+
this.schemaSwaps++;
|
|
836
|
+
this.view = void 0;
|
|
837
|
+
this.seen.clear();
|
|
838
|
+
return `schema swapped (hash ${next.hash}) \u2014 decode extension rebuilt`;
|
|
839
|
+
}
|
|
443
840
|
onDelta(delta, now) {
|
|
444
841
|
this.checkVisibility(delta);
|
|
445
842
|
this.matchWrites(delta, now);
|
|
@@ -449,7 +846,7 @@ var BotObserver = class {
|
|
|
449
846
|
this.checkVisibility(delta);
|
|
450
847
|
const names = delta.collections.map((c) => c.name).join(",");
|
|
451
848
|
const kind = simulationOnly(
|
|
452
|
-
this.
|
|
849
|
+
this.ext,
|
|
453
850
|
delta,
|
|
454
851
|
(c, id) => this.predictsBody ? this.predictsBody(c, id) : false
|
|
455
852
|
);
|
|
@@ -470,7 +867,20 @@ var BotObserver = class {
|
|
|
470
867
|
true
|
|
471
868
|
);
|
|
472
869
|
}
|
|
473
|
-
|
|
870
|
+
const fields = /* @__PURE__ */ new Set();
|
|
871
|
+
for (const dc of delta.collections) {
|
|
872
|
+
const desc = this.ext.collections.find(
|
|
873
|
+
(c) => c.name === dc.name
|
|
874
|
+
);
|
|
875
|
+
for (const op of dc.ops) {
|
|
876
|
+
if (op.op !== "update") {
|
|
877
|
+
fields.add(`${op.op} ${op.id}`);
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
for (const index of op.mask.fields) fields.add(desc?.fields[index]?.name ?? `#${index}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return `corrected ${names} [${[...fields].join(" ")}]${clientTick !== void 0 ? ` (clientTick ${clientTick})` : ""}`;
|
|
474
884
|
}
|
|
475
885
|
/**
|
|
476
886
|
* The room's `correct` event, one per correction op — the only source of `previous` (the local
|
|
@@ -483,7 +893,7 @@ var BotObserver = class {
|
|
|
483
893
|
this.suppressedCorrections++;
|
|
484
894
|
return;
|
|
485
895
|
}
|
|
486
|
-
const desc = this.
|
|
896
|
+
const desc = this.ext.collections.find(
|
|
487
897
|
(c) => c.name === correction.collection
|
|
488
898
|
);
|
|
489
899
|
const predictedBody = desc?.physics !== void 0 && correction.fields.length > 0 && correction.fields.every((f) => desc.physics?.bodyFields.has(f));
|
|
@@ -551,14 +961,9 @@ var BotObserver = class {
|
|
|
551
961
|
* frame — a departing neighbour, not a leak.
|
|
552
962
|
*/
|
|
553
963
|
checkVisibility(delta) {
|
|
554
|
-
if (this.view) applyDelta(this.
|
|
964
|
+
if (this.view) applyDelta(this.ext, this.view, delta);
|
|
555
965
|
this.countSpatialDelta(delta);
|
|
556
|
-
for (const leak of deltaVisibilityLeaks(
|
|
557
|
-
this.options.ext,
|
|
558
|
-
this.role,
|
|
559
|
-
delta,
|
|
560
|
-
this.spatialContext()
|
|
561
|
-
)) {
|
|
966
|
+
for (const leak of deltaVisibilityLeaks(this.ext, this.role, delta, this.spatialContext())) {
|
|
562
967
|
this.violate("visibility-leak", leak);
|
|
563
968
|
}
|
|
564
969
|
this.remember(delta);
|
|
@@ -566,12 +971,12 @@ var BotObserver = class {
|
|
|
566
971
|
countSpatialDelta(delta) {
|
|
567
972
|
if (!this.view || this.id === "") return;
|
|
568
973
|
for (const collection of delta.collections) {
|
|
569
|
-
const desc = this.
|
|
974
|
+
const desc = this.ext.collection(collection.name);
|
|
570
975
|
if (desc.visibility === "spatial-grid") this.spatialOpsJudged += collection.ops.length;
|
|
571
976
|
}
|
|
572
977
|
}
|
|
573
978
|
countSpatialSnapshot(state) {
|
|
574
|
-
for (const desc of this.
|
|
979
|
+
for (const desc of this.ext.collections) {
|
|
575
980
|
if (desc.visibility !== "spatial-grid") continue;
|
|
576
981
|
this.spatialOpsJudged += state[desc.name]?.size ?? 0;
|
|
577
982
|
}
|
|
@@ -585,7 +990,7 @@ var BotObserver = class {
|
|
|
585
990
|
return ids;
|
|
586
991
|
}
|
|
587
992
|
rememberSnapshot(state) {
|
|
588
|
-
for (const desc of this.
|
|
993
|
+
for (const desc of this.ext.collections) {
|
|
589
994
|
if (desc.kind !== "entity") continue;
|
|
590
995
|
const collection = state[desc.name];
|
|
591
996
|
if (!collection) continue;
|
|
@@ -610,6 +1015,7 @@ var BotObserver = class {
|
|
|
610
1015
|
} catch {
|
|
611
1016
|
name = `code ${error.code}`;
|
|
612
1017
|
}
|
|
1018
|
+
if (name === "E_STARTING") return `${name} (room starting \u2014 not counted)`;
|
|
613
1019
|
if (!error.fatal) {
|
|
614
1020
|
this.errors++;
|
|
615
1021
|
this.violate("handler-error", `${name}: ${error.message}`);
|
|
@@ -738,6 +1144,7 @@ function freshValue(rng, desc, ctx) {
|
|
|
738
1144
|
}
|
|
739
1145
|
|
|
740
1146
|
// src/report.ts
|
|
1147
|
+
import { mergeProfiles } from "@irtio/protocol";
|
|
741
1148
|
function percentile(sorted, p) {
|
|
742
1149
|
if (sorted.length === 0) return 0;
|
|
743
1150
|
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1));
|
|
@@ -809,7 +1216,39 @@ function detailFor(name, observers, violations, thresholds) {
|
|
|
809
1216
|
const count = observers.reduce((sum, o) => sum + o.disconnects, 0);
|
|
810
1217
|
return count === 0 ? "every bot stayed connected" : `${count} disconnect(s)${examples(observers, name)}`;
|
|
811
1218
|
}
|
|
1219
|
+
case "tick-health":
|
|
1220
|
+
return "";
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
function tickHealthResult(reading, thresholds) {
|
|
1224
|
+
const where = reading?.source !== void 0 ? ` (from ${reading.source})` : "";
|
|
1225
|
+
if (reading === void 0 || reading.unavailable !== void 0) {
|
|
1226
|
+
return {
|
|
1227
|
+
name: "tick-health",
|
|
1228
|
+
ok: true,
|
|
1229
|
+
state: "unavailable",
|
|
1230
|
+
violations: 0,
|
|
1231
|
+
detail: reading?.unavailable ?? "the server was not asked for its tick counters, so this run says nothing about them"
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
if (reading.overruns === void 0) {
|
|
1235
|
+
return {
|
|
1236
|
+
name: "tick-health",
|
|
1237
|
+
ok: true,
|
|
1238
|
+
state: "unavailable",
|
|
1239
|
+
violations: 0,
|
|
1240
|
+
detail: `no overrun count came back${where}`
|
|
1241
|
+
};
|
|
812
1242
|
}
|
|
1243
|
+
const worst = reading.maxTickMs !== void 0 ? `, worst tick ${round(reading.maxTickMs)} ms` : "";
|
|
1244
|
+
const violated = reading.overruns > thresholds.overrunsMax;
|
|
1245
|
+
return {
|
|
1246
|
+
name: "tick-health",
|
|
1247
|
+
ok: !violated,
|
|
1248
|
+
state: violated ? "violation" : "ok",
|
|
1249
|
+
violations: violated ? reading.overruns : 0,
|
|
1250
|
+
detail: `${reading.overruns} tick overrun(s) in the run window, tolerated ${thresholds.overrunsMax}${worst}${where}` + (violated ? " \u2014 the room fell behind and dropped the backlog" : "")
|
|
1251
|
+
};
|
|
813
1252
|
}
|
|
814
1253
|
function buildReport(options) {
|
|
815
1254
|
const { observers, roomId, lags } = options;
|
|
@@ -817,9 +1256,17 @@ function buildReport(options) {
|
|
|
817
1256
|
const durationMs = Math.max(1, options.durationMs);
|
|
818
1257
|
const seconds = durationMs / 1e3;
|
|
819
1258
|
const invariants = INVARIANT_NAMES.map((name) => {
|
|
1259
|
+
if (name === "tick-health") return tickHealthResult(options.tickHealth, thresholds);
|
|
820
1260
|
const violations = total(observers, name);
|
|
821
1261
|
const ok = name === "handler-error" ? violations <= thresholds.handlerErrorsMax : violations === 0;
|
|
822
|
-
|
|
1262
|
+
const state = ok ? "ok" : "violation";
|
|
1263
|
+
return {
|
|
1264
|
+
name,
|
|
1265
|
+
ok,
|
|
1266
|
+
state,
|
|
1267
|
+
violations,
|
|
1268
|
+
detail: detailFor(name, observers, violations, thresholds)
|
|
1269
|
+
};
|
|
823
1270
|
});
|
|
824
1271
|
const perBot = observers.map((o) => o.stats);
|
|
825
1272
|
const totals = {
|
|
@@ -852,10 +1299,20 @@ function buildReport(options) {
|
|
|
852
1299
|
bytesOutPerSecondPerBot: round(totals.bytesOut / seconds / bots),
|
|
853
1300
|
convergence,
|
|
854
1301
|
convergenceLagMs: convergence?.p50Ms,
|
|
855
|
-
tracePath: options.tracePath
|
|
1302
|
+
tracePath: options.tracePath,
|
|
1303
|
+
...options.profiles !== void 0 && options.profiles.length > 0 ? { profile: options.profiles.reduce(mergeProfiles) } : {}
|
|
856
1304
|
};
|
|
857
1305
|
}
|
|
858
1306
|
|
|
1307
|
+
// src/scenario.ts
|
|
1308
|
+
function defineScenario(scenario) {
|
|
1309
|
+
return scenario;
|
|
1310
|
+
}
|
|
1311
|
+
function looksLikeScenario(value) {
|
|
1312
|
+
const s = value;
|
|
1313
|
+
return typeof s === "object" && s !== null && Number.isInteger(s.bots) && s.bots > 0 && typeof s.script === "function" && typeof s.assert === "function";
|
|
1314
|
+
}
|
|
1315
|
+
|
|
859
1316
|
// src/script.ts
|
|
860
1317
|
import { rpcTable } from "@irtio/protocol";
|
|
861
1318
|
function ownableCollections(schema) {
|
|
@@ -871,11 +1328,16 @@ function writableFields(desc, cheat) {
|
|
|
871
1328
|
function voidServerRpcs(schema) {
|
|
872
1329
|
return rpcTable(schema).filter((r) => r.direction === "server" && r.returns === void 0);
|
|
873
1330
|
}
|
|
874
|
-
function
|
|
1331
|
+
function asCollection2(value) {
|
|
875
1332
|
return value && typeof value.ownerOf === "function" ? value : void 0;
|
|
876
1333
|
}
|
|
1334
|
+
function cheatPredicate(cheat) {
|
|
1335
|
+
if (cheat === void 0) return () => false;
|
|
1336
|
+
if (typeof cheat === "function") return (index) => cheat(index) === true;
|
|
1337
|
+
return () => cheat;
|
|
1338
|
+
}
|
|
877
1339
|
function randomScript(schema, options = {}) {
|
|
878
|
-
const
|
|
1340
|
+
const cheats = cheatPredicate(options.cheat);
|
|
879
1341
|
const stepMs = options.stepMs ?? 50;
|
|
880
1342
|
const step = options.step ?? 8;
|
|
881
1343
|
const rpcChance = options.rpcChance ?? 0.05;
|
|
@@ -883,24 +1345,25 @@ function randomScript(schema, options = {}) {
|
|
|
883
1345
|
const collections = ownableCollections(schema);
|
|
884
1346
|
const rpcs = voidServerRpcs(schema);
|
|
885
1347
|
return async (bot) => {
|
|
1348
|
+
const cheat = cheats(bot.index);
|
|
886
1349
|
const room = bot.room;
|
|
887
1350
|
const origins = /* @__PURE__ */ new Map();
|
|
888
1351
|
while (!bot.stopped) {
|
|
889
1352
|
for (const desc of collections) {
|
|
890
|
-
const collection =
|
|
1353
|
+
const collection = asCollection2(room.state[desc.name]);
|
|
891
1354
|
if (!collection) continue;
|
|
892
1355
|
for (const id of [...collection.ids()]) {
|
|
893
1356
|
if (collection.ownerOf(id) !== room.me) continue;
|
|
894
1357
|
const instance = collection.get(id);
|
|
895
1358
|
if (!instance) continue;
|
|
896
|
-
writeFields(bot, desc, id, instance, origins);
|
|
1359
|
+
writeFields(bot, desc, id, instance, origins, cheat);
|
|
897
1360
|
}
|
|
898
1361
|
}
|
|
899
1362
|
if (rpcs.length > 0 && bot.rng.chance(rpcChance)) await callRandomRpc(bot, room.call, rpcs);
|
|
900
1363
|
await bot.wait(stepMs);
|
|
901
1364
|
}
|
|
902
1365
|
};
|
|
903
|
-
function writeFields(bot, desc, id, instance, origins) {
|
|
1366
|
+
function writeFields(bot, desc, id, instance, origins, cheat) {
|
|
904
1367
|
const fields = writableFields(desc, cheat);
|
|
905
1368
|
if (fields.length === 0) return;
|
|
906
1369
|
const count = Math.min(fieldsPerStep, fields.length);
|
|
@@ -947,30 +1410,48 @@ function relayEchoScript(options = {}) {
|
|
|
947
1410
|
// src/spawn.ts
|
|
948
1411
|
import {
|
|
949
1412
|
joinRelay,
|
|
950
|
-
joinRoom
|
|
1413
|
+
joinRoom,
|
|
1414
|
+
webSocketTransport
|
|
951
1415
|
} from "@irtio/client";
|
|
952
|
-
import { relaySchema, withBuiltins } from "@irtio/protocol";
|
|
1416
|
+
import { relaySchema, withBuiltins as withBuiltins2 } from "@irtio/protocol";
|
|
953
1417
|
var DEFAULT_SEED = 96016;
|
|
954
1418
|
var DEFAULT_TRACE_LIMIT = 4096;
|
|
1419
|
+
var DEFAULT_JOIN_TIMEOUT_MS = 2e4;
|
|
1420
|
+
var DEFAULT_ROOM_GONE_GRACE_MS = 5e3;
|
|
1421
|
+
var ROOM_GONE_POLL_MS = 250;
|
|
955
1422
|
function per(value, index) {
|
|
956
1423
|
if (value === void 0) return void 0;
|
|
957
1424
|
return typeof value === "function" ? value(index) : value;
|
|
958
1425
|
}
|
|
959
1426
|
var BotImpl = class {
|
|
960
|
-
constructor(index, room, observer, seed, startedAt) {
|
|
1427
|
+
constructor(index, room, observer, seed, startedAt, conditions, shots) {
|
|
961
1428
|
this.index = index;
|
|
962
1429
|
this.room = room;
|
|
963
1430
|
this.observer = observer;
|
|
964
1431
|
this.startedAt = startedAt;
|
|
1432
|
+
this.conditions = conditions;
|
|
1433
|
+
this.shots = shots;
|
|
965
1434
|
this.rng = makeRng(seed);
|
|
966
1435
|
}
|
|
967
1436
|
index;
|
|
968
1437
|
room;
|
|
969
1438
|
observer;
|
|
970
1439
|
startedAt;
|
|
1440
|
+
conditions;
|
|
1441
|
+
shots;
|
|
971
1442
|
stopped = false;
|
|
972
1443
|
rng;
|
|
973
1444
|
stopListeners = /* @__PURE__ */ new Set();
|
|
1445
|
+
shot(request) {
|
|
1446
|
+
const tick = this.room.tick;
|
|
1447
|
+
this.shots.push({
|
|
1448
|
+
...request,
|
|
1449
|
+
bot: this.index,
|
|
1450
|
+
sentAt: Date.now(),
|
|
1451
|
+
clientTick: typeof tick === "number" ? tick : void 0,
|
|
1452
|
+
uplinkMs: (this.conditions?.rttMs ?? 0) / 2
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
974
1455
|
get id() {
|
|
975
1456
|
return this.observer.id;
|
|
976
1457
|
}
|
|
@@ -1025,6 +1506,43 @@ var BotImpl = class {
|
|
|
1025
1506
|
}
|
|
1026
1507
|
}
|
|
1027
1508
|
};
|
|
1509
|
+
var JoinTimeoutError = class extends Error {
|
|
1510
|
+
constructor(bot, timeoutMs, lastStatus) {
|
|
1511
|
+
super(
|
|
1512
|
+
`irtio bots: bot ${bot} did not finish joining within ${timeoutMs} ms (last status: ${lastStatus})` + (lastStatus === "starting" ? " \u2014 the room kept answering E_STARTING, so it never came up" : "")
|
|
1513
|
+
);
|
|
1514
|
+
this.bot = bot;
|
|
1515
|
+
this.timeoutMs = timeoutMs;
|
|
1516
|
+
this.lastStatus = lastStatus;
|
|
1517
|
+
}
|
|
1518
|
+
bot;
|
|
1519
|
+
timeoutMs;
|
|
1520
|
+
lastStatus;
|
|
1521
|
+
name = "JoinTimeoutError";
|
|
1522
|
+
};
|
|
1523
|
+
async function withJoinTimeout(joining, index, timeoutMs, observer, sockets) {
|
|
1524
|
+
if (timeoutMs <= 0) return joining;
|
|
1525
|
+
let timer;
|
|
1526
|
+
try {
|
|
1527
|
+
return await Promise.race([
|
|
1528
|
+
joining,
|
|
1529
|
+
new Promise((_resolve, reject) => {
|
|
1530
|
+
timer = setTimeout(() => {
|
|
1531
|
+
for (const socket of sockets) {
|
|
1532
|
+
try {
|
|
1533
|
+
socket.close();
|
|
1534
|
+
} catch {
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
reject(new JoinTimeoutError(index, timeoutMs, observer.lastStatus));
|
|
1538
|
+
}, timeoutMs);
|
|
1539
|
+
})
|
|
1540
|
+
]);
|
|
1541
|
+
} finally {
|
|
1542
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
var CONDITIONS_SEED_OFFSET = 377022;
|
|
1028
1546
|
async function spawnBots(n, options = {}) {
|
|
1029
1547
|
if (!Number.isInteger(n) || n < 1) {
|
|
1030
1548
|
throw new Error(`irtio bots: spawnBots needs at least one bot, got ${String(n)}`);
|
|
@@ -1035,15 +1553,21 @@ async function spawnBots(n, options = {}) {
|
|
|
1035
1553
|
correctionsPerSecMax: options.correctionsPerSecMax ?? DEFAULT_THRESHOLDS.correctionsPerSecMax,
|
|
1036
1554
|
handlerErrorsMax: options.handlerErrorsMax ?? DEFAULT_THRESHOLDS.handlerErrorsMax,
|
|
1037
1555
|
mispredictionMagnitudeMax: options.mispredictionMagnitudeMax ?? DEFAULT_THRESHOLDS.mispredictionMagnitudeMax,
|
|
1038
|
-
snapsMax: options.snapsMax ?? DEFAULT_THRESHOLDS.snapsMax
|
|
1556
|
+
snapsMax: options.snapsMax ?? DEFAULT_THRESHOLDS.snapsMax,
|
|
1557
|
+
overrunsMax: options.overrunsMax ?? DEFAULT_THRESHOLDS.overrunsMax
|
|
1039
1558
|
};
|
|
1040
|
-
const ext = options.schema ?
|
|
1559
|
+
const ext = options.schema ? withBuiltins2(options.schema) : relaySchema;
|
|
1041
1560
|
const seed = options.seed ?? DEFAULT_SEED;
|
|
1042
1561
|
const traceLimit = options.traceLimit ?? DEFAULT_TRACE_LIMIT;
|
|
1043
1562
|
const writeLog = new WriteLog();
|
|
1044
1563
|
const lags = [];
|
|
1045
1564
|
const observers = [];
|
|
1046
1565
|
const bots = [];
|
|
1566
|
+
const shots = [];
|
|
1567
|
+
const conditions = [];
|
|
1568
|
+
const joinTimeoutMs = options.joinTimeoutMs ?? DEFAULT_JOIN_TIMEOUT_MS;
|
|
1569
|
+
const roomGoneGraceMs = options.roomGoneGraceMs ?? DEFAULT_ROOM_GONE_GRACE_MS;
|
|
1570
|
+
const baseTransport = options.transport ?? webSocketTransport;
|
|
1047
1571
|
async function joinOne(index, roomId2) {
|
|
1048
1572
|
const observer = new BotObserver({
|
|
1049
1573
|
index,
|
|
@@ -1056,34 +1580,76 @@ async function spawnBots(n, options = {}) {
|
|
|
1056
1580
|
});
|
|
1057
1581
|
const role = per(options.role, index);
|
|
1058
1582
|
const name = per(options.name, index);
|
|
1583
|
+
const sockets = [];
|
|
1584
|
+
const asked = per(options.conditions, index);
|
|
1585
|
+
const injected = hasConditions(asked) ? asked : void 0;
|
|
1586
|
+
const counters = newConditionCounters();
|
|
1587
|
+
conditions[index] = { bot: index, conditions: injected, counters };
|
|
1588
|
+
const recording = {
|
|
1589
|
+
connect(url) {
|
|
1590
|
+
const socket = baseTransport.connect(url);
|
|
1591
|
+
sockets.push(socket);
|
|
1592
|
+
return socket;
|
|
1593
|
+
}
|
|
1594
|
+
};
|
|
1595
|
+
const transport = injected === void 0 ? recording : conditionedTransport(
|
|
1596
|
+
recording,
|
|
1597
|
+
injected,
|
|
1598
|
+
makeRng(seed + index + CONDITIONS_SEED_OFFSET),
|
|
1599
|
+
{
|
|
1600
|
+
counters
|
|
1601
|
+
}
|
|
1602
|
+
);
|
|
1059
1603
|
const common = {
|
|
1060
1604
|
room: roomId2,
|
|
1061
1605
|
...options.url !== void 0 ? { url: options.url } : {},
|
|
1062
1606
|
...options.key !== void 0 ? { key: options.key } : {},
|
|
1063
1607
|
...role !== void 0 ? { role } : {},
|
|
1064
1608
|
...name !== void 0 ? { name } : {},
|
|
1065
|
-
|
|
1609
|
+
transport,
|
|
1066
1610
|
onFrame: (dir, type, bytes) => observer.onFrame(dir, type, bytes),
|
|
1067
1611
|
onStatus: (status) => observer.onStatus(status)
|
|
1068
1612
|
};
|
|
1069
|
-
const
|
|
1613
|
+
const joining = options.schema ? joinRoom(options.schema, {
|
|
1070
1614
|
...common,
|
|
1071
1615
|
...options.flushMs !== void 0 ? { writeIntervalMs: options.flushMs } : {},
|
|
1072
1616
|
...options.rpc !== void 0 ? { rpc: options.rpc } : {},
|
|
1073
|
-
...options.physics !== void 0 ? { physics: options.physics } : {}
|
|
1074
|
-
|
|
1617
|
+
...options.physics !== void 0 ? { physics: options.physics } : {},
|
|
1618
|
+
...options.profile === true ? { profile: true } : {}
|
|
1619
|
+
}) : joinRelay(common);
|
|
1620
|
+
const room = await withJoinTimeout(joining, index, joinTimeoutMs, observer, sockets);
|
|
1075
1621
|
room.on("correct", (correction) => observer.onCorrection(correction));
|
|
1076
1622
|
const prediction = room.prediction;
|
|
1077
1623
|
if (prediction) observer.predictsBody = (c, id) => prediction.predicts(c, id);
|
|
1078
1624
|
observers[index] = observer;
|
|
1079
|
-
return new BotImpl(
|
|
1625
|
+
return new BotImpl(
|
|
1626
|
+
index,
|
|
1627
|
+
room,
|
|
1628
|
+
observer,
|
|
1629
|
+
seed + index,
|
|
1630
|
+
startedAt,
|
|
1631
|
+
injected,
|
|
1632
|
+
shots
|
|
1633
|
+
);
|
|
1080
1634
|
}
|
|
1081
1635
|
const first = await joinOne(0, options.room ?? "");
|
|
1082
1636
|
bots.push(first);
|
|
1083
1637
|
const roomId = first.room.id;
|
|
1084
1638
|
if (n > 1) {
|
|
1085
|
-
const rest = await Promise.
|
|
1086
|
-
|
|
1639
|
+
const rest = await Promise.allSettled(
|
|
1640
|
+
Array.from({ length: n - 1 }, (_, i) => joinOne(i + 1, roomId))
|
|
1641
|
+
);
|
|
1642
|
+
const failure = rest.find((r) => r.status === "rejected");
|
|
1643
|
+
if (failure !== void 0) {
|
|
1644
|
+
for (const settled of rest) {
|
|
1645
|
+
if (settled.status === "fulfilled") settled.value.room.leave();
|
|
1646
|
+
}
|
|
1647
|
+
first.room.leave();
|
|
1648
|
+
throw failure.reason;
|
|
1649
|
+
}
|
|
1650
|
+
for (const settled of rest) {
|
|
1651
|
+
if (settled.status === "fulfilled") bots.push(settled.value);
|
|
1652
|
+
}
|
|
1087
1653
|
}
|
|
1088
1654
|
const scriptErrors = [];
|
|
1089
1655
|
let firstError;
|
|
@@ -1101,36 +1667,77 @@ async function spawnBots(n, options = {}) {
|
|
|
1101
1667
|
}
|
|
1102
1668
|
});
|
|
1103
1669
|
const finished = Promise.all(scripts).then(() => void 0);
|
|
1670
|
+
let endedBy;
|
|
1104
1671
|
let deadline;
|
|
1105
1672
|
if (options.durationMs !== void 0) {
|
|
1106
1673
|
deadline = setTimeout(() => {
|
|
1674
|
+
endedBy ??= "duration";
|
|
1107
1675
|
for (const bot of bots) bot.stop();
|
|
1108
1676
|
}, options.durationMs);
|
|
1109
|
-
deadline.unref?.();
|
|
1110
1677
|
}
|
|
1678
|
+
let goneSince = 0;
|
|
1679
|
+
let goneTimer;
|
|
1680
|
+
if (roomGoneGraceMs > 0) {
|
|
1681
|
+
goneTimer = setInterval(() => {
|
|
1682
|
+
const down = observers.every(
|
|
1683
|
+
(o) => o.lastStatus === "reconnecting" || o.lastStatus === "closed"
|
|
1684
|
+
);
|
|
1685
|
+
if (!down) {
|
|
1686
|
+
goneSince = 0;
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
goneSince ||= Date.now();
|
|
1690
|
+
if (Date.now() - goneSince < roomGoneGraceMs) return;
|
|
1691
|
+
endedBy ??= "room-gone";
|
|
1692
|
+
for (const bot of bots) bot.stop();
|
|
1693
|
+
}, ROOM_GONE_POLL_MS);
|
|
1694
|
+
goneTimer.unref?.();
|
|
1695
|
+
}
|
|
1696
|
+
void finished.then(() => {
|
|
1697
|
+
endedBy ??= "scripts";
|
|
1698
|
+
if (deadline) clearTimeout(deadline);
|
|
1699
|
+
if (goneTimer) clearInterval(goneTimer);
|
|
1700
|
+
});
|
|
1111
1701
|
let stoppedAt;
|
|
1702
|
+
let tickHealth;
|
|
1112
1703
|
const rings = () => observers.map((o) => o.ring);
|
|
1704
|
+
let profiles;
|
|
1113
1705
|
const runner = {
|
|
1114
1706
|
bots,
|
|
1115
1707
|
roomId,
|
|
1116
1708
|
trace: makeTrace(startedAt, rings),
|
|
1117
1709
|
scriptErrors,
|
|
1710
|
+
shots,
|
|
1711
|
+
conditions,
|
|
1712
|
+
get endedBy() {
|
|
1713
|
+
return endedBy;
|
|
1714
|
+
},
|
|
1118
1715
|
[Symbol.iterator]: () => bots[Symbol.iterator](),
|
|
1119
1716
|
async done() {
|
|
1120
1717
|
await finished;
|
|
1121
1718
|
if (firstError !== void 0) throw firstError;
|
|
1122
1719
|
},
|
|
1720
|
+
recordTickHealth(reading) {
|
|
1721
|
+
tickHealth = reading;
|
|
1722
|
+
},
|
|
1123
1723
|
report() {
|
|
1724
|
+
if (options.profile === true) {
|
|
1725
|
+
const live = bots.map((bot) => bot.room.profile?.total()).filter((p) => p !== void 0);
|
|
1726
|
+
if (live.length > 0) profiles = live;
|
|
1727
|
+
}
|
|
1124
1728
|
return buildReport({
|
|
1125
1729
|
observers,
|
|
1126
1730
|
roomId,
|
|
1127
1731
|
durationMs: (stoppedAt ?? Date.now()) - startedAt,
|
|
1128
1732
|
lags,
|
|
1129
|
-
thresholds
|
|
1733
|
+
thresholds,
|
|
1734
|
+
tickHealth,
|
|
1735
|
+
...profiles !== void 0 ? { profiles } : {}
|
|
1130
1736
|
});
|
|
1131
1737
|
},
|
|
1132
1738
|
async stop() {
|
|
1133
1739
|
if (deadline) clearTimeout(deadline);
|
|
1740
|
+
if (goneTimer) clearInterval(goneTimer);
|
|
1134
1741
|
for (const bot of bots) bot.stop();
|
|
1135
1742
|
await finished;
|
|
1136
1743
|
stoppedAt ??= Date.now();
|
|
@@ -1141,22 +1748,170 @@ async function spawnBots(n, options = {}) {
|
|
|
1141
1748
|
};
|
|
1142
1749
|
return runner;
|
|
1143
1750
|
}
|
|
1751
|
+
|
|
1752
|
+
// src/timeline.ts
|
|
1753
|
+
var Collection = class {
|
|
1754
|
+
constructor(raw, entity) {
|
|
1755
|
+
this.raw = raw;
|
|
1756
|
+
this.entity = entity;
|
|
1757
|
+
}
|
|
1758
|
+
raw;
|
|
1759
|
+
entity;
|
|
1760
|
+
entry(id) {
|
|
1761
|
+
if (!this.entity) return void 0;
|
|
1762
|
+
const row = this.raw[id];
|
|
1763
|
+
return typeof row === "object" && row !== null ? row : void 0;
|
|
1764
|
+
}
|
|
1765
|
+
get(id) {
|
|
1766
|
+
const value = this.entry(id)?.value;
|
|
1767
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
1768
|
+
}
|
|
1769
|
+
has(id) {
|
|
1770
|
+
return this.entity && Object.hasOwn(this.raw, id);
|
|
1771
|
+
}
|
|
1772
|
+
ids() {
|
|
1773
|
+
return this.entity ? Object.keys(this.raw) : [];
|
|
1774
|
+
}
|
|
1775
|
+
get size() {
|
|
1776
|
+
return this.entity ? Object.keys(this.raw).length : 0;
|
|
1777
|
+
}
|
|
1778
|
+
owner(id) {
|
|
1779
|
+
const owner = this.entry(id)?.owner;
|
|
1780
|
+
return typeof owner === "string" && owner !== "" ? owner : void 0;
|
|
1781
|
+
}
|
|
1782
|
+
get value() {
|
|
1783
|
+
return this.entity ? void 0 : this.raw;
|
|
1784
|
+
}
|
|
1785
|
+
};
|
|
1786
|
+
var UnknownCollectionError = class extends Error {
|
|
1787
|
+
name = "UnknownCollectionError";
|
|
1788
|
+
constructor(collection, known) {
|
|
1789
|
+
super(
|
|
1790
|
+
`irtio scenario: this room has no collection named "${collection}". It has: ${known.length > 0 ? known.join(", ") : "none"}.`
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
var TickNotRecordedError = class extends Error {
|
|
1795
|
+
constructor(tick, first, last, dropped) {
|
|
1796
|
+
super(
|
|
1797
|
+
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.` : ".")
|
|
1798
|
+
);
|
|
1799
|
+
this.tick = tick;
|
|
1800
|
+
}
|
|
1801
|
+
tick;
|
|
1802
|
+
name = "TickNotRecordedError";
|
|
1803
|
+
};
|
|
1804
|
+
function momentOf(dump, index, read) {
|
|
1805
|
+
const frame = dump.frames[index];
|
|
1806
|
+
read(frame.tick);
|
|
1807
|
+
const known = Object.keys(dump.collections);
|
|
1808
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1809
|
+
return new Proxy({}, {
|
|
1810
|
+
get(_target, property) {
|
|
1811
|
+
if (typeof property !== "string") return void 0;
|
|
1812
|
+
const cached = cache.get(property);
|
|
1813
|
+
if (cached) return cached;
|
|
1814
|
+
const kind = dump.collections[property];
|
|
1815
|
+
if (kind === void 0) throw new UnknownCollectionError(property, known);
|
|
1816
|
+
const raw = frame.state[property];
|
|
1817
|
+
const view = new Collection(
|
|
1818
|
+
typeof raw === "object" && raw !== null ? raw : {},
|
|
1819
|
+
kind === "entity"
|
|
1820
|
+
);
|
|
1821
|
+
cache.set(property, view);
|
|
1822
|
+
return view;
|
|
1823
|
+
},
|
|
1824
|
+
has: (_target, property) => typeof property === "string" && property in dump.collections,
|
|
1825
|
+
ownKeys: () => [...known],
|
|
1826
|
+
getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true })
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
function makeTimeline(dump) {
|
|
1830
|
+
const index = /* @__PURE__ */ new Map();
|
|
1831
|
+
dump.frames.forEach((f, i) => index.set(f.tick, i));
|
|
1832
|
+
const ticks = dump.frames.map((f) => f.tick);
|
|
1833
|
+
const results = [];
|
|
1834
|
+
let readTicks = [];
|
|
1835
|
+
const read = (tick) => {
|
|
1836
|
+
readTicks.push(tick);
|
|
1837
|
+
};
|
|
1838
|
+
const at = (tick) => {
|
|
1839
|
+
const i = index.get(tick);
|
|
1840
|
+
if (i === void 0) {
|
|
1841
|
+
throw new TickNotRecordedError(tick, ticks[0], ticks[ticks.length - 1], dump.dropped);
|
|
1842
|
+
}
|
|
1843
|
+
return momentOf(dump, i, read);
|
|
1844
|
+
};
|
|
1845
|
+
return {
|
|
1846
|
+
roomId: dump.roomId,
|
|
1847
|
+
ticks,
|
|
1848
|
+
dropped: dump.dropped,
|
|
1849
|
+
at,
|
|
1850
|
+
find(match) {
|
|
1851
|
+
for (let i = 0; i < dump.frames.length; i++) {
|
|
1852
|
+
const tick = dump.frames[i].tick;
|
|
1853
|
+
const state = momentOf(dump, i, read);
|
|
1854
|
+
if (match(state, tick)) return { tick, state };
|
|
1855
|
+
}
|
|
1856
|
+
return void 0;
|
|
1857
|
+
},
|
|
1858
|
+
check(name, assertion) {
|
|
1859
|
+
readTicks = [];
|
|
1860
|
+
try {
|
|
1861
|
+
assertion();
|
|
1862
|
+
results.push({
|
|
1863
|
+
name,
|
|
1864
|
+
ok: true,
|
|
1865
|
+
...readTicks.length > 0 ? { tick: readTicks[readTicks.length - 1] } : {},
|
|
1866
|
+
detail: readTicks.length === 0 ? "held (read no tick)" : `held, reading tick ${readTicks[readTicks.length - 1]}` + (readTicks.length > 1 ? ` (${readTicks.length} ticks read)` : "")
|
|
1867
|
+
});
|
|
1868
|
+
} catch (err) {
|
|
1869
|
+
results.push({
|
|
1870
|
+
name,
|
|
1871
|
+
ok: false,
|
|
1872
|
+
...readTicks.length > 0 ? { tick: readTicks[readTicks.length - 1] } : {},
|
|
1873
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
},
|
|
1877
|
+
get results() {
|
|
1878
|
+
return results;
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1144
1882
|
export {
|
|
1145
1883
|
BotObserver,
|
|
1884
|
+
DEFAULT_JOIN_TIMEOUT_MS,
|
|
1885
|
+
DEFAULT_REORDER_MS,
|
|
1886
|
+
DEFAULT_ROOM_GONE_GRACE_MS,
|
|
1146
1887
|
DEFAULT_SEED,
|
|
1147
1888
|
DEFAULT_THRESHOLDS,
|
|
1148
1889
|
DEFAULT_TRACE_LIMIT,
|
|
1149
1890
|
INVARIANT_NAMES,
|
|
1891
|
+
JoinTimeoutError,
|
|
1892
|
+
TickNotRecordedError,
|
|
1150
1893
|
TraceRing,
|
|
1894
|
+
UnknownCollectionError,
|
|
1151
1895
|
WriteLog,
|
|
1152
1896
|
buildReport,
|
|
1897
|
+
captureClientState,
|
|
1898
|
+
cheatPredicate,
|
|
1899
|
+
conditionedTransport,
|
|
1153
1900
|
convergenceStats,
|
|
1901
|
+
correlateShots,
|
|
1902
|
+
defineScenario,
|
|
1154
1903
|
deltaVisibilityLeaks,
|
|
1904
|
+
describeConditions,
|
|
1905
|
+
diffAgainstSave,
|
|
1155
1906
|
frameName,
|
|
1156
1907
|
frameVisibilityLeaks,
|
|
1157
1908
|
freshValue,
|
|
1909
|
+
hasConditions,
|
|
1910
|
+
looksLikeScenario,
|
|
1158
1911
|
makeRng,
|
|
1912
|
+
makeTimeline,
|
|
1159
1913
|
makeTrace,
|
|
1914
|
+
newConditionCounters,
|
|
1160
1915
|
nextValue,
|
|
1161
1916
|
randomScript,
|
|
1162
1917
|
relayEchoScript,
|