/** * 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[] = []; // Where the canvas was moved to: on the top-down view your own body is an // arrow drawn about the origin, so the last translate of a frame is where // this client thinks you are. const moves: { x: number; y: number }[] = []; 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[]) => { if (prop === "translate") moves.push({ x: Number(args[0]), y: Number(args[1]) }); return 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 release = (code: string) => { for (const fn of listeners.get("keyup") ?? []) fn({ code }); }; 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", "roster", "feed", "perf", ]) 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; // A real browser attaches the session cookie to the websocket handshake on // its own; this stub is not a browser, so it carries the cookie the page's // own fetch was handed, the way one actually would. let sessionCookie = ""; let openedSocket: WebSocket | null = null; const RealWebSocket = globalThis.WebSocket; globalThis.WebSocket = function (url: string) { openedSocket = new RealWebSocket(url, { headers: { cookie: sessionCookie } } as unknown as string[]); return openedSocket; } as unknown as typeof WebSocket; const runFrame = (at = performance.now()) => { const pending = frames.splice(0, frames.length); for (const fn of pending) fn(at); }; 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 first = await fetch(base + "/"); sessionCookie = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; const page = await first.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); // The one socket this module opens is already under way; put the global // back so another test file's raw `new WebSocket(...)` is not silently // handed a cookie this test happened to pick up. globalThis.WebSocket = RealWebSocket; 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"); // The team is on screen whether anybody is in sight or not: five slots. const slots = nodes.get("roster")!.children as { className: string }[]; expect(slots.length).toBe(5); expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1); // Run the match on a few seconds: bots trade, and the feed fills with // names — and with nothing that says where anybody is. const arena = await import("./arena.ts"); for (let i = 0; i < 40 && (nodes.get("feed")!.children as unknown[]).length === 0; i++) { arena.advance(5); await Bun.sleep(5); } const feedRows = nodes.get("feed")!.children as { children: { textContent: string }[] }[]; expect(feedRows.length).toBeGreaterThan(0); // killer ▸ victim, and the gun or the ability it was: four spans, no numbers. const row = feedRows[0]!.children.map((span) => span.textContent).join(""); expect(row).toContain(" ▸ "); expect(row).not.toMatch(/\d/); // A frame of the first-person view draws the world into its own buffer and // blits it, with the minimap's tiles blitted too. It is a picture, but it // is not one fill a screen column any more, nor one a map tile: the map has // 570 tiles and the canvas 900 columns, and the frame used to pay for both. const tally = () => ({ fills: drawn.filter((op) => op.startsWith("fillRect")).length, blits: drawn.filter((op) => op.startsWith("drawImage")).length, }); runFrame(); // whatever was cached at this size is cached before we count drawn.length = 0; runFrame(); const fp = tally(); expect(fp.blits).toBeGreaterThanOrEqual(2); // the world, and the map's tiles expect(fp.fills).toBeGreaterThan(40); expect(fp.fills).toBeLessThan(570); // Tab swaps that frame for the whole map. Its tiles are drawn once into a // canvas of their own and blitted from then on: the first such frame pays // for all 570 of them, and no frame after it pays for any. for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop }); drawn.length = 0; runFrame(); const built = tally(); drawn.length = 0; runFrame(); const top = tally(); expect(built.fills).toBeGreaterThan(400); expect(top.blits).toBe(1); expect(top.fills).toBeLessThan(100); for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" }); drawn.length = 0; runFrame(); expect(tally().blits).toBeGreaterThanOrEqual(2); // 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" }); // Where you are looking is this browser's between snapshots. Turning with // the arrow key, the angle the server is told only ever climbs: the client // used to take `snap.me.angle` back on every snapshot, so the view was // dragged to a stale angle thirty times a second and what it sent stepped // backwards in between. const shortWay = (from: number, to: number) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI; press("ArrowRight"); const angles: number[] = []; for (let i = 0; i < 60; i++) { runFrame(performance.now()); await Bun.sleep(10); angles.push(seat.input.angle); } release("ArrowRight"); const steps = angles.slice(1).map((angle, i) => shortWay(angles[i]!, angle)); expect(Math.min(...steps)).toBeGreaterThan(-1e-9); expect(steps.reduce((sum, step) => sum + step, 0)).toBeGreaterThan(0.2); // 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); // Your own body is drawn from a guess about your own movement, not from // the last thing the server said. With the arena stopped — no snapshot can // arrive at all — the drawn body still carries the keys you are holding // forward, as far as the cap and no further, and stops there. press("KeyV"); // the top-down view, where your own body is an arrow for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1); await Bun.sleep(60); runFrame(); arena.stopArena(); // Walk whichever way is open from where the server last put us. for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code); const { blockedCircle } = await import("./map.ts"); const clear = ([ ["KeyW", 0, -1], ["KeyS", 0, 1], ["KeyA", -1, 0], ["KeyD", 1, 0], ] as const).find(([, dx, dy]) => [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)), )!; expect(clear).toBeDefined(); press(clear[0]); const arrow = () => moves[moves.length - 1]!; const startedAt = { x: seat.x, y: seat.y }; const scale = 900 / (30 * 80); // canvas pixels to a world unit const t0 = performance.now(); for (let i = 1; i <= 10; i++) { moves.length = 0; runFrame(t0 + i * 40); } const drawnAt = arrow(); // The drawn body is ahead of the server's last word by the input held // since it was sent — a quarter of a second of running at most, and then // it waits, because past that a guess would be a claim. Smoothing toward // a snapshot that cannot arrive would have gone nowhere at all. const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2]; const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1]; expect(lead).toBeGreaterThan(30); // A quarter second of THIS seat's movement, not of a walking one: haste // multiplies the speed by 1.35, and a seat that happened to be hasted led // by 84 where a bound written around the base 250 expected 72. The cap is // in time (predict.js LEAD_MAX), so the bound has to be read off the same // speed table the client used, or it measures the match's mood instead of // the rule. const { moveSpeed } = await import("./glide.ts"); const { LEAD_MAX } = await import("../static/predict.js"); expect(lead).toBeLessThan(LEAD_MAX * moveSpeed(seat) + 10); expect(Math.abs(sideways)).toBeLessThan(6); // Nothing on the server moved: this is a drawing, and the match is still // the only thing that says where anybody is. expect(seat.x).toBe(startedAt.x); expect(seat.y).toBe(startedAt.y); arena.startArena(); // Back to the first-person view for the last of it, and a frame of it to // measure: the one before is the one that rebuilds anything cached. press("KeyV"); runFrame(); drawn.length = 0; runFrame(); const onePixel = tally().fills; expect(onePixel).toBeGreaterThan(40); // The frame is not the screen's pixel count. On a retina display the same // window is twice the canvas, and the world is still drawn at a capped // internal width and blitted up: the work does not double with the device // pixel ratio, which is what made this choppy on a retina Mac. (globalThis as { devicePixelRatio: number }).devicePixelRatio = 2; for (const fn of listeners.get("resize") ?? []) fn({}); expect(nodes.get("view")!.width).toBe(1800); runFrame(); // the first frame at a new size redraws the minimap's tiles drawn.length = 0; runFrame(); expect(drawn.some((op) => op.startsWith("drawImage"))).toBe(true); expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(onePixel * 1.3); (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1; for (const fn of listeners.get("resize") ?? []) fn({}); // ` prints the numbers, so "it feels choppy" and "it delivers 30Hz" can be // told apart: the frame rate, the mean frame, the snapshot rate, the // interpolation delay, and the width the world is really drawn at. const perf = nodes.get("perf")!; expect((perf.style as Record).display).not.toBe("block"); press("Backquote"); expect((perf.style as Record).display).toBe("block"); const readout = String(perf.textContent); expect(readout).toContain("fps"); expect(readout).toContain("ms/frame"); expect(readout).toContain("Hz in"); expect(readout).toContain("ms behind"); expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas press("Backquote"); expect((perf.style as Record).display).toBe("none"); // This test's loopback address is shared with every other test in the // run; close the socket so the address is free for the next real // connection rather than left holding a seat for the rest of the suite. openedSocket?.close(); await Bun.sleep(50); }, 15000); test("a snapshot with no seat in it draws the watching view instead of throwing", async () => { // The shape no other test covers: score, round, clock and feed, and no `me` // and no players at all. The client used to reach straight into `snap.me` // on the way in, so a second tab's first frame threw and the page stopped // drawing. This is the server's own spectator snapshot, handed to the // bundled client the way the socket hands it over. const { spectatorSnapshot } = await import("./arena.ts"); const deliver = (why: "address" | "cookie") => openedSocket!.onmessage!({ data: spectatorSnapshot(why) } as MessageEvent); drawn.length = 0; deliver("address"); runFrame(); // It says what the match is doing, and why this connection is watching. expect(String(nodes.get("phase")!.textContent)).toContain("round"); expect(String(nodes.get("hp")!.textContent)).toBe("watching"); expect(String(nodes.get("prompt")!.textContent)).toContain("address"); // Nothing on screen that would need a position to fill in. expect((nodes.get("kit")!.children as unknown[]).length).toBe(0); expect((nodes.get("roster")!.children as unknown[]).length).toBe(0); expect((nodes.get("atkboard")!.children as unknown[]).length).toBe(0); // And the canvas is words on a panel, not a map: one rectangle, no tiles. expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1); expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3); // The other reason reads differently, and the frame after it still draws. deliver("cookie"); runFrame(); expect(String(nodes.get("prompt")!.textContent)).toContain("cookie"); }); test("your own camera is never drawn as a stranger arriving", async () => { // At spawn the buy phase turns prediction off, and the frame used to fall // through to the treatments meant for other people: drawn in the past, and // slid in from where it was last seen. On a respawn that slides the CAMERA // across the room — the shake somebody reported the first time they played // a round from the start. const { spectatorSnapshot } = await import("./arena.ts"); expect(typeof spectatorSnapshot).toBe("function"); const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text(); // The rule is small enough to state: own id short-circuits before both. const look = bundle.slice(bundle.indexOf("const look = (p) =>")); const body = look.slice(0, look.indexOf("};") + 2); const ownReturn = body.indexOf("if (p.id === snap.me.id) return p;"); const slideCall = body.indexOf("slide(presence"); expect(ownReturn).toBeGreaterThan(-1); expect(slideCall).toBeGreaterThan(ownReturn); }); test("the buy phase is predicted, and its pen is the server's own rule", async () => { const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text(); // Prediction is no longer switched off for the whole buy phase: it is on // whenever the browser knows the pen, which it learns from the map it is // already given. Drawing your own body straight from a 30Hz snapshot while // rendering at 60 is a stutter, and that is what the phase used to do. expect(bundle).toContain('snap.phase !== "buy" || pens !== null'); // And the pen is the server's number, not a guess: 120 from a spawn tile. expect(bundle).toContain("best > 120"); // A refused step holds where it was rather than snapping to the server. const predict = bundle.slice(bundle.indexOf("function predict(")); const holds = predict.indexOf("local.x = was.x"); const anchors = predict.indexOf("anchor(local, snap.me)"); expect(holds).toBeGreaterThan(-1); expect(holds).toBeLessThan(anchors); // hold first; anchor only if still stuck });