fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 175dd79b69cf6138da9f8452299c4973735c3119
| 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 | // A frame of the first-person view actually draws walls: a slice a column, |
| 195 | // far more rectangles than the map has tiles. |
| 196 | drawn.length = 0; |
| 197 | runFrame(); |
| 198 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800); |
| 199 | |
| 200 | // Tab swaps that frame for the whole map — many fewer rectangles. |
| 201 | for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop }); |
| 202 | drawn.length = 0; |
| 203 | runFrame(); |
| 204 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(800); |
| 205 | for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" }); |
| 206 | drawn.length = 0; |
| 207 | runFrame(); |
| 208 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800); |
| 209 | |
| 210 | // Holding W reaches the server as a direction, and it is the way we face. |
| 211 | const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; |
| 212 | press("KeyW"); |
| 213 | await Bun.sleep(160); |
| 214 | expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9); |
| 215 | expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4); |
| 216 | |
| 217 | // V falls back to the top-down view, and it draws too. |
| 218 | press("KeyV"); |
| 219 | drawn.length = 0; |
| 220 | runFrame(); |
| 221 | expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true); |
| 222 | expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" }); |
| 223 | press("KeyV"); |
| 224 | expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" }); |
| 225 | |
| 226 | // H toggles the card either way, and the button closes it for good. |
| 227 | const help = nodes.get("help")!.style as Record<string, string>; |
| 228 | const before = help.display; |
| 229 | press("KeyH"); |
| 230 | expect(help.display).not.toBe(before); |
| 231 | if (help.display !== "grid") press("KeyH"); |
| 232 | expect(help.display).toBe("grid"); |
| 233 | click(nodes.get("helpclose")!); |
| 234 | expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none"); |
| 235 | expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1"); |
| 236 | |
| 237 | // B works the buy panel either way — it opens itself in the buy phase. |
| 238 | const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean }; |
| 239 | const wasOpen = panel.contains("on"); |
| 240 | press("KeyB"); |
| 241 | expect(panel.contains("on")).toBe(!wasOpen); |
| 242 | |
| 243 | // And the socket really took a seat in the running match. |
| 244 | expect(seat.bot).toBe(false); |
| 245 | }, 15000); |