quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 31494a25fdd48724e276e99986bb4f49dec5802d
| 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 | |
| 13 | import { mkdtempSync } from "node:fs"; |
| 14 | import { tmpdir } from "node:os"; |
| 15 | import { join } from "node:path"; |
| 16 | |
| 17 | process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); |
| 18 | process.env.PORT ??= "0"; |
| 19 | const { server } = await import("./server.ts"); |
| 20 | const base = `http://${server.hostname}:${server.port}`; |
| 21 | |
| 22 | // ------------------------------------------------------------ the stub DOM |
| 23 | |
| 24 | const drawn: string[] = []; |
| 25 | /** |
| 26 | * Every canvas the client asked the document for. The widest is its offscreen |
| 27 | * world buffer, whose width is however many columns the frame budget settled |
| 28 | * on; the other is the minimap's tile layer. Keeping them is what lets a test |
| 29 | * bound the frame's cost as a fraction of the columns actually used, instead |
| 30 | * of against a number somebody picked once. |
| 31 | */ |
| 32 | const canvases: Record<string, unknown>[] = []; |
| 33 | const worldColumns = (): number => Math.max(0, ...canvases.map((c) => Number(c.width) || 0)); |
| 34 | // Where the canvas was moved to: on the top-down view your own body is an |
| 35 | // arrow drawn about the origin, so the last translate of a frame is where |
| 36 | // this client thinks you are. |
| 37 | const moves: { x: number; y: number }[] = []; |
| 38 | const listeners = new Map<string, ((event: unknown) => void)[]>(); |
| 39 | const frames: ((now: number) => void)[] = []; |
| 40 | |
| 41 | const noop = () => {}; |
| 42 | const context = () => |
| 43 | new Proxy( |
| 44 | { |
| 45 | measureText: (text: string) => ({ width: text.length * 6 }), |
| 46 | createLinearGradient: () => ({ addColorStop: noop }), |
| 47 | } as Record<string, unknown>, |
| 48 | { |
| 49 | get(target, prop: string) { |
| 50 | if (prop in target) return target[prop]; |
| 51 | if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return ""; |
| 52 | if (prop === "globalAlpha" || prop === "lineWidth") return 1; |
| 53 | return (...args: unknown[]) => { |
| 54 | if (prop === "translate") moves.push({ x: Number(args[0]), y: Number(args[1]) }); |
| 55 | return drawn.push(`${prop}(${args.length})`); |
| 56 | }; |
| 57 | }, |
| 58 | set: () => true, |
| 59 | }, |
| 60 | ); |
| 61 | |
| 62 | function element(id: string, attrs: Record<string, string> = {}) { |
| 63 | const node: Record<string, unknown> = { |
| 64 | id, |
| 65 | dataset: { ...attrs }, |
| 66 | style: {}, |
| 67 | textContent: "", |
| 68 | title: "", |
| 69 | disabled: false, |
| 70 | type: "", |
| 71 | children: [] as unknown[], |
| 72 | classList: { |
| 73 | on: new Set<string>(), |
| 74 | add(name: string) { |
| 75 | (this.on as Set<string>).add(name); |
| 76 | }, |
| 77 | remove(name: string) { |
| 78 | (this.on as Set<string>).delete(name); |
| 79 | }, |
| 80 | toggle(name: string, force?: boolean) { |
| 81 | const on = force ?? !(this.on as Set<string>).has(name); |
| 82 | if (on) (this.on as Set<string>).add(name); |
| 83 | else (this.on as Set<string>).delete(name); |
| 84 | return on; |
| 85 | }, |
| 86 | contains(name: string) { |
| 87 | return (this.on as Set<string>).has(name); |
| 88 | }, |
| 89 | }, |
| 90 | events: new Map<string, ((event: unknown) => void)[]>(), |
| 91 | addEventListener(kind: string, fn: (event: unknown) => void) { |
| 92 | const map = node.events as Map<string, ((event: unknown) => void)[]>; |
| 93 | map.set(kind, [...(map.get(kind) ?? []), fn]); |
| 94 | }, |
| 95 | append(...kids: unknown[]) { |
| 96 | (node.children as unknown[]).push(...kids); |
| 97 | }, |
| 98 | replaceChildren(...kids: unknown[]) { |
| 99 | node.children = kids; |
| 100 | }, |
| 101 | getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 570 }), |
| 102 | getContext: () => context(), |
| 103 | requestPointerLock: noop, |
| 104 | setPointerCapture: noop, |
| 105 | width: 900, |
| 106 | height: 570, |
| 107 | }; |
| 108 | return node; |
| 109 | } |
| 110 | |
| 111 | const click = (node: Record<string, unknown>) => { |
| 112 | for (const fn of (node.events as Map<string, ((e: unknown) => void)[]>).get("click") ?? []) fn({}); |
| 113 | }; |
| 114 | const press = (code: string, key = "") => { |
| 115 | for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop }); |
| 116 | }; |
| 117 | const release = (code: string) => { |
| 118 | for (const fn of listeners.get("keyup") ?? []) fn({ code }); |
| 119 | }; |
| 120 | |
| 121 | const nodes = new Map<string, Record<string, unknown>>(); |
| 122 | for (const id of [ |
| 123 | "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle", |
| 124 | "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage", |
| 125 | "log", "atkboard", "defboard", "helpagents", "roster", "feed", "perf", |
| 126 | ]) |
| 127 | nodes.set(id, element(id)); |
| 128 | |
| 129 | const GUNS = ["classic", "ghost", "sheriff", "spectre", "phantom", "vandal", "operator"]; |
| 130 | const buys = [ |
| 131 | ...GUNS.map((gun, i) => element(`g${i}`, { buy: gun, cost: gun === "vandal" ? "2900" : "0" })), |
| 132 | element("b2", { buy: "c", cost: "0" }), |
| 133 | element("b3", { buy: "q", cost: "0" }), |
| 134 | ]; |
| 135 | const agentButtons = [element("a1", { agent: "veil" })]; |
| 136 | const scoreRed = element("red"); |
| 137 | const scoreBlue = element("blue"); |
| 138 | |
| 139 | Object.assign(globalThis, { |
| 140 | document: { |
| 141 | getElementById: (id: string) => nodes.get(id) ?? null, |
| 142 | querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue), |
| 143 | querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons), |
| 144 | createElement: (tag: string) => { |
| 145 | const node = element(tag); |
| 146 | if (tag === "canvas") canvases.push(node); |
| 147 | return node; |
| 148 | }, |
| 149 | addEventListener: noop, |
| 150 | pointerLockElement: null, |
| 151 | exitPointerLock: noop, |
| 152 | }, |
| 153 | HTMLInputElement: class {}, |
| 154 | matchMedia: () => ({ matches: false }), |
| 155 | devicePixelRatio: 1, |
| 156 | localStorage: { |
| 157 | store: new Map<string, string>(), |
| 158 | getItem(key: string) { |
| 159 | return this.store.get(key) ?? null; |
| 160 | }, |
| 161 | setItem(key: string, value: string) { |
| 162 | this.store.set(key, value); |
| 163 | }, |
| 164 | }, |
| 165 | requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn), |
| 166 | addEventListener: (kind: string, fn: (event: unknown) => void) => { |
| 167 | listeners.set(kind, [...(listeners.get(kind) ?? []), fn]); |
| 168 | }, |
| 169 | location: new URL(base), |
| 170 | }); |
| 171 | |
| 172 | // Relative fetches, the way a page makes them. |
| 173 | const realFetch = globalThis.fetch; |
| 174 | globalThis.fetch = ((input: string, init?: RequestInit) => |
| 175 | realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch; |
| 176 | |
| 177 | // A real browser attaches the session cookie to the websocket handshake on |
| 178 | // its own; this stub is not a browser, so it carries the cookie the page's |
| 179 | // own fetch was handed, the way one actually would. |
| 180 | let sessionCookie = ""; |
| 181 | let openedSocket: WebSocket | null = null; |
| 182 | const RealWebSocket = globalThis.WebSocket; |
| 183 | globalThis.WebSocket = function (url: string) { |
| 184 | openedSocket = new RealWebSocket(url, { headers: { cookie: sessionCookie } } as unknown as string[]); |
| 185 | return openedSocket; |
| 186 | } as unknown as typeof WebSocket; |
| 187 | |
| 188 | const runFrame = (at = performance.now()) => { |
| 189 | const pending = frames.splice(0, frames.length); |
| 190 | for (const fn of pending) fn(at); |
| 191 | }; |
| 192 | const until = async (what: () => boolean, ms = 4000) => { |
| 193 | const stop = Date.now() + ms; |
| 194 | while (Date.now() < stop) { |
| 195 | runFrame(); |
| 196 | if (what()) return true; |
| 197 | await Bun.sleep(20); |
| 198 | } |
| 199 | return false; |
| 200 | }; |
| 201 | |
| 202 | // ------------------------------------------------------------------ the run |
| 203 | |
| 204 | test("the bundled client connects, paints the HUD and draws a frame", async () => { |
| 205 | // Every test in this run reaches the server from the one loopback address, |
| 206 | // and a playing seat is rationed per address on purpose. A socket another |
| 207 | // file closed a moment ago may not have given its seat back yet, and this |
| 208 | // client would then be handed a spectator view and wait four seconds for a |
| 209 | // health readout that never comes — the coin-flip failure the suite had. |
| 210 | // Start from a clean slate, the way the server's own seat tests do. |
| 211 | const { releaseAddress } = await import("./arena.ts"); |
| 212 | for (const address of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) releaseAddress(address); |
| 213 | const first = await fetch(base + "/"); |
| 214 | sessionCookie = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 215 | const page = await first.text(); |
| 216 | const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!; |
| 217 | const script = await (await fetch(base + src)).text(); |
| 218 | const path = join(process.env.FORGE_APP_DATA!, "client.mjs"); |
| 219 | await Bun.write(path, script); |
| 220 | |
| 221 | await import(path); |
| 222 | expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true); |
| 223 | // The one socket this module opens is already under way; put the global |
| 224 | // back so another test file's raw `new WebSocket(...)` is not silently |
| 225 | // handed a cookie this test happened to pick up. |
| 226 | globalThis.WebSocket = RealWebSocket; |
| 227 | expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true); |
| 228 | |
| 229 | // The HUD says what the match says. |
| 230 | expect(String(nodes.get("phase")!.textContent)).toContain("round"); |
| 231 | expect(String(nodes.get("creds")!.textContent)).toContain("cr"); |
| 232 | // The kit is four buttons, each named after the ability it casts. |
| 233 | expect((nodes.get("kit")!.children as unknown[]).length).toBe(4); |
| 234 | // One prompt, in words, about the round that is running. |
| 235 | expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8); |
| 236 | // And the buy panel prices itself off the agent's real kit. |
| 237 | expect(String(buys.find((b) => (b.dataset as { buy: string }).buy === "c")!.title)).toContain("charge"); |
| 238 | // The gun you hold is the one gun button marked as yours. |
| 239 | const held = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; |
| 240 | const ownedGuns = buys.filter( |
| 241 | (b) => GUNS.includes((b.dataset as { buy: string }).buy) && (b.classList as { contains: (n: string) => boolean }).contains("owned"), |
| 242 | ); |
| 243 | expect(ownedGuns.map((b) => (b.dataset as { buy: string }).buy)).toEqual([held.weapon]); |
| 244 | |
| 245 | // The team is on screen whether anybody is in sight or not: five slots. |
| 246 | const slots = nodes.get("roster")!.children as { className: string }[]; |
| 247 | expect(slots.length).toBe(5); |
| 248 | expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1); |
| 249 | |
| 250 | // Run the match on a few seconds: bots trade, and the feed fills with |
| 251 | // names — and with nothing that says where anybody is. |
| 252 | const arena = await import("./arena.ts"); |
| 253 | for (let i = 0; i < 40 && (nodes.get("feed")!.children as unknown[]).length === 0; i++) { |
| 254 | arena.advance(5); |
| 255 | await Bun.sleep(5); |
| 256 | } |
| 257 | const feedRows = nodes.get("feed")!.children as { children: { textContent: string }[] }[]; |
| 258 | expect(feedRows.length).toBeGreaterThan(0); |
| 259 | // killer ▸ victim, and the gun or the ability it was: four spans, no numbers. |
| 260 | const row = feedRows[0]!.children.map((span) => span.textContent).join(""); |
| 261 | expect(row).toContain(" ▸ "); |
| 262 | expect(row).not.toMatch(/\d/); |
| 263 | |
| 264 | // A frame of the first-person view draws the world into its own buffer and |
| 265 | // blits it, with the minimap's tiles blitted too. It is a picture, but it is |
| 266 | // NOT one fill a screen column, nor one a map tile: the map has 570 tiles and |
| 267 | // the buffer up to 960 columns, and the frame used to pay for both. The |
| 268 | // minimap's tiles are the blit; the walls are a run a fill. |
| 269 | // |
| 270 | // That sentence is the whole assertion, and it is now written as one. It |
| 271 | // used to demand more than 40 fills and fewer than 900, which failed about |
| 272 | // one full-suite run in twenty-five: the loop above advances the SHARED |
| 273 | // match until somebody dies — five to two hundred seconds of play — so the |
| 274 | // view being counted was wherever that left the seat. Alone it draws 154 to |
| 275 | // 236 fills; the run that failed drew 31, and a preview drew 525. Every one |
| 276 | // of those is a correct frame of a different view. A bound on the count of |
| 277 | // an unfixed view can only ever be luck. |
| 278 | // |
| 279 | // So the bounds are the two things true of EVERY view: it drew something, |
| 280 | // and it cost fewer fills than the frame had columns. The column count is |
| 281 | // read off the client's own offscreen buffer rather than hardcoded, because |
| 282 | // the frame budget moves the world between 960 and 448 columns and a |
| 283 | // constant cannot tell "a run a wall" at one rung from "a fill a column" at |
| 284 | // another. |
| 285 | const tally = () => ({ |
| 286 | fills: drawn.filter((op) => op.startsWith("fillRect")).length, |
| 287 | blits: drawn.filter((op) => op.startsWith("drawImage")).length, |
| 288 | }); |
| 289 | runFrame(); // whatever was cached at this size is cached before we count |
| 290 | drawn.length = 0; |
| 291 | runFrame(); |
| 292 | const fp = tally(); |
| 293 | drawn.length = 0; |
| 294 | runFrame(); |
| 295 | const again = tally(); |
| 296 | |
| 297 | expect(fp.blits).toBeGreaterThanOrEqual(2); // the world, and the map's tiles |
| 298 | expect(fp.fills).toBeGreaterThan(0); // it drew a world, not an empty buffer |
| 299 | expect(fp.fills).toBeLessThan(worldColumns()); // run-batched, not per column |
| 300 | // The same state drawn twice costs the same. A frame whose cost wanders |
| 301 | // while nothing moves is a renderer bug, and it is the shape of thing the |
| 302 | // old bound was mistaking for flakiness. |
| 303 | expect(again.fills).toBe(fp.fills); |
| 304 | |
| 305 | // Tab swaps that frame for the whole map. Its tiles are drawn once into a |
| 306 | // canvas of their own and blitted from then on: the first such frame pays |
| 307 | // for all 570 of them, and no frame after it pays for any. |
| 308 | for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop }); |
| 309 | drawn.length = 0; |
| 310 | runFrame(); |
| 311 | const built = tally(); |
| 312 | drawn.length = 0; |
| 313 | runFrame(); |
| 314 | const top = tally(); |
| 315 | expect(built.fills).toBeGreaterThan(400); |
| 316 | expect(top.blits).toBe(1); |
| 317 | expect(top.fills).toBeLessThan(100); |
| 318 | for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" }); |
| 319 | drawn.length = 0; |
| 320 | runFrame(); |
| 321 | expect(tally().blits).toBeGreaterThanOrEqual(2); |
| 322 | // Holding W reaches the server as a direction, and it is the way we face. |
| 323 | const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; |
| 324 | press("KeyW"); |
| 325 | await Bun.sleep(160); |
| 326 | expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9); |
| 327 | expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4); |
| 328 | |
| 329 | // V falls back to the top-down view, and it draws too. |
| 330 | press("KeyV"); |
| 331 | drawn.length = 0; |
| 332 | runFrame(); |
| 333 | expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true); |
| 334 | expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" }); |
| 335 | press("KeyV"); |
| 336 | expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" }); |
| 337 | |
| 338 | // Where you are looking is this browser's between snapshots. Turning with |
| 339 | // the arrow key, the angle the server is told only ever climbs: the client |
| 340 | // used to take `snap.me.angle` back on every snapshot, so the view was |
| 341 | // dragged to a stale angle thirty times a second and what it sent stepped |
| 342 | // backwards in between. |
| 343 | const shortWay = (from: number, to: number) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI; |
| 344 | press("ArrowRight"); |
| 345 | const angles: number[] = []; |
| 346 | for (let i = 0; i < 60; i++) { |
| 347 | runFrame(performance.now()); |
| 348 | await Bun.sleep(10); |
| 349 | angles.push(seat.input.angle); |
| 350 | } |
| 351 | release("ArrowRight"); |
| 352 | const steps = angles.slice(1).map((angle, i) => shortWay(angles[i]!, angle)); |
| 353 | expect(Math.min(...steps)).toBeGreaterThan(-1e-9); |
| 354 | expect(steps.reduce((sum, step) => sum + step, 0)).toBeGreaterThan(0.2); |
| 355 | |
| 356 | // H toggles the card either way, and the button closes it for good. |
| 357 | const help = nodes.get("help")!.style as Record<string, string>; |
| 358 | const before = help.display; |
| 359 | press("KeyH"); |
| 360 | expect(help.display).not.toBe(before); |
| 361 | if (help.display !== "grid") press("KeyH"); |
| 362 | expect(help.display).toBe("grid"); |
| 363 | click(nodes.get("helpclose")!); |
| 364 | expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none"); |
| 365 | expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1"); |
| 366 | |
| 367 | // B works the buy panel either way — it opens itself in the buy phase. |
| 368 | const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean }; |
| 369 | const wasOpen = panel.contains("on"); |
| 370 | press("KeyB"); |
| 371 | expect(panel.contains("on")).toBe(!wasOpen); |
| 372 | |
| 373 | // And the socket really took a seat in the running match. |
| 374 | expect(seat.bot).toBe(false); |
| 375 | |
| 376 | // Your own body is drawn from a guess about your own movement, not from |
| 377 | // the last thing the server said. With the arena stopped — no snapshot can |
| 378 | // arrive at all — the drawn body still carries the keys you are holding |
| 379 | // forward, as far as the cap and no further, and stops there. |
| 380 | press("KeyV"); // the top-down view, where your own body is an arrow |
| 381 | // The measurement needs a live round and a seat that can walk. The arena |
| 382 | // has been run through whole rounds above with this seat standing still, |
| 383 | // so by now it may be dead, mid-plant, slowed, or stood inside a barrier a |
| 384 | // bot put up — and a guess about a body that cannot move is rightly nothing. |
| 385 | // Put it on its feet, clear what abilities left, and let a snapshot carry |
| 386 | // that to the client before the clock is stopped; try again if a round |
| 387 | // ends or a bot lands a kill in that moment. |
| 388 | for (let tries = 0; tries < 10; tries++) { |
| 389 | for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1); |
| 390 | Object.assign(seat, { alive: true, hp: 100, planting: 0, defusing: 0, haste: 0, slow: 0 }); |
| 391 | arena.match().zones.length = 0; |
| 392 | arena.advance(1 / 30); |
| 393 | await Bun.sleep(80); |
| 394 | runFrame(); |
| 395 | arena.stopArena(); |
| 396 | if (arena.match().phase === "live" && seat.alive && arena.match().zones.length === 0) break; |
| 397 | arena.startArena(); |
| 398 | } |
| 399 | expect(arena.match().phase).toBe("live"); |
| 400 | expect(seat.alive).toBe(true); |
| 401 | // Walk whichever way is open from where the server last put us. |
| 402 | for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code); |
| 403 | const { blockedCircle } = await import("./map.ts"); |
| 404 | const clear = ([ |
| 405 | ["KeyW", 0, -1], |
| 406 | ["KeyS", 0, 1], |
| 407 | ["KeyA", -1, 0], |
| 408 | ["KeyD", 1, 0], |
| 409 | ] as const).find(([, dx, dy]) => |
| 410 | [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)), |
| 411 | )!; |
| 412 | expect(clear).toBeDefined(); |
| 413 | press(clear[0]); |
| 414 | const arrow = () => moves[moves.length - 1]!; |
| 415 | const startedAt = { x: seat.x, y: seat.y }; |
| 416 | const scale = 900 / (30 * 80); // canvas pixels to a world unit |
| 417 | const t0 = performance.now(); |
| 418 | for (let i = 1; i <= 10; i++) { |
| 419 | moves.length = 0; |
| 420 | runFrame(t0 + i * 40); |
| 421 | } |
| 422 | const drawnAt = arrow(); |
| 423 | // The drawn body is ahead of the server's last word by the input held |
| 424 | // since it was sent — a quarter of a second of running at most, and then |
| 425 | // it waits, because past that a guess would be a claim. Smoothing toward |
| 426 | // a snapshot that cannot arrive would have gone nowhere at all. |
| 427 | const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2]; |
| 428 | const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1]; |
| 429 | expect(lead).toBeGreaterThan(30); |
| 430 | // A quarter second of THIS seat's movement, not of a walking one: haste |
| 431 | // multiplies the speed by 1.35, and a seat that happened to be hasted led |
| 432 | // by 84 where a bound written around the base 250 expected 72. The cap is |
| 433 | // in time (predict.js LEAD_MAX), so the bound has to be read off the same |
| 434 | // speed table the client used, or it measures the match's mood instead of |
| 435 | // the rule. |
| 436 | // The lead is the cap plus the slack the guess is allowed over the answer |
| 437 | // (predict.js LEAD_SLACK: the lag your own keys take to reach the server). |
| 438 | const { moveSpeed } = await import("./engine/glide.ts"); |
| 439 | const { LEAD_MAX, LEAD_SLACK } = await import("../static/engine/predict.js"); |
| 440 | expect(lead).toBeLessThan((LEAD_MAX + LEAD_SLACK) * moveSpeed(seat) + 10); |
| 441 | expect(Math.abs(sideways)).toBeLessThan(6); |
| 442 | // Nothing on the server moved: this is a drawing, and the match is still |
| 443 | // the only thing that says where anybody is. |
| 444 | expect(seat.x).toBe(startedAt.x); |
| 445 | expect(seat.y).toBe(startedAt.y); |
| 446 | arena.startArena(); |
| 447 | |
| 448 | // Back to the first-person view for the last of it, and a frame of it to |
| 449 | // measure: the one before is the one that rebuilds anything cached. |
| 450 | press("KeyV"); |
| 451 | runFrame(); |
| 452 | drawn.length = 0; |
| 453 | runFrame(); |
| 454 | const onePixel = tally().fills; |
| 455 | expect(onePixel).toBeGreaterThan(40); |
| 456 | |
| 457 | // The frame is not the screen's pixel count. On a retina display the same |
| 458 | // window is twice the canvas, and the world is still drawn at a capped |
| 459 | // internal width and blitted up: the work does not double with the device |
| 460 | // pixel ratio, which is what made this choppy on a retina Mac. |
| 461 | (globalThis as { devicePixelRatio: number }).devicePixelRatio = 2; |
| 462 | for (const fn of listeners.get("resize") ?? []) fn({}); |
| 463 | expect(nodes.get("view")!.width).toBe(1800); |
| 464 | runFrame(); // the first frame at a new size redraws the minimap's tiles |
| 465 | drawn.length = 0; |
| 466 | runFrame(); |
| 467 | expect(drawn.some((op) => op.startsWith("drawImage"))).toBe(true); |
| 468 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(onePixel * 1.3); |
| 469 | (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1; |
| 470 | for (const fn of listeners.get("resize") ?? []) fn({}); |
| 471 | |
| 472 | // ` prints the numbers, so "it feels choppy" and "it delivers 30Hz" can be |
| 473 | // told apart: the frame rate, the mean frame, the snapshot rate, the |
| 474 | // interpolation delay, and the width the world is really drawn at. |
| 475 | const perf = nodes.get("perf")!; |
| 476 | expect((perf.style as Record<string, string>).display).not.toBe("block"); |
| 477 | press("Backquote"); |
| 478 | expect((perf.style as Record<string, string>).display).toBe("block"); |
| 479 | const readout = String(perf.textContent); |
| 480 | expect(readout).toContain("fps"); |
| 481 | expect(readout).toContain("ms/frame"); |
| 482 | expect(readout).toContain("Hz in"); |
| 483 | expect(readout).toContain("ms behind"); |
| 484 | expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas |
| 485 | // And the count of corrections taken outright, per minute: a stutter is a |
| 486 | // jump, and this is how many there have been. |
| 487 | expect(readout).toMatch(/\d+ jumps \([\d.]+\/min\)/); |
| 488 | press("Backquote"); |
| 489 | expect((perf.style as Record<string, string>).display).toBe("none"); |
| 490 | |
| 491 | // This test's loopback address is shared with every other test in the |
| 492 | // run; close the socket so the address is free for the next real |
| 493 | // connection rather than left holding a seat for the rest of the suite. |
| 494 | openedSocket?.close(); |
| 495 | await Bun.sleep(50); |
| 496 | }, 15000); |
| 497 | |
| 498 | test("a snapshot with no seat in it draws the watching view instead of throwing", async () => { |
| 499 | // The shape no other test covers: score, round, clock and feed, and no `me` |
| 500 | // and no players at all. The client used to reach straight into `snap.me` |
| 501 | // on the way in, so a second tab's first frame threw and the page stopped |
| 502 | // drawing. This is the server's own spectator snapshot, handed to the |
| 503 | // bundled client the way the socket hands it over. |
| 504 | const { spectatorSnapshot } = await import("./arena.ts"); |
| 505 | const deliver = (why: "address" | "cookie") => |
| 506 | openedSocket!.onmessage!({ data: spectatorSnapshot(why) } as MessageEvent); |
| 507 | |
| 508 | drawn.length = 0; |
| 509 | deliver("address"); |
| 510 | runFrame(); |
| 511 | |
| 512 | // It says what the match is doing, and why this connection is watching. |
| 513 | expect(String(nodes.get("phase")!.textContent)).toContain("round"); |
| 514 | expect(String(nodes.get("hp")!.textContent)).toBe("watching"); |
| 515 | expect(String(nodes.get("prompt")!.textContent)).toContain("address"); |
| 516 | // Nothing on screen that would need a position to fill in. |
| 517 | expect((nodes.get("kit")!.children as unknown[]).length).toBe(0); |
| 518 | expect((nodes.get("roster")!.children as unknown[]).length).toBe(0); |
| 519 | expect((nodes.get("atkboard")!.children as unknown[]).length).toBe(0); |
| 520 | // And the canvas is words on a panel, not a map: one rectangle, no tiles. |
| 521 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1); |
| 522 | expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3); |
| 523 | |
| 524 | // The other reason reads differently, and the frame after it still draws. |
| 525 | deliver("cookie"); |
| 526 | runFrame(); |
| 527 | expect(String(nodes.get("prompt")!.textContent)).toContain("cookie"); |
| 528 | }); |
| 529 | |
| 530 | test("your own camera is never drawn as a stranger arriving", async () => { |
| 531 | // At spawn the buy phase turns prediction off, and the frame used to fall |
| 532 | // through to the treatments meant for other people: drawn in the past, and |
| 533 | // slid in from where it was last seen. On a respawn that slides the CAMERA |
| 534 | // across the room — the shake somebody reported the first time they played |
| 535 | // a round from the start. |
| 536 | const { spectatorSnapshot } = await import("./arena.ts"); |
| 537 | expect(typeof spectatorSnapshot).toBe("function"); |
| 538 | const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text(); |
| 539 | // The rule is small enough to state: own id short-circuits before both. |
| 540 | const look = bundle.slice(bundle.indexOf("const look = (p) =>")); |
| 541 | const body = look.slice(0, look.indexOf("};") + 2); |
| 542 | const ownReturn = body.indexOf("if (p.id === snap.me.id) return p;"); |
| 543 | const slideCall = body.indexOf("slide(presence"); |
| 544 | expect(ownReturn).toBeGreaterThan(-1); |
| 545 | expect(slideCall).toBeGreaterThan(ownReturn); |
| 546 | }); |
| 547 | |
| 548 | test("the buy phase is predicted, and its pen is the server's own rule", async () => { |
| 549 | const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text(); |
| 550 | // Prediction is no longer switched off for the whole buy phase: it is on |
| 551 | // whenever the browser knows the pen, which it learns from the map it is |
| 552 | // already given. Drawing your own body straight from a 30Hz snapshot while |
| 553 | // rendering at 60 is a stutter, and that is what the phase used to do. |
| 554 | expect(bundle).toContain('snap.phase !== "buy" || pens !== null'); |
| 555 | // And the pen is the server's number, not a guess: 120 from a spawn tile. |
| 556 | expect(bundle).toContain("best > 120"); |
| 557 | // A refused step holds where it was rather than snapping to the server: |
| 558 | // the pen is handed to the one step function as the place it is refused. |
| 559 | expect(bundle).toContain("stepLocal(local, snap.me, held, speed"); |
| 560 | expect(bundle).toContain("world, refused)"); |
| 561 | expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)"); |
| 562 | }); |
| 563 | |
| 564 | test("the drawn body runs at the speed the server sent, and never at one it works out", async () => { |
| 565 | // Two snapshots a client that re-derived the rule would read backwards: one |
| 566 | // that says stopped with every visible reason to be running, and one that |
| 567 | // says running with a visible reason to be stopped. The drawing follows the |
| 568 | // number both times, which is the whole of what it knows about movement. |
| 569 | const arena = await import("./arena.ts"); |
| 570 | const { blockedCircle } = await import("./map.ts"); |
| 571 | arena.stopArena(); |
| 572 | const body = arena.match().players[0]!; |
| 573 | const base = JSON.parse(arena.snapshot(body.id)); |
| 574 | |
| 575 | // Somewhere with a hundred units of room in one direction to walk into. |
| 576 | const clear = ( |
| 577 | [ |
| 578 | ["KeyW", 0, -1], |
| 579 | ["KeyS", 0, 1], |
| 580 | ["KeyA", -1, 0], |
| 581 | ["KeyD", 1, 0], |
| 582 | ] as const |
| 583 | ).find(([, dx, dy]) => [25, 50, 75, 100].every((step) => !blockedCircle(body.x + dx * step, body.y + dy * step, 16)))!; |
| 584 | expect(clear).toBeDefined(); |
| 585 | |
| 586 | // The top-down view, where your own body is the last thing translated to. |
| 587 | if ((nodes.get("stage")!.dataset as Record<string, string>).mode !== "top") press("KeyV"); |
| 588 | expect((nodes.get("stage")!.dataset as Record<string, string>).mode).toBe("top"); |
| 589 | for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code); |
| 590 | press(clear[0]); |
| 591 | |
| 592 | const mine = (base.players as Record<string, unknown>[]).find((p) => p.id === base.me.id) ?? { |
| 593 | id: base.me.id, |
| 594 | name: body.name, |
| 595 | agent: base.me.agent, |
| 596 | angle: 0, |
| 597 | hp: 100, |
| 598 | armour: 0, |
| 599 | mate: true, |
| 600 | spike: false, |
| 601 | blind: false, |
| 602 | revealed: false, |
| 603 | }; |
| 604 | const scale = 900 / (30 * 80); // canvas pixels to a world unit, as above |
| 605 | const deliver = (me: Record<string, unknown>) => { |
| 606 | const snap = { |
| 607 | ...base, |
| 608 | phase: "live", |
| 609 | zones: [], |
| 610 | // Your own body is on the map as one of the bodies you can see, and it |
| 611 | // is the one the arrow is drawn for. |
| 612 | players: [{ ...mine, alive: true, x: body.x, y: body.y }], |
| 613 | shots: [], |
| 614 | me: { ...base.me, alive: true, x: body.x, y: body.y, planting: 0, ...me }, |
| 615 | }; |
| 616 | openedSocket!.onmessage!({ data: JSON.stringify(snap) } as MessageEvent); |
| 617 | }; |
| 618 | /** How far past the server's own word the body is drawn, after ten frames. */ |
| 619 | const leadOf = (me: Record<string, unknown>) => { |
| 620 | deliver(me); |
| 621 | const t0 = performance.now(); |
| 622 | runFrame(t0); // the first frame after a jump takes the server's word |
| 623 | for (let i = 1; i <= 10; i++) { |
| 624 | moves.length = 0; |
| 625 | runFrame(t0 + i * 40); |
| 626 | } |
| 627 | const at = moves[moves.length - 1]!; |
| 628 | return (at.x / scale - body.x) * clear[1] + (at.y / scale - body.y) * clear[2]; |
| 629 | }; |
| 630 | |
| 631 | // Stopped, with nothing in the snapshot to say why: no planting, no |
| 632 | // slow, a live round, a light weapon. A client working it out for itself |
| 633 | // would run this body the whole quarter second. |
| 634 | expect(leadOf({ speed: 0 })).toBeLessThan(1); |
| 635 | // And moving, mid-plant — a reason the old client read as stopped. The |
| 636 | // number is what counts, so the body walks. |
| 637 | const walking = leadOf({ speed: 250, planting: 1 }); |
| 638 | expect(walking).toBeGreaterThan(30); |
| 639 | // And the number is the whole of it: half the speed, half the lead. The |
| 640 | // cap on a guess is a length of time (predict.js LEAD_MAX), so how far |
| 641 | // ahead the drawing may be is the server's speed and nothing else. |
| 642 | const half = leadOf({ speed: 125 }); |
| 643 | expect(half / walking).toBeGreaterThan(0.45); |
| 644 | expect(half / walking).toBeLessThan(0.55); |
| 645 | |
| 646 | release(clear[0]); |
| 647 | openedSocket?.close(); |
| 648 | }); |