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

forked from benjivers/val

workbenchthis machine
Files d5bfb1d9a8d63f55abff941a2ed8e47fbe709050
387 lines · 16.0 KB · ~4.7k 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 actually draws walls: a slice a column,
236
  // far more rectangles than the map has tiles.
237
  drawn.length = 0;
238
  runFrame();
239
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800);
240
241
  // Tab swaps that frame for the whole map — many fewer rectangles.
242
  for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop });
243
  drawn.length = 0;
244
  runFrame();
245
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(800);
246
  for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" });
247
  drawn.length = 0;
248
  runFrame();
249
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800);
250
  const onePixel = drawn.filter((op) => op.startsWith("fillRect")).length;
251
252
  // The frame is not the screen's pixel count. On a retina display the same
253
  // window is twice the canvas, and the world is still drawn at a capped
254
  // internal width and blitted up: the work does not double with the device
255
  // pixel ratio, which is what made this choppy on a retina Mac.
256
  (globalThis as { devicePixelRatio: number }).devicePixelRatio = 2;
257
  for (const fn of listeners.get("resize") ?? []) fn({});
258
  expect(nodes.get("view")!.width).toBe(1800);
259
  drawn.length = 0;
260
  runFrame();
261
  expect(drawn.some((op) => op.startsWith("drawImage"))).toBe(true);
262
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(onePixel * 1.3);
263
  (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1;
264
  for (const fn of listeners.get("resize") ?? []) fn({});
265
266
  // ` prints the numbers, so "it feels choppy" and "it delivers 30Hz" can be
267
  // told apart: the frame rate, the mean frame, the snapshot rate, the
268
  // interpolation delay, and the width the world is really drawn at.
269
  const perf = nodes.get("perf")!;
270
  expect((perf.style as Record<string, string>).display).not.toBe("block");
271
  press("Backquote");
272
  expect((perf.style as Record<string, string>).display).toBe("block");
273
  const readout = String(perf.textContent);
274
  expect(readout).toContain("fps");
275
  expect(readout).toContain("ms/frame");
276
  expect(readout).toContain("Hz in");
277
  expect(readout).toContain("ms behind");
278
  expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas
279
  press("Backquote");
280
  expect((perf.style as Record<string, string>).display).toBe("none");
281
282
  // Holding W reaches the server as a direction, and it is the way we face.
283
  const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
284
  press("KeyW");
285
  await Bun.sleep(160);
286
  expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9);
287
  expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4);
288
289
  // V falls back to the top-down view, and it draws too.
290
  press("KeyV");
291
  drawn.length = 0;
292
  runFrame();
293
  expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true);
294
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" });
295
  press("KeyV");
296
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" });
297
298
  // Where you are looking is this browser's between snapshots. Turning with
299
  // the arrow key, the angle the server is told only ever climbs: the client
300
  // used to take `snap.me.angle` back on every snapshot, so the view was
301
  // dragged to a stale angle thirty times a second and what it sent stepped
302
  // backwards in between.
303
  const shortWay = (from: number, to: number) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
304
  press("ArrowRight");
305
  const angles: number[] = [];
306
  for (let i = 0; i < 60; i++) {
307
    runFrame(performance.now());
308
    await Bun.sleep(10);
309
    angles.push(seat.input.angle);
310
  }
311
  release("ArrowRight");
312
  const steps = angles.slice(1).map((angle, i) => shortWay(angles[i]!, angle));
313
  expect(Math.min(...steps)).toBeGreaterThan(-1e-9);
314
  expect(steps.reduce((sum, step) => sum + step, 0)).toBeGreaterThan(0.2);
315
316
  // H toggles the card either way, and the button closes it for good.
317
  const help = nodes.get("help")!.style as Record<string, string>;
318
  const before = help.display;
319
  press("KeyH");
320
  expect(help.display).not.toBe(before);
321
  if (help.display !== "grid") press("KeyH");
322
  expect(help.display).toBe("grid");
323
  click(nodes.get("helpclose")!);
324
  expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none");
325
  expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1");
326
327
  // B works the buy panel either way — it opens itself in the buy phase.
328
  const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean };
329
  const wasOpen = panel.contains("on");
330
  press("KeyB");
331
  expect(panel.contains("on")).toBe(!wasOpen);
332
333
  // And the socket really took a seat in the running match.
334
  expect(seat.bot).toBe(false);
335
336
  // Your own body is drawn from a guess about your own movement, not from
337
  // the last thing the server said. With the arena stopped — no snapshot can
338
  // arrive at all — the drawn body still carries the keys you are holding
339
  // forward, as far as the cap and no further, and stops there.
340
  press("KeyV"); // the top-down view, where your own body is an arrow
341
  for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1);
342
  await Bun.sleep(60);
343
  runFrame();
344
  arena.stopArena();
345
  // Walk whichever way is open from where the server last put us.
346
  for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code);
347
  const { blockedCircle } = await import("./map.ts");
348
  const clear = ([
349
    ["KeyW", 0, -1],
350
    ["KeyS", 0, 1],
351
    ["KeyA", -1, 0],
352
    ["KeyD", 1, 0],
353
  ] as const).find(([, dx, dy]) =>
354
    [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)),
355
  )!;
356
  expect(clear).toBeDefined();
357
  press(clear[0]);
358
  const arrow = () => moves[moves.length - 1]!;
359
  const startedAt = { x: seat.x, y: seat.y };
360
  const scale = 900 / (30 * 80); // canvas pixels to a world unit
361
  const t0 = performance.now();
362
  for (let i = 1; i <= 10; i++) {
363
    moves.length = 0;
364
    runFrame(t0 + i * 40);
365
  }
366
  const drawnAt = arrow();
367
  // The drawn body is ahead of the server's last word by the input held
368
  // since it was sent — a quarter of a second of running at most, and then
369
  // it waits, because past that a guess would be a claim. Smoothing toward
370
  // a snapshot that cannot arrive would have gone nowhere at all.
371
  const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2];
372
  const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1];
373
  expect(lead).toBeGreaterThan(30);
374
  expect(lead).toBeLessThan(0.25 * 250 + 10);
375
  expect(Math.abs(sideways)).toBeLessThan(6);
376
  // Nothing on the server moved: this is a drawing, and the match is still
377
  // the only thing that says where anybody is.
378
  expect(seat.x).toBe(startedAt.x);
379
  expect(seat.y).toBe(startedAt.y);
380
  arena.startArena();
381
382
  // This test's loopback address is shared with every other test in the
383
  // run; close the socket so the address is free for the next real
384
  // connection rather than left holding a seat for the rest of the suite.
385
  openedSocket?.close();
386
  await Bun.sleep(50);
387
}, 15000);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts