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

forked from benjivers/val

workbenchthis machine
Files ad62f806cbbb0d37daa89167b8c4dd87c1d77999
492 lines · 21.5 KB · ~6.3k 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
import { mkdtempSync } from "node:fs";
13
import { tmpdir } from "node:os";
14
import { join } from "node:path";
15
16
process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-"));
17
process.env.PORT ??= "0";
18
const { server } = await import("./server.ts");
19
const base = `http://${server.hostname}:${server.port}`;
20
21
// ------------------------------------------------------------ the stub DOM
22
23
const drawn: string[] = [];
24
// Where the canvas was moved to: on the top-down view your own body is an
25
// arrow drawn about the origin, so the last translate of a frame is where
26
// this client thinks you are.
27
const moves: { x: number; y: number }[] = [];
28
const listeners = new Map<string, ((event: unknown) => void)[]>();
29
const frames: ((now: number) => void)[] = [];
30
31
const noop = () => {};
32
const context = () =>
33
  new Proxy(
34
    {
35
      measureText: (text: string) => ({ width: text.length * 6 }),
36
      createLinearGradient: () => ({ addColorStop: noop }),
37
    } as Record<string, unknown>,
38
    {
39
      get(target, prop: string) {
40
        if (prop in target) return target[prop];
41
        if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return "";
42
        if (prop === "globalAlpha" || prop === "lineWidth") return 1;
43
        return (...args: unknown[]) => {
44
          if (prop === "translate") moves.push({ x: Number(args[0]), y: Number(args[1]) });
45
          return drawn.push(`${prop}(${args.length})`);
46
        };
47
      },
48
      set: () => true,
49
    },
50
  );
51
52
function element(id: string, attrs: Record<string, string> = {}) {
53
  const node: Record<string, unknown> = {
54
    id,
55
    dataset: { ...attrs },
56
    style: {},
57
    textContent: "",
58
    title: "",
59
    disabled: false,
60
    type: "",
61
    children: [] as unknown[],
62
    classList: {
63
      on: new Set<string>(),
64
      add(name: string) {
65
        (this.on as Set<string>).add(name);
66
      },
67
      remove(name: string) {
68
        (this.on as Set<string>).delete(name);
69
      },
70
      toggle(name: string, force?: boolean) {
71
        const on = force ?? !(this.on as Set<string>).has(name);
72
        if (on) (this.on as Set<string>).add(name);
73
        else (this.on as Set<string>).delete(name);
74
        return on;
75
      },
76
      contains(name: string) {
77
        return (this.on as Set<string>).has(name);
78
      },
79
    },
80
    events: new Map<string, ((event: unknown) => void)[]>(),
81
    addEventListener(kind: string, fn: (event: unknown) => void) {
82
      const map = node.events as Map<string, ((event: unknown) => void)[]>;
83
      map.set(kind, [...(map.get(kind) ?? []), fn]);
84
    },
85
    append(...kids: unknown[]) {
86
      (node.children as unknown[]).push(...kids);
87
    },
88
    replaceChildren(...kids: unknown[]) {
89
      node.children = kids;
90
    },
91
    getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 570 }),
92
    getContext: () => context(),
93
    requestPointerLock: noop,
94
    setPointerCapture: noop,
95
    width: 900,
96
    height: 570,
97
  };
98
  return node;
99
}
100
101
const click = (node: Record<string, unknown>) => {
102
  for (const fn of (node.events as Map<string, ((e: unknown) => void)[]>).get("click") ?? []) fn({});
103
};
104
const press = (code: string, key = "") => {
105
  for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop });
106
};
107
const release = (code: string) => {
108
  for (const fn of listeners.get("keyup") ?? []) fn({ code });
109
};
110
111
const nodes = new Map<string, Record<string, unknown>>();
112
for (const id of [
113
  "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle",
114
  "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage",
115
  "log", "atkboard", "defboard", "helpagents", "roster", "feed", "perf",
116
])
117
  nodes.set(id, element(id));
118
119
const buys = [
120
  element("b1", { buy: "vandal", cost: "2900" }),
121
  element("b2", { buy: "c", cost: "0" }),
122
  element("b3", { buy: "q", cost: "0" }),
123
];
124
const agentButtons = [element("a1", { agent: "veil" })];
125
const scoreRed = element("red");
126
const scoreBlue = element("blue");
127
128
Object.assign(globalThis, {
129
  document: {
130
    getElementById: (id: string) => nodes.get(id) ?? null,
131
    querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue),
132
    querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons),
133
    createElement: (tag: string) => element(tag),
134
    addEventListener: noop,
135
    pointerLockElement: null,
136
    exitPointerLock: noop,
137
  },
138
  HTMLInputElement: class {},
139
  matchMedia: () => ({ matches: false }),
140
  devicePixelRatio: 1,
141
  localStorage: {
142
    store: new Map<string, string>(),
143
    getItem(key: string) {
144
      return this.store.get(key) ?? null;
145
    },
146
    setItem(key: string, value: string) {
147
      this.store.set(key, value);
148
    },
149
  },
150
  requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn),
151
  addEventListener: (kind: string, fn: (event: unknown) => void) => {
152
    listeners.set(kind, [...(listeners.get(kind) ?? []), fn]);
153
  },
154
  location: new URL(base),
155
});
156
157
// Relative fetches, the way a page makes them.
158
const realFetch = globalThis.fetch;
159
globalThis.fetch = ((input: string, init?: RequestInit) =>
160
  realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch;
161
162
// A real browser attaches the session cookie to the websocket handshake on
163
// its own; this stub is not a browser, so it carries the cookie the page's
164
// own fetch was handed, the way one actually would.
165
let sessionCookie = "";
166
let openedSocket: WebSocket | null = null;
167
const RealWebSocket = globalThis.WebSocket;
168
globalThis.WebSocket = function (url: string) {
169
  openedSocket = new RealWebSocket(url, { headers: { cookie: sessionCookie } } as unknown as string[]);
170
  return openedSocket;
171
} as unknown as typeof WebSocket;
172
173
const runFrame = (at = performance.now()) => {
174
  const pending = frames.splice(0, frames.length);
175
  for (const fn of pending) fn(at);
176
};
177
const until = async (what: () => boolean, ms = 4000) => {
178
  const stop = Date.now() + ms;
179
  while (Date.now() < stop) {
180
    runFrame();
181
    if (what()) return true;
182
    await Bun.sleep(20);
183
  }
184
  return false;
185
};
186
187
// ------------------------------------------------------------------ the run
188
189
test("the bundled client connects, paints the HUD and draws a frame", async () => {
190
  const first = await fetch(base + "/");
191
  sessionCookie = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
192
  const page = await first.text();
193
  const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!;
194
  const script = await (await fetch(base + src)).text();
195
  const path = join(process.env.FORGE_APP_DATA!, "client.mjs");
196
  await Bun.write(path, script);
197
198
  await import(path);
199
  expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true);
200
  // The one socket this module opens is already under way; put the global
201
  // back so another test file's raw `new WebSocket(...)` is not silently
202
  // handed a cookie this test happened to pick up.
203
  globalThis.WebSocket = RealWebSocket;
204
  expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true);
205
206
  // The HUD says what the match says.
207
  expect(String(nodes.get("phase")!.textContent)).toContain("round");
208
  expect(String(nodes.get("creds")!.textContent)).toContain("cr");
209
  // The kit is four buttons, each named after the ability it casts.
210
  expect((nodes.get("kit")!.children as unknown[]).length).toBe(4);
211
  // One prompt, in words, about the round that is running.
212
  expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8);
213
  // And the buy panel prices itself off the agent's real kit.
214
  expect(String(buys[1]!.title)).toContain("charge");
215
216
  // The team is on screen whether anybody is in sight or not: five slots.
217
  const slots = nodes.get("roster")!.children as { className: string }[];
218
  expect(slots.length).toBe(5);
219
  expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1);
220
221
  // Run the match on a few seconds: bots trade, and the feed fills with
222
  // names — and with nothing that says where anybody is.
223
  const arena = await import("./arena.ts");
224
  for (let i = 0; i < 40 && (nodes.get("feed")!.children as unknown[]).length === 0; i++) {
225
    arena.advance(5);
226
    await Bun.sleep(5);
227
  }
228
  const feedRows = nodes.get("feed")!.children as { children: { textContent: string }[] }[];
229
  expect(feedRows.length).toBeGreaterThan(0);
