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

forked from benjivers/val

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

Keyboard shortcuts