| 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 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 |
|
| 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 |
|
| 164 |
|
| 165 |
|
| 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 |
|
| 189 |
|
| 190 |
test("the bundled client connects, paints the HUD and draws a frame", async () => { |
| 191 |
|
| 192 |
|
| 193 |
|
| 194 |
|
| 195 |
|
| 196 |
|
| 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 |
|
| 210 |
|
| 211 |
|
| 212 |
globalThis.WebSocket = RealWebSocket; |
| 213 |
expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true); |
| 214 |
|
| 215 |
|
| 216 |
expect(String(nodes.get("phase")!.textContent)).toContain("round"); |
| 217 |
expect(String(nodes.get("creds")!.textContent)).toContain("cr"); |
| 218 |
|
| 219 |
expect((nodes.get("kit")!.children as unknown[]).length).toBe(4); |
| 220 |
|
| 221 |
expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8); |
| 222 |
|
| 223 |
expect(String(buys.find((b) => (b.dataset as { buy: string }).buy === "c")!.title)).toContain("charge"); |
| 224 |
|
| 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 |
|
| 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 |
|
| 237 |
|
| 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 |
|
| 246 |
const row = feedRows[0]!.children.map((span) => span.textContent).join(""); |
| 247 |
expect(row).toContain(" ▸ "); |
| 248 |
expect(row).not.toMatch(/\d/); |
| 249 |
|
| 250 |
|
| 251 |
|
| 252 |
|
| 253 |
|
| 254 |
|
| 255 |
|
| 256 |
|
| 257 |
|
| 258 |
const tally = () => ({ |
| 259 |
fills: drawn.filter((op) => op.startsWith("fillRect")).length, |
| 260 |
blits: drawn.filter((op) => op.startsWith("drawImage")).length, |
| 261 |
}); |
| 262 |
runFrame(); |
| 263 |
drawn.length = 0; |
| 264 |
runFrame(); |
| 265 |
const fp = tally(); |
| 266 |
expect(fp.blits).toBeGreaterThanOrEqual(2); |
| 267 |
expect(fp.fills).toBeGreaterThan(40); |
| 268 |
expect(fp.fills).toBeLessThan(900); |
| 269 |
|
| 270 |
|
| 271 |
|
| 272 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 304 |
|
| 305 |
|
| 306 |
|
| 307 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 339 |
expect(seat.bot).toBe(false); |
| 340 |
|
| 341 |
|
| 342 |
|
| 343 |
|
| 344 |
|
| 345 |
press("KeyV"); |
| 346 |
|
| 347 |
|
| 348 |
|
| 349 |
|
| 350 |
|
| 351 |
|
| 352 |
|
| 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 |
|
| 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); |
| 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 |
|
| 389 |
|
| 390 |
|
| 391 |
|
| 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 |
|
| 396 |
|
| 397 |
|
| 398 |
|
| 399 |
|
| 400 |
|
| 401 |
|
| 402 |
|
| 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 |
|
| 408 |
|
| 409 |
expect(seat.x).toBe(startedAt.x); |
| 410 |
expect(seat.y).toBe(startedAt.y); |
| 411 |
arena.startArena(); |
| 412 |
|
| 413 |
|
| 414 |
|
| 415 |
press("KeyV"); |
| 416 |
runFrame(); |
| 417 |
drawn.length = 0; |
| 418 |
runFrame(); |
| 419 |
const onePixel = tally().fills; |
| 420 |
expect(onePixel).toBeGreaterThan(40); |
| 421 |
|
| 422 |
|
| 423 |
|
| 424 |
|
| 425 |
|
| 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(); |
| 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 |
|
| 438 |
|
| 439 |
|
| 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"); |
| 450 |
|
| 451 |
|
| 452 |
expect(readout).toMatch(/\d+ jumps \([\d.]+\/min\)/); |
| 453 |
press("Backquote"); |
| 454 |
expect((perf.style as Record<string, string>).display).toBe("none"); |
| 455 |
|
| 456 |
|
| 457 |
|
| 458 |
|
| 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 |
|
| 465 |
|
| 466 |
|
| 467 |
|
| 468 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 486 |
expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1); |
| 487 |
expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3); |
| 488 |
|
| 489 |
|
| 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 |
|
| 497 |
|
| 498 |
|
| 499 |
|
| 500 |
|
| 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 |
|
| 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 |
|
| 516 |
|
| 517 |
|
| 518 |
|
| 519 |
expect(bundle).toContain('snap.phase !== "buy" || pens !== null'); |
| 520 |
|
| 521 |
expect(bundle).toContain("best > 120"); |
| 522 |
|
| 523 |
|
| 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 |
}); |