230
  // killer ▸ victim, and the gun or the ability it was: four spans, no numbers.
231
  const row = feedRows[0]!.children.map((span) => span.textContent).join("");
232
  expect(row).toContain(" ▸ ");
233
  expect(row).not.toMatch(/\d/);
234
235
  // A frame of the first-person view draws the world into its own buffer and
236
  // blits it, with the minimap's tiles blitted too. It is a picture, but it
237
  // is not one fill a screen column any more, nor one a map tile: the map has
238
  // 570 tiles and the canvas 900 columns, and the frame used to pay for both.
239
  const tally = () => ({
240
    fills: drawn.filter((op) => op.startsWith("fillRect")).length,
241
    blits: drawn.filter((op) => op.startsWith("drawImage")).length,
242
  });
243
  runFrame(); // whatever was cached at this size is cached before we count
244
  drawn.length = 0;
245
  runFrame();
246
  const fp = tally();
247
  expect(fp.blits).toBeGreaterThanOrEqual(2); // the world, and the map's tiles
248
  expect(fp.fills).toBeGreaterThan(40);
249
  expect(fp.fills).toBeLessThan(570);
250
251
  // Tab swaps that frame for the whole map. Its tiles are drawn once into a
252
  // canvas of their own and blitted from then on: the first such frame pays
253
  // for all 570 of them, and no frame after it pays for any.
254
  for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop });
255
  drawn.length = 0;
256
  runFrame();
257
  const built = tally();
258
  drawn.length = 0;
259
  runFrame();
260
  const top = tally();
261
  expect(built.fills).toBeGreaterThan(400);
262
  expect(top.blits).toBe(1);
263
  expect(top.fills).toBeLessThan(100);
264
  for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" });
265
  drawn.length = 0;
266
  runFrame();
267
  expect(tally().blits).toBeGreaterThanOrEqual(2);
268
  // Holding W reaches the server as a direction, and it is the way we face.
269
  const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
270
  press("KeyW");
271
  await Bun.sleep(160);
272
  expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9);
273
  expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4);
274
275
  // V falls back to the top-down view, and it draws too.
276
  press("KeyV");
277
  drawn.length = 0;
278
  runFrame();
279
  expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true);
280
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" });
281
  press("KeyV");
282
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" });
283
284
  // Where you are looking is this browser's between snapshots. Turning with
285
  // the arrow key, the angle the server is told only ever climbs: the client
286
  // used to take `snap.me.angle` back on every snapshot, so the view was
287
  // dragged to a stale angle thirty times a second and what it sent stepped
288
  // backwards in between.
289
  const shortWay = (from: number, to: number) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
290
  press("ArrowRight");
291
  const angles: number[] = [];
292
  for (let i = 0; i < 60; i++) {
293
    runFrame(performance.now());
294
    await Bun.sleep(10);
295
    angles.push(seat.input.angle);
296
  }
297
  release("ArrowRight");
298
  const steps = angles.slice(1).map((angle, i) => shortWay(angles[i]!, angle));
299
  expect(Math.min(...steps)).toBeGreaterThan(-1e-9);
300
  expect(steps.reduce((sum, step) => sum + step, 0)).toBeGreaterThan(0.2);
301
302
  // H toggles the card either way, and the button closes it for good.
303
  const help = nodes.get("help")!.style as Record<string, string>;
304
  const before = help.display;
305
  press("KeyH");
306
  expect(help.display).not.toBe(before);
307
  if (help.display !== "grid") press("KeyH");
308
  expect(help.display).toBe("grid");
309
  click(nodes.get("helpclose")!);
310
  expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none");
311
  expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1");
312
313
  // B works the buy panel either way — it opens itself in the buy phase.
314
  const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean };
315
  const wasOpen = panel.contains("on");
316
  press("KeyB");
317
  expect(panel.contains("on")).toBe(!wasOpen);
318
319
  // And the socket really took a seat in the running match.
320
  expect(seat.bot).toBe(false);
321
322
  // Your own body is drawn from a guess about your own movement, not from
323
  // the last thing the server said. With the arena stopped — no snapshot can
324
  // arrive at all — the drawn body still carries the keys you are holding
325
  // forward, as far as the cap and no further, and stops there.
326
  press("KeyV"); // the top-down view, where your own body is an arrow
327
  for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1);
328
  await Bun.sleep(60);
329
  runFrame();
330
  arena.stopArena();
331
  // Walk whichever way is open from where the server last put us.
332
  for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code);
333
  const { blockedCircle } = await import("./map.ts");
334
  const clear = ([
335
    ["KeyW", 0, -1],
336
    ["KeyS", 0, 1],
337
    ["KeyA", -1, 0],
338
    ["KeyD", 1, 0],
339
  ] as const).find(([, dx, dy]) =>
340
    [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)),
341
  )!;
342
  expect(clear).toBeDefined();
343
  press(clear[0]);
344
  const arrow = () => moves[moves.length - 1]!;
345
  const startedAt = { x: seat.x, y: seat.y };
346
  const scale = 900 / (30 * 80); // canvas pixels to a world unit
347
  const t0 = performance.now();
348
  for (let i = 1; i <= 10; i++) {
349
    moves.length = 0;
350
    runFrame(t0 + i * 40);
351
  }
352
  const drawnAt = arrow();
353
  // The drawn body is ahead of the server's last word by the input held
354
  // since it was sent — a quarter of a second of running at most, and then
355
  // it waits, because past that a guess would be a claim. Smoothing toward
356
  // a snapshot that cannot arrive would have gone nowhere at all.
357
  const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2];
358
  const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1];
359
  expect(lead).toBeGreaterThan(30);
360
  // A quarter second of THIS seat's movement, not of a walking one: haste
361
  // multiplies the speed by 1.35, and a seat that happened to be hasted led
362
  // by 84 where a bound written around the base 250 expected 72. The cap is
363
  // in time (predict.js LEAD_MAX), so the bound has to be read off the same
364
  // speed table the client used, or it measures the match's mood instead of
365
  // the rule.
366
  // The lead is the cap plus the slack the guess is allowed over the answer
367
  // (predict.js LEAD_SLACK: the lag your own keys take to reach the server).
368
  const { moveSpeed } = await import("./glide.ts");
369
  const { LEAD_MAX, LEAD_SLACK } = await import("../static/predict.js");
370
  expect(lead).toBeLessThan((LEAD_MAX + LEAD_SLACK) * moveSpeed(seat) + 10);
371
  expect(Math.abs(sideways)).toBeLessThan(6);
372
  // Nothing on the server moved: this is a drawing, and the match is still
373
  // the only thing that says where anybody is.
374
  expect(seat.x).toBe(startedAt.x);
375
  expect(seat.y).toBe(startedAt.y);
376
  arena.startArena();
377
378
  // Back to the first-person view for the last of it, and a frame of it to
379
  // measure: the one before is the one that rebuilds anything cached.
380
  press("KeyV");
381
  runFrame();
382
  drawn.length = 0;
383
  runFrame();
384
  const onePixel = tally().fills;
385
  expect(onePixel).toBeGreaterThan(40);
386
387
  // The frame is not the screen's pixel count. On a retina display the same
388
  // window is twice the canvas, and the world is still drawn at a capped
389
  // internal width and blitted up: the work does not double with the device
390
  // pixel ratio, which is what made this choppy on a retina Mac.
391
  (globalThis as { devicePixelRatio: number }).devicePixelRatio = 2;
392
  for (const fn of listeners.get("resize") ?? []) fn({});
393
  expect(nodes.get("view")!.width).toBe(1800);
394
  runFrame(); // the first frame at a new size redraws the minimap's tiles
395
  drawn.length = 0;
396
  runFrame();
397
  expect(drawn.some((op) => op.startsWith("drawImage"))).toBe(true);
398
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(onePixel * 1.3);
399
  (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1;
400
  for (const fn of listeners.get("resize") ?? []) fn({});
401
402
  // ` prints the numbers, so "it feels choppy" and "it delivers 30Hz" can be
403
  // told apart: the frame rate, the mean frame, the snapshot rate, the
404
  // interpolation delay, and the width the world is really drawn at.
405
  const perf = nodes.get("perf")!;
406
  expect((perf.style as Record<string, string>).display).not.toBe("block");
407
  press("Backquote");
408
  expect((perf.style as Record<string, string>).display).toBe("block");
409
  const readout = String(perf.textContent);
410
  expect(readout).toContain("fps");
411
  expect(readout).toContain("ms/frame");
412
  expect(readout).toContain("Hz in");
413
  expect(readout).toContain("ms behind");
414
  expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas
415
  // And the count of corrections taken outright, per minute: a stutter is a
416
  // jump, and this is how many there have been.
417
  expect(readout).toMatch(/\d+ jumps \([\d.]+\/min\)/);
418
  press("Backquote");
419
  expect((perf.style as Record<string, string>).display).toBe("none");
420
421
  // This test's loopback address is shared with every other test in the
422
  // run; close the socket so the address is free for the next real
423
  // connection rather than left holding a seat for the rest of the suite.
424
  openedSocket?.close();
425
  await Bun.sleep(50);
426
}, 15000);
427
428
test("a snapshot with no seat in it draws the watching view instead of throwing", async () => {
429
  // The shape no other test covers: score, round, clock and feed, and no `me`
430
  // and no players at all. The client used to reach straight into `snap.me`
431
  // on the way in, so a second tab's first frame threw and the page stopped
432
  // drawing. This is the server's own spectator snapshot, handed to the
433
  // bundled client the way the socket hands it over.
434
  const { spectatorSnapshot } = await import("./arena.ts");
435
  const deliver = (why: "address" | "cookie") =>
436
    openedSocket!.onmessage!({ data: spectatorSnapshot(why) } as MessageEvent);
437
438
  drawn.length = 0;
439
  deliver("address");
440
  runFrame();
441
442
  // It says what the match is doing, and why this connection is watching.
443
  expect(String(nodes.get("phase")!.textContent)).toContain("round");
444
  expect(String(nodes.get("hp")!.textContent)).toBe("watching");
445
  expect(String(nodes.get("prompt")!.textContent)).toContain("address");
446
  // Nothing on screen that would need a position to fill in.
447
  expect((nodes.get("kit")!.children as unknown[]).length).toBe(0);
448
  expect((nodes.get("roster")!.children as unknown[]).length).toBe(0);
449
  expect((nodes.get("atkboard")!.children as unknown[]).length).toBe(0);
450
  // And the canvas is words on a panel, not a map: one rectangle, no tiles.
451
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1);
452
  expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3);
453
454
  // The other reason reads differently, and the frame after it still draws.
455
  deliver("cookie");
456
  runFrame();
457
  expect(String(nodes.get("prompt")!.textContent)).toContain("cookie");
458
});
459
460
test("your own camera is never drawn as a stranger arriving", async () => {
461
  // At spawn the buy phase turns prediction off, and the frame used to fall
462
  // through to the treatments meant for other people: drawn in the past, and
463
  // slid in from where it was last seen. On a respawn that slides the CAMERA
464
  // across the room — the shake somebody reported the first time they played
465
  // a round from the start.
466
  const { spectatorSnapshot } = await import("./arena.ts");
467
  expect(typeof spectatorSnapshot).toBe("function");
468
  const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text();
469
  // The rule is small enough to state: own id short-circuits before both.
470
  const look = bundle.slice(bundle.indexOf("const look = (p) =>"));
471
  const body = look.slice(0, look.indexOf("};") + 2);
472
  const ownReturn = body.indexOf("if (p.id === snap.me.id) return p;");
473
  const slideCall = body.indexOf("slide(presence");
474
  expect(ownReturn).toBeGreaterThan(-1);
475
  expect(slideCall).toBeGreaterThan(ownReturn);
476
});
477
478
test("the buy phase is predicted, and its pen is the server's own rule", async () => {
479
  const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text();
480
  // Prediction is no longer switched off for the whole buy phase: it is on
481
  // whenever the browser knows the pen, which it learns from the map it is
482
  // already given. Drawing your own body straight from a 30Hz snapshot while
483
  // rendering at 60 is a stutter, and that is what the phase used to do.
484
  expect(bundle).toContain('snap.phase !== "buy" || pens !== null');
485
  // And the pen is the server's number, not a guess: 120 from a spawn tile.
486
  expect(bundle).toContain("best > 120");
487
  // A refused step holds where it was rather than snapping to the server:
488
  // the pen is handed to the one step function as the place it is refused.
489
  expect(bundle).toContain("stepLocal(local, snap.me, held, speed");
490
  expect(bundle).toContain("world, refused)");
491
  expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)");
492
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts