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

forked from benjivers/val

workbenchthis machine
Files 2345b5223e387cb66b1c8373aac8440e0c8bfdb0
1
/**
2
 * The client, played.
3
 *
4
 * The bundle the browser is served is loaded here against a stub document
5
 * and a canvas that only writes down what it was told to draw, and pointed
6
 * at the running server over a real socket. It is not a browser, but it is
7
 * the whole path a person takes: connect, take a snapshot, paint the HUD,
8
 * draw a frame, press a key, send an input back.
9
 */
10
11
import { expect, test } from "bun:test";
12
import { TICK } from "./arena.ts";
13
import { HEIGHT, WIDTH } from "./map.ts";
14
import { mkdtempSync } from "node:fs";
15
import { tmpdir } from "node:os";
16
import { join } from "node:path";
17
18
process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-"));
19
process.env.PORT ??= "0";
20
const { server } = await import("./server.ts");
21
const base = `http://${server.hostname}:${server.port}`;
22
23
// ------------------------------------------------------------ the stub DOM
24
25
const drawn: string[] = [];
26
/**
27
 * Every canvas the client asked the document for. The widest is its offscreen
28
 * world buffer, whose width is however many columns the frame budget settled
29
 * on; the other is the minimap's tile layer. Keeping them is what lets a test
30
 * bound the frame's cost as a fraction of the columns actually used, instead
31
 * of against a number somebody picked once.
32
 */
33
const canvases: Record<string, unknown>[] = [];
34
const worldColumns = (): number => Math.max(0, ...canvases.map((c) => Number(c.width) || 0));
35
// Where the canvas was moved to: on the top-down view your own body is an
36
// arrow drawn about the origin, so the last translate of a frame is where
37
// this client thinks you are.
38
const moves: { x: number; y: number }[] = [];
39
const listeners = new Map<string, ((event: unknown) => void)[]>();
40
const frames: ((now: number) => void)[] = [];
41
42
const noop = () => {};
43
const context = () =>
44
  new Proxy(
45
    {
46
      measureText: (text: string) => ({ width: text.length * 6 }),
47
      createLinearGradient: () => ({ addColorStop: noop }),
48
    } as Record<string, unknown>,
49
    {
50
      get(target, prop: string) {
51
        if (prop in target) return target[prop];
52
        if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return "";
53
        if (prop === "globalAlpha" || prop === "lineWidth") return 1;
54
        return (...args: unknown[]) => {
55
          if (prop === "translate") moves.push({ x: Number(args[0]), y: Number(args[1]) });
56
          return drawn.push(`${prop}(${args.length})`);
57
        };
58
      },
59
      set: () => true,
60
    },
61
  );
62
63
function element(id: string, attrs: Record<string, string> = {}) {
64
  const node: Record<string, unknown> = {
65
    id,
66
    dataset: { ...attrs },
67
    style: {},
68
    textContent: "",
69
    title: "",
70
    disabled: false,
71
    type: "",
72
    children: [] as unknown[],
73
    classList: {
74
      on: new Set<string>(),
75
      add(name: string) {
76
        (this.on as Set<string>).add(name);
77
      },
78
      remove(name: string) {
79
        (this.on as Set<string>).delete(name);
80
      },
81
      toggle(name: string, force?: boolean) {
82
        const on = force ?? !(this.on as Set<string>).has(name);
83
        if (on) (this.on as Set<string>).add(name);
84
        else (this.on as Set<string>).delete(name);
85
        return on;
86
      },
87
      contains(name: string) {
88
        return (this.on as Set<string>).has(name);
89
      },
90
    },
91
    events: new Map<string, ((event: unknown) => void)[]>(),
92
    addEventListener(kind: string, fn: (event: unknown) => void) {
93
      const map = node.events as Map<string, ((event: unknown) => void)[]>;
94
      map.set(kind, [...(map.get(kind) ?? []), fn]);
95
    },
96
    append(...kids: unknown[]) {
97
      (node.children as unknown[]).push(...kids);
98
    },
99
    replaceChildren(...kids: unknown[]) {
100
      node.children = kids;
101
    },
102
    getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 570 }),
103
    getContext: () => context(),
104
    requestPointerLock: noop,
105
    setPointerCapture: noop,
106
    width: 900,
107
    height: 570,
108
  };
109
  return node;
110
}
111
112
const click = (node: Record<string, unknown>) => {
113
  for (const fn of (node.events as Map<string, ((e: unknown) => void)[]>).get("click") ?? []) fn({});
114
};
115
const press = (code: string, key = "") => {
116
  for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop });
117
};
118
const release = (code: string) => {
119
  for (const fn of listeners.get("keyup") ?? []) fn({ code });
120
};
121
122
const nodes = new Map<string, Record<string, unknown>>();
123
for (const id of [
124
  "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle",
125
  "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage",
126
  "log", "atkboard", "defboard", "helpagents", "roster", "feed", "perf",
127
])
128
  nodes.set(id, element(id));
129
130
const GUNS = ["classic", "ghost", "sheriff", "spectre", "phantom", "vandal", "operator"];
131
const buys = [
132
  ...GUNS.map((gun, i) => element(`g${i}`, { buy: gun, cost: gun === "vandal" ? "2900" : "0" })),
133
  element("b2", { buy: "c", cost: "0" }),
134
  element("b3", { buy: "q", cost: "0" }),
135
];
136
const agentButtons = [element("a1", { agent: "veil" })];
137
const scoreRed = element("red");
138
const scoreBlue = element("blue");
139
140
Object.assign(globalThis, {
141
  document: {
142
    getElementById: (id: string) => nodes.get(id) ?? null,
143
    querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue),
144
    querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons),
145
    createElement: (tag: string) => {
146
      const node = element(tag);
147
      if (tag === "canvas") canvases.push(node);
148
      return node;
149
    },
150
    addEventListener: noop,
151
    pointerLockElement: null,
152
    exitPointerLock: noop,
153
  },
154
  HTMLInputElement: class {},
155
  matchMedia: () => ({ matches: false }),
156
  devicePixelRatio: 1,
157
  localStorage: {
158
    store: new Map<string, string>(),
159
    getItem(key: string) {
160
      return this.store.get(key) ?? null;
161
    },
162
    setItem(key: string, value: string) {
163
      this.store.set(key, value);
164
    },
165
  },
166
  requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn),
167
  addEventListener: (kind: string, fn: (event: unknown) => void) => {
168
    listeners.set(kind, [...(listeners.get(kind) ?? []), fn]);
169
  },
170
  location: new URL(base),
171
});
172
173
// Relative fetches, the way a page makes them.
174
const realFetch = globalThis.fetch;
175
globalThis.fetch = ((input: string, init?: RequestInit) =>
176
  realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch;
177
178
// A real browser attaches the session cookie to the websocket handshake on
179
// its own; this stub is not a browser, so it carries the cookie the page's
180
// own fetch was handed, the way one actually would.
181
let sessionCookie = "";
182
let openedSocket: WebSocket | null = null;
183
const RealWebSocket = globalThis.WebSocket;
184
globalThis.WebSocket = function (url: string) {
185
  openedSocket = new RealWebSocket(url, { headers: { cookie: sessionCookie } } as unknown as string[]);
186
  return openedSocket;
187
} as unknown as typeof WebSocket;
188
189
const runFrame = (at = performance.now()) => {
190
  const pending = frames.splice(0, frames.length);
191
  for (const fn of pending) fn(at);
192
};
193
const until = async (what: () => boolean, ms = 4000) => {
194
  const stop = Date.now() + ms;
195
  while (Date.now() < stop) {
196
    runFrame();
197
    if (what()) return true;
198
    await Bun.sleep(20);
199
  }
200
  return false;
201
};
202
203
// ------------------------------------------------------------------ the run
204
205
test("the bundled client connects, paints the HUD and draws a frame", async () => {
206
  // Every test in this run reaches the server from the one loopback address,
207
  // and a playing seat is rationed per address on purpose. A socket another
208
  // file closed a moment ago may not have given its seat back yet, and this
209
  // client would then be handed a spectator view and wait four seconds for a
210
  // health readout that never comes — the coin-flip failure the suite had.
211
  // Start from a clean slate, the way the server's own seat tests do.
212
  const { releaseAddress } = await import("./arena.ts");
213
  for (const address of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) releaseAddress(address);
214
  const first = await fetch(base + "/");
215
  sessionCookie = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
216
  const page = await first.text();
217
  const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!;
218
  const script = await (await fetch(base + src)).text();
219
  const path = join(process.env.FORGE_APP_DATA!, "client.mjs");
220
  await Bun.write(path, script);
221
222
  await import(path);
223
  expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true);
224
  // The one socket this module opens is already under way; put the global
225
  // back so another test file's raw `new WebSocket(...)` is not silently
226
  // handed a cookie this test happened to pick up.
227
  globalThis.WebSocket = RealWebSocket;
228
  expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true);
229
230
  // The HUD says what the match says.
231
  expect(String(nodes.get("phase")!.textContent)).toContain("round");
232
  expect(String(nodes.get("creds")!.textContent)).toContain("cr");
233
  // The kit is four buttons, each named after the ability it casts.
234
  expect((nodes.get("kit")!.children as unknown[]).length).toBe(4);
235
  // One prompt, in words, about the round that is running.
236
  expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8);
237
  // And the buy panel prices itself off the agent's real kit.
238
  expect(String(buys.find((b) => (b.dataset as { buy: string }).buy === "c")!.title)).toContain("charge");
239
  // The gun you hold is the one gun button marked as yours.
240
  const held = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
241
  const ownedGuns = buys.filter(
242
    (b) => GUNS.includes((b.dataset as { buy: string }).buy) && (b.classList as { contains: (n: string) => boolean }).contains("owned"),
243
  );
244
  expect(ownedGuns.map((b) => (b.dataset as { buy: string }).buy)).toEqual([held.weapon]);
245
246
  // The team is on screen whether anybody is in sight or not: five slots.
247
  const slots = nodes.get("roster")!.children as { className: string }[];
248
  expect(slots.length).toBe(5);
249
  expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1);
250
251
  // Run the match on a few seconds: bots trade, and the feed fills with
252
  // names — and with nothing that says where anybody is.
253
  const arena = await import("./arena.ts");
254
  for (let i = 0; i < 40 && (nodes.get("feed")!.children as unknown[]).length === 0; i++) {
255
    arena.advance(5);
256
    await Bun.sleep(5);
257
  }
258
  const feedRows = nodes.get("feed")!.children as { children: { textContent: string }[] }[];
259
  expect(feedRows.length).toBeGreaterThan(0);
260
  // killer ▸ victim, and the gun or the ability it was: four spans, no numbers.
261
  const row = feedRows[0]!.children.map((span) => span.textContent).join("");
262
  expect(row).toContain(" ▸ ");
263
  expect(row).not.toMatch(/\d/);
264
265
  // A frame of the first-person view draws the world into its own buffer and
266
  // blits it, with the minimap's tiles blitted too. It is a picture, but it is
267
  // not one fill a screen column, nor one a map tile: the map has 570 tiles
268
  // and the buffer up to 960 columns, and the frame used to pay for both.
269
  // The minimap's tiles are the blit; the walls are a run a fill.
270
  //
271
  // What is asserted is that run-batching, and NOT a particular count. This
272
  // used to demand more than 40 fills, which failed about one full-suite run
273
  // in twenty-five while passing forty runs out of forty on its own. The loop
274
  // above advances the shared match until somebody dies — anywhere between
275
  // five and two hundred seconds of play — so the view being measured was
276
  // wherever that happened to leave the seat: 154 to 236 fills when run
277
  // alone, and 31 on the run that failed. A floor on a number nobody chose,
278
  // over a view nobody fixed, is not a test.
279
  //
280
  // So the seat is stood somewhere known first. The walls in shot are then
281
  // the same every run; the few fills that still move are the bodies standing
282
  // among them, which is why the bound below is a fraction rather than an
283
  // equality.
284
  const arenaNow = await import("./arena.ts");
285
  const camera = arenaNow.match().players.find((p) => !p.bot)!;
286
  camera.x = WIDTH / 2;
287
  camera.y = HEIGHT / 2;
288
  camera.angle = 0;
289
  camera.alive = true;
290
  camera.blind = 0;
291
  arenaNow.advance(TICK * 2); // a snapshot carrying that position reaches the client
292
  await until(() => true);
293
294
  const tally = () => ({
295
    fills: drawn.filter((op) => op.startsWith("fillRect")).length,
296
    blits: drawn.filter((op) => op.startsWith("drawImage")).length,
297
  });
298
  runFrame(); // whatever was cached at this size is cached before we count
299
  drawn.length = 0;
300
  runFrame();
301
  const fp = tally();
302
  drawn.length = 0;
303
  runFrame();
304
  const again = tally();
305
306
  expect(fp.blits).toBeGreaterThanOrEqual(2); // the world, and the map's tiles
307
  // It drew a world, not an empty buffer.
308
  expect(fp.fills).toBeGreaterThan(0);
309
  // Run-batched, said against the columns the frame ACTUALLY used. The budget
310
  // moves the world between 960 and 448 columns depending on what frames cost,
311
  // so no constant can separate "a run a wall" from "a fill a column": at the
312
  // coarsest rung a per-column frame is 448, which sits under any constant
313
  // loose enough to survive the finest one.
314
  expect(fp.fills).toBeLessThan(worldColumns() / 2);
315
  // The same state drawn twice costs the same. A frame whose cost wanders
316
  // while nothing moves is a renderer bug, and it is the shape of thing the
317
  // old bound was mistaking for flakiness.
318
  expect(again.fills).toBe(fp.fills);
319
320
  // Tab swaps that frame for the whole map. Its tiles are drawn once into a
321
  // canvas of their own and blitted from then on: the first such frame pays
322
  // for all 570 of them, and no frame after it pays for any.
323
  for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop });
324
  drawn.length = 0;
325
  runFrame();
326
  const built = tally();
327
  drawn.length = 0;
328
  runFrame();
329
  const top = tally();
330
  expect(built.fills).toBeGreaterThan(400);
331
  expect(top.blits).toBe(1);
332
  expect(top.fills).toBeLessThan(100);
333
  for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" });
334
  drawn.length = 0;
335
  runFrame();
336
  expect(tally().blits).toBeGreaterThanOrEqual(2);
337
  // Holding W reaches the server as a direction, and it is the way we face.
338
  const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
339
  press("KeyW");
340
  await Bun.sleep(160);
341
  expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9);
342
  expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4);
343
344
  // V falls back to the top-down view, and it draws too.
345
  press("KeyV");
346
  drawn.length = 0;
347
  runFrame();
348
  expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true);
349
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" });
350
  press("KeyV");
351
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" });
352
353
  // Where you are looking is this browser's between snapshots. Turning with
354
  // the arrow key, the angle the server is told only ever climbs: the client
355
  // used to take `snap.me.angle` back on every snapshot, so the view was
356
  // dragged to a stale angle thirty times a second and what it sent stepped
357
  // backwards in between.
358
  const shortWay = (from: number, to: number) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
359
  press("ArrowRight");
360
  const angles: number[] = [];
361
  for (let i = 0; i < 60; i++) {
362
    runFrame(performance.now());
363
    await Bun.sleep(10);
364
    angles.push(seat.input.angle);
365
  }
366
  release("ArrowRight");
367
  const steps = angles.slice(1).map((angle, i) => shortWay(angles[i]!, angle));
368
  expect(Math.min(...steps)).toBeGreaterThan(-1e-9);
369
  expect(steps.reduce((sum, step) => sum + step, 0)).toBeGreaterThan(0.2);
370
371
  // H toggles the card either way, and the button closes it for good.
372
  const help = nodes.get("help")!.style as Record<string, string>;
373
  const before = help.display;
374
  press("KeyH");
375
  expect(help.display).not.toBe(before);
376
  if (help.display !== "grid") press("KeyH");
377
  expect(help.display).toBe("grid");
378
  click(nodes.get("helpclose")!);
379
  expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none");
380
  expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1");
381
382
  // B works the buy panel either way — it opens itself in the buy phase.
383
  const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean };
384
  const wasOpen = panel.contains("on");
385
  press("KeyB");
386
  expect(panel.contains("on")).toBe(!wasOpen);
387
388
  // And the socket really took a seat in the running match.
389
  expect(seat.bot).toBe(false);
390
391
  // Your own body is drawn from a guess about your own movement, not from
392
  // the last thing the server said. With the arena stopped — no snapshot can
393
  // arrive at all — the drawn body still carries the keys you are holding
394
  // forward, as far as the cap and no further, and stops there.
395
  press("KeyV"); // the top-down view, where your own body is an arrow
396
  // The measurement needs a live round and a seat that can walk. The arena
397
  // has been run through whole rounds above with this seat standing still,
398
  // so by now it may be dead, mid-plant, slowed, or stood inside a barrier a
399
  // bot put up — and a guess about a body that cannot move is rightly nothing.
400
  // Put it on its feet, clear what abilities left, and let a snapshot carry
401
  // that to the client before the clock is stopped; try again if a round
402
  // ends or a bot lands a kill in that moment.
403
  for (let tries = 0; tries < 10; tries++) {
404
    for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1);
405
    Object.assign(seat, { alive: true, hp: 100, planting: 0, defusing: 0, haste: 0, slow: 0 });
406
    arena.match().zones.length = 0;
407
    arena.advance(1 / 30);
408
    await Bun.sleep(80);
409
    runFrame();
410
    arena.stopArena();
411
    if (arena.match().phase === "live" && seat.alive && arena.match().zones.length === 0) break;
412
    arena.startArena();
413
  }
414
  expect(arena.match().phase).toBe("live");
415
  expect(seat.alive).toBe(true);
416
  // Walk whichever way is open from where the server last put us.
417
  for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code);
418
  const { blockedCircle } = await import("./map.ts");
419
  const clear = ([
420
    ["KeyW", 0, -1],
421
    ["KeyS", 0, 1],
422
    ["KeyA", -1, 0],
423
    ["KeyD", 1, 0],
424
  ] as const).find(([, dx, dy]) =>
425
    [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)),
426
  )!;
427
  expect(clear).toBeDefined();
428
  press(clear[0]);
429
  const arrow = () => moves[moves.length - 1]!;
430
  const startedAt = { x: seat.x, y: seat.y };
431
  const scale = 900 / (30 * 80); // canvas pixels to a world unit
432
  const t0 = performance.now();
433
  for (let i = 1; i <= 10; i++) {
434
    moves.length = 0;
435
    runFrame(t0 + i * 40);
436
  }
437
  const drawnAt = arrow();
438
  // The drawn body is ahead of the server's last word by the input held
439
  // since it was sent — a quarter of a second of running at most, and then
440
  // it waits, because past that a guess would be a claim. Smoothing toward
441
  // a snapshot that cannot arrive would have gone nowhere at all.
442
  const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2];
443
  const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1];
444
  expect(lead).toBeGreaterThan(30);
445
  // A quarter second of THIS seat's movement, not of a walking one: haste
446
  // multiplies the speed by 1.35, and a seat that happened to be hasted led
447
  // by 84 where a bound written around the base 250 expected 72. The cap is
448
  // in time (predict.js LEAD_MAX), so the bound has to be read off the same
449
  // speed table the client used, or it measures the match's mood instead of
450
  // the rule.
451
  // The lead is the cap plus the slack the guess is allowed over the answer
452
  // (predict.js LEAD_SLACK: the lag your own keys take to reach the server).
453
  const { moveSpeed } = await import("./engine/glide.ts");
454
  const { LEAD_MAX, LEAD_SLACK } = await import("../static/engine/predict.js");
455
  expect(lead).toBeLessThan((LEAD_MAX + LEAD_SLACK) * moveSpeed(seat) + 10);
456
  expect(Math.abs(sideways)).toBeLessThan(6);
457
  // Nothing on the server moved: this is a drawing, and the match is still
458
  // the only thing that says where anybody is.
459
  expect(seat.x).toBe(startedAt.x);
460
  expect(seat.y).toBe(startedAt.y);
461
  arena.startArena();
462
463
  // Back to the first-person view for the last of it, and a frame of it to
464
  // measure: the one before is the one that rebuilds anything cached.
465
  press("KeyV");
466
  runFrame();
467
  drawn.length = 0;
468
  runFrame();
469
  const onePixel = tally().fills;
470
  expect(onePixel).toBeGreaterThan(40);
471
472
  // The frame is not the screen's pixel count. On a retina display the same
473
  // window is twice the canvas, and the world is still drawn at a capped
474
  // internal width and blitted up: the work does not double with the device
475
  // pixel ratio, which is what made this choppy on a retina Mac.
476
  (globalThis as { devicePixelRatio: number }).devicePixelRatio = 2;
477
  for (const fn of listeners.get("resize") ?? []) fn({});
478
  expect(nodes.get("view")!.width).toBe(1800);
479
  runFrame(); // the first frame at a new size redraws the minimap's tiles
480
  drawn.length = 0;
481
  runFrame();
482
  expect(drawn.some((op) => op.startsWith("drawImage"))).toBe(true);
483
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(onePixel * 1.3);
484
  (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1;
485
  for (const fn of listeners.get("resize") ?? []) fn({});
486
487
  // ` prints the numbers, so "it feels choppy" and "it delivers 30Hz" can be
488
  // told apart: the frame rate, the mean frame, the snapshot rate, the
489
  // interpolation delay, and the width the world is really drawn at.
490
  const perf = nodes.get("perf")!;
491
  expect((perf.style as Record<string, string>).display).not.toBe("block");
492
  press("Backquote");
493
  expect((perf.style as Record<string, string>).display).toBe("block");
494
  const readout = String(perf.textContent);
495
  expect(readout).toContain("fps");
496
  expect(readout).toContain("ms/frame");
497
  expect(readout).toContain("Hz in");
498
  expect(readout).toContain("ms behind");
499
  expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas
500
  // And the count of corrections taken outright, per minute: a stutter is a
501
  // jump, and this is how many there have been.
502
  expect(readout).toMatch(/\d+ jumps \([\d.]+\/min\)/);
503
  press("Backquote");
504
  expect((perf.style as Record<string, string>).display).toBe("none");
505
506
  // This test's loopback address is shared with every other test in the
507
  // run; close the socket so the address is free for the next real
508
  // connection rather than left holding a seat for the rest of the suite.
509
  openedSocket?.close();
510
  await Bun.sleep(50);
511
}, 15000);
512
513
test("a snapshot with no seat in it draws the watching view instead of throwing", async () => {
514
  // The shape no other test covers: score, round, clock and feed, and no `me`
515
  // and no players at all. The client used to reach straight into `snap.me`
516
  // on the way in, so a second tab's first frame threw and the page stopped
517
  // drawing. This is the server's own spectator snapshot, handed to the
518
  // bundled client the way the socket hands it over.
519
  const { spectatorSnapshot } = await import("./arena.ts");
520
  const deliver = (why: "address" | "cookie") =>
521
    openedSocket!.onmessage!({ data: spectatorSnapshot(why) } as MessageEvent);
522
523
  drawn.length = 0;
524
  deliver("address");
525
  runFrame();
526
527
  // It says what the match is doing, and why this connection is watching.
528
  expect(String(nodes.get("phase")!.textContent)).toContain("round");
529
  expect(String(nodes.get("hp")!.textContent)).toBe("watching");
530
  expect(String(nodes.get("prompt")!.textContent)).toContain("address");
531
  // Nothing on screen that would need a position to fill in.
532
  expect((nodes.get("kit")!.children as unknown[]).length).toBe(0);
533
  expect((nodes.get("roster")!.children as unknown[]).length).toBe(0);
534
  expect((nodes.get("atkboard")!.children as unknown[]).length).toBe(0);
535
  // And the canvas is words on a panel, not a map: one rectangle, no tiles.
536
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1);
537
  expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3);
538
539
  // The other reason reads differently, and the frame after it still draws.
540
  deliver("cookie");
541
  runFrame();
542
  expect(String(nodes.get("prompt")!.textContent)).toContain("cookie");
543
});
544
545
test("your own camera is never drawn as a stranger arriving", async () => {
546
  // At spawn the buy phase turns prediction off, and the frame used to fall
547
  // through to the treatments meant for other people: drawn in the past, and
548
  // slid in from where it was last seen. On a respawn that slides the CAMERA
549
  // across the room — the shake somebody reported the first time they played
550
  // a round from the start.
551
  const { spectatorSnapshot } = await import("./arena.ts");
552
  expect(typeof spectatorSnapshot).toBe("function");
553
  const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text();
554
  // The rule is small enough to state: own id short-circuits before both.
555
  const look = bundle.slice(bundle.indexOf("const look = (p) =>"));
556
  const body = look.slice(0, look.indexOf("};") + 2);
557
  const ownReturn = body.indexOf("if (p.id === snap.me.id) return p;");
558
  const slideCall = body.indexOf("slide(presence");
559
  expect(ownReturn).toBeGreaterThan(-1);
560
  expect(slideCall).toBeGreaterThan(ownReturn);
561
});
562
563
test("the buy phase is predicted, and its pen is the server's own rule", async () => {
564
  const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text();
565
  // Prediction is no longer switched off for the whole buy phase: it is on
566
  // whenever the browser knows the pen, which it learns from the map it is
567
  // already given. Drawing your own body straight from a 30Hz snapshot while
568
  // rendering at 60 is a stutter, and that is what the phase used to do.
569
  expect(bundle).toContain('snap.phase !== "buy" || pens !== null');
570
  // And the pen is the server's number, not a guess: 120 from a spawn tile.
571
  expect(bundle).toContain("best > 120");
572
  // A refused step holds where it was rather than snapping to the server:
573
  // the pen is handed to the one step function as the place it is refused.
574
  expect(bundle).toContain("stepLocal(local, snap.me, held, speed");
575
  expect(bundle).toContain("world, refused)");
576
  expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)");
577
});
578
579
test("the drawn body runs at the speed the server sent, and never at one it works out", async () => {
580
  // Two snapshots a client that re-derived the rule would read backwards: one
581
  // that says stopped with every visible reason to be running, and one that
582
  // says running with a visible reason to be stopped. The drawing follows the
583
  // number both times, which is the whole of what it knows about movement.
584
  const arena = await import("./arena.ts");
585
  const { blockedCircle } = await import("./map.ts");
586
  arena.stopArena();
587
  const body = arena.match().players[0]!;
588
  const base = JSON.parse(arena.snapshot(body.id));
589
590
  // Somewhere with a hundred units of room in one direction to walk into.
591
  const clear = (
592
    [
593
      ["KeyW", 0, -1],
594
      ["KeyS", 0, 1],
595
      ["KeyA", -1, 0],
596
      ["KeyD", 1, 0],
597
    ] as const
598
  ).find(([, dx, dy]) => [25, 50, 75, 100].every((step) => !blockedCircle(body.x + dx * step, body.y + dy * step, 16)))!;
599
  expect(clear).toBeDefined();
600
601
  // The top-down view, where your own body is the last thing translated to.
602
  if ((nodes.get("stage")!.dataset as Record<string, string>).mode !== "top") press("KeyV");
603
  expect((nodes.get("stage")!.dataset as Record<string, string>).mode).toBe("top");
604
  for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code);
605
  press(clear[0]);
606
607
  const mine = (base.players as Record<string, unknown>[]).find((p) => p.id === base.me.id) ?? {
608
    id: base.me.id,
609
    name: body.name,
610
    agent: base.me.agent,
611
    angle: 0,
612
    hp: 100,
613
    armour: 0,
614
    mate: true,
615
    spike: false,
616
    blind: false,
617
    revealed: false,
618
  };
619
  const scale = 900 / (30 * 80); // canvas pixels to a world unit, as above
620
  const deliver = (me: Record<string, unknown>) => {
621
    const snap = {
622
      ...base,
623
      phase: "live",
624
      zones: [],
625
      // Your own body is on the map as one of the bodies you can see, and it
626
      // is the one the arrow is drawn for.
627
      players: [{ ...mine, alive: true, x: body.x, y: body.y }],
628
      shots: [],
629
      me: { ...base.me, alive: true, x: body.x, y: body.y, planting: 0, ...me },
630
    };
631
    openedSocket!.onmessage!({ data: JSON.stringify(snap) } as MessageEvent);
632
  };
633
  /** How far past the server's own word the body is drawn, after ten frames. */
634
  const leadOf = (me: Record<string, unknown>) => {
635
    deliver(me);
636
    const t0 = performance.now();
637
    runFrame(t0); // the first frame after a jump takes the server's word
638
    for (let i = 1; i <= 10; i++) {
639
      moves.length = 0;
640
      runFrame(t0 + i * 40);
641
    }
642
    const at = moves[moves.length - 1]!;
643
    return (at.x / scale - body.x) * clear[1] + (at.y / scale - body.y) * clear[2];
644
  };
645
646
  // Stopped, with nothing in the snapshot to say why: no planting, no
647
  // slow, a live round, a light weapon. A client working it out for itself
648
  // would run this body the whole quarter second.
649
  expect(leadOf({ speed: 0 })).toBeLessThan(1);
650
  // And moving, mid-plant — a reason the old client read as stopped. The
651
  // number is what counts, so the body walks.
652
  const walking = leadOf({ speed: 250, planting: 1 });
653
  expect(walking).toBeGreaterThan(30);
654
  // And the number is the whole of it: half the speed, half the lead. The
655
  // cap on a guess is a length of time (predict.js LEAD_MAX), so how far
656
  // ahead the drawing may be is the server's speed and nothing else.
657
  const half = leadOf({ speed: 125 });
658
  expect(half / walking).toBeGreaterThan(0.45);
659
  expect(half / walking).toBeLessThan(0.55);
660
661
  release(clear[0]);
662
  openedSocket?.close();
663
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts