wren/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files e7ab8b5cd8018f30a36edbeaab5c8b2644c60bcf
1
/**
2
 * The whole feel, measured.
3
 *
4
 * A wire between the real match and the real client modules: snapshots leave
5
 * on the server's ticks, arrive a little late and a little unevenly, and the
6
 * browser side draws sixty frames a second from them exactly the way val.js
7
 * does. What comes out is a set of numbers a person's "it stutters" can be
8
 * held against — how even the per-frame steps are, how often a correction
9
 * jumps, how large the largest jump is — and every one of them is a rule
10
 * this file enforces from here on.
11
 */
12
13
import { expect, test } from "bun:test";
14
import { mkdtempSync } from "node:fs";
15
import { tmpdir } from "node:os";
16
import { join } from "node:path";
17
import { BODY_R, RUN } from "./engine/glide.ts";
18
import { solidTile, TILE } from "./map.ts";
19
import { newMatch, setInput, speedOf, startRound, tick } from "./match.ts";
20
import { runBots } from "./bots.ts";
21
import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/engine/interp.js";
22
import { makeLocal, stepLocal } from "../static/engine/predict.js";
23
import { makeYaw, settle, told } from "../static/engine/yaw.js";
24
25
// The arena records finished matches under FORGE_APP_DATA the moment it is imported.
26
process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-"));
27
const { broadcastEvery, makeValArena } = await import("./arena.ts");
28
29
const round2 = (n: number) => Math.round(n * 10) / 10;
30
const world = { solid: solidTile, tile: TILE, r: BODY_R };
31
32
/** A seeded wobble, so the run is the same every time. */
33
function rng(seed: number) {
34
  let s = seed | 0;
35
  return () => {
36
    s = (s + 0x6d2b79f5) | 0;
37
    let t = Math.imul(s ^ (s >>> 15), s | 1);
38
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
39
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
40
  };
41
}
42
43
/**
44
 * The wire as it was measured: 30 a second, p50 gap 33ms, p99 41–48ms, no
45
 * gap over 100ms. Each packet takes a base latency plus a wobble, and now
46
 * and then one is held a dozen milliseconds longer.
47
 */
48
function jitter(random: () => number): number {
49
  const u = random();
50
  const v = random();
51
  const gauss = Math.sqrt(-2 * Math.log(1 - u)) * Math.cos(2 * Math.PI * v);
52
  return Math.abs(gauss) * 0.004 + (random() < 0.03 ? 0.012 : 0);
53
}
54
55
const spreadOf = (steps: number[]) => {
56
  const mean = steps.reduce((s, x) => s + x, 0) / steps.length;
57
  const sd = Math.sqrt(steps.reduce((s, x) => s + (x - mean) ** 2, 0) / steps.length);
58
  return { mean, spread: sd / mean };
59
};
60
61
type Body = { id: string; x: number; y: number; angle: number };
62
type Packet = { at: number; stamp: number; me: Body & { alive: boolean; phase: string; speed: number }; bodies: Body[] };
63
64
/**
65
 * A person walking east across open ground for two seconds, sending intent
66
 * every 50ms, hearing back thirty times a second over a jittery wire, and
67
 * drawn at sixty frames — with the input reaching the server one way late.
68
 *
69
 * The packet carries `speed` the way the arena's snapshot does — the match's
70
 * own answer, to a tenth — and the drawing uses that and nothing else, which
71
 * is what val.js does. `on` is whatever the walk is carrying: a haste, an
72
 * operator, the states whose speed is not the plain run.
73
 */
74
function walkOwnBody(base = 0.02, on: Partial<{ haste: number; slow: number; weapon: string }> = {}) {
75
  const random = rng(7);
76
  const state = newMatch(11);
77
  const me = state.players[0]!;
78
  me.bot = false;
79
  for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) {
80
    runBots(state, 1 / 30);
81
    tick(state, 1 / 30);
82
  }
83
  // Somewhere open: the middle of the map, facing east.
84
  me.x = 1000;
85
  me.y = 640;
86
  me.hp = 100;
87
  me.alive = true;
88
  me.haste = 0;
89
  me.slow = 0;
90
  me.weapon = "classic";
91
  Object.assign(me, on);
92
  const packets: Packet[] = [];
93
  const inputs: { at: number; mx: number; my: number }[] = [];
94
  const held = { mx: 0, my: 0 };
95
  const walkFrom = 0.3;
96
  const walkUntil = 1.6;
97
  // The server's clock: one tick every 33ms; snapshots leave right after.
98
  for (let k = 0; k * (1 / 30) < 2.2; k++) {
99
    const t = k / 30;
100
    while (inputs.length && inputs[0]!.at <= t) {
101
      const input = inputs.shift()!;
102
      setInput(me, { mx: input.mx, my: input.my, angle: 0 });
103
    }
104
    // The browser's send timer is on its own clock: every 50ms, arriving one
105
    // way of latency later.
106
    for (let s = Math.ceil((t - 1 / 30) / 0.05); s * 0.05 < t; s++) {
107
      if (s * 0.05 < t - 1 / 30) continue;
108
      const sentAt = s * 0.05;
109
      const moving = sentAt >= walkFrom && sentAt < walkUntil;
110
      inputs.push({ at: sentAt + base, mx: moving ? 1 : 0, my: 0 });
111
    }
112
    inputs.sort((a, b) => a.at - b.at);
113
    tick(state, 1 / 30);
114
    packets.push({
115
      at: t + base + jitter(random),
116
      stamp: 0,
117
      me: {
118
        id: me.id,
119
        x: round2(me.x),
120
        y: round2(me.y),
121
        angle: round2(me.angle),
122
        alive: me.alive,
123
        phase: state.phase,
124
        speed: round2(speedOf(state, me)),
125
      },
126
      bodies: [],
127
    });
128
  }
129
  packets.sort((a, b) => a.at - b.at);
130
131
  const timeline = makeTimeline();
132
  const local = makeLocal();
133
  let snap: Packet | null = null;
134
  let next = 0;
135
  let previous = 0;
136
  const steps: number[] = [];
137
  let back = 0;
138
  let anchors = 0;
139
  for (let f = 0; f / 60 < 2.2; f++) {
140
    const now = f / 60;
141
    while (next < packets.length && packets[next]!.at <= now) {
142
      snap = packets[next]!;
143
      snap.stamp = arrive(timeline, snap.at);
144
      next += 1;
145
    }
146
    if (!snap) continue;
147
    held.mx = now >= walkFrom && now < walkUntil ? 1 : 0;
148
    const before = local.anchored;
149
    // The speed the server sent, exactly as val.js passes it: no rule about
150
    // movement is re-derived on this side of the wire.
151
    stepLocal(local, snap.me, held, snap.me.speed, now - snap.stamp, 1 / 60, world, null);
152
    // The first word about where you are is taken outright: that one is not a jump.
153
    if (before > 0) anchors += local.anchored - before;
154
    if (f > 0 && previous !== 0 && now > walkFrom + 0.2 && now < walkUntil - 0.05) {
155
      const step = local.x - previous;
156
      steps.push(step);
157
      back = Math.min(back, step);
158
    }
159
    previous = local.x;
160
  }
161
  return { steps, back, anchors, local, server: me };
162
}
163
164
test("your own body, walking, takes even steps and never a backward one", () => {
165
  const { steps, back, anchors } = walkOwnBody();
166
  const { mean, spread } = spreadOf(steps);
167
  console.log(
168
    `own body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` +
169
      `largest backward step ${back.toFixed(2)}, anchors ${anchors}`,
170
  );
171
  // Running east at 250 a second is 4.17 a frame.
172
  expect(mean * 60).toBeGreaterThan(RUN * 0.9);
173
  expect(mean * 60).toBeLessThan(RUN * 1.1);
174
  // Even: the per-frame step does not beat against the wire's jitter.
175
  expect(spread).toBeLessThan(0.05);
176
  // And a step backwards is a stutter by definition.
177
  expect(back).toBeGreaterThan(-0.01);
178
  expect(anchors).toBe(0);
179
});
180
181
test("a hasted walk is drawn at the speed the wire carried, rounding and all", () => {
182
  // Haste is the case where the match's own answer is not a whole number:
183
  // 250 × 1.35 is 337.5, and the wire carries it to a tenth, like every other
184
  // number in the snapshot. What is checked is that the drawing runs at the
185
  // speed the server will use — no correction, no backward step, and no body
186
  // drawn out past where the server is about to put it.
187
  const { steps, back, anchors, local, server } = walkOwnBody(0.02, { haste: 10 });
188
  const { mean, spread } = spreadOf(steps);
189
  console.log(
190
    `hasted body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` +
191
      `largest backward step ${back.toFixed(2)}, anchors ${anchors}, drawn ${(local.x - server.x).toFixed(1)} past the server`,
192
  );
193
  expect(mean * 60).toBeGreaterThan(RUN * 1.35 * 0.9);
194
  expect(mean * 60).toBeLessThan(RUN * 1.35 * 1.1);
195
  expect(spread).toBeLessThan(0.05);
196
  expect(back).toBeGreaterThan(-0.01);
197
  expect(anchors).toBe(0);
198
});
199
200
test("starting and stopping never pulls you back, and the speed never dips a third", () => {
201
  // The first tenth of a second after the key goes down the server has not
202
  // heard yet, and the guess used to be yanked back to the server's still
203
  // body on each of the first two snapshots. Letting go, the server runs on
204
  // for the same lag and the guess must not be dragged back to meet it.
205
  const random = rng(3);
206
  const timeline = makeTimeline();
207
  const local = makeLocal();
208
  const me = { x: 1000, y: 640, angle: 0, alive: true };
209
  const held = { mx: 0, my: 0 };
210
  const hop = 0.03; // one way, plus the send timer's own phase
211
  let serverX = 1000;
212
  const packets: { at: number; x: number }[] = [];
213
  for (let k = 0; k / 30 < 2; k++) {
214
    const t = k / 30;
215
    // The server hears the key a hop after the next 50ms send.
216
    const heardDown = Math.ceil(0.3 / 0.05) * 0.05 + hop;
217
    const heardUp = Math.ceil(1.2 / 0.05) * 0.05 + hop;
218
    if (t > heardDown && t <= heardUp) serverX += RUN / 30;
219
    packets.push({ at: t + hop + jitter(random), x: round2(serverX) });
220
  }
221
  let next = 0;
222
  let previous = 0;
223
  let back = 0;
224
  let slowest = Infinity;
225
  for (let f = 0; f / 60 < 2; f++) {
226
    const now = f / 60;
227
    while (next < packets.length && packets[next]!.at <= now) {
228
      me.x = packets[next]!.x;
229
      arrive(timeline, packets[next]!.at);
230
      next += 1;
231
    }
232
    if (next === 0) continue;
233
    held.mx = now >= 0.3 && now < 1.2 ? 1 : 0;
234
    stepLocal(local, me, held, RUN, now - timeline.stamp, 1 / 60, world, null);
235
    const step = local.x - previous;
236
    if (now > 0.3 + 1 / 60) back = Math.min(back, step);
237
    if (now > 0.3 + 1 / 60 && now < 1.2) slowest = Math.min(slowest, step);
238
    previous = local.x;
239
  }
240
  console.log(
241
    `walk start and stop: largest backward step ${back.toFixed(2)}, slowest frame while walking ${slowest.toFixed(2)} of ${(RUN / 60).toFixed(2)}, ` +
242
      `settled ${(local.x - serverX).toFixed(1)} from the server`,
243
  );
244
  expect(back).toBeGreaterThan(-0.01);
245
  expect(slowest).toBeGreaterThan((RUN / 60) * 0.67);
246
  // Where you stopped is where the server stops: the guess settles onto it.
247
  expect(Math.abs(local.x - serverX)).toBeLessThan(1);
248
});
249
250
/**
251
 * Everybody else: a bot walking, heard about thirty times a second over the
252
 * same wire, drawn a little in the past between the samples on hand.
253
 */
254
function watchBots() {
255
  const random = rng(5);
256
  const state = newMatch(23);
257
  const step = () => {
258
    runBots(state, 1 / 30);
259
    tick(state, 1 / 30);
260
  };
261
  for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step();
262
  const packets: { at: number; bodies: Body[] }[] = [];
263
  for (let k = 0; k < 90; k++) {
264
    step();
265
    packets.push({
266
      at: k / 30 + 0.02 + jitter(random),
267
      bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })),
268
    });
269
  }
270
  const timeline = makeTimeline();
271
  const paths = new Map<string, { x: number; y: number }[]>();
272
  const delays: number[] = [];
273
  let ahead = 0;
274
  let frames = 0;
275
  let next = 0;
276
  for (let f = 0; f / 60 < 2.8; f++) {
277
    const now = f / 60;
278
    while (next < packets.length && packets[next]!.at <= now) {
279
      const stamp = arrive(timeline, packets[next]!.at);
280
      for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp);
281
      next += 1;
282
    }
283
    if (next < 4) continue;
284
    const drawAt = renderTime(timeline, now);
285
    delays.push(now - drawAt);
286
    frames += 1;
287
    if (drawAt > timeline.stamp) ahead += 1;
288
    for (const p of packets[next - 1]!.bodies) {
289
      const at = bodyAt(timeline, p, drawAt);
290
      paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]);
291
    }
292
  }
293
  const walked = [...paths.values()]
294
    .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y)))
295
    .map((steps) => steps.filter((s) => s > 0.001))
296
    .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!;
297
  return { walked, restarts: timeline.restarts, delays, ahead, frames, timeline };
298
}
299
300
test("everybody else walks at an even speed over a jittery wire, and nobody restarts", () => {
301
  const { walked, restarts, delays, ahead, frames } = watchBots();
302
  const { mean, spread } = spreadOf(walked);
303
  const dmin = Math.min(...delays);
304
  const dmax = Math.max(...delays);
305
  console.log(
306
    `others: ${walked.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` +
307
      `restarts ${restarts}, delay ${(dmin * 1000).toFixed(1)}–${(dmax * 1000).toFixed(1)}ms, ` +
308
      `drawn past the newest sample ${ahead}/${frames} frames`,
309
  );
310
  expect(walked.length).toBeGreaterThan(40);
311
  expect(mean * 60).toBeGreaterThan(150);
312
  expect(spread).toBeLessThan(0.08);
313
  // A body that was in sight all along never jumps to start again.
314
  expect(restarts).toBe(0);
315
  // The moment drawn is never later than the newest word: no guessing ahead.
316
  expect(ahead).toBe(0);
317
});
318
319
test("the render clock never steps backwards or stalls, whatever the wire does", () => {
320
  const random = rng(9);
321
  const timeline = makeTimeline();
322
  let arrival = 0.02;
323
  let drawn = 0;
324
  let worstShort = Infinity;
325
  let worstLong = 0;
326
  for (let f = 0; f / 60 < 5; f++) {
327
    const now = f / 60;
328
    while (arrival <= now) {
329
      arrive(timeline, arrival);
330
      arrival += 1 / 30 + jitter(random) - 0.002;
331
    }
332
    const at = renderTime(timeline, now);
333
    if (f > 30 && drawn > 0) {
334
      worstShort = Math.min(worstShort, at - drawn);
335
      worstLong = Math.max(worstLong, at - drawn);
336
    }
337
    drawn = at;
338
  }
339
  console.log(`render clock: frame advance ${(worstShort * 1000).toFixed(2)}–${(worstLong * 1000).toFixed(2)}ms of 16.67`);
340
  // Within a few percent of real time, every frame: 16.67ms give or take.
341
  expect(worstShort * 60).toBeGreaterThan(0.97);
342
  expect(worstLong * 60).toBeLessThan(1.03);
343
});
344
345
test("a burst of snapshots does not walk the even clock into the future", () => {
346
  // A tab back from the background is handed everything it missed at once.
347
  // The clock those samples are filed on may lead the arrivals by half a
348
  // beat and no more, or the sent angles age out and every echo of your own
349
  // view comes back as a correction.
350
  const timeline = makeTimeline();
351
  let at = 10;
352
  for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30));
353
  const before = timeline.stamp;
354
  for (let i = 0; i < 150; i++) {
355
    const stamp = arrive(timeline, (at += 0.0002));
356
    expect(stamp - at).toBeLessThanOrEqual(timeline.interval / 2 + 1e-9);
357
    expect(stamp).toBeGreaterThanOrEqual(before);
358
  }
359
  // And once the wire is itself again, the beat is back on it.
360
  for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30));
361
  expect(Math.abs(timeline.stamp - at)).toBeLessThan(0.01);
362
});
363
364
test("a round start points you at the site once, and nothing stomps it a tick later", () => {
365
  const state = newMatch(31);
366
  const me = state.players[0]!;
367
  me.bot = false;
368
  const yaw = makeYaw();
369
  let corrections = 0;
370
  let worstRound = 0;
371
  let at = 0;
372
  const hear = () => {
373
    const before = yaw.angle;
374
    settle(yaw, { id: me.id, angle: round2(me.angle), alive: me.alive }, at);
375
    if (yaw.angle !== before) corrections += 1;
376
  };
377
  hear();
378
  // A dozen round starts, each followed by a second of ticks with the client
379
  // echoing back exactly what it was pointed at, the way the input timer
380
  // does — and each echo reaching the server a tick after it was sent.
381
  const inFlight: number[] = [];
382
  for (let round = 0; round < 12; round++) {
383
    const were = corrections;
384
    startRound(state);
385
    hear(); // the snapshot that follows the tick which started the round
386
    for (let k = 0; k < 30; k++) {
387
      at += 1 / 30;
388
      if (inFlight.length) setInput(me, { angle: inFlight.shift()! });
389
      if (k % 2 === 0) {
390
        told(yaw, yaw.angle, at);
391
        inFlight.push(yaw.angle);
392
      }
393
      tick(state, 1 / 30);
394
      hear();
395
    }
396
    // Facing the site the server put us at, still, a second later.
397
    const centre = me.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 };
398
    const want = Math.atan2(centre.y - me.y, centre.x - me.x);
399
    expect(Math.abs(((yaw.angle - want + Math.PI * 3) % (Math.PI * 2)) - Math.PI)).toBeLessThan(0.11);
400
    worstRound = Math.max(worstRound, corrections - were);
401
  }
402
  console.log(`round starts: ${corrections} camera corrections over 12 rounds, at most ${worstRound} in one`);
403
  // At most one a round — the spawn — and never a second one to angle zero.
404
  expect(worstRound).toBeLessThanOrEqual(1);
405
});
406
407
/**
408
 * The match's own motion, sampled at the rate the server broadcasts it and
409
 * drawn at the rate a browser draws: what a bot walking across the map
410
 * actually looks like. The samples carry exactly what a snapshot carries —
411
 * a position rounded to a tenth, thirty times a second.
412
 *
413
 * The measure is the spread of the per-frame steps. A body drawn at the
414
 * server's speed takes even steps; the exponential chase this replaced took
415
 * a long step just after each snapshot and shorter ones after it, which is
416
 * choppiness with a number on it — twice this spread, measured the same way.
417
 */
418
test("the simulation's own motion is drawn at an even speed", async () => {
419
  const { runBots } = await import("./bots.ts");
420
  const { newMatch, tick } = await import("./match.ts");
421
  const round2 = (n: number) => Math.round(n * 10) / 10;
422
423
  // A match of its own: nothing here touches the arena everybody shares.
424
  const state = newMatch();
425
  const step = () => {
426
    runBots(state, 1 / 30);
427
    tick(state, 1 / 30);
428
  };
429
  for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step();
430
  const stream: { t: number; bodies: { id: string; x: number; y: number; angle: number }[] }[] = [];
431
  for (let i = 0; i < 60; i++) {
432
    step();
433
    stream.push({
434
      t: i / 30,
435
      bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })),
436
    });
437
  }
438
439
  const timeline = makeTimeline();
440
  const paths = new Map<string, { x: number; y: number }[]>();
441
  let next = 0;
442
  for (let f = 0; f / 60 < 1.8; f++) {
443
    const now = f / 60;
444
    while (next < stream.length && stream[next]!.t <= now) {
445
      arrive(timeline, stream[next]!.t);
446
      for (const p of stream[next]!.bodies) feed(timeline, p.id, p, stream[next]!.t);
447
      next += 1;
448
    }
449
    for (const p of stream[Math.max(0, next - 1)]!.bodies) {
450
      const at = bodyAt(timeline, p, now - delay(timeline));
451
      paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]);
452
    }
453
  }
454
455
  // Whoever walked furthest in that second and a half.
456
  const walked = [...paths.values()]
457
    .filter((path) => path.length > 80)
458
    .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y)))
459
    .map((steps) => steps.filter((step) => step > 0.001))
460
    .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!;
461
  expect(walked.length).toBeGreaterThan(30);
462
  const mean = walked.reduce((s, x) => s + x, 0) / walked.length;
463
  const spread = Math.sqrt(walked.reduce((s, x) => s + (x - mean) ** 2, 0) / walked.length) / mean;
464
  // A body moving at the server's own speed: about four units every sixtieth
465
  // of a second, which is the run speed.
466
  expect(mean * 60).toBeGreaterThan(150);
467
  expect(spread).toBeLessThan(0.15);
468
});
469
470
/**
471
 * Everybody else, as the real arena sends them to a real seat in a room of
472
 * `seats` people: the cadence the arena picks for that room, the vision
473
 * filter, the sticky board, all of it — over the same jittery wire as
474
 * `watchBots`, drawn the same way. The room is what is being measured, so
475
 * the seats sit idle and the bots do the walking.
476
 *
477
 * The wire opens three seconds before the round goes live and the first
478
 * three seconds of the rush are measured: the render delay creeps toward
479
 * its mark at two percent of real time, so a timeline that has just met a
480
 * 15Hz stream spends two seconds drawing bodies past the newest sample
481
 * while it gets there. That is what a room felt at the moment its third
482
 * seat filled; here each cadence is measured settled, and `cold` says how
483
 * many frames the buy phase spent guessing ahead.
484
 */
485
function watchRoom(seats: number, cadence: (filled: number) => number) {
486
  const random = rng(5);
487
  const arena = makeValArena(newMatch(23), () => {}, cadence);
488
  const packets: { at: number; bodies: Body[] }[] = [];
489
  let t = 0;
490
  let viewer = "";
491
  for (let i = 0; i < seats; i++) {
492
    const seat = arena.takeSeat(`10.9.0.${i + 1}`, `feel-${i}`, `Seat ${i}`)!;
493
    if (i === 0) viewer = seat.id;
494
    arena.attach(`feel-${i}`, seat.id, `10.9.0.${i + 1}`, `t${i}`, (data) => {
495
      if (i !== 0) return;
496
      const snap = JSON.parse(data) as { players: Body[] };
497
      packets.push({ at: t + 0.02 + jitter(random), bodies: snap.players.filter((p) => p.id !== viewer) });
498
    });
499
  }
500
  const tickOnce = () => {
501
    t += 1 / 30;
502
    arena.advance(1 / 30);
503
  };
504
  // The last three seconds of the buy phase, then the first three of the round.
505
  const state = arena.match();
506
  while (!(state.phase === "buy" && state.clock <= 3)) tickOnce();
507
  packets.length = 0;
508
  t = 0;
509
  let warm = 0;
510
  while (state.phase !== "live") {
511
    tickOnce();
512
    warm = t;
513
  }
514
  for (let k = 0; k < 90; k++) tickOnce();
515
  packets.sort((a, b) => a.at - b.at);
516
517
  const timeline = makeTimeline();
518
  const paths = new Map<string, { x: number; y: number }[]>();
519
  const delays: number[] = [];
520
  let ahead = 0;
521
  let cold = 0;
522
  let frames = 0;
523
  let next = 0;
524
  let restartsWarm = 0;
525
  for (let f = 0; f / 60 < warm + 2.8; f++) {
526
    const now = f / 60;
527
    while (next < packets.length && packets[next]!.at <= now) {
528
      const stamp = arrive(timeline, packets[next]!.at);
529
      for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp);
530
      next += 1;
531
    }
532
    if (next < 4) continue;
533
    const drawAt = renderTime(timeline, now);
534
    if (now < warm) {
535
      if (drawAt > timeline.stamp) cold += 1;
536
      restartsWarm = timeline.restarts;
537
      continue;
538
    }
539
    delays.push(now - drawAt);
540
    frames += 1;
541
    if (drawAt > timeline.stamp) ahead += 1;
542
    for (const p of packets[next - 1]!.bodies) {
543
      const at = bodyAt(timeline, p, drawAt);
544
      paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]);
545
    }
546
  }
547
  const walked = [...paths.values()]
548
    .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y)))
549
    .map((steps) => steps.filter((s) => s > 0.001))
550
    .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!;
551
  const { mean, spread } = spreadOf(walked);
552
  return {
553
    packets: packets.filter((p) => p.at >= warm).length,
554
    steps: walked.length,
555
    mean,
556
    spread,
557
    restarts: timeline.restarts - restartsWarm,
558
    delay: { min: Math.min(...delays), max: Math.max(...delays), now: delay(timeline) },
559
    ahead,
560
    cold,
561
    frames,
562
  };
563
}
564
565
/** The rule as it stood before: every other tick from the third seat. */
566
const halvedAtThree = (filled: number): number => (filled <= 2 ? 1 : 2);
567
568
test("in a room of four or eight, everybody else is drawn 45ms behind, not 87, with no new jitter", () => {
569
  for (const seats of [4, 8]) {
570
    const before = watchRoom(seats, halvedAtThree);
571
    const after = watchRoom(seats, broadcastEvery);
572
    for (const [label, r] of [
573
      ["before", before],
574
      [" after", after],
575
    ] as const) {
576
      console.log(
577
        `room of ${seats}, ${label}: ${r.packets} snapshots in 3s, ${r.steps} steps, mean ${r.mean.toFixed(2)}/frame, ` +
578
          `spread ${(r.spread * 100).toFixed(1)}%, restarts ${r.restarts}, ` +
579
          `delay ${(r.delay.min * 1000).toFixed(1)}–${(r.delay.max * 1000).toFixed(1)}ms (${(r.delay.now * 1000).toFixed(1)} in force), ` +
580
          `drawn past the newest sample ${r.ahead}/${r.frames} frames, ${r.cold} while the delay settled`,
581
      );
582
    }
583
    // Thirty a second reach this seat, not fifteen.
584
    expect(after.packets).toBeGreaterThanOrEqual(85);
585
    expect(before.packets).toBeLessThanOrEqual(46);
586
    // And so every other body is drawn at the floor of the render delay, not
587
    // 1.3 intervals of a halved rate: 45ms behind the present, not 87.
588
    expect(after.delay.max).toBeLessThan(0.05);
589
    expect(before.delay.max).toBeGreaterThan(0.08);
590
    // Closer to the present buys no jitter: the steps are never less even
591
    // than they were at the halved rate, and no body in sight all along
592
    // started again. In the room of four the body measured runs clean and
593
    // holds the small room's 8% (`watchBots`); in the room of eight the two
594
    // bots left thread past the idle bodies at spawn, and those stops are the
595
    // server's, the same in both columns, so only the comparison is held.
596
    expect(after.steps).toBeGreaterThan(40);
597
    expect(after.mean * 60).toBeGreaterThan(150);
598
    if (seats === 4) expect(after.spread).toBeLessThan(0.08);
599
    expect(after.spread).toBeLessThanOrEqual(before.spread + 0.01);
600
    expect(after.restarts).toBeLessThanOrEqual(before.restarts);
601
    // A 45ms delay over a 33ms beat leaves 12ms for a packet to be late, and
602
    // the wire holds one in thirty by about that: the odd frame is drawn on
603
    // the newest pair's velocity, one interval at most, which a room of two
604
    // gets today. What must not appear is the run of guessed frames a
605
    // timeline spends settling on a halved rate.
606
    expect(after.ahead).toBeLessThanOrEqual(Math.ceil(after.frames * 0.03));
607
    expect(before.cold).toBeGreaterThan(10);
608
    expect(after.cold).toBeLessThanOrEqual(2);
609
  }
610
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts