pike/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 470af835242d7c193b45544c677099a00451ce7b
| 1 | import { expect, test } from "bun:test"; |
| 2 | import { mkdtempSync } from "node:fs"; |
| 3 | import { tmpdir } from "node:os"; |
| 4 | import { join } from "node:path"; |
| 5 | |
| 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 7 | process.env.PORT = "0"; |
| 8 | const { server } = await import("./server.ts"); |
| 9 | const { match, snapshot, advance, takeSeat, release, releaseAddress } = await import("./arena.ts"); |
| 10 | const { visible } = await import("./match.ts"); |
| 11 | const base = `http://${server.hostname}:${server.port}`; |
| 12 | |
| 13 | test("serves a live match on the first paint, with no script needed", async () => { |
| 14 | const response = await fetch(base + "/"); |
| 15 | expect(response.status).toBe(200); |
| 16 | const body = await response.text(); |
| 17 | expect(body).toContain("<h1>val</h1>"); |
| 18 | // The score, the round and both team sheets are in the HTML itself. |
| 19 | expect(body).toContain("red " + match().score.red); |
| 20 | expect(body).toContain("round " + match().round); |
| 21 | for (const player of match().players) expect(body).toContain(player.name); |
| 22 | }); |
| 23 | |
| 24 | test("the page hands out a session cookie and the script is named by content", async () => { |
| 25 | const response = await fetch(base + "/", { redirect: "manual" }); |
| 26 | const cookie = response.headers.get("set-cookie") ?? ""; |
| 27 | expect(cookie).toContain("val_session="); |
| 28 | expect(cookie).toContain("HttpOnly"); |
| 29 | const body = await response.text(); |
| 30 | const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(body); |
| 31 | expect(src).not.toBe(null); |
| 32 | const script = await fetch(base + src![1]!); |
| 33 | expect(script.status).toBe(200); |
| 34 | expect(script.headers.get("cache-control")).toContain("immutable"); |
| 35 | expect(await script.text()).toContain("WebSocket"); |
| 36 | }); |
| 37 | |
| 38 | test("a name is taken from a same-origin form and sanitised", async () => { |
| 39 | // Every test in this file reaches the server from the same address, and a |
| 40 | // playing seat is rationed per address on purpose. Earlier tests here take |
| 41 | // that seat and never open a socket to close, so nothing frees it: without |
| 42 | // this line the join below is correctly refused and the assertion measures |
| 43 | // the leftovers of another test rather than this one's behaviour. |
| 44 | releaseAddress("127.0.0.1"); |
| 45 | releaseAddress("::1"); |
| 46 | releaseAddress("::ffff:127.0.0.1"); |
| 47 | const form = new FormData(); |
| 48 | form.set("name", ' Wren<script>" '); |
| 49 | const posted = await fetch(base + "/join", { method: "POST", body: form, redirect: "manual" }); |
| 50 | expect(posted.status).toBe(303); |
| 51 | const cookie = posted.headers.getSetCookie().join(" "); |
| 52 | expect(cookie).toContain("val_name="); |
| 53 | // The angle brackets and the quote never reach the cookie or the match. |
| 54 | expect(cookie).not.toContain("<"); |
| 55 | expect(cookie).not.toContain("%3C"); |
| 56 | expect(match().players.every((p) => !p.name.includes("<"))).toBe(true); |
| 57 | // The seat that name took is a real player in the running match. |
| 58 | expect(match().players.some((p) => !p.bot && p.name.includes("Wren"))).toBe(true); |
| 59 | |
| 60 | // A join never opens a socket to close, so nothing ever frees the address |
| 61 | // it claimed on its own; every other test in this run shares this same |
| 62 | // loopback address, so free it explicitly rather than starve them of it. |
| 63 | const sessionCookie = posted.headers.getSetCookie().find((c) => c.startsWith("val_session=")); |
| 64 | const session = sessionCookie?.split(";")[0]?.split("=")[1]; |
| 65 | if (session) release(session, ""); |
| 66 | }); |
| 67 | |
| 68 | test("refuses a cross-site post", async () => { |
| 69 | const form = new FormData(); |
| 70 | form.set("name", "Evil"); |
| 71 | const posted = await fetch(base + "/join", { |
| 72 | method: "POST", |
| 73 | body: form, |
| 74 | redirect: "manual", |
| 75 | headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" }, |
| 76 | }); |
| 77 | expect(posted.status).toBe(403); |
| 78 | expect(match().players.some((p) => p.name === "Evil")).toBe(false); |
| 79 | }); |
| 80 | |
| 81 | test("stands on the security floor", async () => { |
| 82 | const response = await fetch(base + "/"); |
| 83 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 84 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 85 | const missing = await fetch(base + "/nope"); |
| 86 | expect(missing.status).toBe(404); |
| 87 | expect(missing.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 88 | }); |
| 89 | |
| 90 | test("a bug answers with the floor, not the runtime's page", async () => { |
| 91 | // A percent-escape that is not one: whether the runtime decodes it or |
| 92 | // throws, the answer carries the floor and never a stack trace. |
| 93 | const response = await fetch(base + "/join", { |
| 94 | method: "POST", |
| 95 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 96 | body: "name=%E0%A4%A", |
| 97 | redirect: "manual", |
| 98 | }); |
| 99 | expect([303, 500]).toContain(response.status); |
| 100 | expect(await response.text()).not.toContain("src/server.ts"); |
| 101 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 102 | }); |
| 103 | |
| 104 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 105 | const response = await fetch(base + "/join", { |
| 106 | method: "POST", |
| 107 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 108 | body: "name=" + "x".repeat(1024 * 1024), |
| 109 | }); |
| 110 | expect(response.status).toBe(413); |
| 111 | }); |
| 112 | |
| 113 | test("HEAD sees the app up", async () => { |
| 114 | const response = await server.fetch(new Request(base + "/", { method: "HEAD" })); |
| 115 | expect(response.status).toBe(200); |
| 116 | expect(await response.text()).toBe(""); |
| 117 | }); |
| 118 | |
| 119 | test("the map is served as data, once, for the client to draw", async () => { |
| 120 | const response = await fetch(base + "/map.json"); |
| 121 | const body = (await response.json()) as { rows: string[]; tile: number }; |
| 122 | expect(body.tile).toBe(80); |
| 123 | expect(body.rows.length).toBeGreaterThan(10); |
| 124 | expect(body.rows.some((r) => r.includes("A"))).toBe(true); |
| 125 | expect(body.rows.some((r) => r.includes("B"))).toBe(true); |
| 126 | }); |
| 127 | |
| 128 | test("a websocket is refused to another site", async () => { |
| 129 | const response = await server.fetch( |
| 130 | new Request(base + "/ws", { headers: { "sec-fetch-site": "cross-site", origin: "https://evil.example" } }), |
| 131 | ); |
| 132 | expect(response.status).toBe(403); |
| 133 | }); |
| 134 | |
| 135 | test("a snapshot only carries the enemies that viewer can see", () => { |
| 136 | const state = match(); |
| 137 | const viewer = state.players.find((p) => p.side === "atk")!; |
| 138 | const enemy = state.players.find((p) => p.side === "def")!; |
| 139 | viewer.x = 1200; |
| 140 | viewer.y = 1320; |
| 141 | viewer.blind = 0; |
| 142 | // Both of them alive on purpose: the arena is one live match that other |
| 143 | // tests run forward, and a dead enemy is invisible for a different reason |
| 144 | // than the one this test is about. |
| 145 | viewer.alive = true; |
| 146 | enemy.alive = true; |
| 147 | enemy.x = 1200; |
| 148 | enemy.y = 1240; |
| 149 | enemy.revealed = 0; |
| 150 | const near = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[] }; |
| 151 | expect(near.players.some((p) => p.id === enemy.id)).toBe(true); |
| 152 | // Behind the wall on the far side of the map, the same enemy is gone. |
| 153 | enemy.x = 200; |
| 154 | enemy.y = 200; |
| 155 | const far = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[]; board: unknown[] }; |
| 156 | expect(far.players.some((p) => p.id === enemy.id)).toBe(false); |
| 157 | // The scoreboard still lists everyone; it carries no position. |
| 158 | expect(far.board.length).toBe(10); |
| 159 | expect(JSON.stringify(far.board)).not.toContain('"x"'); |
| 160 | }); |
| 161 | |
| 162 | test("the whole snapshot carries no id or position of a body the viewer cannot see", () => { |
| 163 | // Generic on purpose: this scans the whole stringified payload rather |
| 164 | // than one named field, so it would have caught each of the three leaks |
| 165 | // of this shape — a hidden body's id or coordinate reaching the wire |
| 166 | // through some field other than `players` — and would catch a fourth. |
| 167 | const state = match(); |
| 168 | const viewer = state.players.find((p) => p.side === "atk")!; |
| 169 | viewer.x = 1200; |
| 170 | viewer.y = 1320; |
| 171 | viewer.blind = 0; |
| 172 | viewer.alive = true; |
| 173 | const enemy = state.players.find((p) => p.side === "def")!; |
| 174 | enemy.alive = true; |
| 175 | enemy.revealed = 0; |
| 176 | // A distinctive decimal, not a round number, so it cannot be mistaken for |
| 177 | // an unrelated field (hp, credits, damage) that legitimately holds 200. |
| 178 | enemy.x = 217.3; |
| 179 | enemy.y = 233.9; |
| 180 | expect(visible(state, viewer, enemy)).toBe(false); |
| 181 | |
| 182 | // A molly lands at the thrower's own feet — the shape any thrown ability |
| 183 | // takes — planted on the enemy this viewer cannot see. |
| 184 | const zoneId = state.nextZone++; |
| 185 | state.zones.push({ |
| 186 | id: zoneId, |
| 187 | kind: "molly", |
| 188 | owner: enemy.id, |
| 189 | side: enemy.side, |
| 190 | x: enemy.x, |
| 191 | y: enemy.y, |
| 192 | r: 40, |
| 193 | life: 5, |
| 194 | fuse: 0, |
| 195 | beat: 0, |
| 196 | pulses: 0, |
| 197 | damage: 0, |
| 198 | blocks: false, |
| 199 | solid: false, |
| 200 | }); |
| 201 | |
| 202 | const text = snapshot(viewer.id); |
| 203 | const round2 = (n: number) => Math.round(n * 10) / 10; |
| 204 | // A number counts only bare, not as a substring of a larger one — "217.3" |
| 205 | // inside "1217.3" would be a false alarm, "217.3" on its own is the leak. |
| 206 | const bare = (n: number) => new RegExp(String.raw`(?<![\d.-])${n}(?![\d.])`); |
| 207 | // The scoreboard is a named, reviewed exception: it lists every id on |
| 208 | // purpose (kills, deaths, credits for the whole match), never a position. |
| 209 | // The id check walks everything else; the position check still walks the |
| 210 | // scoreboard too, so a coordinate added there would still be caught. |
| 211 | const { board: _board, ...rest } = JSON.parse(text) as Record<string, unknown>; |
| 212 | expect(JSON.stringify(rest)).not.toContain(JSON.stringify(enemy.id)); |
| 213 | expect(bare(round2(enemy.x)).test(text)).toBe(false); |
| 214 | expect(bare(round2(enemy.y)).test(text)).toBe(false); |
| 215 | |
| 216 | state.zones = state.zones.filter((z) => z.id !== zoneId); |
| 217 | }); |
| 218 | |
| 219 | test("a command from a seat plants the spike, and nonsense from anyone else does nothing", async () => { |
| 220 | const { command } = await import("./arena.ts"); |
| 221 | const { siteCentre } = await import("./map.ts"); |
| 222 | const state = match(); |
| 223 | state.phase = "live"; |
| 224 | state.clock = 100; |
| 225 | const spot = siteCentre("B"); |
| 226 | const carrier = state.players.find((p) => p.side === "atk")!; |
| 227 | // Nobody is a bot for these five seconds: no fight can end the round |
| 228 | // underneath the plant and make this depend on who shot whom. And nobody |
| 229 | // else is mid-plant: this arena is live and shared, and once in a dozen |
| 230 | // runs a bot was standing on A with the spike and F held when this test |
| 231 | // began. Left with `spike` set and `use` held, it planted on A first, and |
| 232 | // every seat test after this one failed because the bots were never |
| 233 | // restored — the deploy gate's coin flip. |
| 234 | for (const player of state.players) { |
| 235 | player.bot = false; |
| 236 | player.alive = true; |
| 237 | player.hp = 100; |
| 238 | player.spike = false; |
| 239 | player.planting = 0; |
| 240 | player.input.fire = false; |
| 241 | player.input.use = false; |
| 242 | player.input.mx = 0; |
| 243 | player.input.my = 0; |
| 244 | } |
| 245 | try { |
| 246 | carrier.x = spot.x; |
| 247 | carrier.y = spot.y; |
| 248 | carrier.spike = true; |
| 249 | state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }; |
| 250 | // Garbage is ignored, and a seat nobody holds cannot be driven. |
| 251 | command(carrier.id, { t: "i", mx: "left" as unknown as number, a: Number.NaN, u: "yes" }); |
| 252 | command("nobody", { t: "i", u: true }); |
| 253 | expect(carrier.input.use).toBe(false); |
| 254 | command(carrier.id, { t: "i", u: true, mx: 0, my: 0 }); |
| 255 | advance(5); |
| 256 | expect(state.spike.planted).toBe(true); |
| 257 | expect(state.spike.site).toBe("B"); |
| 258 | } finally { |
| 259 | for (const player of state.players) player.bot = true; |
| 260 | } |
| 261 | }); |
| 262 | |
| 263 | test("the scoreboard is sent when it changes and left out when it has not", async () => { |
| 264 | // Ten names, agents and tallies were almost half of every snapshot, thirty |
| 265 | // times a second, for a table that changes a few times a round. A seat is |
| 266 | // sent it the first time and again only when it differs. |
| 267 | const { snapshotForSeat } = await import("./arena.ts"); |
| 268 | const state = match(); |
| 269 | const player = state.players[0]!; |
| 270 | const seat = { playerId: player.id, token: "t", address: "198.51.100.200", send: () => {}, stickyCache: "" }; |
| 271 | const first = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[]; last?: unknown }; |
| 272 | expect(first.board?.length).toBe(10); |
| 273 | const second = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[] }; |
| 274 | expect(second.board).toBeUndefined(); |
| 275 | // Something on the board moves — a kill — and it comes back with the next tick. |
| 276 | player.kills += 1; |
| 277 | const third = JSON.parse(snapshotForSeat(seat)) as { board?: { kills: number }[] }; |
| 278 | expect(third.board?.length).toBe(10); |
| 279 | expect(third.board?.find((p) => (p as { id?: string }).id === player.id)?.kills).toBe(player.kills); |
| 280 | // The full snapshot a socket is handed on open, and every test reads, is untouched. |
| 281 | expect((JSON.parse(snapshot(player.id)) as { board: unknown[] }).board.length).toBe(10); |
| 282 | // The bytes: the board is the bulk of what was going out. |
| 283 | const withBoard = Buffer.byteLength(snapshot(player.id)); |
| 284 | const without = Buffer.byteLength(snapshotForSeat(seat)); |
| 285 | expect(without).toBeLessThan(withBoard * 0.7); |
| 286 | }); |
| 287 | |
| 288 | test("the match keeps running and is never parked", () => { |
| 289 | const state = match(); |
| 290 | const before = state.now; |
| 291 | advance(30); |
| 292 | expect(state.now).toBeGreaterThan(before); |
| 293 | expect(["buy", "live", "end", "over"]).toContain(state.phase); |
| 294 | }); |
| 295 | |
| 296 | test("the kit is public: every ability, its price and what it does", async () => { |
| 297 | const response = await fetch(base + "/agents.json"); |
| 298 | expect(response.status).toBe(200); |
| 299 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 300 | const body = (await response.json()) as { agents: { id: string; abilities: Record<string, { name: string; cost: number; text: string }> }[] }; |
| 301 | expect(body.agents.length).toBe(5); |
| 302 | const veil = body.agents.find((a) => a.id === "veil")!; |
| 303 | expect(veil.abilities.c.name).toBe("Cloudburst"); |
| 304 | expect(veil.abilities.c.cost).toBe(100); |
| 305 | expect(veil.abilities.e.text).toContain("smoke"); |
| 306 | // The ultimate is never for sale, and no server-only state leaks out. |
| 307 | expect(veil.abilities.x.cost).toBe(0); |
| 308 | expect(JSON.stringify(body)).not.toContain("kind"); |
| 309 | }); |
| 310 | |
| 311 | test("the page carries the first-run card and the prompt line", async () => { |
| 312 | const body = await (await fetch(base + "/")).text(); |
| 313 | expect(body).toContain('id="help"'); |
| 314 | expect(body).toContain("How to play"); |
| 315 | expect(body).toContain('id="prompt"'); |
| 316 | // Every buy button says what it costs, so the panel is readable at a glance, |
| 317 | // and the rows are labelled for somebody meeting the panel for the first |
| 318 | // time: which of these is a gun, which is armour, which is an ability. |
| 319 | expect(body).toContain('data-buy="vandal" data-cost="2900"'); |
| 320 | for (const label of ["agent", "gun", "armour", "abilities"]) expect(body).toContain(`<span class="lbl">${label}</span>`); |
| 321 | expect(body).not.toContain("refused server-side"); |
| 322 | expect(body).toContain('id="viewtoggle"'); |
| 323 | }); |
| 324 | |
| 325 | test("the first paint says who took the round while it is over", async () => { |
| 326 | // Somebody arriving between rounds used to read "round over" and nothing |
| 327 | // else; the outcome was in the log three lines down at eighty percent. |
| 328 | const state = match(); |
| 329 | const was = { phase: state.phase, clock: state.clock, last: state.last }; |
| 330 | state.phase = "end"; |
| 331 | state.clock = 4; |
| 332 | state.last = { round: state.round, winner: "blue", why: "Spike defused" }; |
| 333 | try { |
| 334 | const body = await (await fetch(base + "/")).text(); |
| 335 | expect(body).toContain("round over — blue took it, spike defused"); |
| 336 | } finally { |
| 337 | Object.assign(state, was); |
| 338 | } |
| 339 | }); |
| 340 | |
| 341 | test("a stale socket closing does not evict a live reconnect's seat", async () => { |
| 342 | // A reload opens the new socket before the old one's close event lands. |
| 343 | // The old close must not turn a player who is still connected into a bot. |
| 344 | const { attach, release } = await import("./arena.ts"); |
| 345 | const player = takeSeat("203.0.113.1", "reload-session", "Reload")!; |
| 346 | attach("reload-session", player.id, "203.0.113.1", "first", () => {}); |
| 347 | attach("reload-session", player.id, "203.0.113.1", "second", () => {}); |
| 348 | release("reload-session", "first"); |
| 349 | expect(player.bot).toBe(false); |
| 350 | }); |
| 351 | |
| 352 | test("one session cannot claim two seats on opposite teams", () => { |
| 353 | // A websocket upgrade finishes on a later tick than the request that asked |
| 354 | // for it, so two requests for the same session used to both read the seat |
| 355 | // map before either had written to it — one client, two seats, opposite |
| 356 | // sides, the vision of both. Nothing here waits for a socket to open: |
| 357 | // the claim has to land the moment a seat is handed out. |
| 358 | const first = takeSeat("203.0.113.2", "cheat-session", "Cheat")!; |
| 359 | const second = takeSeat("203.0.113.2", "cheat-session", "Cheat")!; |
| 360 | expect(second.id).toBe(first.id); |
| 361 | expect(match().players.filter((p) => !p.bot && p.name === "Cheat").length).toBe(1); |
| 362 | }); |
| 363 | |
| 364 | test("one address cannot hold seats on both sides through a second session", async () => { |
| 365 | // Three GETs of "/" mint three different, equally legitimate-looking |
| 366 | // cookies. A session names a browser tab, not a person, so capping by |
| 367 | // session alone still lets one machine sit on both teams and read both |
| 368 | // snapshots — every position on the map, no forged traffic required. |
| 369 | const { addressCapped } = await import("./arena.ts"); |
| 370 | const first = takeSeat("198.51.100.9", "session-a", "OneMachine")!; |
| 371 | expect(addressCapped("198.51.100.9", "session-b")).toBe(true); |
| 372 | const second = takeSeat("198.51.100.9", "session-b", "OneMachine"); |
| 373 | expect(second).toBe(null); |
| 374 | // The same session reconnecting is not a second claim, and is let through. |
| 375 | expect(addressCapped("198.51.100.9", "session-a")).toBe(false); |
| 376 | expect(takeSeat("198.51.100.9", "session-a", "OneMachine")!.id).toBe(first.id); |
| 377 | }); |
| 378 | |
| 379 | test("a real second connection from one address gets a spectator view, positions and all excluded", async () => { |
| 380 | // The end-to-end path: two sockets from this test's own loopback address, |
| 381 | // one cookie each. The second must come back with no seat and nothing in |
| 382 | // its snapshot that carries a coordinate — score and clock, not a map. |
| 383 | // This loopback address is shared with every other test that runs a real |
| 384 | // request; start from a clean slate rather than trust none of them left it |
| 385 | // holding a seat. |
| 386 | releaseAddress("127.0.0.1"); |
| 387 | const first = await fetch(base + "/"); |
| 388 | const cookieOne = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 389 | const second = await fetch(base + "/"); |
| 390 | const cookieTwo = (second.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 391 | expect(cookieOne).not.toBe(cookieTwo); |
| 392 | |
| 393 | const wsUrl = base.replace(/^http/, "ws") + "/ws"; |
| 394 | const wsOne = new WebSocket(wsUrl, { headers: { cookie: cookieOne } } as unknown as string[]); |
| 395 | const snapOne = await new Promise<{ spectator?: boolean; me?: unknown }>((resolve, reject) => { |
| 396 | wsOne.onmessage = (event) => resolve(JSON.parse(String(event.data))); |
| 397 | wsOne.onerror = reject; |
| 398 | }); |
| 399 | // The first connection from this address is the one that actually plays. |
| 400 | expect(snapOne.spectator).toBeUndefined(); |
| 401 | expect(snapOne.me).toBeDefined(); |
| 402 | |
| 403 | const wsTwo = new WebSocket(wsUrl, { headers: { cookie: cookieTwo } } as unknown as string[]); |
| 404 | const snapTwo = await new Promise<unknown>((resolve, reject) => { |
| 405 | wsTwo.onmessage = (event) => resolve(JSON.parse(String(event.data))); |
| 406 | wsTwo.onerror = reject; |
| 407 | }); |
| 408 | // It says why it has no seat, because the two reasons have different |
| 409 | // answers, and it carries the kill feed — names and a gun, never a place. |
| 410 | expect(snapTwo).toMatchObject({ t: "s", spectator: true, why: "address" }); |
| 411 | expect(Array.isArray((snapTwo as { feed?: unknown[] }).feed)).toBe(true); |
| 412 | // No coordinate anywhere, not under "me", not under a different key either. |
| 413 | expect(JSON.stringify(snapTwo)).not.toMatch(/"x"|"y"/); |
| 414 | |
| 415 | // And a connection carrying no session at all is told the other reason. |
| 416 | const wsThree = new WebSocket(wsUrl); |
| 417 | const snapThree = await new Promise<unknown>((resolve, reject) => { |
| 418 | wsThree.onmessage = (event) => resolve(JSON.parse(String(event.data))); |
| 419 | wsThree.onerror = reject; |
| 420 | }); |
| 421 | expect(snapThree).toMatchObject({ spectator: true, why: "cookie" }); |
| 422 | |
| 423 | wsOne.close(); |
| 424 | wsTwo.close(); |
| 425 | wsThree.close(); |
| 426 | await Bun.sleep(50); |
| 427 | }); |
| 428 | |
| 429 | test("two people who pick the same name do not end up with the same name", () => { |
| 430 | // Everything that addresses a player — the kill feed, the round log, the |
| 431 | // scoreboard — does it by name, so the match may never hold two of one. |
| 432 | const first = takeSeat("198.51.100.10", "clash-a", "Twin")!; |
| 433 | const second = takeSeat("198.51.100.11", "clash-b", "Twin")!; |
| 434 | expect(first.name).toBe("Twin"); |
| 435 | expect(second.name).not.toBe("Twin"); |
| 436 | expect(second.name.startsWith("Twin")).toBe(true); |
| 437 | expect(second.name.length).toBeLessThanOrEqual(16); |
| 438 | const names = match().players.map((p) => p.name); |
| 439 | expect(new Set(names).size).toBe(names.length); |
| 440 | }); |
| 441 | |
| 442 | test("an empty room freezes the world instead of playing on", async () => { |
| 443 | const { match, beat, takeSeat, attach, release } = await import("./arena.ts"); |
| 444 | // Whatever else this file left attached, the rule is about the gate itself: |
| 445 | // with nobody there a beat of real time changes nothing. |
| 446 | const { attendance } = await import("./arena.ts"); |
| 447 | const start = attendance(); |
| 448 | const quiet = { now: match().now, round: match().round }; |
| 449 | if (start === 0) { |
| 450 | for (let i = 0; i < 450; i += 1) beat(); |
| 451 | expect({ now: match().now, round: match().round }).toEqual(quiet); |
| 452 | } |
| 453 | |
| 454 | // Somebody arrives and time starts again from where it stopped. |
| 455 | const seat = takeSeat("198.51.100.77", "empty-room-session", "Guest"); |
| 456 | expect(seat).not.toBeNull(); |
| 457 | attach("empty-room-session", seat!.id, "198.51.100.77", "tok", () => {}); |
| 458 | const before = match().now; |
| 459 | for (let i = 0; i < 30; i += 1) beat(); |
| 460 | expect(match().now).toBeGreaterThan(before); |
| 461 | release("empty-room-session", "tok"); |
| 462 | }); |
| 463 | |
| 464 | test("attendance counts seats and watchers, and nothing else", async () => { |
| 465 | const { attendance, takeSeat, attach, release, watch, unwatch } = await import("./arena.ts"); |
| 466 | const base = attendance(); |
| 467 | const seat = takeSeat("198.51.100.78", "counted-session", "Guest"); |
| 468 | attach("counted-session", seat!.id, "198.51.100.78", "tok2", () => {}); |
| 469 | expect(attendance()).toBe(base + 1); |
| 470 | watch("watcher-token", () => {}); |
| 471 | expect(attendance()).toBe(base + 2); |
| 472 | unwatch("watcher-token"); |
| 473 | release("counted-session", "tok2"); |
| 474 | expect(attendance()).toBe(base); |
| 475 | }); |