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

forked from benjivers/val

workbenchthis machine
Files 2b02293bfc790bea85a76bcf6e00b485eb141679
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
const listeners = new Map<string, ((event: unknown) => void)[]>();
25
const frames: ((now: number) => void)[] = [];
26
27
const noop = () => {};
28
const context = () =>
29
  new Proxy(
30
    {
31
      measureText: (text: string) => ({ width: text.length * 6 }),
32
      createLinearGradient: () => ({ addColorStop: noop }),
33
    } as Record<string, unknown>,
34
    {
35
      get(target, prop: string) {
36
        if (prop in target) return target[prop];
37
        if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return "";
38
        if (prop === "globalAlpha" || prop === "lineWidth") return 1;
39
        return (...args: unknown[]) => drawn.push(`${prop}(${args.length})`);
40
      },
41
      set: () => true,
42
    },
43
  );
44
45
function element(id: string, attrs: Record<string, string> = {}) {
46
  const node: Record<string, unknown> = {
47
    id,
48
    dataset: { ...attrs },
49
    style: {},
50
    textContent: "",
51
    title: "",
52
    disabled: false,
53
    type: "",
54
    children: [] as unknown[],
55
    classList: {
56
      on: new Set<string>(),
57
      add(name: string) {
58
        (this.on as Set<string>).add(name);
59
      },
60
      remove(name: string) {
61
        (this.on as Set<string>).delete(name);
62
      },
63
      toggle(name: string, force?: boolean) {
64
        const on = force ?? !(this.on as Set<string>).has(name);
65
        if (on) (this.on as Set<string>).add(name);
66
        else (this.on as Set<string>).delete(name);
67
        return on;
68
      },
69
      contains(name: string) {
70
        return (this.on as Set<string>).has(name);
71
      },
72
    },
73
    events: new Map<string, ((event: unknown) => void)[]>(),
74
    addEventListener(kind: string, fn: (event: unknown) => void) {
75
      const map = node.events as Map<string, ((event: unknown) => void)[]>;
76
      map.set(kind, [...(map.get(kind) ?? []), fn]);
77
    },
78
    append(...kids: unknown[]) {
79
      (node.children as unknown[]).push(...kids);
80
    },
81
    replaceChildren(...kids: unknown[]) {
82
      node.children = kids;
83
    },
84
    getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 570 }),
85
    getContext: () => context(),
86
    requestPointerLock: noop,
87
    setPointerCapture: noop,
88
    width: 900,
89
    height: 570,
90
  };
91
  return node;
92
}
93
94
const click = (node: Record<string, unknown>) => {
95
  for (const fn of (node.events as Map<string, ((e: unknown) => void)[]>).get("click") ?? []) fn({});
96
};
97
const press = (code: string, key = "") => {
98
  for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop });
99
};
100
101
const nodes = new Map<string, Record<string, unknown>>();
102
for (const id of [
103
  "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle",
104
  "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage",
105
  "log", "atkboard", "defboard", "helpagents", "roster", "feed",
106
])
107
  nodes.set(id, element(id));
108
109
const buys = [
110
  element("b1", { buy: "vandal", cost: "2900" }),
111
  element("b2", { buy: "c", cost: "0" }),
112
  element("b3", { buy: "q", cost: "0" }),
113
];
114
const agentButtons = [element("a1", { agent: "veil" })];
115
const scoreRed = element("red");
116
const scoreBlue = element("blue");
117
118
Object.assign(globalThis, {
119
  document: {
120
    getElementById: (id: string) => nodes.get(id) ?? null,
121
    querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue),
122
    querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons),
123
    createElement: (tag: string) => element(tag),
124
    addEventListener: noop,
125
    pointerLockElement: null,
126
    exitPointerLock: noop,
127
  },
128
  HTMLInputElement: class {},
129
  matchMedia: () => ({ matches: false }),
130
  devicePixelRatio: 1,
131
  localStorage: {
132
    store: new Map<string, string>(),
133
    getItem(key: string) {
134
      return this.store.get(key) ?? null;
135
    },
136
    setItem(key: string, value: string) {
137
      this.store.set(key, value);
138
    },
139
  },
140
  requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn),
141
  addEventListener: (kind: string, fn: (event: unknown) => void) => {
142
    listeners.set(kind, [...(listeners.get(kind) ?? []), fn]);
143
  },
144
  location: new URL(base),
145
});
146
147
// Relative fetches, the way a page makes them.
148
const realFetch = globalThis.fetch;
149
globalThis.fetch = ((input: string, init?: RequestInit) =>
150
  realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch;
151
152
const runFrame = () => {
153
  const pending = frames.splice(0, frames.length);
154
  for (const fn of pending) fn(performance.now());
155
};
156
const until = async (what: () => boolean, ms = 4000) => {
157
  const stop = Date.now() + ms;
158
  while (Date.now() < stop) {
159
    runFrame();
160
    if (what()) return true;
161
    await Bun.sleep(20);
162
  }
163
  return false;
164
};
165
166
// ------------------------------------------------------------------ the run
167
168
test("the bundled client connects, paints the HUD and draws a frame", async () => {
169
  const page = await (await fetch(base + "/")).text();
170
  const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!;
171
  const script = await (await fetch(base + src)).text();
172
  const path = join(process.env.FORGE_APP_DATA!, "client.mjs");
173
  await Bun.write(path, script);
174
175
  await import(path);
176
  expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true);
177
  expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true);
178
179
  // The HUD says what the match says.
180
  expect(String(nodes.get("phase")!.textContent)).toContain("round");
181
  expect(String(nodes.get("creds")!.textContent)).toContain("cr");
182
  // The kit is four buttons, each named after the ability it casts.
183
  expect((nodes.get("kit")!.children as unknown[]).length).toBe(4);
184
  // One prompt, in words, about the round that is running.
185
  expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8);
186
  // And the buy panel prices itself off the agent's real kit.
187
  expect(String(buys[1]!.title)).toContain("charge");
188
189
  // The team is on screen whether anybody is in sight or not: five slots.
190
  const slots = nodes.get("roster")!.children as { className: string }[];
191
  expect(slots.length).toBe(5);
192
  expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1);
193
194
  // Run the match on a few seconds: bots trade, and the feed fills with
195
  // names — and with nothing that says where anybody is.
196
  const arena = await import("./arena.ts");
197
  for (let i = 0; i < 40 && (nodes.get("feed")!.children as unknown[]).length === 0; i++) {
198
    arena.advance(5);
199
    await Bun.sleep(5);
200
  }
201
  const feedRows = nodes.get("feed")!.children as { children: { textContent: string }[] }[];
202
  expect(feedRows.length).toBeGreaterThan(0);
203
  // killer ▸ victim, and the gun or the ability it was: four spans, no numbers.
204
  const row = feedRows[0]!.children.map((span) => span.textContent).join("");
205
  expect(row).toContain(" ▸ ");
206
  expect(row).not.toMatch(/\d/);
207
208
  // A frame of the first-person view actually draws walls: a slice a column,
209
  // far more rectangles than the map has tiles.
210
  drawn.length = 0;
211
  runFrame();
212
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800);
213
214
  // Tab swaps that frame for the whole map — many fewer rectangles.
215
  for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop });
216
  drawn.length = 0;
217
  runFrame();
218
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(800);
219
  for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" });
220
  drawn.length = 0;
221
  runFrame();
222
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800);
223
224
  // Holding W reaches the server as a direction, and it is the way we face.
225
  const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
226
  press("KeyW");
227
  await Bun.sleep(160);
228
  expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9);
229
  expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4);
230
231
  // V falls back to the top-down view, and it draws too.
232
  press("KeyV");
233
  drawn.length = 0;
234
  runFrame();
235
  expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true);
236
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" });
237
  press("KeyV");
238
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" });
239
240
  // H toggles the card either way, and the button closes it for good.
241
  const help = nodes.get("help")!.style as Record<string, string>;
242
  const before = help.display;
243
  press("KeyH");
244
  expect(help.display).not.toBe(before);
245
  if (help.display !== "grid") press("KeyH");
246
  expect(help.display).toBe("grid");
247
  click(nodes.get("helpclose")!);
248
  expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none");
249
  expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1");
250
251
  // B works the buy panel either way — it opens itself in the buy phase.
252
  const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean };
253
  const wasOpen = panel.contains("on");
254
  press("KeyB");
255
  expect(panel.contains("on")).toBe(!wasOpen);
256
257
  // And the socket really took a seat in the running match.
258
  expect(seat.bot).toBe(false);
259
}, 15000);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts