| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 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 |
|
| 22 |
|
| 23 |
const drawn: string[] = []; |
| 24 |
|
| 25 |
|
| 26 |
|
| 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 buys = [ |
| 120 |
element("b1", { buy: "vandal", cost: "2900" }), |
| 121 |
element("b2", { buy: "c", cost: "0" }), |
| 122 |
element("b3", { buy: "q", cost: "0" }), |
| 123 |
]; |
| 124 |
const agentButtons = [element("a1", { agent: "veil" })]; |
| 125 |
const scoreRed = element("red"); |
| 126 |
const scoreBlue = element("blue"); |
| 127 |
|
| 128 |
Object.assign(globalThis, { |
| 129 |
document: { |
| 130 |
getElementById: (id: string) => nodes.get(id) ?? null, |
| 131 |
querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue), |
| 132 |
querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons), |
| 133 |
createElement: (tag: string) => element(tag), |
| 134 |
addEventListener: noop, |
| 135 |
pointerLockElement: null, |
| 136 |
exitPointerLock: noop, |
| 137 |
}, |
| 138 |
HTMLInputElement: class {}, |
| 139 |
matchMedia: () => ({ matches: false }), |
| 140 |
devicePixelRatio: 1, |
| 141 |
localStorage: { |
| 142 |
store: new Map<string, string>(), |
| 143 |
getItem(key: string) { |
| 144 |
return this.store.get(key) ?? null; |
| 145 |
}, |
| 146 |
setItem(key: string, value: string) { |
| 147 |
this.store.set(key, value); |
| 148 |
}, |
| 149 |
}, |
| 150 |
requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn), |
| 151 |
addEventListener: (kind: string, fn: (event: unknown) => void) => { |
| 152 |
listeners.set(kind, [...(listeners.get(kind) ?? []), fn]); |
| 153 |
}, |
| 154 |
location: new URL(base), |
| 155 |
}); |
| 156 |
|
| 157 |
|
| 158 |
const realFetch = globalThis.fetch; |
| 159 |
globalThis.fetch = ((input: string, init?: RequestInit) => |
| 160 |
realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch; |
| 161 |
|
| 162 |
|
| 163 |
|
| 164 |
|
| 165 |
let sessionCookie = ""; |
| 166 |
let openedSocket: WebSocket | null = null; |
| 167 |
const RealWebSocket = globalThis.WebSocket; |
| 168 |
globalThis.WebSocket = function (url: string) { |
| 169 |
openedSocket = new RealWebSocket(url, { headers: { cookie: sessionCookie } } as unknown as string[]); |
| 170 |
return openedSocket; |
| 171 |
} as unknown as typeof WebSocket; |
| 172 |
|
| 173 |
const runFrame = (at = performance.now()) => { |
| 174 |
const pending = frames.splice(0, frames.length); |
| 175 |
for (const fn of pending) fn(at); |
| 176 |
}; |
| 177 |
const until = async (what: () => boolean, ms = 4000) => { |
| 178 |
const stop = Date.now() + ms; |
| 179 |
while (Date.now() < stop) { |
| 180 |
runFrame(); |
| 181 |
if (what()) return true; |
| 182 |
await Bun.sleep(20); |
| 183 |
} |
| 184 |
return false; |
| 185 |
}; |
| 186 |
|
| 187 |
|
| 188 |
|
| 189 |
test("the bundled client connects, paints the HUD and draws a frame", async () => { |
| 190 |
const first = await fetch(base + "/"); |
| 191 |
sessionCookie = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 192 |
const page = await first.text(); |
| 193 |
const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!; |
| 194 |
const script = await (await fetch(base + src)).text(); |
| 195 |
const path = join(process.env.FORGE_APP_DATA!, "client.mjs"); |
| 196 |
await Bun.write(path, script); |
| 197 |
|
| 198 |
await import(path); |
| 199 |
expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true); |
| 200 |
|
| 201 |
|
| 202 |
|
| 203 |
globalThis.WebSocket = RealWebSocket; |
| 204 |
expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true); |
| 205 |
|
| 206 |
|
| 207 |
expect(String(nodes.get("phase")!.textContent)).toContain("round"); |
| 208 |
expect(String(nodes.get("creds")!.textContent)).toContain("cr"); |
| 209 |
|
| 210 |
expect((nodes.get("kit")!.children as unknown[]).length).toBe(4); |
| 211 |
|
| 212 |
expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8); |
| 213 |
|
| 214 |
expect(String(buys[1]!.title)).toContain("charge"); |
| 215 |
|
| 216 |
|
| 217 |
const slots = nodes.get("roster")!.children as { className: string }[]; |
| 218 |
expect(slots.length).toBe(5); |
| 219 |
expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1); |
| 220 |
|
| 221 |
|
| 222 |
|
| 223 |
const arena = await import("./arena.ts"); |
| 224 |
for (let i = 0; i < 40 && (nodes.get("feed")!.children as unknown[]).length === 0; i++) { |
| 225 |
arena.advance(5); |
| 226 |
await Bun.sleep(5); |
| 227 |
} |
| 228 |
const feedRows = nodes.get("feed")!.children as { children: { textContent: string }[] }[]; |
| 229 |
expect(feedRows.length).toBeGreaterThan(0); |
| 230 |
|
| 231 |
const row = feedRows[0]!.children.map((span) => span.textContent).join(""); |
| 232 |
expect(row).toContain(" ▸ "); |
| 233 |
expect(row).not.toMatch(/\d/); |
| 234 |
|
| 235 |
|
| 236 |
|
| 237 |
|
| 238 |
|
| 239 |
const tally = () => ({ |
| 240 |
fills: drawn.filter((op) => op.startsWith("fillRect")).length, |
| 241 |
blits: drawn.filter((op) => op.startsWith("drawImage")).length, |
| 242 |
}); |
| 243 |
runFrame(); |
| 244 |
drawn.length = 0; |
| 245 |
runFrame(); |
| 246 |
const fp = tally(); |
| 247 |
expect(fp.blits).toBeGreaterThanOrEqual(2); |
| 248 |
expect(fp.fills).toBeGreaterThan(40); |
| 249 |
expect(fp.fills).toBeLessThan(570); |
| 250 |
|
| 251 |
|
| 252 |
|
| 253 |
|
| 254 |
for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop }); |
| 255 |
drawn.length = 0; |
| 256 |
runFrame(); |
| 257 |
const built = tally(); |
| 258 |
drawn.length = 0; |
| 259 |
runFrame(); |
| 260 |
const top = tally(); |
| 261 |
expect(built.fills).toBeGreaterThan(400); |
| 262 |
expect(top.blits).toBe(1); |
| 263 |
expect(top.fills).toBeLessThan(100); |
| 264 |
for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" }); |
| 265 |
drawn.length = 0; |
| 266 |
runFrame(); |
| 267 |
expect(tally().blits).toBeGreaterThanOrEqual(2); |
| 268 |
|
| 269 |
const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; |
| 270 |
press("KeyW"); |
| 271 |
await Bun.sleep(160); |
| 272 |
expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9); |
| 273 |
expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4); |
| 274 |
|
| 275 |
|
| 276 |
press("KeyV"); |
| 277 |
drawn.length = 0; |
| 278 |
runFrame(); |
| 279 |
expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true); |
| 280 |
expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" }); |
| 281 |
press("KeyV"); |
| 282 |
expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" }); |
| 283 |
|
| 284 |
|
| 285 |
|
| 286 |
|
| 287 |
|
| 288 |
|
| 289 |
const shortWay = (from: number, to: number) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI; |
| 290 |
press("ArrowRight"); |
| 291 |
const angles: number[] = []; |
| 292 |
for (let i = 0; i < 60; i++) { |
| 293 |
runFrame(performance.now()); |
| 294 |
await Bun.sleep(10); |
| 295 |
angles.push(seat.input.angle); |
| 296 |
} |
| 297 |
release("ArrowRight"); |
| 298 |
const steps = angles.slice(1).map((angle, i) => shortWay(angles[i]!, angle)); |
| 299 |
expect(Math.min(...steps)).toBeGreaterThan(-1e-9); |
| 300 |
expect(steps.reduce((sum, step) => sum + step, 0)).toBeGreaterThan(0.2); |
| 301 |
|
| 302 |
|
| 303 |
const help = nodes.get("help")!.style as Record<string, string>; |
| 304 |
const before = help.display; |
| 305 |
press("KeyH"); |
| 306 |
expect(help.display).not.toBe(before); |
| 307 |
if (help.display !== "grid") press("KeyH"); |
| 308 |
expect(help.display).toBe("grid"); |
| 309 |
click(nodes.get("helpclose")!); |
| 310 |
expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none"); |
| 311 |
expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1"); |
| 312 |
|
| 313 |
|
| 314 |
const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean }; |
| 315 |
const wasOpen = panel.contains("on"); |
| 316 |
press("KeyB"); |
| 317 |
expect(panel.contains("on")).toBe(!wasOpen); |
| 318 |
|
| 319 |
|
| 320 |
expect(seat.bot).toBe(false); |
| 321 |
|
| 322 |
|
| 323 |
|
| 324 |
|
| 325 |
|
| 326 |
press("KeyV"); |
| 327 |
for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1); |
| 328 |
await Bun.sleep(60); |
| 329 |
runFrame(); |
| 330 |
arena.stopArena(); |
| 331 |
|
| 332 |
for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code); |
| 333 |
const { blockedCircle } = await import("./map.ts"); |
| 334 |
const clear = ([ |
| 335 |
["KeyW", 0, -1], |
| 336 |
["KeyS", 0, 1], |
| 337 |
["KeyA", -1, 0], |
| 338 |
["KeyD", 1, 0], |
| 339 |
] as const).find(([, dx, dy]) => |
| 340 |
[20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)), |
| 341 |
)!; |
| 342 |
expect(clear).toBeDefined(); |
| 343 |
press(clear[0]); |
| 344 |
const arrow = () => moves[moves.length - 1]!; |
| 345 |
const startedAt = { x: seat.x, y: seat.y }; |
| 346 |
const scale = 900 / (30 * 80); |
| 347 |
const t0 = performance.now(); |
| 348 |
for (let i = 1; i <= 10; i++) { |
| 349 |
moves.length = 0; |
| 350 |
runFrame(t0 + i * 40); |
| 351 |
} |
| 352 |
const drawnAt = arrow(); |
| 353 |
|
| 354 |
|
| 355 |
|
| 356 |
|
| 357 |
const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2]; |
| 358 |
const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1]; |
| 359 |
expect(lead).toBeGreaterThan(30); |
| 360 |
expect(lead).toBeLessThan(0.25 * 250 + 10); |
| 361 |
expect(Math.abs(sideways)).toBeLessThan(6); |
| 362 |
|
| 363 |
|
| 364 |
expect(seat.x).toBe(startedAt.x); |
| 365 |
expect(seat.y).toBe(startedAt.y); |
| 366 |
arena.startArena(); |
| 367 |
}, 15000); |
| 368 |
|
| 369 |
test("a snapshot with no seat in it draws the watching view instead of throwing", async () => { |
| 370 |
|
| 371 |
|
| 372 |
|
| 373 |
|
| 374 |
|
| 375 |
const { spectatorSnapshot } = await import("./arena.ts"); |
| 376 |
const deliver = (why: "address" | "cookie") => |
| 377 |
openedSocket!.onmessage!({ data: spectatorSnapshot(why) } as MessageEvent); |
| 378 |
|
| 379 |
drawn.length = 0; |
| 380 |
deliver("address"); |
| 381 |
runFrame(); |
| 382 |
|
| 383 |
|
| 384 |
expect(String(nodes.get("phase")!.textContent)).toContain("round"); |
| 385 |
expect(String(nodes.get("hp")!.textContent)).toBe("watching"); |
| 386 |
expect(String(nodes.get("prompt")!.textContent)).toContain("address"); |
| 387 |
|
| 388 |
expect((nodes.get("kit")!.children as unknown[]).length).toBe(0); |
| 389 |
expect((nodes.get("roster")!.children as unknown[]).length).toBe(0); |
| 390 |
expect((nodes.get("atkboard")!.children as unknown[]).length).toBe(0); |
| 391 |
|
| 392 |
expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1); |
| 393 |
expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3); |
| 394 |
|
| 395 |
|
| 396 |
deliver("cookie"); |
| 397 |
runFrame(); |
| 398 |
expect(String(nodes.get("prompt")!.textContent)).toContain("cookie"); |
| 399 |
|
| 400 |
|
| 401 |
|
| 402 |
press("KeyV"); |
| 403 |
runFrame(); |
| 404 |
drawn.length = 0; |
| 405 |
runFrame(); |
| 406 |
const onePixel = tally().fills; |
| 407 |
expect(onePixel).toBeGreaterThan(40); |
| 408 |
|
| 409 |
|
| 410 |
|
| 411 |
|
| 412 |
|
| 413 |
(globalThis as { devicePixelRatio: number }).devicePixelRatio = 2; |
| 414 |
for (const fn of listeners.get("resize") ?? []) fn({}); |
| 415 |
expect(nodes.get("view")!.width).toBe(1800); |
| 416 |
runFrame(); |
| 417 |
drawn.length = 0; |
| 418 |
runFrame(); |
| 419 |
expect(drawn.some((op) => op.startsWith("drawImage"))).toBe(true); |
| 420 |
expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(onePixel * 1.3); |
| 421 |
(globalThis as { devicePixelRatio: number }).devicePixelRatio = 1; |
| 422 |
for (const fn of listeners.get("resize") ?? []) fn({}); |
| 423 |
|
| 424 |
|
| 425 |
|
| 426 |
|
| 427 |
const perf = nodes.get("perf")!; |
| 428 |
expect((perf.style as Record<string, string>).display).not.toBe("block"); |
| 429 |
press("Backquote"); |
| 430 |
expect((perf.style as Record<string, string>).display).toBe("block"); |
| 431 |
const readout = String(perf.textContent); |
| 432 |
expect(readout).toContain("fps"); |
| 433 |
expect(readout).toContain("ms/frame"); |
| 434 |
expect(readout).toContain("Hz in"); |
| 435 |
expect(readout).toContain("ms behind"); |
| 436 |
expect(readout).toContain("900×570"); |
| 437 |
press("Backquote"); |
| 438 |
expect((perf.style as Record<string, string>).display).toBe("none"); |
| 439 |
|
| 440 |
|
| 441 |
|
| 442 |
|
| 443 |
openedSocket?.close(); |
| 444 |
await Bun.sleep(50); |
| 445 |
}); |