rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 884b6d52e6dbc7666a34ac021063e9d1241a348a
| 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", "perf", |
| 116 | ]) |
| 117 | nodes.set(id, element(id)); |
| 118 | |
| 119 | const GUNS = ["classic", "ghost", "sheriff", "spectre", "phantom", "vandal", "operator"]; |
| 120 | const buys = [ |
| 121 | ...GUNS.map((gun, i) => element(`g${i}`, { buy: gun, cost: gun === "vandal" ? "2900" : "0" })), |
| 122 | element("b2", { buy: "c", cost: "0" }), |
| 123 | element("b3", { buy: "q", cost: "0" }), |
| 124 | ]; |
| 125 | const agentButtons = [element("a1", { agent: "veil" })]; |
| 126 | const scoreRed = element("red"); |
| 127 | const scoreBlue = element("blue"); |
| 128 | |
| 129 | Object.assign(globalThis, { |
| 130 | document: { |
| 131 | getElementById: (id: string) => nodes.get(id) ?? null, |
| 132 | querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue), |
| 133 | querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons), |
| 134 | createElement: (tag: string) => element(tag), |
| 135 | addEventListener: noop, |
| 136 | pointerLockElement: null, |
| 137 | exitPointerLock: noop, |
| 138 | }, |
| 139 | HTMLInputElement: class {}, |
| 140 | matchMedia: () => ({ matches: false }), |
| 141 | devicePixelRatio: 1, |
| 142 | localStorage: { |
| 143 | store: new Map<string, string>(), |
| 144 | getItem(key: string) { |
| 145 | return this.store.get(key) ?? null; |
| 146 | }, |
| 147 | setItem(key: string, value: string) { |
| 148 | this.store.set(key, value); |
| 149 | }, |
| 150 | }, |
| 151 | requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn), |
| 152 | addEventListener: (kind: string, fn: (event: unknown) => void) => { |
| 153 | listeners.set(kind, [...(listeners.get(kind) ?? []), fn]); |
| 154 | }, |
| 155 | location: new URL(base), |
| 156 | }); |
| 157 | |
| 158 | // Relative fetches, the way a page makes them. |
| 159 | const realFetch = globalThis.fetch; |
| 160 | globalThis.fetch = ((input: string, init?: RequestInit) => |
| 161 | realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch; |
| 162 | |
| 163 | // A real browser attaches the session cookie to the websocket handshake on |
| 164 | // its own; this stub is not a browser, so it carries the cookie the page's |
| 165 | // own fetch was handed, the way one actually would. |
| 166 | let sessionCookie = ""; |
| 167 | let openedSocket: WebSocket | null = null; |
| 168 | const RealWebSocket = globalThis.WebSocket; |
| 169 | globalThis.WebSocket = function (url: string) { |
| 170 | openedSocket = new RealWebSocket(url, { headers: { cookie: sessionCookie } } as unknown as string[]); |
| 171 | return openedSocket; |
| 172 | } as unknown as typeof WebSocket; |
| 173 | |
| 174 | const runFrame = (at = performance.now()) => { |
| 175 | const pending = frames.splice(0, frames.length); |
| 176 | for (const fn of pending) fn(at); |
| 177 | }; |
| 178 | const until = async (what: () => boolean, ms = 4000) => { |
| 179 | const stop = Date.now() + ms; |
| 180 | while (Date.now() < stop) { |
| 181 | runFrame(); |
| 182 | if (what()) return true; |
| 183 | await Bun.sleep(20); |
| 184 | } |
| 185 | return false; |
| 186 | }; |
| 187 | |
| 188 | // ------------------------------------------------------------------ the run |
| 189 | |
| 190 | test("the bundled client connects, paints the HUD and draws a frame", async () => { |
| 191 | // Every test in this run reaches the server from the one loopback address, |
| 192 | // and a playing seat is rationed per address on purpose. A socket another |
| 193 | // file closed a moment ago may not have given its seat back yet, and this |
| 194 | // client would then be handed a spectator view and wait four seconds for a |
| 195 | // health readout that never comes — the coin-flip failure the suite had. |
| 196 | // Start from a clean slate, the way the server's own seat tests do. |
| 197 | const { releaseAddress } = await import("./arena.ts"); |
| 198 | for (const address of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) releaseAddress(address); |
| 199 | const first = await fetch(base + "/"); |
| 200 | sessionCookie = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 201 | const page = await first.text(); |
| 202 | const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!; |
| 203 | const script = await (await fetch(base + src)).text(); |
| 204 | const path = join(process.env.FORGE_APP_DATA!, "client.mjs"); |
| 205 | await Bun.write(path, script); |
| 206 | |
| 207 | await import(path); |
| 208 | expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true); |
| 209 | // The one socket this module opens is already under way; put the global |
| 210 | // back so another test file's raw `new WebSocket(...)` is not silently |
| 211 | // handed a cookie this test happened to pick up. |
| 212 | globalThis.WebSocket = RealWebSocket; |
| 213 | expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true); |
| 214 | |
| 215 | // The HUD says what the match says. |
| 216 | expect(String(nodes.get("phase")!.textContent)).toContain("round"); |
| 217 | expect(String(nodes.get("creds")!.textContent)).toContain("cr"); |
| 218 | // The kit is four buttons, each named after the ability it casts. |
| 219 | expect((nodes.get("kit")!.children as unknown[]).length).toBe(4); |
| 220 | // One prompt, in words, about the round that is running. |
| 221 | expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8); |
| 222 | // And the buy panel prices itself off the agent's real kit. |
| 223 | expect(String(buys.find((b) => (b.dataset as { buy: string }).buy === "c")!.title)).toContain("charge"); |
| 224 | // The gun you hold is the one gun button marked as yours. |
| 225 | const held = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; |
| 226 | const ownedGuns = buys.filter( |
| 227 | (b) => GUNS.includes((b.dataset as { buy: string }).buy) && (b.classList as { contains: (n: string) => boolean }).contains("owned"), |
| 228 | ); |
| 229 | expect(ownedGuns.map((b) => (b.dataset as { buy: string }).buy)).toEqual([held.weapon]); |
| 230 | |
| 231 | // The team is on screen whether anybody is in sight or not: five slots. |
| 232 | const slots = nodes.get("roster")!.children as { className: string }[]; |
| 233 | expect(slots.length).toBe(5); |
| 234 | expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1); |
| 235 | |
| 236 | // Run the match on a few seconds: bots trade, and the feed fills with |
| 237 | // names — and with nothing that says where anybody is. |
| 238 | const arena = await import("./arena.ts"); |
| 239 | for (let i = 0; i < 40 && (nodes.get("feed")!.children as unknown[]).length === 0; i++) { |
| 240 | arena.advance(5); |
| 241 | await Bun.sleep(5); |
| 242 | } |
| 243 | const feedRows = nodes.get("feed")!.children as { children: { textContent: string }[] }[]; |
| 244 | expect(feedRows.length).toBeGreaterThan(0); |
| 245 | // killer ▸ victim, and the gun or the ability it was: four spans, no numbers. |
| 246 | const row = feedRows[0]!.children.map((span) => span.textContent).join(""); |
| 247 | expect(row).toContain(" ▸ "); |
| 248 | expect(row).not.toMatch(/\d/); |
| 249 | |
| 250 | // A frame of the first-person view draws the world into its own buffer and |
| 251 | // blits it, with the minimap's tiles blitted too. It is a picture, but it |
| 252 | // is not one fill a screen column any more, nor one a map tile: the map has |
| 253 | // 570 tiles and the canvas 900 columns, and the frame used to pay for both. |
| 254 | // The minimap's tiles are the blit; the walls are a run a fill, and how |
| 255 | // many runs a view has depends on where the seat happens to be standing — |
| 256 | // a long corridor is many depths — so the bound is the columns, which no |
| 257 | // view can reach, rather than the tile count, which a corridor can. |
| 258 | const tally = () => ({ |
| 259 | fills: drawn.filter((op) => op.startsWith("fillRect")).length, |
| 260 | blits: drawn.filter((op) => op.startsWith("drawImage")).length, |
| 261 | }); |
| 262 | runFrame(); // whatever was cached at this size is cached before we count |
| 263 | drawn.length = 0; |
| 264 | runFrame(); |
| 265 | const fp = tally(); |
| 266 | expect(fp.blits).toBeGreaterThanOrEqual(2); // the world, and the map's tiles |
| 267 | expect(fp.fills).toBeGreaterThan(40); |
| 268 | expect(fp.fills).toBeLessThan(900); |
| 269 | |
| 270 | // Tab swaps that frame for the whole map. Its tiles are drawn once into a |
| 271 | // canvas of their own and blitted from then on: the first such frame pays |
| 272 | // for all 570 of them, and no frame after it pays for any. |
| 273 | for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop }); |
| 274 | drawn.length = 0; |
| 275 | runFrame(); |
| 276 | const built = tally(); |
| 277 | drawn.length = 0; |
| 278 | runFrame(); |
| 279 | const top = tally(); |
| 280 | expect(built.fills).toBeGreaterThan(400); |
| 281 | expect(top.blits).toBe(1); |
| 282 | expect(top.fills).toBeLessThan(100); |
| 283 | for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" }); |
| 284 | drawn.length = 0; |
| 285 | runFrame(); |
| 286 | expect(tally().blits).toBeGreaterThanOrEqual(2); |
| 287 | // Holding W reaches the server as a direction, and it is the way we face. |
| 288 | const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; |
| 289 | press("KeyW"); |
| 290 | await Bun.sleep(160); |
| 291 | expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9); |
| 292 | expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4); |
| 293 | |
| 294 | // V falls back to the top-down view, and it draws too. |
| 295 | press("KeyV"); |
| 296 | drawn.length = 0; |
| 297 | runFrame(); |
| 298 | expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true); |
| 299 | expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" }); |
| 300 | press("KeyV"); |
| 301 | expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" }); |
| 302 | |
| 303 | // Where you are looking is this browser's between snapshots. Turning with |
| 304 | // the arrow key, the angle the server is told only ever climbs: the client |
| 305 | // used to take `snap.me.angle` back on every snapshot, so the view was |
| 306 | // dragged to a stale angle thirty times a second and what it sent stepped |
| 307 | // backwards in between. |
| 308 | const shortWay = (from: number, to: number) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI; |
| 309 | press("ArrowRight"); |
| 310 | const angles: number[] = []; |
| 311 | for (let i = 0; i < 60; i++) { |
| 312 | runFrame(performance.now()); |
| 313 | await Bun.sleep(10); |
| 314 | angles.push(seat.input.angle); |
| 315 | } |
| 316 | release("ArrowRight"); |
| 317 | const steps = angles.slice(1).map((angle, i) => shortWay(angles[i]!, angle)); |
| 318 | expect(Math.min(...steps)).toBeGreaterThan(-1e-9); |
| 319 | expect(steps.reduce((sum, step) => sum + step, 0)).toBeGreaterThan(0.2); |
| 320 | |
| 321 | // H toggles the card either way, and the button closes it for good. |
| 322 | const help = nodes.get("help")!.style as Record<string, string>; |
| 323 | const before = help.display; |
| 324 | press("KeyH"); |
| 325 | expect(help.display).not.toBe(before); |
| 326 | if (help.display !== "grid") press("KeyH"); |
| 327 | expect(help.display).toBe("grid"); |
| 328 | click(nodes.get("helpclose")!); |
| 329 | expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none"); |
| 330 | expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1"); |
| 331 | |
| 332 | // B works the buy panel either way — it opens itself in the buy phase. |
| 333 | const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean }; |
| 334 | const wasOpen = panel.contains("on"); |
| 335 | press("KeyB"); |
| 336 | expect(panel.contains("on")).toBe(!wasOpen); |
| 337 | |
| 338 | // And the socket really took a seat in the running match. |
| 339 | expect(seat.bot).toBe(false); |
| 340 | |
| 341 | // Your own body is drawn from a guess about your own movement, not from |
| 342 | // the last thing the server said. With the arena stopped — no snapshot can |
| 343 | // arrive at all — the drawn body still carries the keys you are holding |
| 344 | // forward, as far as the cap and no further, and stops there. |
| 345 | press("KeyV"); // the top-down view, where your own body is an arrow |
| 346 | // The measurement needs a live round and a seat that can walk. The arena |
| 347 | // has been run through whole rounds above with this seat standing still, |
| 348 | // so by now it may be dead, mid-plant, slowed, or stood inside a barrier a |
| 349 | // bot put up — and a guess about a body that cannot move is rightly nothing. |
| 350 | // Put it on its feet, clear what abilities left, and let a snapshot carry |
| 351 | // that to the client before the clock is stopped; try again if a round |
| 352 | // ends or a bot lands a kill in that moment. |
| 353 | for (let tries = 0; tries < 10; tries++) { |
| 354 | for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1); |
| 355 | Object.assign(seat, { alive: true, hp: 100, planting: 0, defusing: 0, haste: 0, slow: 0 }); |
| 356 | arena.match().zones.length = 0; |
| 357 | arena.advance(1 / 30); |
| 358 | await Bun.sleep(80); |
| 359 | runFrame(); |
| 360 | arena.stopArena(); |
| 361 | if (arena.match().phase === "live" && seat.alive && arena.match().zones.length === 0) break; |
| 362 | arena.startArena(); |
| 363 | } |
| 364 | expect(arena.match().phase).toBe("live"); |
| 365 | expect(seat.alive).toBe(true); |
| 366 | // Walk whichever way is open from where the server last put us. |
| 367 | for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code); |
| 368 | const { blockedCircle } = await import("./map.ts"); |
| 369 | const clear = ([ |
| 370 | ["KeyW", 0, -1], |
| 371 | ["KeyS", 0, 1], |
| 372 | ["KeyA", -1, 0], |
| 373 | ["KeyD", 1, 0], |
| 374 | ] as const).find(([, dx, dy]) => |
| 375 | [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)), |
| 376 | )!; |
| 377 | expect(clear).toBeDefined(); |
| 378 | press(clear[0]); |
| 379 | const arrow = () => moves[moves.length - 1]!; |
| 380 | const startedAt = { x: seat.x, y: seat.y }; |
| 381 | const scale = 900 / (30 * 80); // canvas pixels to a world unit |
| 382 | const t0 = performance.now(); |
| 383 | for (let i = 1; i <= 10; i++) { |
| 384 | moves.length = 0; |
| 385 | runFrame(t0 + i * 40); |
| 386 | } |
| 387 | const drawnAt = arrow(); |
| 388 | // The drawn body is ahead of the server's last word by the input held |
| 389 | // since it was sent — a quarter of a second of running at most, and then |
| 390 | // it waits, because past that a guess would be a claim. Smoothing toward |
| 391 | // a snapshot that cannot arrive would have gone nowhere at all. |
| 392 | const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2]; |
| 393 | const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1]; |
| 394 | expect(lead).toBeGreaterThan(30); |
| 395 | // A quarter second of THIS seat's movement, not of a walking one: haste |
| 396 | // multiplies the speed by 1.35, and a seat that happened to be hasted led |
| 397 | // by 84 where a bound written around the base 250 expected 72. The cap is |
| 398 | // in time (predict.js LEAD_MAX), so the bound has to be read off the same |
| 399 | // speed table the client used, or it measures the match's mood instead of |
| 400 | // the rule. |
| 401 | // The lead is the cap plus the slack the guess is allowed over the answer |
| 402 | // (predict.js LEAD_SLACK: the lag your own keys take to reach the server). |
| 403 | const { moveSpeed } = await import("./engine/glide.ts"); |
| 404 | const { LEAD_MAX, LEAD_SLACK } = await import("../static/engine/predict.js"); |
| 405 | expect(lead).toBeLessThan((LEAD_MAX + LEAD_SLACK) * moveSpeed(seat) + 10); |
| 406 | expect(Math.abs(sideways)).toBeLessThan(6); |
| 407 | // Nothing on the server moved: this is a drawing, and the match is still |
| 408 | // the only thing that says where anybody is. |
| 409 | expect(seat.x).toBe(startedAt.x); |
| 410 | expect(seat.y).toBe(startedAt.y); |
| 411 | arena.startArena(); |
| 412 | |
| 413 | // Back to the first-person view for the last of it, and a frame of it to |
| 414 | // measure: the one before is the one that rebuilds anything cached. |
| 415 | press("KeyV"); |
| 416 | runFrame(); |
| 417 | drawn.length = 0; |
| 418 | runFrame(); |
| 419 | const onePixel = tally().fills; |
| 420 | expect(onePixel).toBeGreaterThan(40); |
| 421 | |
| 422 | // The frame is not the screen's pixel count. On a retina display the same |
| 423 | // window is twice the canvas, and the world is still drawn at a capped |
| 424 | // internal width and blitted up: the work does not double with the device |
| 425 | // pixel ratio, which is what made this choppy on a retina Mac. |
| 426 | (globalThis as { devicePixelRatio: number }).devicePixelRatio = 2; |
| 427 | for (const fn of listeners.get("resize") ?? []) fn({}); |
| 428 | expect(nodes.get("view")!.width).toBe(1800); |
| 429 | runFrame(); // the first frame at a new size redraws the minimap's tiles |
| 430 | drawn.length = 0; |
| 431 | runFrame(); |
| 432 | expect(drawn.some((op) => op.startsWith("drawImage"))).toBe(true); |
| 433 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(onePixel * 1.3); |
| 434 | (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1; |
| 435 | for (const fn of listeners.get("resize") ?? []) fn({}); |
| 436 | |
| 437 | // ` prints the numbers, so "it feels choppy" and "it delivers 30Hz" can be |
| 438 | // told apart: the frame rate, the mean frame, the snapshot rate, the |
| 439 | // interpolation delay, and the width the world is really drawn at. |
| 440 | const perf = nodes.get("perf")!; |
| 441 | expect((perf.style as Record<string, string>).display).not.toBe("block"); |
| 442 | press("Backquote"); |
| 443 | expect((perf.style as Record<string, string>).display).toBe("block"); |
| 444 | const readout = String(perf.textContent); |
| 445 | expect(readout).toContain("fps"); |
| 446 | expect(readout).toContain("ms/frame"); |
| 447 | expect(readout).toContain("Hz in"); |
| 448 | expect(readout).toContain("ms behind"); |
| 449 | expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas |
| 450 | // And the count of corrections taken outright, per minute: a stutter is a |
| 451 | // jump, and this is how many there have been. |
| 452 | expect(readout).toMatch(/\d+ jumps \([\d.]+\/min\)/); |
| 453 | press("Backquote"); |
| 454 | expect((perf.style as Record<string, string>).display).toBe("none"); |
| 455 | |
| 456 | // This test's loopback address is shared with every other test in the |
| 457 | // run; close the socket so the address is free for the next real |
| 458 | // connection rather than left holding a seat for the rest of the suite. |
| 459 | openedSocket?.close(); |
| 460 | await Bun.sleep(50); |
| 461 | }, 15000); |
| 462 | |
| 463 | test("a snapshot with no seat in it draws the watching view instead of throwing", async () => { |
| 464 | // The shape no other test covers: score, round, clock and feed, and no `me` |
| 465 | // and no players at all. The client used to reach straight into `snap.me` |
| 466 | // on the way in, so a second tab's first frame threw and the page stopped |
| 467 | // drawing. This is the server's own spectator snapshot, handed to the |
| 468 | // bundled client the way the socket hands it over. |
| 469 | const { spectatorSnapshot } = await import("./arena.ts"); |
| 470 | const deliver = (why: "address" | "cookie") => |
| 471 | openedSocket!.onmessage!({ data: spectatorSnapshot(why) } as MessageEvent); |
| 472 | |
| 473 | drawn.length = 0; |
| 474 | deliver("address"); |
| 475 | runFrame(); |
| 476 | |
| 477 | // It says what the match is doing, and why this connection is watching. |
| 478 | expect(String(nodes.get("phase")!.textContent)).toContain("round"); |
| 479 | expect(String(nodes.get("hp")!.textContent)).toBe("watching"); |
| 480 | expect(String(nodes.get("prompt")!.textContent)).toContain("address"); |
| 481 | // Nothing on screen that would need a position to fill in. |
| 482 | expect((nodes.get("kit")!.children as unknown[]).length).toBe(0); |
| 483 | expect((nodes.get("roster")!.children as unknown[]).length).toBe(0); |
| 484 | expect((nodes.get("atkboard")!.children as unknown[]).length).toBe(0); |
| 485 | // And the canvas is words on a panel, not a map: one rectangle, no tiles. |
| 486 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1); |
| 487 | expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3); |
| 488 | |
| 489 | // The other reason reads differently, and the frame after it still draws. |
| 490 | deliver("cookie"); |
| 491 | runFrame(); |
| 492 | expect(String(nodes.get("prompt")!.textContent)).toContain("cookie"); |
| 493 | }); |
| 494 | |
| 495 | test("your own camera is never drawn as a stranger arriving", async () => { |
| 496 | // At spawn the buy phase turns prediction off, and the frame used to fall |
| 497 | // through to the treatments meant for other people: drawn in the past, and |
| 498 | // slid in from where it was last seen. On a respawn that slides the CAMERA |
| 499 | // across the room — the shake somebody reported the first time they played |
| 500 | // a round from the start. |
| 501 | const { spectatorSnapshot } = await import("./arena.ts"); |
| 502 | expect(typeof spectatorSnapshot).toBe("function"); |
| 503 | const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text(); |
| 504 | // The rule is small enough to state: own id short-circuits before both. |
| 505 | const look = bundle.slice(bundle.indexOf("const look = (p) =>")); |
| 506 | const body = look.slice(0, look.indexOf("};") + 2); |
| 507 | const ownReturn = body.indexOf("if (p.id === snap.me.id) return p;"); |
| 508 | const slideCall = body.indexOf("slide(presence"); |
| 509 | expect(ownReturn).toBeGreaterThan(-1); |
| 510 | expect(slideCall).toBeGreaterThan(ownReturn); |
| 511 | }); |
| 512 | |
| 513 | test("the buy phase is predicted, and its pen is the server's own rule", async () => { |
| 514 | const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text(); |
| 515 | // Prediction is no longer switched off for the whole buy phase: it is on |
| 516 | // whenever the browser knows the pen, which it learns from the map it is |
| 517 | // already given. Drawing your own body straight from a 30Hz snapshot while |
| 518 | // rendering at 60 is a stutter, and that is what the phase used to do. |
| 519 | expect(bundle).toContain('snap.phase !== "buy" || pens !== null'); |
| 520 | // And the pen is the server's number, not a guess: 120 from a spawn tile. |
| 521 | expect(bundle).toContain("best > 120"); |
| 522 | // A refused step holds where it was rather than snapping to the server: |
| 523 | // the pen is handed to the one step function as the place it is refused. |
| 524 | expect(bundle).toContain("stepLocal(local, snap.me, held, speed"); |
| 525 | expect(bundle).toContain("world, refused)"); |
| 526 | expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)"); |
| 527 | }); |