@irtio/bots 0.1.0 → 0.2.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 +134 -9
- package/dist/index.js +339 -32
- package/package.json +5 -5
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AnySchema, Delta,
|
|
2
|
-
import { Room, RelayRoom, Transport, JoinOptions } from '@irtio/client';
|
|
1
|
+
import { PlainState, AnySchema, Delta, TypeDesc } from '@irtio/schema';
|
|
2
|
+
import { Correction, Room, RelayRoom, Transport, JoinOptions } from '@irtio/client';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The built-in invariants, and the pure checkers behind the two that need real protocol knowledge.
|
|
@@ -14,7 +14,7 @@ import { Room, RelayRoom, Transport, JoinOptions } from '@irtio/client';
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/** The invariants every simulation checks, in report order. */
|
|
17
|
-
declare const INVARIANT_NAMES: readonly ["schema-validity", "visibility-leak", "bandwidth", "handler-error", "correction-storm", "disconnects"];
|
|
17
|
+
declare const INVARIANT_NAMES: readonly ["schema-validity", "visibility-leak", "bandwidth", "handler-error", "correction-storm", "misprediction", "snaps", "disconnects"];
|
|
18
18
|
type InvariantName = (typeof INVARIANT_NAMES)[number];
|
|
19
19
|
interface InvariantResult {
|
|
20
20
|
readonly name: InvariantName;
|
|
@@ -23,6 +23,27 @@ interface InvariantResult {
|
|
|
23
23
|
/** One line a human can act on: the threshold, the peak, the first offenders. */
|
|
24
24
|
readonly detail: string;
|
|
25
25
|
}
|
|
26
|
+
interface SpatialVisibilityContext {
|
|
27
|
+
/**
|
|
28
|
+
* The state the anchor and the judged positions are read from. Server truth when a test has it
|
|
29
|
+
* (the runtime harness does); a bot has none and passes its **own materialized view** instead —
|
|
30
|
+
* see `slackCells` and `BotObserver` for why that still answers the AOI question honestly.
|
|
31
|
+
*/
|
|
32
|
+
readonly authority: PlainState;
|
|
33
|
+
readonly clientId: string;
|
|
34
|
+
/** Ids this viewer was previously sent, so a legitimate AOI departure remove is allowed. */
|
|
35
|
+
readonly seen?: ReadonlyMap<string, ReadonlySet<string>>;
|
|
36
|
+
/**
|
|
37
|
+
* Extra cells added to every grid radius before judging. Defaults to 0 — server truth needs no
|
|
38
|
+
* slack — but a viewer judging from its own view has an anchor that may be one tick stale
|
|
39
|
+
* relative to the frame in hand, so a record sitting exactly on the boundary could be flagged
|
|
40
|
+
* for a move the viewer has not been told about yet. Widening the neighbourhood can only ever
|
|
41
|
+
* make the detector *miss* a marginal leak; it can never make it invent one. That asymmetry is
|
|
42
|
+
* the whole justification: a checker that under-reports is a checker, a checker that
|
|
43
|
+
* over-reports is noise that gets switched off.
|
|
44
|
+
*/
|
|
45
|
+
readonly slackCells?: number;
|
|
46
|
+
}
|
|
26
47
|
/** Thresholds the counting invariants compare against. */
|
|
27
48
|
interface InvariantThresholds {
|
|
28
49
|
/** `bandwidth`: inbound bytes per second, per bot. */
|
|
@@ -31,19 +52,27 @@ interface InvariantThresholds {
|
|
|
31
52
|
readonly correctionsPerSecMax: number;
|
|
32
53
|
/** `handler-error`: tolerated REPLY errors + non-fatal ERROR frames across the whole run. */
|
|
33
54
|
readonly handlerErrorsMax: number;
|
|
55
|
+
/**
|
|
56
|
+
* `misprediction`: numeric distance one correction may snap a bot's prediction, summed over the
|
|
57
|
+
* fields it carried. Infinite by default — the invariant reports the measured numbers and only
|
|
58
|
+
* fails when a run tightens this.
|
|
59
|
+
*/
|
|
60
|
+
readonly mispredictionMagnitudeMax: number;
|
|
61
|
+
/** `snaps`: tolerated cap-exceeded reconciliations (corrections past the resim window), per run. */
|
|
62
|
+
readonly snapsMax: number;
|
|
34
63
|
}
|
|
35
64
|
declare const DEFAULT_THRESHOLDS: InvariantThresholds;
|
|
36
65
|
/**
|
|
37
66
|
* Collections a `DELTA` named that `role` is not allowed to see. An op count of zero is not a
|
|
38
67
|
* leak — an empty collection entry carries no information — but any add, update or remove is.
|
|
39
68
|
*/
|
|
40
|
-
declare function deltaVisibilityLeaks(ext: AnySchema, role: string, delta: Delta): string[];
|
|
69
|
+
declare function deltaVisibilityLeaks(ext: AnySchema, role: string, delta: Delta, spatial?: SpatialVisibilityContext): string[];
|
|
41
70
|
/**
|
|
42
71
|
* The same rule for a join snapshot. The wire format always has a slot for every collection —
|
|
43
72
|
* a hidden one is encoded empty — so the leak is *content*: an entity collection with records in
|
|
44
73
|
* it, or a singleton holding anything other than its zero record.
|
|
45
74
|
*/
|
|
46
|
-
declare function snapshotVisibilityLeaks(ext: AnySchema, role: string, state: PlainState): string[];
|
|
75
|
+
declare function snapshotVisibilityLeaks(ext: AnySchema, role: string, state: PlainState, spatial?: Omit<SpatialVisibilityContext, 'seen'>): string[];
|
|
47
76
|
/**
|
|
48
77
|
* The socket-free entry point for the frames that carry a delta (`DELTA`, `CORRECT`): hand it the
|
|
49
78
|
* frame type and its payload and it decodes and checks. Any other type returns `[]` — a join
|
|
@@ -51,7 +80,7 @@ declare function snapshotVisibilityLeaks(ext: AnySchema, role: string, state: Pl
|
|
|
51
80
|
* rather than being one. A payload that will not decode throws, and the caller counts that as a
|
|
52
81
|
* `schema-validity` violation rather than a visibility one.
|
|
53
82
|
*/
|
|
54
|
-
declare function frameVisibilityLeaks(ext: AnySchema, role: string, type: number, payload: Uint8Array): string[];
|
|
83
|
+
declare function frameVisibilityLeaks(ext: AnySchema, role: string, type: number, payload: Uint8Array, spatial?: SpatialVisibilityContext): string[];
|
|
55
84
|
|
|
56
85
|
/**
|
|
57
86
|
* The trace recorder: every frame in and out, per bot, with a timestamp and the decoded frame
|
|
@@ -126,6 +155,28 @@ interface BotStats {
|
|
|
126
155
|
readonly bytesIn: number;
|
|
127
156
|
readonly bytesOut: number;
|
|
128
157
|
readonly corrections: number;
|
|
158
|
+
/**
|
|
159
|
+
* D22: corrections that touched **only** simulation-owned body fields. Not a disagreement —
|
|
160
|
+
* the client cannot write those fields, so it never predicted them; this is how a
|
|
161
|
+
* server-authoritative body reaches its owner, once per tick, by design. Counted apart from
|
|
162
|
+
* `corrections` so a healthy physics room does not read as a correction storm. Week 10 changes
|
|
163
|
+
* the meaning: once the client re-steps predicted bodies, these become real mispredictions.
|
|
164
|
+
*/
|
|
165
|
+
readonly syncCorrections: number;
|
|
166
|
+
/**
|
|
167
|
+
* D22 part 2: predicted-body corrections whose values matched the local prediction within the
|
|
168
|
+
* epsilon — authority confirming the prediction, not disagreeing with it. Counted apart so a
|
|
169
|
+
* healthy predicted body's steady state stays quiet.
|
|
170
|
+
*/
|
|
171
|
+
readonly suppressedCorrections: number;
|
|
172
|
+
/** Correction *ops* seen via the room's `correct` event (a `CORRECT` frame may carry several). */
|
|
173
|
+
readonly mispredictions: number;
|
|
174
|
+
/** Summed numeric distance between predicted and corrected values, across the run. */
|
|
175
|
+
readonly mispredictionMagnitude: number;
|
|
176
|
+
/** The single worst correction's numeric distance. */
|
|
177
|
+
readonly mispredictionMax: number;
|
|
178
|
+
/** Corrections that outran the resim window and snapped instead of replaying. */
|
|
179
|
+
readonly snaps: number;
|
|
129
180
|
/** Outbound `CALL` frames: RPCs this bot asked the server for. */
|
|
130
181
|
readonly calls: number;
|
|
131
182
|
/** Error replies plus non-fatal `ERROR` frames. */
|
|
@@ -162,8 +213,8 @@ interface ObserverOptions {
|
|
|
162
213
|
declare class BotObserver {
|
|
163
214
|
private readonly options;
|
|
164
215
|
readonly ring: TraceRing;
|
|
165
|
-
readonly violations: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "disconnects", string[]>;
|
|
166
|
-
readonly counts: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "disconnects", number>;
|
|
216
|
+
readonly violations: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects", string[]>;
|
|
217
|
+
readonly counts: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects", number>;
|
|
167
218
|
id: string;
|
|
168
219
|
role: string;
|
|
169
220
|
roomId: string;
|
|
@@ -174,14 +225,47 @@ declare class BotObserver {
|
|
|
174
225
|
bytesIn: number;
|
|
175
226
|
bytesOut: number;
|
|
176
227
|
corrections: number;
|
|
228
|
+
syncCorrections: number;
|
|
229
|
+
suppressedCorrections: number;
|
|
230
|
+
mispredictions: number;
|
|
231
|
+
/**
|
|
232
|
+
* Does this bot's client currently predict `collection[id]` in a local world? Assigned by
|
|
233
|
+
* `spawnBots` once the room exists (`room.prediction`); prediction status is client-local, so
|
|
234
|
+
* it cannot be derived from the bytes the observer otherwise sticks to.
|
|
235
|
+
*/
|
|
236
|
+
predictsBody: ((collection: string, id: string) => boolean) | undefined;
|
|
237
|
+
mispredictionMagnitude: number;
|
|
238
|
+
mispredictionMax: number;
|
|
239
|
+
snaps: number;
|
|
177
240
|
calls: number;
|
|
178
241
|
errors: number;
|
|
179
242
|
disconnects: number;
|
|
180
243
|
peakBytesInPerSec: number;
|
|
181
244
|
peakCorrectionsPerSec: number;
|
|
245
|
+
/**
|
|
246
|
+
* Spatial-grid ops actually put to the AOI policy. Zero on a run whose room has no spatial
|
|
247
|
+
* collection; zero on a run that *does* and would mean the invariant passed vacuously, which is
|
|
248
|
+
* exactly the bug this counter exists to make visible in the report.
|
|
249
|
+
*/
|
|
250
|
+
spatialOpsJudged: number;
|
|
251
|
+
/**
|
|
252
|
+
* The bot's own materialized view: the `WELCOME` snapshot, plus every `DELTA`/`CORRECT` since.
|
|
253
|
+
*
|
|
254
|
+
* This is not server truth and never claims to be. It is what the AOI contract is *about*: the
|
|
255
|
+
* viewer's anchor is the record in the same collection whose id is the viewer's client id, and
|
|
256
|
+
* that record is always visible to its own client, so the bot holds its own true anchor. Every
|
|
257
|
+
* other record's position arrives inside the very op being judged — a record the server should
|
|
258
|
+
* not have sent is convicted by the position the server itself reported for it. The question
|
|
259
|
+
* answered is "was I sent something outside my own radius", which is the whole contract.
|
|
260
|
+
*/
|
|
261
|
+
private view;
|
|
262
|
+
/** Ids delivered so far, per collection, so an AOI departure `remove` is not read as a leak. */
|
|
263
|
+
private readonly seen;
|
|
182
264
|
private readonly bytesWindow;
|
|
183
265
|
private readonly correctWindow;
|
|
184
266
|
private readonly cooldown;
|
|
267
|
+
/** The last inbound `CORRECT` trace entry, so `onCorrection` can finish its note. */
|
|
268
|
+
private lastCorrectEntry;
|
|
185
269
|
constructor(options: ObserverOptions);
|
|
186
270
|
get stats(): BotStats;
|
|
187
271
|
/** Counts a violation always; keeps an example, rate-limited per invariant when asked. */
|
|
@@ -195,6 +279,25 @@ declare class BotObserver {
|
|
|
195
279
|
private onWelcome;
|
|
196
280
|
private onDelta;
|
|
197
281
|
private onCorrect;
|
|
282
|
+
/**
|
|
283
|
+
* The room's `correct` event, one per correction op — the only source of `previous` (the local
|
|
284
|
+
* prediction the server overrode) and of the `snapped` flag, neither of which a frame-level
|
|
285
|
+
* decode can know. Wired up by `spawnBots` via `room.on('correct', ...)`.
|
|
286
|
+
*/
|
|
287
|
+
onCorrection(correction: Correction): void;
|
|
288
|
+
private spatialContext;
|
|
289
|
+
/**
|
|
290
|
+
* Applies the delta to the bot's view *first*, then judges: a record the server should not have
|
|
291
|
+
* sent has to be in the view for its own reported position to convict it. `seen` is updated
|
|
292
|
+
* afterwards, so a `remove` is judged against what the bot had been told about before this
|
|
293
|
+
* frame — a departing neighbour, not a leak.
|
|
294
|
+
*/
|
|
295
|
+
private checkVisibility;
|
|
296
|
+
private countSpatialDelta;
|
|
297
|
+
private countSpatialSnapshot;
|
|
298
|
+
private ids;
|
|
299
|
+
private rememberSnapshot;
|
|
300
|
+
private remember;
|
|
198
301
|
private onError;
|
|
199
302
|
private onReply;
|
|
200
303
|
private eachScalar;
|
|
@@ -277,6 +380,18 @@ interface SimulationTotals {
|
|
|
277
380
|
readonly bytesIn: number;
|
|
278
381
|
readonly bytesOut: number;
|
|
279
382
|
readonly corrections: number;
|
|
383
|
+
/** D22: corrections carrying only simulated body state — the sync path, not a disagreement. */
|
|
384
|
+
readonly syncCorrections: number;
|
|
385
|
+
/** D22 part 2: predicted-body corrections matching the prediction within epsilon (quiet). */
|
|
386
|
+
readonly suppressedCorrections: number;
|
|
387
|
+
/** Correction ops seen via the rooms' `correct` events, across every bot. */
|
|
388
|
+
readonly mispredictions: number;
|
|
389
|
+
/** Summed numeric misprediction magnitude across every bot. */
|
|
390
|
+
readonly mispredictionMagnitude: number;
|
|
391
|
+
/** The single worst correction's numeric magnitude, across every bot. */
|
|
392
|
+
readonly mispredictionMax: number;
|
|
393
|
+
/** Cap-exceeded reconciliations (corrections past the resim window), across every bot. */
|
|
394
|
+
readonly snaps: number;
|
|
280
395
|
readonly calls: number;
|
|
281
396
|
readonly errors: number;
|
|
282
397
|
}
|
|
@@ -382,6 +497,10 @@ interface SpawnOptionsBase {
|
|
|
382
497
|
readonly correctionsPerSecMax?: number;
|
|
383
498
|
/** `handler-error` tolerance for a run that expects some RPCs to be refused. */
|
|
384
499
|
readonly handlerErrorsMax?: number;
|
|
500
|
+
/** `misprediction` threshold: numeric units one correction may snap. Default: infinite. */
|
|
501
|
+
readonly mispredictionMagnitudeMax?: number;
|
|
502
|
+
/** `snaps` tolerance: cap-exceeded reconciliations per bot. Default: infinite. */
|
|
503
|
+
readonly snapsMax?: number;
|
|
385
504
|
/** @internal Wrap `webSocketTransport` to get at the socket (the reconnection scenarios). */
|
|
386
505
|
readonly transport?: Transport;
|
|
387
506
|
}
|
|
@@ -390,6 +509,12 @@ interface SpawnOptions<S extends AnySchema> extends SpawnOptionsBase {
|
|
|
390
509
|
readonly script?: BotScript<S>;
|
|
391
510
|
/** Implementations for server → client RPCs, shared by every bot. */
|
|
392
511
|
readonly rpc?: JoinOptions<S>['rpc'];
|
|
512
|
+
/**
|
|
513
|
+
* D22 part 2: the shared world-builder, passed to every bot's `joinRoom({ physics })`. With
|
|
514
|
+
* it, bots predict physics bodies exactly as a browser client would, and body-field
|
|
515
|
+
* corrections classify as real mispredictions (in world units) instead of `syncCorrections`.
|
|
516
|
+
*/
|
|
517
|
+
readonly physics?: JoinOptions<S>['physics'];
|
|
393
518
|
}
|
|
394
519
|
/** Schema-less relay: no schema, so `joinRelay` and a `RelayRoom`. */
|
|
395
520
|
interface RelaySpawnOptions extends SpawnOptionsBase {
|
|
@@ -466,4 +591,4 @@ interface RelayEchoScriptOptions {
|
|
|
466
591
|
*/
|
|
467
592
|
declare function relayEchoScript(options?: RelayEchoScriptOptions): BotScript<undefined>;
|
|
468
593
|
|
|
469
|
-
export { type Bot, BotObserver, type BotRoom, type BotRunner, type BotScript, type BotStats, type BuildReportOptions, type ConvergenceStats, DEFAULT_SEED, DEFAULT_THRESHOLDS, DEFAULT_TRACE_LIMIT, INVARIANT_NAMES, type InvariantName, type InvariantResult, type InvariantThresholds, type ObserverOptions, type RandomScriptOptions, type RelayEchoScriptOptions, type RelaySpawnOptions, type Rng, type SimulationReport, type SimulationTotals, type SpawnOptions, type Trace, type TraceDump, type TraceEntry, TraceRing, type UntilOptions, type ValueContext, WriteLog, buildReport, convergenceStats, deltaVisibilityLeaks, frameName, frameVisibilityLeaks, freshValue, makeRng, makeTrace, nextValue, randomScript, relayEchoScript, snapshotVisibilityLeaks, spawnBots };
|
|
594
|
+
export { type Bot, BotObserver, type BotRoom, type BotRunner, type BotScript, type BotStats, type BuildReportOptions, type ConvergenceStats, DEFAULT_SEED, DEFAULT_THRESHOLDS, DEFAULT_TRACE_LIMIT, INVARIANT_NAMES, type InvariantName, type InvariantResult, type InvariantThresholds, type ObserverOptions, type RandomScriptOptions, type RelayEchoScriptOptions, type RelaySpawnOptions, type Rng, type SimulationReport, type SimulationTotals, type SpatialVisibilityContext, type SpawnOptions, type Trace, type TraceDump, type TraceEntry, TraceRing, type UntilOptions, type ValueContext, WriteLog, buildReport, convergenceStats, deltaVisibilityLeaks, frameName, frameVisibilityLeaks, freshValue, makeRng, makeTrace, nextValue, randomScript, relayEchoScript, snapshotVisibilityLeaks, spawnBots };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/invariants.ts
|
|
2
2
|
import { FrameType } from "@irtio/protocol";
|
|
3
|
-
import { visibleNames } from "@irtio/runtime";
|
|
3
|
+
import { createVisibilityPolicy, visibleNames } from "@irtio/runtime";
|
|
4
4
|
import {
|
|
5
5
|
decodeDelta,
|
|
6
6
|
defaultRecord
|
|
@@ -11,21 +11,60 @@ var INVARIANT_NAMES = [
|
|
|
11
11
|
"bandwidth",
|
|
12
12
|
"handler-error",
|
|
13
13
|
"correction-storm",
|
|
14
|
+
"misprediction",
|
|
15
|
+
"snaps",
|
|
14
16
|
"disconnects"
|
|
15
17
|
];
|
|
16
18
|
var DEFAULT_THRESHOLDS = {
|
|
17
19
|
budgetBytesPerSec: 128e3,
|
|
18
20
|
correctionsPerSecMax: 5,
|
|
19
|
-
handlerErrorsMax: 0
|
|
21
|
+
handlerErrorsMax: 0,
|
|
22
|
+
mispredictionMagnitudeMax: Number.POSITIVE_INFINITY,
|
|
23
|
+
snapsMax: Number.POSITIVE_INFINITY
|
|
20
24
|
};
|
|
21
|
-
|
|
25
|
+
var widenedSchemas = /* @__PURE__ */ new WeakMap();
|
|
26
|
+
function widenGrids(ext, slack) {
|
|
27
|
+
if (slack === 0) return ext;
|
|
28
|
+
let bySlack = widenedSchemas.get(ext);
|
|
29
|
+
if (!bySlack) {
|
|
30
|
+
bySlack = /* @__PURE__ */ new Map();
|
|
31
|
+
widenedSchemas.set(ext, bySlack);
|
|
32
|
+
}
|
|
33
|
+
const cached = bySlack.get(slack);
|
|
34
|
+
if (cached) return cached;
|
|
35
|
+
const collections = ext.collections.map(
|
|
36
|
+
(desc) => desc.visibility === "spatial-grid" && desc.grid ? { ...desc, grid: { ...desc.grid, radius: desc.grid.radius + slack } } : desc
|
|
37
|
+
);
|
|
38
|
+
const widened = { ...ext, collections };
|
|
39
|
+
bySlack.set(slack, widened);
|
|
40
|
+
return widened;
|
|
41
|
+
}
|
|
42
|
+
function policyFor(ext, role, spatial) {
|
|
43
|
+
if (!spatial) return void 0;
|
|
44
|
+
const slack = spatial.slackCells ?? 0;
|
|
45
|
+
return createVisibilityPolicy(widenGrids(ext, slack), spatial.authority, spatial.clientId, role);
|
|
46
|
+
}
|
|
47
|
+
function radiusText(desc, spatial) {
|
|
48
|
+
const slack = spatial?.slackCells ?? 0;
|
|
49
|
+
return `${desc.grid?.radius ?? "?"}${slack > 0 ? ` (+${slack} slack)` : ""}`;
|
|
50
|
+
}
|
|
51
|
+
function deltaVisibilityLeaks(ext, role, delta, spatial) {
|
|
22
52
|
const allowed = visibleNames(ext, role);
|
|
53
|
+
const policy = policyFor(ext, role, spatial);
|
|
23
54
|
const leaks = [];
|
|
24
55
|
for (const collection of delta.collections) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
56
|
+
const desc = ext.collection(collection.name);
|
|
57
|
+
const bad = collection.ops.filter((op) => {
|
|
58
|
+
if (!allowed.has(collection.name)) return true;
|
|
59
|
+
if (desc.visibility !== "spatial-grid" || !policy) return false;
|
|
60
|
+
if (op.op === "remove") return !(spatial?.seen?.get(desc.name)?.has(op.id) ?? false);
|
|
61
|
+
return !policy.maySeeEntity(desc, op.id);
|
|
62
|
+
});
|
|
63
|
+
if (bad.length === 0) continue;
|
|
64
|
+
const ops = bad.map((op) => `${op.op}:${op.id}`).join(",");
|
|
65
|
+
leaks.push(
|
|
66
|
+
`delta@${delta.tick} carried ${collection.name} (${ops}) to ${spatial?.clientId ?? `role ${role || '""'}`}` + (desc.visibility === "spatial-grid" ? ` outside radius ${radiusText(desc, spatial)} from anchor ${spatial?.clientId ?? "?"}` : "")
|
|
67
|
+
);
|
|
29
68
|
}
|
|
30
69
|
return leaks;
|
|
31
70
|
}
|
|
@@ -36,10 +75,21 @@ function isDefaultRecord(desc, value) {
|
|
|
36
75
|
}
|
|
37
76
|
return true;
|
|
38
77
|
}
|
|
39
|
-
function snapshotVisibilityLeaks(ext, role, state) {
|
|
78
|
+
function snapshotVisibilityLeaks(ext, role, state, spatial) {
|
|
40
79
|
const allowed = visibleNames(ext, role);
|
|
80
|
+
const policy = policyFor(ext, role, spatial);
|
|
41
81
|
const leaks = [];
|
|
42
82
|
for (const desc of ext.collections) {
|
|
83
|
+
if (desc.visibility === "spatial-grid" && policy) {
|
|
84
|
+
const collection = state[desc.name];
|
|
85
|
+
const ids = collection ? [...collection.ids()].filter((id) => !policy.maySeeEntity(desc, id)) : [];
|
|
86
|
+
if (ids.length > 0) {
|
|
87
|
+
leaks.push(
|
|
88
|
+
`snapshot carried ${desc.name}[${ids.join(",")}] to ${spatial?.clientId ?? "?"} outside radius ${radiusText(desc, spatial)}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
43
93
|
if (allowed.has(desc.name)) continue;
|
|
44
94
|
if (desc.kind === "entity") {
|
|
45
95
|
const size = state[desc.name]?.size ?? 0;
|
|
@@ -53,9 +103,9 @@ function snapshotVisibilityLeaks(ext, role, state) {
|
|
|
53
103
|
}
|
|
54
104
|
return leaks;
|
|
55
105
|
}
|
|
56
|
-
function frameVisibilityLeaks(ext, role, type, payload) {
|
|
106
|
+
function frameVisibilityLeaks(ext, role, type, payload, spatial) {
|
|
57
107
|
if (type !== FrameType.DELTA && type !== FrameType.CORRECT) return [];
|
|
58
|
-
return deltaVisibilityLeaks(ext, role, decodeDelta(ext, payload));
|
|
108
|
+
return deltaVisibilityLeaks(ext, role, decodeDelta(ext, payload), spatial);
|
|
59
109
|
}
|
|
60
110
|
|
|
61
111
|
// src/observer.ts
|
|
@@ -65,10 +115,14 @@ import {
|
|
|
65
115
|
decodeFrame,
|
|
66
116
|
decodeReply,
|
|
67
117
|
decodeWelcome,
|
|
68
|
-
errorByCode
|
|
118
|
+
errorByCode,
|
|
119
|
+
readCorrectClientTick
|
|
69
120
|
} from "@irtio/protocol";
|
|
70
121
|
import {
|
|
122
|
+
ByteReader,
|
|
123
|
+
applyDelta,
|
|
71
124
|
decodeDelta as decodeDelta2,
|
|
125
|
+
decodeDeltaFrom,
|
|
72
126
|
decodeSnapshot
|
|
73
127
|
} from "@irtio/schema";
|
|
74
128
|
|
|
@@ -136,6 +190,28 @@ function makeTrace(startedAt, rings) {
|
|
|
136
190
|
}
|
|
137
191
|
|
|
138
192
|
// src/observer.ts
|
|
193
|
+
function simulationOnly(ext, delta, predicts) {
|
|
194
|
+
let sawSync = false;
|
|
195
|
+
let sawPredicted = false;
|
|
196
|
+
for (const dc of delta.collections) {
|
|
197
|
+
const desc = ext.collections.find((c) => c.name === dc.name);
|
|
198
|
+
const physics = desc?.physics;
|
|
199
|
+
if (!desc || !physics) return "mixed";
|
|
200
|
+
for (const op of dc.ops) {
|
|
201
|
+
if (op.op !== "update") return "mixed";
|
|
202
|
+
const predicted = predicts(dc.name, op.id);
|
|
203
|
+
for (const index of op.mask.fields) {
|
|
204
|
+
const field = desc.fields[index];
|
|
205
|
+
if (!field || !physics.bodyFields.has(field.name)) return "mixed";
|
|
206
|
+
if (predicted) sawPredicted = true;
|
|
207
|
+
else sawSync = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (sawPredicted && !sawSync) return "predicted";
|
|
212
|
+
if (sawSync && !sawPredicted) return "sync";
|
|
213
|
+
return "mixed";
|
|
214
|
+
}
|
|
139
215
|
var WriteLog = class {
|
|
140
216
|
constructor(limit = 4096) {
|
|
141
217
|
this.limit = limit;
|
|
@@ -177,6 +253,7 @@ var SecondWindow = class {
|
|
|
177
253
|
return this.sum;
|
|
178
254
|
}
|
|
179
255
|
};
|
|
256
|
+
var SPATIAL_SLACK_CELLS = 1;
|
|
180
257
|
var DETAIL_LIMIT = 5;
|
|
181
258
|
var VIOLATION_COOLDOWN_MS = 1e3;
|
|
182
259
|
var BotObserver = class {
|
|
@@ -198,14 +275,47 @@ var BotObserver = class {
|
|
|
198
275
|
bytesIn = 0;
|
|
199
276
|
bytesOut = 0;
|
|
200
277
|
corrections = 0;
|
|
278
|
+
syncCorrections = 0;
|
|
279
|
+
suppressedCorrections = 0;
|
|
280
|
+
mispredictions = 0;
|
|
281
|
+
/**
|
|
282
|
+
* Does this bot's client currently predict `collection[id]` in a local world? Assigned by
|
|
283
|
+
* `spawnBots` once the room exists (`room.prediction`); prediction status is client-local, so
|
|
284
|
+
* it cannot be derived from the bytes the observer otherwise sticks to.
|
|
285
|
+
*/
|
|
286
|
+
predictsBody;
|
|
287
|
+
mispredictionMagnitude = 0;
|
|
288
|
+
mispredictionMax = 0;
|
|
289
|
+
snaps = 0;
|
|
201
290
|
calls = 0;
|
|
202
291
|
errors = 0;
|
|
203
292
|
disconnects = 0;
|
|
204
293
|
peakBytesInPerSec = 0;
|
|
205
294
|
peakCorrectionsPerSec = 0;
|
|
295
|
+
/**
|
|
296
|
+
* Spatial-grid ops actually put to the AOI policy. Zero on a run whose room has no spatial
|
|
297
|
+
* collection; zero on a run that *does* and would mean the invariant passed vacuously, which is
|
|
298
|
+
* exactly the bug this counter exists to make visible in the report.
|
|
299
|
+
*/
|
|
300
|
+
spatialOpsJudged = 0;
|
|
301
|
+
/**
|
|
302
|
+
* The bot's own materialized view: the `WELCOME` snapshot, plus every `DELTA`/`CORRECT` since.
|
|
303
|
+
*
|
|
304
|
+
* This is not server truth and never claims to be. It is what the AOI contract is *about*: the
|
|
305
|
+
* viewer's anchor is the record in the same collection whose id is the viewer's client id, and
|
|
306
|
+
* that record is always visible to its own client, so the bot holds its own true anchor. Every
|
|
307
|
+
* other record's position arrives inside the very op being judged — a record the server should
|
|
308
|
+
* not have sent is convicted by the position the server itself reported for it. The question
|
|
309
|
+
* answered is "was I sent something outside my own radius", which is the whole contract.
|
|
310
|
+
*/
|
|
311
|
+
view;
|
|
312
|
+
/** Ids delivered so far, per collection, so an AOI departure `remove` is not read as a leak. */
|
|
313
|
+
seen = /* @__PURE__ */ new Map();
|
|
206
314
|
bytesWindow = new SecondWindow();
|
|
207
315
|
correctWindow = new SecondWindow();
|
|
208
316
|
cooldown = /* @__PURE__ */ new Map();
|
|
317
|
+
/** The last inbound `CORRECT` trace entry, so `onCorrection` can finish its note. */
|
|
318
|
+
lastCorrectEntry;
|
|
209
319
|
get stats() {
|
|
210
320
|
return {
|
|
211
321
|
index: this.options.index,
|
|
@@ -216,6 +326,12 @@ var BotObserver = class {
|
|
|
216
326
|
bytesIn: this.bytesIn,
|
|
217
327
|
bytesOut: this.bytesOut,
|
|
218
328
|
corrections: this.corrections,
|
|
329
|
+
syncCorrections: this.syncCorrections,
|
|
330
|
+
suppressedCorrections: this.suppressedCorrections,
|
|
331
|
+
mispredictions: this.mispredictions,
|
|
332
|
+
mispredictionMagnitude: this.mispredictionMagnitude,
|
|
333
|
+
mispredictionMax: this.mispredictionMax,
|
|
334
|
+
snaps: this.snaps,
|
|
219
335
|
calls: this.calls,
|
|
220
336
|
errors: this.errors,
|
|
221
337
|
disconnects: this.disconnects,
|
|
@@ -268,7 +384,7 @@ var BotObserver = class {
|
|
|
268
384
|
note = err instanceof Error ? err.message : String(err);
|
|
269
385
|
this.violate("schema-validity", `${frameName(type)} did not decode: ${note}`);
|
|
270
386
|
}
|
|
271
|
-
|
|
387
|
+
const entry = {
|
|
272
388
|
at: now - this.options.startedAt,
|
|
273
389
|
bot: this.options.index,
|
|
274
390
|
dir,
|
|
@@ -276,7 +392,9 @@ var BotObserver = class {
|
|
|
276
392
|
frame: frameName(type),
|
|
277
393
|
bytes: bytes.length,
|
|
278
394
|
...note !== void 0 ? { note } : {}
|
|
279
|
-
}
|
|
395
|
+
};
|
|
396
|
+
this.ring.push(entry);
|
|
397
|
+
if (dir === "in" && type === FrameType3.CORRECT) this.lastCorrectEntry = entry;
|
|
280
398
|
}
|
|
281
399
|
/** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
|
|
282
400
|
inspect(dir, type, bytes, now) {
|
|
@@ -291,8 +409,11 @@ var BotObserver = class {
|
|
|
291
409
|
return this.onWelcome(decodeWelcome(payload));
|
|
292
410
|
case FrameType3.DELTA:
|
|
293
411
|
return this.onDelta(decodeDelta2(this.options.ext, payload), now);
|
|
294
|
-
case FrameType3.CORRECT:
|
|
295
|
-
|
|
412
|
+
case FrameType3.CORRECT: {
|
|
413
|
+
const r = new ByteReader(payload);
|
|
414
|
+
const delta = decodeDeltaFrom(this.options.ext, r);
|
|
415
|
+
return this.onCorrect(delta, readCorrectClientTick(r), now);
|
|
416
|
+
}
|
|
296
417
|
case FrameType3.ERROR:
|
|
297
418
|
return this.onError(payload);
|
|
298
419
|
case FrameType3.REPLY:
|
|
@@ -306,23 +427,40 @@ var BotObserver = class {
|
|
|
306
427
|
this.role = welcome.role;
|
|
307
428
|
this.roomId = welcome.roomId;
|
|
308
429
|
const snapshot = decodeSnapshot(this.options.ext, welcome.snapshot);
|
|
309
|
-
|
|
430
|
+
this.view = snapshot.state;
|
|
431
|
+
this.rememberSnapshot(snapshot.state);
|
|
432
|
+
this.countSpatialSnapshot(snapshot.state);
|
|
433
|
+
for (const leak of snapshotVisibilityLeaks(
|
|
434
|
+
this.options.ext,
|
|
435
|
+
this.role,
|
|
436
|
+
snapshot.state,
|
|
437
|
+
this.spatialContext()
|
|
438
|
+
)) {
|
|
310
439
|
this.violate("visibility-leak", leak);
|
|
311
440
|
}
|
|
312
441
|
return `joined ${welcome.roomId} as ${welcome.clientId}/${welcome.role || "default"}`;
|
|
313
442
|
}
|
|
314
443
|
onDelta(delta, now) {
|
|
315
|
-
|
|
316
|
-
this.violate("visibility-leak", leak);
|
|
317
|
-
}
|
|
444
|
+
this.checkVisibility(delta);
|
|
318
445
|
this.matchWrites(delta, now);
|
|
319
446
|
return void 0;
|
|
320
447
|
}
|
|
321
|
-
onCorrect(delta, now) {
|
|
322
|
-
this.
|
|
323
|
-
|
|
324
|
-
|
|
448
|
+
onCorrect(delta, clientTick, now) {
|
|
449
|
+
this.checkVisibility(delta);
|
|
450
|
+
const names = delta.collections.map((c) => c.name).join(",");
|
|
451
|
+
const kind = simulationOnly(
|
|
452
|
+
this.options.ext,
|
|
453
|
+
delta,
|
|
454
|
+
(c, id) => this.predictsBody ? this.predictsBody(c, id) : false
|
|
455
|
+
);
|
|
456
|
+
if (kind === "sync") {
|
|
457
|
+
this.syncCorrections++;
|
|
458
|
+
return `synced ${names}`;
|
|
325
459
|
}
|
|
460
|
+
if (kind === "predicted") {
|
|
461
|
+
return `judged ${names}${clientTick !== void 0 ? ` (clientTick ${clientTick})` : ""}`;
|
|
462
|
+
}
|
|
463
|
+
this.corrections++;
|
|
326
464
|
const perSec = this.correctWindow.add(now, 1);
|
|
327
465
|
if (perSec > this.peakCorrectionsPerSec) this.peakCorrectionsPerSec = perSec;
|
|
328
466
|
if (perSec > this.options.thresholds.correctionsPerSecMax) {
|
|
@@ -332,7 +470,137 @@ var BotObserver = class {
|
|
|
332
470
|
true
|
|
333
471
|
);
|
|
334
472
|
}
|
|
335
|
-
return `corrected ${
|
|
473
|
+
return `corrected ${names}${clientTick !== void 0 ? ` (clientTick ${clientTick})` : ""}`;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* The room's `correct` event, one per correction op — the only source of `previous` (the local
|
|
477
|
+
* prediction the server overrode) and of the `snapped` flag, neither of which a frame-level
|
|
478
|
+
* decode can know. Wired up by `spawnBots` via `room.on('correct', ...)`.
|
|
479
|
+
*/
|
|
480
|
+
onCorrection(correction) {
|
|
481
|
+
if (correction.simulation) return;
|
|
482
|
+
if (correction.suppressed) {
|
|
483
|
+
this.suppressedCorrections++;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const desc = this.options.ext.collections.find(
|
|
487
|
+
(c) => c.name === correction.collection
|
|
488
|
+
);
|
|
489
|
+
const predictedBody = desc?.physics !== void 0 && correction.fields.length > 0 && correction.fields.every((f) => desc.physics?.bodyFields.has(f));
|
|
490
|
+
if (predictedBody) {
|
|
491
|
+
this.corrections++;
|
|
492
|
+
const now = Date.now();
|
|
493
|
+
const perSec = this.correctWindow.add(now, 1);
|
|
494
|
+
if (perSec > this.peakCorrectionsPerSec) this.peakCorrectionsPerSec = perSec;
|
|
495
|
+
if (perSec > this.options.thresholds.correctionsPerSecMax) {
|
|
496
|
+
this.violate(
|
|
497
|
+
"correction-storm",
|
|
498
|
+
`${perSec} corrections/s over the ${this.options.thresholds.correctionsPerSecMax}/s threshold`,
|
|
499
|
+
true
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
let magnitude = 0;
|
|
504
|
+
for (const field of correction.fields) {
|
|
505
|
+
const prev = correction.previous[field];
|
|
506
|
+
const next = correction.patch[field];
|
|
507
|
+
if (typeof prev === "number" && typeof next === "number") {
|
|
508
|
+
magnitude += Math.abs(next - prev);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
this.mispredictions++;
|
|
512
|
+
this.mispredictionMagnitude += magnitude;
|
|
513
|
+
if (magnitude > this.mispredictionMax) this.mispredictionMax = magnitude;
|
|
514
|
+
const thresholds = this.options.thresholds;
|
|
515
|
+
if (magnitude > thresholds.mispredictionMagnitudeMax) {
|
|
516
|
+
this.violate(
|
|
517
|
+
"misprediction",
|
|
518
|
+
`${correction.collection}[${correction.id}] snapped ${magnitude.toFixed(1)} units, over the ${thresholds.mispredictionMagnitudeMax} threshold`,
|
|
519
|
+
true
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (correction.snapped) {
|
|
523
|
+
this.snaps++;
|
|
524
|
+
if (this.snaps > thresholds.snapsMax) {
|
|
525
|
+
this.violate(
|
|
526
|
+
"snaps",
|
|
527
|
+
`snap #${this.snaps} (${correction.collection}[${correction.id}] @tick ${correction.tick}) over the tolerated ${thresholds.snapsMax}`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const entry = this.lastCorrectEntry;
|
|
532
|
+
if (entry) {
|
|
533
|
+
const what = correction.snapped ? `${correction.collection}[${correction.id}] snapped \u0394${magnitude.toFixed(1)}` : `${correction.collection}[${correction.id}] \u0394${magnitude.toFixed(1)} replayed ${correction.replayed}`;
|
|
534
|
+
entry.note = entry.note === void 0 ? what : `${entry.note} | ${what}`;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// -- visibility -----------------------------------------------------------
|
|
538
|
+
spatialContext() {
|
|
539
|
+
if (!this.view || this.id === "") return void 0;
|
|
540
|
+
return {
|
|
541
|
+
authority: this.view,
|
|
542
|
+
clientId: this.id,
|
|
543
|
+
seen: this.seen,
|
|
544
|
+
slackCells: SPATIAL_SLACK_CELLS
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Applies the delta to the bot's view *first*, then judges: a record the server should not have
|
|
549
|
+
* sent has to be in the view for its own reported position to convict it. `seen` is updated
|
|
550
|
+
* afterwards, so a `remove` is judged against what the bot had been told about before this
|
|
551
|
+
* frame — a departing neighbour, not a leak.
|
|
552
|
+
*/
|
|
553
|
+
checkVisibility(delta) {
|
|
554
|
+
if (this.view) applyDelta(this.options.ext, this.view, delta);
|
|
555
|
+
this.countSpatialDelta(delta);
|
|
556
|
+
for (const leak of deltaVisibilityLeaks(
|
|
557
|
+
this.options.ext,
|
|
558
|
+
this.role,
|
|
559
|
+
delta,
|
|
560
|
+
this.spatialContext()
|
|
561
|
+
)) {
|
|
562
|
+
this.violate("visibility-leak", leak);
|
|
563
|
+
}
|
|
564
|
+
this.remember(delta);
|
|
565
|
+
}
|
|
566
|
+
countSpatialDelta(delta) {
|
|
567
|
+
if (!this.view || this.id === "") return;
|
|
568
|
+
for (const collection of delta.collections) {
|
|
569
|
+
const desc = this.options.ext.collection(collection.name);
|
|
570
|
+
if (desc.visibility === "spatial-grid") this.spatialOpsJudged += collection.ops.length;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
countSpatialSnapshot(state) {
|
|
574
|
+
for (const desc of this.options.ext.collections) {
|
|
575
|
+
if (desc.visibility !== "spatial-grid") continue;
|
|
576
|
+
this.spatialOpsJudged += state[desc.name]?.size ?? 0;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
ids(collection) {
|
|
580
|
+
let ids = this.seen.get(collection);
|
|
581
|
+
if (!ids) {
|
|
582
|
+
ids = /* @__PURE__ */ new Set();
|
|
583
|
+
this.seen.set(collection, ids);
|
|
584
|
+
}
|
|
585
|
+
return ids;
|
|
586
|
+
}
|
|
587
|
+
rememberSnapshot(state) {
|
|
588
|
+
for (const desc of this.options.ext.collections) {
|
|
589
|
+
if (desc.kind !== "entity") continue;
|
|
590
|
+
const collection = state[desc.name];
|
|
591
|
+
if (!collection) continue;
|
|
592
|
+
const ids = this.ids(desc.name);
|
|
593
|
+
for (const id of collection.ids()) ids.add(id);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
remember(delta) {
|
|
597
|
+
for (const collection of delta.collections) {
|
|
598
|
+
const ids = this.ids(collection.name);
|
|
599
|
+
for (const op of collection.ops) {
|
|
600
|
+
if (op.op === "remove") ids.delete(op.id);
|
|
601
|
+
else ids.add(op.id);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
336
604
|
}
|
|
337
605
|
onError(payload) {
|
|
338
606
|
const error = decodeErrorPayload(payload);
|
|
@@ -503,8 +771,11 @@ function detailFor(name, observers, violations, thresholds) {
|
|
|
503
771
|
switch (name) {
|
|
504
772
|
case "schema-validity":
|
|
505
773
|
return violations === 0 ? "every snapshot and delta decoded" : `${violations} frame(s) failed to decode${examples(observers, name)}`;
|
|
506
|
-
case "visibility-leak":
|
|
507
|
-
|
|
774
|
+
case "visibility-leak": {
|
|
775
|
+
const spatial = observers.reduce((sum, o) => sum + o.spatialOpsJudged, 0);
|
|
776
|
+
const judged = `, ${spatial} spatial-grid op(s) judged against each bot's own anchor`;
|
|
777
|
+
return violations === 0 ? `no collection reached a role that cannot see it${judged}` : `${violations} leak(s)${judged}${examples(observers, name)}`;
|
|
778
|
+
}
|
|
508
779
|
case "bandwidth": {
|
|
509
780
|
const worst = peak(observers, (o) => o.peakBytesInPerSec);
|
|
510
781
|
return `peak ${worst} B/s in per bot, budget ${thresholds.budgetBytesPerSec} B/s${violations === 0 ? "" : examples(observers, name)}`;
|
|
@@ -515,8 +786,24 @@ function detailFor(name, observers, violations, thresholds) {
|
|
|
515
786
|
}
|
|
516
787
|
case "correction-storm": {
|
|
517
788
|
const corrections = observers.reduce((sum, o) => sum + o.corrections, 0);
|
|
789
|
+
const synced = observers.reduce((sum, o) => sum + o.syncCorrections, 0);
|
|
790
|
+
const suppressed = observers.reduce((sum, o) => sum + o.suppressedCorrections, 0);
|
|
518
791
|
const worst = peak(observers, (o) => o.peakCorrectionsPerSec);
|
|
519
|
-
return `${corrections} correction(s), peak ${worst}/s per bot, threshold ${thresholds.correctionsPerSecMax}/s${violations === 0 ? "" : examples(observers, name)}`;
|
|
792
|
+
return `${corrections} correction(s), peak ${worst}/s per bot, threshold ${thresholds.correctionsPerSecMax}/s${synced > 0 ? ` (+${synced} body-sync)` : ""}${suppressed > 0 ? ` (+${suppressed} within-epsilon)` : ""}${violations === 0 ? "" : examples(observers, name)}`;
|
|
793
|
+
}
|
|
794
|
+
case "misprediction": {
|
|
795
|
+
const count = observers.reduce((sum, o) => sum + o.mispredictions, 0);
|
|
796
|
+
if (count === 0) return "no corrections, nothing mispredicted";
|
|
797
|
+
const totalMagnitude = observers.reduce((sum, o) => sum + o.mispredictionMagnitude, 0);
|
|
798
|
+
const worst = peak(observers, (o) => o.mispredictionMax);
|
|
799
|
+
const cap = thresholds.mispredictionMagnitudeMax;
|
|
800
|
+
return `peak ${round(worst)} units, mean ${round(totalMagnitude / count)} over ${count} correction(s)${Number.isFinite(cap) ? `, threshold ${cap}` : ""}${violations === 0 ? "" : examples(observers, name)}`;
|
|
801
|
+
}
|
|
802
|
+
case "snaps": {
|
|
803
|
+
const count = observers.reduce((sum, o) => sum + o.snaps, 0);
|
|
804
|
+
if (count === 0) return "no snaps (every correction replayed within the resim window)";
|
|
805
|
+
const cap = thresholds.snapsMax;
|
|
806
|
+
return `${count} snap(s) (cap-exceeded reconciliations)${Number.isFinite(cap) ? `, tolerated ${cap}` : ""}${violations === 0 ? "" : examples(observers, name)}`;
|
|
520
807
|
}
|
|
521
808
|
case "disconnects": {
|
|
522
809
|
const count = observers.reduce((sum, o) => sum + o.disconnects, 0);
|
|
@@ -541,6 +828,12 @@ function buildReport(options) {
|
|
|
541
828
|
bytesIn: perBot.reduce((sum, b) => sum + b.bytesIn, 0),
|
|
542
829
|
bytesOut: perBot.reduce((sum, b) => sum + b.bytesOut, 0),
|
|
543
830
|
corrections: perBot.reduce((sum, b) => sum + b.corrections, 0),
|
|
831
|
+
syncCorrections: perBot.reduce((sum, b) => sum + b.syncCorrections, 0),
|
|
832
|
+
suppressedCorrections: perBot.reduce((sum, b) => sum + b.suppressedCorrections, 0),
|
|
833
|
+
mispredictions: perBot.reduce((sum, b) => sum + b.mispredictions, 0),
|
|
834
|
+
mispredictionMagnitude: perBot.reduce((sum, b) => sum + b.mispredictionMagnitude, 0),
|
|
835
|
+
mispredictionMax: perBot.reduce((max, b) => Math.max(max, b.mispredictionMax), 0),
|
|
836
|
+
snaps: perBot.reduce((sum, b) => sum + b.snaps, 0),
|
|
544
837
|
calls: perBot.reduce((sum, b) => sum + b.calls, 0),
|
|
545
838
|
errors: perBot.reduce((sum, b) => sum + b.errors, 0)
|
|
546
839
|
};
|
|
@@ -570,6 +863,11 @@ function ownableCollections(schema) {
|
|
|
570
863
|
(c) => c.kind === "entity" && !c.serverOwned
|
|
571
864
|
);
|
|
572
865
|
}
|
|
866
|
+
function writableFields(desc, cheat) {
|
|
867
|
+
if (!desc.physics || cheat) return desc.fields;
|
|
868
|
+
const intents = new Set(desc.physics.intents);
|
|
869
|
+
return desc.fields.filter((f) => intents.has(f.name));
|
|
870
|
+
}
|
|
573
871
|
function voidServerRpcs(schema) {
|
|
574
872
|
return rpcTable(schema).filter((r) => r.direction === "server" && r.returns === void 0);
|
|
575
873
|
}
|
|
@@ -603,14 +901,17 @@ function randomScript(schema, options = {}) {
|
|
|
603
901
|
}
|
|
604
902
|
};
|
|
605
903
|
function writeFields(bot, desc, id, instance, origins) {
|
|
606
|
-
const
|
|
904
|
+
const fields = writableFields(desc, cheat);
|
|
905
|
+
if (fields.length === 0) return;
|
|
906
|
+
const count = Math.min(fieldsPerStep, fields.length);
|
|
607
907
|
for (let i = 0; i < count; i++) {
|
|
608
|
-
const field = bot.rng.pick(
|
|
908
|
+
const field = bot.rng.pick(fields);
|
|
609
909
|
if (!field) continue;
|
|
610
910
|
const current = instance[field.name];
|
|
611
911
|
const key = `${desc.name} ${id} ${field.name}`;
|
|
612
912
|
if (typeof current === "number" && !origins.has(key)) origins.set(key, current);
|
|
613
|
-
const
|
|
913
|
+
const isIntent = !cheat && desc.physics?.intents.includes(field.name) === true;
|
|
914
|
+
const value = isIntent ? Math.fround(bot.rng.float(-1, 1)) : nextValue(bot.rng, field.type, current, {
|
|
614
915
|
step,
|
|
615
916
|
cheat,
|
|
616
917
|
origin: origins.get(key)
|
|
@@ -732,7 +1033,9 @@ async function spawnBots(n, options = {}) {
|
|
|
732
1033
|
const thresholds = {
|
|
733
1034
|
budgetBytesPerSec: options.budgetBytesPerSec ?? DEFAULT_THRESHOLDS.budgetBytesPerSec,
|
|
734
1035
|
correctionsPerSecMax: options.correctionsPerSecMax ?? DEFAULT_THRESHOLDS.correctionsPerSecMax,
|
|
735
|
-
handlerErrorsMax: options.handlerErrorsMax ?? DEFAULT_THRESHOLDS.handlerErrorsMax
|
|
1036
|
+
handlerErrorsMax: options.handlerErrorsMax ?? DEFAULT_THRESHOLDS.handlerErrorsMax,
|
|
1037
|
+
mispredictionMagnitudeMax: options.mispredictionMagnitudeMax ?? DEFAULT_THRESHOLDS.mispredictionMagnitudeMax,
|
|
1038
|
+
snapsMax: options.snapsMax ?? DEFAULT_THRESHOLDS.snapsMax
|
|
736
1039
|
};
|
|
737
1040
|
const ext = options.schema ? withBuiltins(options.schema) : relaySchema;
|
|
738
1041
|
const seed = options.seed ?? DEFAULT_SEED;
|
|
@@ -766,8 +1069,12 @@ async function spawnBots(n, options = {}) {
|
|
|
766
1069
|
const room = options.schema ? await joinRoom(options.schema, {
|
|
767
1070
|
...common,
|
|
768
1071
|
...options.flushMs !== void 0 ? { writeIntervalMs: options.flushMs } : {},
|
|
769
|
-
...options.rpc !== void 0 ? { rpc: options.rpc } : {}
|
|
1072
|
+
...options.rpc !== void 0 ? { rpc: options.rpc } : {},
|
|
1073
|
+
...options.physics !== void 0 ? { physics: options.physics } : {}
|
|
770
1074
|
}) : await joinRelay(common);
|
|
1075
|
+
room.on("correct", (correction) => observer.onCorrection(correction));
|
|
1076
|
+
const prediction = room.prediction;
|
|
1077
|
+
if (prediction) observer.predictsBody = (c, id) => prediction.predicts(c, id);
|
|
771
1078
|
observers[index] = observer;
|
|
772
1079
|
return new BotImpl(index, room, observer, seed + index, startedAt);
|
|
773
1080
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irtio/bots",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "irtio bot runtime: N real clients, scripted behaviours, trace recorder, built-in invariants",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
"dist"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@irtio/
|
|
23
|
-
"@irtio/
|
|
24
|
-
"@irtio/
|
|
25
|
-
"@irtio/
|
|
22
|
+
"@irtio/runtime": "0.2.0",
|
|
23
|
+
"@irtio/client": "0.2.0",
|
|
24
|
+
"@irtio/protocol": "0.2.0",
|
|
25
|
+
"@irtio/schema": "0.2.0"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "tsup",
|