@irtio/bots 0.1.0 → 0.3.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 CHANGED
@@ -1,5 +1,5 @@
1
- import { AnySchema, Delta, PlainState, TypeDesc } from '@irtio/schema';
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
- function deltaVisibilityLeaks(ext, role, delta) {
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
- if (allowed.has(collection.name)) continue;
26
- if (collection.ops.length === 0) continue;
27
- const ops = collection.ops.map((op) => op.op).join(",");
28
- leaks.push(`delta@${delta.tick} carried ${collection.name} (${ops}) to role ${role || '""'}`);
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
- this.ring.push({
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
- return this.onCorrect(decodeDelta2(this.options.ext, payload), now);
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
- for (const leak of snapshotVisibilityLeaks(this.options.ext, this.role, snapshot.state)) {
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
- for (const leak of deltaVisibilityLeaks(this.options.ext, this.role, delta)) {
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.corrections++;
323
- for (const leak of deltaVisibilityLeaks(this.options.ext, this.role, delta)) {
324
- this.violate("visibility-leak", leak);
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 ${delta.collections.map((c) => c.name).join(",")}`;
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
- return violations === 0 ? "no collection reached a role that cannot see it" : `${violations} leak(s)${examples(observers, name)}`;
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 count = Math.min(fieldsPerStep, desc.fields.length);
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(desc.fields);
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 value = nextValue(bot.rng, field.type, current, {
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.1.0",
3
+ "version": "0.3.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/client": "0.1.0",
23
- "@irtio/protocol": "0.1.0",
24
- "@irtio/schema": "0.1.0",
25
- "@irtio/runtime": "0.1.0"
22
+ "@irtio/client": "0.3.0",
23
+ "@irtio/protocol": "0.3.0",
24
+ "@irtio/schema": "0.3.0",
25
+ "@irtio/runtime": "0.3.0"
26
26
  },
27
27
  "scripts": {
28
28
  "build": "tsup",