/** * The client, played. * * The bundle the browser is served is loaded here against a stub document * and a canvas that only writes down what it was told to draw, and pointed * at the running server over a real socket. It is not a browser, but it is * the whole path a person takes: connect, take a snapshot, paint the HUD, * draw a frame, press a key, send an input back. */ import { expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); process.env.PORT ??= "0"; const { server } = await import("./server.ts"); const base = `http://${server.hostname}:${server.port}`; // ------------------------------------------------------------ the stub DOM const drawn: string[] = []; const listeners = new Map void)[]>(); const frames: ((now: number) => void)[] = []; const noop = () => {}; const context = () => new Proxy( { measureText: (text: string) => ({ width: text.length * 6 }), createLinearGradient: () => ({ addColorStop: noop }), } as Record, { get(target, prop: string) { if (prop in target) return target[prop]; if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return ""; if (prop === "globalAlpha" || prop === "lineWidth") return 1; return (...args: unknown[]) => drawn.push(`${prop}(${args.length})`); }, set: () => true, }, ); function element(id: string, attrs: Record = {}) { const node: Record = { id, dataset: { ...attrs }, style: {}, textContent: "", title: "", disabled: false, type: "", children: [] as unknown[], classList: { on: new Set(), add(name: string) { (this.on as Set).add(name); }, remove(name: string) { (this.on as Set).delete(name); }, toggle(name: string, force?: boolean) { const on = force ?? !(this.on as Set).has(name); if (on) (this.on as Set).add(name); else (this.on as Set).delete(name); return on; }, contains(name: string) { return (this.on as Set).has(name); }, }, events: new Map void)[]>(), addEventListener(kind: string, fn: (event: unknown) => void) { const map = node.events as Map void)[]>; map.set(kind, [...(map.get(kind) ?? []), fn]); }, append(...kids: unknown[]) { (node.children as unknown[]).push(...kids); }, replaceChildren(...kids: unknown[]) { node.children = kids; }, getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 570 }), getContext: () => context(), requestPointerLock: noop, setPointerCapture: noop, width: 900, height: 570, }; return node; } const click = (node: Record) => { for (const fn of (node.events as Map void)[]>).get("click") ?? []) fn({}); }; const press = (code: string, key = "") => { for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop }); }; const nodes = new Map>(); for (const id of [ "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle", "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage", "log", "atkboard", "defboard", "helpagents", ]) nodes.set(id, element(id)); const buys = [ element("b1", { buy: "vandal", cost: "2900" }), element("b2", { buy: "c", cost: "0" }), element("b3", { buy: "q", cost: "0" }), ]; const agentButtons = [element("a1", { agent: "veil" })]; const scoreRed = element("red"); const scoreBlue = element("blue"); Object.assign(globalThis, { document: { getElementById: (id: string) => nodes.get(id) ?? null, querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue), querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons), createElement: (tag: string) => element(tag), addEventListener: noop, pointerLockElement: null, exitPointerLock: noop, }, HTMLInputElement: class {}, matchMedia: () => ({ matches: false }), devicePixelRatio: 1, localStorage: { store: new Map(), getItem(key: string) { return this.store.get(key) ?? null; }, setItem(key: string, value: string) { this.store.set(key, value); }, }, requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn), addEventListener: (kind: string, fn: (event: unknown) => void) => { listeners.set(kind, [...(listeners.get(kind) ?? []), fn]); }, location: new URL(base), }); // Relative fetches, the way a page makes them. const realFetch = globalThis.fetch; globalThis.fetch = ((input: string, init?: RequestInit) => realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch; const runFrame = () => { const pending = frames.splice(0, frames.length); for (const fn of pending) fn(performance.now()); }; const until = async (what: () => boolean, ms = 4000) => { const stop = Date.now() + ms; while (Date.now() < stop) { runFrame(); if (what()) return true; await Bun.sleep(20); } return false; }; // ------------------------------------------------------------------ the run test("the bundled client connects, paints the HUD and draws a frame", async () => { const page = await (await fetch(base + "/")).text(); const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!; const script = await (await fetch(base + src)).text(); const path = join(process.env.FORGE_APP_DATA!, "client.mjs"); await Bun.write(path, script); await import(path); expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true); expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true); // The HUD says what the match says. expect(String(nodes.get("phase")!.textContent)).toContain("round"); expect(String(nodes.get("creds")!.textContent)).toContain("cr"); // The kit is four buttons, each named after the ability it casts. expect((nodes.get("kit")!.children as unknown[]).length).toBe(4); // One prompt, in words, about the round that is running. expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8); // And the buy panel prices itself off the agent's real kit. expect(String(buys[1]!.title)).toContain("charge"); // A frame of the first-person view actually draws walls: a slice a column, // far more rectangles than the map has tiles. drawn.length = 0; runFrame(); expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800); // Tab swaps that frame for the whole map — many fewer rectangles. for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop }); drawn.length = 0; runFrame(); expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(800); for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" }); drawn.length = 0; runFrame(); expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800); // Holding W reaches the server as a direction, and it is the way we face. const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; press("KeyW"); await Bun.sleep(160); expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9); expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4); // V falls back to the top-down view, and it draws too. press("KeyV"); drawn.length = 0; runFrame(); expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true); expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" }); press("KeyV"); expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" }); // H toggles the card either way, and the button closes it for good. const help = nodes.get("help")!.style as Record; const before = help.display; press("KeyH"); expect(help.display).not.toBe(before); if (help.display !== "grid") press("KeyH"); expect(help.display).toBe("grid"); click(nodes.get("helpclose")!); expect((nodes.get("help")!.style as Record).display).toBe("none"); expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1"); // B works the buy panel either way — it opens itself in the buy phase. const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean }; const wasOpen = panel.contains("on"); press("KeyB"); expect(panel.contains("on")).toBe(!wasOpen); // And the socket really took a seat in the running match. expect(seat.bot).toBe(false); }, 15000);