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

forked from benjivers/val

workbenchthis machine
Files 7524e23c398cf40fee5ee6886d8b9cb75adfcbbf
388 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",
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
251
  // Holding W reaches the server as a direction, and it is the way we face.
252
  const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
253
  press("KeyW");
254
  await Bun.sleep(160);
255
  expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9);
256
  expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4);
257
258
  // V falls back to the top-down view, and it draws too.
259
  press("KeyV");
260
  drawn.length = 0;
261
  runFrame();
262
  expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true);
263
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" });
264
  press("KeyV");
265
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" });
266
267
  // Where you are looking is this browser's between snapshots. Turning with
268
  // the arrow key, the angle the server is told only ever climbs: the client
269
  // used to take `snap.me.angle` back on every snapshot, so the view was
270
  // dragged to a stale angle thirty times a second and what it sent stepped
271
  // backwards in between.
272
  const shortWay = (from: number, to: number) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
273
  press("ArrowRight");
274
  const angles: number[] = [];
275
  for (let i = 0; i < 60; i++) {
276
    runFrame(performance.now());
277
    await Bun.sleep(10);
278
    angles.push(seat.input.angle);
279
  }
280
  release("ArrowRight");
281
  const steps = angles.slice(1).map((angle, i) => shortWay(angles[i]!, angle));
282
  expect(Math.min(...steps)).toBeGreaterThan(-1e-9);
283
  expect(steps.reduce((sum, step) => sum + step, 0)).toBeGreaterThan(0.2);
284
285
  // H toggles the card either way, and the button closes it for good.
286
  const help = nodes.get("help")!.style as Record<string, string>;
287
  const before = help.display;
288
  press("KeyH");
289
  expect(help.display).not.toBe(before);
290
  if (help.display !== "grid") press("KeyH");
291
  expect(help.display).toBe("grid");
292
  click(nodes.get("helpclose")!);
293
  expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none");
294
  expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1");
295
296
  // B works the buy panel either way — it opens itself in the buy phase.
297
  const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean };
298
  const wasOpen = panel.contains("on");
299
  press("KeyB");
300
  expect(panel.contains("on")).toBe(!wasOpen);
301
302
  // And the socket really took a seat in the running match.
303
  expect(seat.bot).toBe(false);
304
305
  // Your own body is drawn from a guess about your own movement, not from
306
  // the last thing the server said. With the arena stopped — no snapshot can
307
  // arrive at all — the drawn body still carries the keys you are holding
308
  // forward, as far as the cap and no further, and stops there.
309
  press("KeyV"); // the top-down view, where your own body is an arrow
310
  for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1);
311
  await Bun.sleep(60);
312
  runFrame();
313
  arena.stopArena();
314
  // Walk whichever way is open from where the server last put us.
315
  for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code);
316
  const { blockedCircle } = await import("./map.ts");
317
  const clear = ([
318
    ["KeyW", 0, -1],
319
    ["KeyS", 0, 1],
320
    ["KeyA", -1, 0],
321
    ["KeyD", 1, 0],
322
  ] as const).find(([, dx, dy]) =>
323
    [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)),
324
  )!;
325
  expect(clear).toBeDefined();
326
  press(clear[0]);
327
  const arrow = () => moves[moves.length - 1]!;
328
  const startedAt = { x: seat.x, y: seat.y };
329
  const scale = 900 / (30 * 80); // canvas pixels to a world unit
330
  const t0 = performance.now();
331
  for (let i = 1; i <= 10; i++) {
332
    moves.length = 0;
333
    runFrame(t0 + i * 40);
334
  }
335
  const drawnAt = arrow();
336
  // The drawn body is ahead of the server's last word by the input held
337
  // since it was sent — a quarter of a second of running at most, and then
338
  // it waits, because past that a guess would be a claim. Smoothing toward
339
  // a snapshot that cannot arrive would have gone nowhere at all.
340
  const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2];
341
  const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1];
342
  expect(lead).toBeGreaterThan(30);
343
  expect(lead).toBeLessThan(0.25 * 250 + 10);
344
  expect(Math.abs(sideways)).toBeLessThan(6);
345
  // Nothing on the server moved: this is a drawing, and the match is still
346
  // the only thing that says where anybody is.
347
  expect(seat.x).toBe(startedAt.x);
348
  expect(seat.y).toBe(startedAt.y);
349
  arena.startArena();
350
}, 15000);
351
352
test("a snapshot with no seat in it draws the watching view instead of throwing", async () => {
353
  // The shape no other test covers: score, round, clock and feed, and no `me`
354
  // and no players at all. The client used to reach straight into `snap.me`
355
  // on the way in, so a second tab's first frame threw and the page stopped
356
  // drawing. This is the server's own spectator snapshot, handed to the
357
  // bundled client the way the socket hands it over.
358
  const { spectatorSnapshot } = await import("./arena.ts");
359
  const deliver = (why: "address" | "cookie") =>
360
    openedSocket!.onmessage!({ data: spectatorSnapshot(why) } as MessageEvent);
361
362
  drawn.length = 0;
363
  deliver("address");
364
  runFrame();
365
366
  // It says what the match is doing, and why this connection is watching.
367
  expect(String(nodes.get("phase")!.textContent)).toContain("round");
368
  expect(String(nodes.get("hp")!.textContent)).toBe("watching");
369
  expect(String(nodes.get("prompt")!.textContent)).toContain("address");
370
  // Nothing on screen that would need a position to fill in.
371
  expect((nodes.get("kit")!.children as unknown[]).length).toBe(0);
372
  expect((nodes.get("roster")!.children as unknown[]).length).toBe(0);
373
  expect((nodes.get("atkboard")!.children as unknown[]).length).toBe(0);
374
  // And the canvas is words on a panel, not a map: one rectangle, no tiles.
375
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1);
376
  expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3);
377
378
  // The other reason reads differently, and the frame after it still draws.
379
  deliver("cookie");
380
  runFrame();
381
  expect(String(nodes.get("prompt")!.textContent)).toContain("cookie");
382
383
  // This test's loopback address is shared with every other test in the
384
  // run; close the socket so the address is free for the next real
385
  // connection rather than left holding a seat for the rest of the suite.
386
  openedSocket?.close();
387
  await Bun.sleep(50);
388
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts