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

forked from benjivers/val

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

Keyboard shortcuts