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

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

Keyboard shortcuts