benjivers/val Public Public: anyone can read this; the crew can change it.

Files 53c9cd79f21c24c95995672b7a3113c152df085c
426 lines · 16.9 KB · ~4.9k tok Raw History
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 { BODY_R, moveSpeed, RUN } from "./engine/glide.ts";
15
import { solidTile, TILE } from "./map.ts";
16
import { newMatch, setInput, startRound, tick } from "./match.ts";
17
import { runBots } from "./bots.ts";
18
import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/engine/interp.js";
19
import { makeLocal, stepLocal } from "../static/engine/predict.js";
20
import { makeYaw, settle, told } from "../static/engine/yaw.js";
21
22
const round2 = (n: number) => Math.round(n * 10) / 10;
23
const world = { solid: solidTile, tile: TILE, r: BODY_R };
24
25
/** A seeded wobble, so the run is the same every time. */
26
function rng(seed: number) {
27
  let s = seed | 0;
28
  return () => {
29
    s = (s + 0x6d2b79f5) | 0;
30
    let t = Math.imul(s ^ (s >>> 15), s | 1);
31
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
32
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
33
  };
34
}
35
36
/**
37
 * The wire as it was measured: 30 a second, p50 gap 33ms, p99 41–48ms, no
38
 * gap over 100ms. Each packet takes a base latency plus a wobble, and now
39
 * and then one is held a dozen milliseconds longer.
40
 */
41
function jitter(random: () => number): number {
42
  const u = random();
43
  const v = random();
44
  const gauss = Math.sqrt(-2 * Math.log(1 - u)) * Math.cos(2 * Math.PI * v);
45
  return Math.abs(gauss) * 0.004 + (random() < 0.03 ? 0.012 : 0);
46
}
47
48
const spreadOf = (steps: number[]) => {
49
  const mean = steps.reduce((s, x) => s + x, 0) / steps.length;
50
  const sd = Math.sqrt(steps.reduce((s, x) => s + (x - mean) ** 2, 0) / steps.length);
51
  return { mean, spread: sd / mean };
52
};
53
54
type Body = { id: string; x: number; y: number; angle: number };
55
type Packet = { at: number; stamp: number; me: Body & { alive: boolean; phase: string }; bodies: Body[] };
56
57
/**
58
 * A person walking east across open ground for two seconds, sending intent
59
 * every 50ms, hearing back thirty times a second over a jittery wire, and
60
 * drawn at sixty frames — with the input reaching the server one way late.
61
 */
62
function walkOwnBody(base = 0.02) {
63
  const random = rng(7);
64
  const state = newMatch(11);
65
  const me = state.players[0]!;
66
  me.bot = false;
67
  for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) {
68
    runBots(state, 1 / 30);
69
    tick(state, 1 / 30);
70
  }
71
  // Somewhere open: the middle of the map, facing east.
72
  me.x = 1000;
73
  me.y = 640;
74
  me.hp = 100;
75
  me.alive = true;
76
  me.haste = 0;
77
  me.slow = 0;
78
  me.weapon = "classic";
79
  const packets: Packet[] = [];
80
  const inputs: { at: number; mx: number; my: number }[] = [];
81
  const held = { mx: 0, my: 0 };
82
  const walkFrom = 0.3;
83
  const walkUntil = 1.6;
84
  // The server's clock: one tick every 33ms; snapshots leave right after.
85
  for (let k = 0; k * (1 / 30) < 2.2; k++) {
86
    const t = k / 30;
87
    while (inputs.length && inputs[0]!.at <= t) {
88
      const input = inputs.shift()!;
89
      setInput(me, { mx: input.mx, my: input.my, angle: 0 });
90
    }
91
    // The browser's send timer is on its own clock: every 50ms, arriving one
92
    // way of latency later.
93
    for (let s = Math.ceil((t - 1 / 30) / 0.05); s * 0.05 < t; s++) {
94
      if (s * 0.05 < t - 1 / 30) continue;
95
      const sentAt = s * 0.05;
96
      const moving = sentAt >= walkFrom && sentAt < walkUntil;
97
      inputs.push({ at: sentAt + base, mx: moving ? 1 : 0, my: 0 });
98
    }
99
    inputs.sort((a, b) => a.at - b.at);
100
    tick(state, 1 / 30);
101
    packets.push({
102
      at: t + base + jitter(random),
103
      stamp: 0,
104
      me: { id: me.id, x: round2(me.x), y: round2(me.y), angle: round2(me.angle), alive: me.alive, phase: state.phase },
105
      bodies: [],
106
    });
107
  }
108
  packets.sort((a, b) => a.at - b.at);
109
110
  const timeline = makeTimeline();
111
  const local = makeLocal();
112
  let snap: Packet | null = null;
113
  let next = 0;
114
  let previous = 0;
115
  const steps: number[] = [];
116
  let back = 0;
117
  let anchors = 0;
118
  for (let f = 0; f / 60 < 2.2; f++) {
119
    const now = f / 60;
120
    while (next < packets.length && packets[next]!.at <= now) {
121
      snap = packets[next]!;
122
      snap.stamp = arrive(timeline, snap.at);
123
      next += 1;
124
    }
125
    if (!snap) continue;
126
    held.mx = now >= walkFrom && now < walkUntil ? 1 : 0;
127
    const before = local.anchored;
128
    stepLocal(local, snap.me, held, moveSpeed({}), now - snap.stamp, 1 / 60, world, null);
129
    // The first word about where you are is taken outright: that one is not a jump.
130
    if (before > 0) anchors += local.anchored - before;
131
    if (f > 0 && previous !== 0 && now > walkFrom + 0.2 && now < walkUntil - 0.05) {
132
      const step = local.x - previous;
133
      steps.push(step);
134
      back = Math.min(back, step);
135
    }
136
    previous = local.x;
137
  }
138
  return { steps, back, anchors, local, server: me };
139
}
140
141
test("your own body, walking, takes even steps and never a backward one", () => {
142
  const { steps, back, anchors } = walkOwnBody();
143
  const { mean, spread } = spreadOf(steps);
144
  console.log(
145
    `own body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` +
146
      `largest backward step ${back.toFixed(2)}, anchors ${anchors}`,
147
  );
148
  // Running east at 250 a second is 4.17 a frame.
149
  expect(mean * 60).toBeGreaterThan(RUN * 0.9);
150
  expect(mean * 60).toBeLessThan(RUN * 1.1);
151
  // Even: the per-frame step does not beat against the wire's jitter.
152
  expect(spread).toBeLessThan(0.05);
153
  // And a step backwards is a stutter by definition.
154
  expect(back).toBeGreaterThan(-0.01);
155
  expect(anchors).toBe(0);
156
});
157
158
test("starting and stopping never pulls you back, and the speed never dips a third", () => {
159
  // The first tenth of a second after the key goes down the server has not
160
  // heard yet, and the guess used to be yanked back to the server's still
161
  // body on each of the first two snapshots. Letting go, the server runs on
162
  // for the same lag and the guess must not be dragged back to meet it.
163
  const random = rng(3);
164
  const timeline = makeTimeline();
165
  const local = makeLocal();
166
  const me = { x: 1000, y: 640, angle: 0, alive: true };
167
  const held = { mx: 0, my: 0 };
168
  const hop = 0.03; // one way, plus the send timer's own phase
169
  let serverX = 1000;
170
  const packets: { at: number; x: number }[] = [];
171
  for (let k = 0; k / 30 < 2; k++) {
172
    const t = k / 30;
173
    // The server hears the key a hop after the next 50ms send.
174
    const heardDown = Math.ceil(0.3 / 0.05) * 0.05 + hop;
175
    const heardUp = Math.ceil(1.2 / 0.05) * 0.05 + hop;
176
    if (t > heardDown && t <= heardUp) serverX += RUN / 30;
177
    packets.push({ at: t + hop + jitter(random), x: round2(serverX) });
178
  }
179
  let next = 0;
180
  let previous = 0;
181
  let back = 0;
182
  let slowest = Infinity;
183
  for (let f = 0; f / 60 < 2; f++) {
184
    const now = f / 60;
185
    while (next < packets.length && packets[next]!.at <= now) {
186
      me.x = packets[next]!.x;
187
      arrive(timeline, packets[next]!.at);
188
      next += 1;
189
    }
190
    if (next === 0) continue;
191
    held.mx = now >= 0.3 && now < 1.2 ? 1 : 0;
192
    stepLocal(local, me, held, RUN, now - timeline.stamp, 1 / 60, world, null);
193
    const step = local.x - previous;
194
    if (now > 0.3 + 1 / 60) back = Math.min(back, step);
195
    if (now > 0.3 + 1 / 60 && now < 1.2) slowest = Math.min(slowest, step);
196
    previous = local.x;
197
  }
198
  console.log(
199
    `walk start and stop: largest backward step ${back.toFixed(2)}, slowest frame while walking ${slowest.toFixed(2)} of ${(RUN / 60).toFixed(2)}, ` +
200
      `settled ${(local.x - serverX).toFixed(1)} from the server`,
201
  );
202
  expect(back).toBeGreaterThan(-0.01);
203
  expect(slowest).toBeGreaterThan((RUN / 60) * 0.67);
204
  // Where you stopped is where the server stops: the guess settles onto it.
205
  expect(Math.abs(local.x - serverX)).toBeLessThan(1);
206
});
207
208
/**
209
 * Everybody else: a bot walking, heard about thirty times a second over the
210
 * same wire, drawn a little in the past between the samples on hand.
211
 */
212
function watchBots() {
213
  const random = rng(5);
214
  const state = newMatch(23);
215
  const step = () => {
216
    runBots(state, 1 / 30);
217
    tick(state, 1 / 30);
218
  };
219
  for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step();
220
  const packets: { at: number; bodies: Body[] }[] = [];
221
  for (let k = 0; k < 90; k++) {
222
    step();
223
    packets.push({
224
      at: k / 30 + 0.02 + jitter(random),
225
      bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })),
226
    });
227
  }
228
  const timeline = makeTimeline();
229
  const paths = new Map<string, { x: number; y: number }[]>();
230
  const delays: number[] = [];
231
  let ahead = 0;
232
  let frames = 0;
233
  let next = 0;
234
  for (let f = 0; f / 60 < 2.8; f++) {
235
    const now = f / 60;
236
    while (next < packets.length && packets[next]!.at <= now) {
237
      const stamp = arrive(timeline, packets[next]!.at);
238
      for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp);
239
      next += 1;
240
    }
241
    if (next < 4) continue;
242
    const drawAt = renderTime(timeline, now);
243
    delays.push(now - drawAt);
244
    frames += 1;
245
    if (drawAt > timeline.stamp) ahead += 1;
246
    for (const p of packets[next - 1]!.bodies) {
247
      const at = bodyAt(timeline, p, drawAt);
248
      paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]);
249
    }
250
  }
251
  const walked = [...paths.values()]
252
    .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y)))
253
    .map((steps) => steps.filter((s) => s > 0.001))
254
    .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!;
255
  return { walked, restarts: timeline.restarts, delays, ahead, frames, timeline };
256
}
257
258
test("everybody else walks at an even speed over a jittery wire, and nobody restarts", () => {
259
  const { walked, restarts, delays, ahead, frames } = watchBots();
260
  const { mean, spread } = spreadOf(walked);
261
  const dmin = Math.min(...delays);
262
  const dmax = Math.max(...delays);
263
  console.log(
264
    `others: ${walked.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` +
265
      `restarts ${restarts}, delay ${(dmin * 1000).toFixed(1)}–${(dmax * 1000).toFixed(1)}ms, ` +
266
      `drawn past the newest sample ${ahead}/${frames} frames`,
267
  );
268
  expect(walked.length).toBeGreaterThan(40);
269
  expect(mean * 60).toBeGreaterThan(150);
270
  expect(spread).toBeLessThan(0.08);
271
  // A body that was in sight all along never jumps to start again.
272
  expect(restarts).toBe(0);
273
  // The moment drawn is never later than the newest word: no guessing ahead.
274
  expect(ahead).toBe(0);
275
});
276
277
test("the render clock never steps backwards or stalls, whatever the wire does", () => {
278
  const random = rng(9);
279
  const timeline = makeTimeline();
280
  let arrival = 0.02;
281
  let drawn = 0;
282
  let worstShort = Infinity;
283
  let worstLong = 0;
284
  for (let f = 0; f / 60 < 5; f++) {
285
    const now = f / 60;
286
    while (arrival <= now) {
287
      arrive(timeline, arrival);
288
      arrival += 1 / 30 + jitter(random) - 0.002;
289
    }
290
    const at = renderTime(timeline, now);
291
    if (f > 30 && drawn > 0) {
292
      worstShort = Math.min(worstShort, at - drawn);
293
      worstLong = Math.max(worstLong, at - drawn);
294
    }
295
    drawn = at;
296
  }
297
  console.log(`render clock: frame advance ${(worstShort * 1000).toFixed(2)}–${(worstLong * 1000).toFixed(2)}ms of 16.67`);
298
  // Within a few percent of real time, every frame: 16.67ms give or take.
299
  expect(worstShort * 60).toBeGreaterThan(0.97);
300
  expect(worstLong * 60).toBeLessThan(1.03);
301
});
302
303
test("a burst of snapshots does not walk the even clock into the future", () => {
304
  // A tab back from the background is handed everything it missed at once.
305
  // The clock those samples are filed on may lead the arrivals by half a
306
  // beat and no more, or the sent angles age out and every echo of your own
307
  // view comes back as a correction.
308
  const timeline = makeTimeline();
309
  let at = 10;
310
  for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30));
311
  const before = timeline.stamp;
312
  for (let i = 0; i < 150; i++) {
313
    const stamp = arrive(timeline, (at += 0.0002));
314
    expect(stamp - at).toBeLessThanOrEqual(timeline.interval / 2 + 1e-9);
315
    expect(stamp).toBeGreaterThanOrEqual(before);
316
  }
317
  // And once the wire is itself again, the beat is back on it.
318
  for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30));
319
  expect(Math.abs(timeline.stamp - at)).toBeLessThan(0.01);
320
});
321
322
test("a round start points you at the site once, and nothing stomps it a tick later", () => {
323
  const state = newMatch(31);
324
  const me = state.players[0]!;
325
  me.bot = false;
326
  const yaw = makeYaw();
327
  let corrections = 0;
328
  let worstRound = 0;
329
  let at = 0;
330
  const hear = () => {
331
    const before = yaw.angle;
332
    settle(yaw, { id: me.id, angle: round2(me.angle), alive: me.alive }, at);
333
    if (yaw.angle !== before) corrections += 1;
334
  };
335
  hear();
336
  // A dozen round starts, each followed by a second of ticks with the client
337
  // echoing back exactly what it was pointed at, the way the input timer
338
  // does — and each echo reaching the server a tick after it was sent.
339
  const inFlight: number[] = [];
340
  for (let round = 0; round < 12; round++) {
341
    const were = corrections;
342
    startRound(state);
343
    hear(); // the snapshot that follows the tick which started the round
344
    for (let k = 0; k < 30; k++) {
345
      at += 1 / 30;
346
      if (inFlight.length) setInput(me, { angle: inFlight.shift()! });
347
      if (k % 2 === 0) {
348
        told(yaw, yaw.angle, at);
349
        inFlight.push(yaw.angle);
350
      }
351
      tick(state, 1 / 30);
352
      hear();
353
    }
354
    // Facing the site the server put us at, still, a second later.
355
    const centre = me.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 };
356
    const want = Math.atan2(centre.y - me.y, centre.x - me.x);
357
    expect(Math.abs(((yaw.angle - want + Math.PI * 3) % (Math.PI * 2)) - Math.PI)).toBeLessThan(0.11);
358
    worstRound = Math.max(worstRound, corrections - were);
359
  }
360
  console.log(`round starts: ${corrections} camera corrections over 12 rounds, at most ${worstRound} in one`);
361
  // At most one a round — the spawn — and never a second one to angle zero.
362
  expect(worstRound).toBeLessThanOrEqual(1);
363
});
364
365
/**
366
 * The match's own motion, sampled at the rate the server broadcasts it and
367
 * drawn at the rate a browser draws: what a bot walking across the map
368
 * actually looks like. The samples carry exactly what a snapshot carries —
369
 * a position rounded to a tenth, thirty times a second.
370
 *
371
 * The measure is the spread of the per-frame steps. A body drawn at the
372
 * server's speed takes even steps; the exponential chase this replaced took
373
 * a long step just after each snapshot and shorter ones after it, which is
374
 * choppiness with a number on it — twice this spread, measured the same way.
375
 */
376
test("the simulation's own motion is drawn at an even speed", async () => {
377
  const { runBots } = await import("./bots.ts");
378
  const { newMatch, tick } = await import("./match.ts");
379
  const round2 = (n: number) => Math.round(n * 10) / 10;
380
381
  // A match of its own: nothing here touches the arena everybody shares.
382
  const state = newMatch();
383
  const step = () => {
384
    runBots(state, 1 / 30);
385
    tick(state, 1 / 30);
386
  };
387
  for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step();
388
  const stream: { t: number; bodies: { id: string; x: number; y: number; angle: number }[] }[] = [];
389
  for (let i = 0; i < 60; i++) {
390
    step();
391
    stream.push({
392
      t: i / 30,
393
      bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })),
394
    });
395
  }
396
397
  const timeline = makeTimeline();
398
  const paths = new Map<string, { x: number; y: number }[]>();
399
  let next = 0;
400
  for (let f = 0; f / 60 < 1.8; f++) {
401
    const now = f / 60;
402
    while (next < stream.length && stream[next]!.t <= now) {
403
      arrive(timeline, stream[next]!.t);
404
      for (const p of stream[next]!.bodies) feed(timeline, p.id, p, stream[next]!.t);
405
      next += 1;
406
    }
407
    for (const p of stream[Math.max(0, next - 1)]!.bodies) {
408
      const at = bodyAt(timeline, p, now - delay(timeline));
409
      paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]);
410
    }
411
  }
412
413
  // Whoever walked furthest in that second and a half.
414
  const walked = [...paths.values()]
415
    .filter((path) => path.length > 80)
416
    .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y)))
417
    .map((steps) => steps.filter((step) => step > 0.001))
418
    .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!;
419
  expect(walked.length).toBeGreaterThan(30);
420
  const mean = walked.reduce((s, x) => s + x, 0) / walked.length;
421
  const spread = Math.sqrt(walked.reduce((s, x) => s + (x - mean) ** 2, 0) / walked.length) / mean;
422
  // A body moving at the server's own speed: about four units every sixtieth
423
  // of a second, which is the run speed.
424
  expect(mean * 60).toBeGreaterThan(150);
425
  expect(spread).toBeLessThan(0.15);
426
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts