Public: anyone can read this; the crew can change it.
forked from trevato/cs
src/rooms/ is held by this repository's own rules: four modules, four test files, and one line that says whose the directory is #342
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/320-src-rooms-is-held-by-this-repository-s-o → main 1 commit fork
8 files
changed +745
−516
src/independence.test.ts +83 −9
| @@ -51,16 +51,27 @@ const SHELL = join(SRC, "pages", "shell.ts"); | ||
| 51 | 51 | const TOOLS = [resolve(ROOT, "scripts", "build.ts"), resolve(ROOT, "scripts", "bundle.test-helper.ts")]; |
| 52 | 52 | |
| 53 | 53 | /** |
| 54 | * The directories that are a game's without being the game: today `src/rl/` is | |
| 55 | * the shooter's and nothing is the range's. The training is the arena — a | |
| 56 | * de_forge duel with the round and the economy taken out — and PPO over it, and | |
| 57 | * it imports `src/client/` module by module because the environment *is* that | |
| 58 | * game. So it counts as the shooter here: it may read `src/client/`, and by the | |
| 59 | * same token it may not read `src/range/`, because the range is not its game | |
| 60 | * and a training run reaching for the other game's plates is exactly the | |
| 61 | * sharing this file exists to refuse. | |
| 54 | * The directories that are a game's without being the game: today `src/rl/` and | |
| 55 | * `src/rooms/` are the shooter's and nothing is the range's. | |
| 56 | * | |
| 57 | * The training is the arena — a de_forge duel with the round and the economy | |
| 58 | * taken out — and PPO over it, and it imports `src/client/` module by module | |
| 59 | * because the environment *is* that game. So it counts as the shooter here: it | |
| 60 | * may read `src/client/`, and by the same token it may not read `src/range/`, | |
| 61 | * because the range is not its game and a training run reaching for the other | |
| 62 | * game's plates is exactly the sharing this file exists to refuse. | |
| 63 | * | |
| 64 | * `src/rooms/` is the same answer for the same reason. A room runs the | |
| 65 | * shooter's own `createWorld` at the shooter's own 64 Hz and the wire packs the | |
| 66 | * shooter's weapons, phases and bomb states by name: multiplayer is that game | |
| 67 | * played by more than one person, not a service beside it. Left as the app's — | |
| 68 | * which is what it was, because nothing said otherwise — `room.ts` could import | |
| 69 | * `src/range/` as freely as `src/client/`, and that one line is exactly what | |
| 70 | * this file exists to refuse. Two of the four modules here reach for no game at | |
| 71 | * all and are the engine's to take; the test below reads them and says so, | |
| 72 | * rather than the next issue starting from a reading. | |
| 62 | 73 | */ |
| 63 | const ALSO: Record<string, readonly string[]> = { app: ["src/rl"] }; | |
| 74 | const ALSO: Record<string, readonly string[]> = { app: ["src/rl", "src/rooms"] }; | |
| 64 | 75 | |
| 65 | 76 | /** |
| 66 | 77 | * The game a source belongs to, or null if it belongs to none. A game's own |
| @@ -96,6 +107,12 @@ test("every game has the sources this test is about, and the app has none", () = | ||
| 96 | 107 | const of = (game: string): string[] => files.filter((f) => gameOf(f) === game); |
| 97 | 108 | expect(of("app")).toContain("src/client/world.ts"); |
| 98 | 109 | expect(of("app")).toContain("src/rl/arena.ts"); |
| 110 | // Multiplayer is the shooter played by more than one person, so the rooms are | |
| 111 | // the shooter's: what a room steps is `src/client/world.ts`. | |
| 112 | expect(of("app")).toContain("src/rooms/room.ts"); | |
| 113 | expect(of("app")).toContain("src/rooms/wire.ts"); | |
| 114 | expect(of("app")).toContain("src/rooms/hub.ts"); | |
| 115 | expect(of("app")).toContain("src/rooms/code.ts"); | |
| 99 | 116 | expect(of("range")).toContain("src/range/targets.ts"); |
| 100 | 117 | expect(of("range")).toContain("src/range/main.ts"); |
| 101 | 118 | // The third game was covered by its registry entry and nothing else: this |
| @@ -137,6 +154,14 @@ test("the rule is refused both ways round, and names who did it", () => { | ||
| 137 | 154 | `src/rl/arena.ts imports "../range/targets.ts"; a game may not import another game`, |
| 138 | 155 | ); |
| 139 | 156 | expect(importsAnotherGame("src/range/pin.ts", "../rl/arena.ts")).toMatch(/another game/); |
| 157 | // And a room is the shooter's, so the range is another game to it too: this | |
| 158 | // is the line the directory gained a rule to refuse. | |
| 159 | expect(importsAnotherGame("src/rooms/room.ts", "../range/targets.ts")).toBe( | |
| 160 | `src/rooms/room.ts imports "../range/targets.ts"; a game may not import another game`, | |
| 161 | ); | |
| 162 | expect(importsAnotherGame("src/rooms/wire.ts", "../range/readout.ts")).toMatch(/another game/); | |
| 163 | expect(importsAnotherGame("src/rooms/hub.ts", "../example/plan.ts")).toMatch(/another game/); | |
| 164 | expect(importsAnotherGame("src/range/main.ts", "../rooms/room.ts")).toMatch(/another game/); | |
| 140 | 165 | // And the third game is held to it from the moment it was written down. |
| 141 | 166 | expect(importsAnotherGame("src/example/main.ts", "../client/world.ts")).toMatch(/another game/); |
| 142 | 167 | expect(importsAnotherGame("src/example/plan.ts", "../range/targets.ts")).toMatch(/another game/); |
| @@ -161,6 +186,13 @@ test("the engine's door, the shell, a sibling and the app are what a game may re | ||
| 161 | 186 | // The training and the shooter are one game between them, both ways. |
| 162 | 187 | expect(importsAnotherGame("src/rl/arena.ts", "../client/world.ts")).toBeNull(); |
| 163 | 188 | expect(importsAnotherGame("src/client/replay.test.ts", "../rl/arena.ts")).toBeNull(); |
| 189 | // A room steps the shooter's world, and the shooter's page reads the room's | |
| 190 | // wire: one game between them, both ways. | |
| 191 | expect(importsAnotherGame("src/rooms/room.ts", "../client/world.ts")).toBeNull(); | |
| 192 | expect(importsAnotherGame("src/rooms/wire.ts", "../client/weapons.ts")).toBeNull(); | |
| 193 | expect(importsAnotherGame("src/client/session.ts", "../rooms/wire.ts")).toBeNull(); | |
| 194 | // And the app is served the rooms the way it is served the games. | |
| 195 | expect(importsAnotherGame("src/server.ts", "./rooms/hub.ts")).toBeNull(); | |
| 164 | 196 | // The app is served both games and is held to neither rule. |
| 165 | 197 | expect(importsAnotherGame("src/server.ts", "./pages/range.ts")).toBeNull(); |
| 166 | 198 | expect(importsAnotherGame("src/pages/games.ts", "../games.ts")).toBeNull(); |
| @@ -184,6 +216,48 @@ test("a shooter file that reached into the range is caught by reading it", () => | ||
| 184 | 216 | ]); |
| 185 | 217 | }); |
| 186 | 218 | |
| 219 | /** Where a relative specifier lands, written the way `gameOf` reads a path. */ | |
| 220 | const targetOf = (file: string, specifier: string): string => | |
| 221 | relative(ROOT, resolve(ROOT, dirname(file), specifier)); | |
| 222 | ||
| 223 | /** | |
| 224 | * Every game's source this file imports, its own siblings not counted: what it | |
| 225 | * would have to stop importing to leave the game it is in. A specifier that is | |
| 226 | * not a path is not a game's, and neither is a file that belongs to no game. | |
| 227 | */ | |
| 228 | function reachesAGame(file: string): string[] { | |
| 229 | const own = dirname(file); | |
| 230 | return specifiersOf(readFileSync(join(ROOT, file), "utf8")) | |
| 231 | .filter((specifier) => specifier.startsWith(".")) | |
| 232 | .map((specifier) => targetOf(file, specifier)) | |
| 233 | .filter((target) => gameOf(target) !== null && !target.startsWith(`${own}/`)); | |
| 234 | } | |
| 235 | ||
| 236 | test("the two room modules that reach for no game are read rather than believed", () => { | |
| 237 | // The step after this one moves `code.ts` and `hub.ts` into the engine, where | |
| 238 | // a module may import no game at all. That they already import none is a fact | |
| 239 | // about the sources, so it is read out of them here: the move starts from | |
| 240 | // this line, and the day somebody adds the convenient import it fails here | |
| 241 | // rather than in the middle of the move. | |
| 242 | expect(reachesAGame("src/rooms/code.ts")).toEqual([]); | |
| 243 | expect(reachesAGame("src/rooms/hub.ts")).toEqual([]); | |
| 244 | // The other two are the shooter's, and say so themselves. | |
| 245 | expect(reachesAGame("src/rooms/room.ts")).toContain("src/client/world.ts"); | |
| 246 | expect(reachesAGame("src/rooms/wire.ts")).toContain("src/client/weapons.ts"); | |
| 247 | }); | |
| 248 | ||
| 249 | test("the rooms reach for the shooter and never for the training", () => { | |
| 250 | // `src/rl/` is the shooter's too, so the rule above would allow this import | |
| 251 | // and nobody would notice it: a room is a match between people and has no | |
| 252 | // business in a training run. Widening `ALSO.app` widened what could be | |
| 253 | // reached for, and this is the line that says what was not. | |
| 254 | const offences: string[] = []; | |
| 255 | for (const file of outsideSources().filter((f) => f.startsWith("src/rooms/"))) | |
| 256 | for (const target of reachesAGame(file)) | |
| 257 | if (target.startsWith("src/rl/")) offences.push(`${file} imports ${target}; a room is not a training run`); | |
| 258 | expect(offences).toEqual([]); | |
| 259 | }); | |
| 260 | ||
| 187 | 261 | /** |
| 188 | 262 | * The range's own rule, which is stricter than the one above and is why the |
| 189 | 263 | * range exists: a module under `src/range/` may import the engine, the page |
src/rooms/code.test.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | /** | |
| 2 | * What a room is called, both ways through the door: what is a code, what is | |
| 3 | * not one, and that a fresh one is drawn from the alphabet a person can read | |
| 4 | * out loud without asking which letter that was. | |
| 5 | * | |
| 6 | * `code.ts` imports nothing at all, so this file needs no world, no clock and | |
| 7 | * no fixture — it is the whole of the module's surface in two tests, which is | |
| 8 | * the point of the module being that small. | |
| 9 | */ | |
| 10 | ||
| 11 | import { expect, test } from "bun:test"; | |
| 12 | import { ALPHABET, isRoomCode, LENGTH, newRoomCode, roomCodeFrom } from "./code.ts"; | |
| 13 | ||
| 14 | test("a room code is six readable characters, and nothing else is one", () => { | |
| 15 | expect(isRoomCode("ABCDEF")).toBe(true); | |
| 16 | expect(isRoomCode("abcdef")).toBe(false); | |
| 17 | expect(isRoomCode("ABCDE")).toBe(false); | |
| 18 | // The characters a person reads wrong are not in the alphabet at all. | |
| 19 | for (const wrong of ["O", "0", "I", "1", "L", "S", "5"]) expect(ALPHABET.includes(wrong)).toBe(false); | |
| 20 | expect(isRoomCode("ABCDE0")).toBe(false); | |
| 21 | expect(isRoomCode({ code: "ABCDEF" })).toBe(false); | |
| 22 | // The boundary upper-cases what a person typed and refuses what is not a code. | |
| 23 | expect(roomCodeFrom(" abcdef ")).toBe("ABCDEF"); | |
| 24 | expect(roomCodeFrom("../../etc/passwd")).toBe(null); | |
| 25 | expect(roomCodeFrom(null)).toBe(null); | |
| 26 | }); | |
| 27 | ||
| 28 | test("a fresh code is drawn from the alphabet and is the stated length", () => { | |
| 29 | for (let i = 0; i < 50; i++) { | |
| 30 | const code = newRoomCode(); | |
| 31 | expect(code.length).toBe(LENGTH); | |
| 32 | expect(isRoomCode(code)).toBe(true); | |
| 33 | } | |
| 34 | // And it is drawn from the bytes it is handed, so a test can choose one. | |
| 35 | expect(newRoomCode((into) => into.fill(0))).toBe((ALPHABET[0] as string).repeat(LENGTH)); | |
| 36 | }); |
src/rooms/code.ts +4
| @@ -14,6 +14,10 @@ | ||
| 14 | 14 | * room about once in ten million tries, against a limiter that allows nowhere |
| 15 | 15 | * near that many. The code is drawn from `crypto.getRandomValues`, not from |
| 16 | 16 | * `Math.random`: it is the only thing standing between a room and a stranger. |
| 17 | * | |
| 18 | * This file imports nothing — not the world, not the hub, not a package — so | |
| 19 | * `code.test.ts` is the whole of it in two tests: what is a code, and that a | |
| 20 | * fresh one is drawn from the alphabet above and is the stated length. | |
| 17 | 21 | */ |
| 18 | 22 | |
| 19 | 23 | /** The symbols a code is made of, and the only ones. */ |
src/rooms/hub.test.ts added +174
| @@ -0,0 +1,174 @@ | ||
| 1 | /** | |
| 2 | * Every room this process is holding, driven by one clock and no port at all. | |
| 3 | * The hub is handed a `Wire` — deliver these bytes to that member, close that | |
| 4 | * member — so a spy wire is the whole of the network here: two rooms that hear | |
| 5 | * their own member and never the other's, a quarter of a second of snapshots | |
| 6 | * counted, a frame over the cap and a flood of frames that both close the | |
| 7 | * connection, the room cap refusing politely, an empty room collected on the | |
| 8 | * sweep past the idle time, and a connection that says nothing losing the seat | |
| 9 | * it is holding. | |
| 10 | * | |
| 11 | * It is also where the directory is held to its own documentation: every test | |
| 12 | * file a source under `src/rooms/` names in backticks is a file that exists, | |
| 13 | * and each of the four modules names the test file that is about it. The habit | |
| 14 | * is `src/engine/guide.test.ts`'s — prose that names a file is a claim, and a | |
| 15 | * claim is read by a test — and it lives here because the hub is the module | |
| 16 | * that names a test outside its own directory. | |
| 17 | */ | |
| 18 | ||
| 19 | import { expect, test } from "bun:test"; | |
| 20 | import { existsSync, readFileSync, readdirSync } from "node:fs"; | |
| 21 | import { join, resolve } from "node:path"; | |
| 22 | import { createHub, CLOSE_FLOOD, CLOSE_TOO_BIG, IDLE_MS, MESSAGE_MAX, ROOM_CAP } from "./hub.ts"; | |
| 23 | import { isIdle, type Room } from "./room.ts"; | |
| 24 | import { forward, opened, spy } from "./stage.test-helper.ts"; | |
| 25 | import { type Player } from "../client/world.ts"; | |
| 26 | ||
| 27 | test("a hub holds its rooms, hears one member and never the other room", () => { | |
| 28 | const wire = spy(); | |
| 29 | const hub = createHub(wire, { now: 0 }); | |
| 30 | const first = hub.open({ ...opened, now: 0 }); | |
| 31 | const second = hub.open({ ...opened, seed: 6, now: 0 }); | |
| 32 | expect(first).not.toBe(null); | |
| 33 | expect(second).not.toBe(null); | |
| 34 | const a = hub.enter((first as Room).code, "s1", "sam", 0); | |
| 35 | const b = hub.enter((second as Room).code, "s2", "kim", 0); | |
| 36 | expect(a).not.toBe(null); | |
| 37 | expect(b).not.toBe(null); | |
| 38 | // Each was told which body is theirs, on which map, with who else is here. | |
| 39 | const hello = wire.sent.get("s1")?.[0] as string; | |
| 40 | expect(JSON.parse(hello).k).toBe("hello"); | |
| 41 | expect(JSON.parse(hello).code).toBe((first as Room).code); | |
| 42 | ||
| 43 | // A quarter of a second: sixteen ticks a room, four snapshots a member. | |
| 44 | for (let ms = 10; ms <= 250; ms += 10) hub.advance(ms); | |
| 45 | const snapshots = (id: string): number => | |
| 46 | (wire.sent.get(id) ?? []).filter((f) => f instanceof Uint8Array).length; | |
| 47 | expect(snapshots("s1")).toBeGreaterThanOrEqual(3); | |
| 48 | expect(snapshots("s1")).toBeLessThanOrEqual(5); | |
| 49 | // The two rooms stepped their own worlds and nobody else's. | |
| 50 | expect((first as Room).tick).toBeGreaterThan(8); | |
| 51 | expect((first as Room).world.players[0]?.pos).not.toEqual((second as Room).world.players[0]?.pos); | |
| 52 | expect(hub.members()).toBe(2); | |
| 53 | expect(hub.lines(250).length).toBe(2); | |
| 54 | ||
| 55 | // One member's input reaches their own room's world, and the other room's | |
| 56 | // member is in no way touched by it. | |
| 57 | const before = [...((second as Room).world.players[(b as { member: { seat: number } }).member.seat] as Player).pos]; | |
| 58 | expect(hub.frame("s1", forward(1, 0), 260)).toBe(true); | |
| 59 | hub.advance(300); | |
| 60 | expect((second as Room).world.players[(b as { member: { seat: number } }).member.seat]?.pos).not.toBe(before); | |
| 61 | }); | |
| 62 | ||
| 63 | test("a frame over the cap or over the limiter closes the connection", () => { | |
| 64 | const wire = spy(); | |
| 65 | const hub = createHub(wire, { now: 0 }); | |
| 66 | const r = hub.open(opened) as Room; | |
| 67 | hub.enter(r.code, "s1", "sam", 0); | |
| 68 | expect(hub.frame("s1", new Uint8Array(MESSAGE_MAX + 1), 1)).toBe(false); | |
| 69 | expect(wire.closed[0]).toEqual({ id: "s1", code: CLOSE_TOO_BIG }); | |
| 70 | // And a caller that sends far more than a page can: closed, and the process | |
| 71 | // is still here to say so. | |
| 72 | const wire2 = spy(); | |
| 73 | const hub2 = createHub(wire2, { now: 0 }); | |
| 74 | const r2 = hub2.open(opened) as Room; | |
| 75 | hub2.enter(r2.code, "s2", "kim", 0); | |
| 76 | let refused = 0; | |
| 77 | for (let i = 0; i < 400; i++) if (!hub2.frame("s2", forward(i, 0), 1)) refused += 1; | |
| 78 | expect(refused).toBeGreaterThan(200); | |
| 79 | expect(wire2.closed.some((c) => c.code === CLOSE_FLOOD)).toBe(true); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the room cap refuses politely, and an empty room is collected", () => { | |
| 83 | const wire = spy(); | |
| 84 | const hub = createHub(wire, { now: 0 }); | |
| 85 | for (let i = 0; i < ROOM_CAP; i++) expect(hub.open({ ...opened, seed: i })).not.toBe(null); | |
| 86 | expect(hub.open(opened)).toBe(null); | |
| 87 | expect(hub.rooms.size).toBe(ROOM_CAP); | |
| 88 | // Nobody ever joined any of them, so they all go on the first sweep past the | |
| 89 | // idle time — and the cap is a cap again. | |
| 90 | hub.advance(IDLE_MS + 1000); | |
| 91 | expect(hub.rooms.size).toBe(0); | |
| 92 | const r = hub.open({ ...opened, now: IDLE_MS + 1000 }) as Room; | |
| 93 | hub.enter(r.code, "s1", "sam", IDLE_MS + 1000); | |
| 94 | // A room with somebody in it is never idle, however long the match runs. | |
| 95 | hub.advance(IDLE_MS * 3); | |
| 96 | expect(hub.rooms.size).toBe(1); | |
| 97 | expect(isIdle(r, IDLE_MS * 3, IDLE_MS)).toBe(false); | |
| 98 | // They leave, and it is collected on the sweep after the idle time. | |
| 99 | hub.exit("s1", IDLE_MS * 3); | |
| 100 | hub.advance(IDLE_MS * 3 + IDLE_MS + 1); | |
| 101 | expect(hub.rooms.size).toBe(0); | |
| 102 | }); | |
| 103 | ||
| 104 | test("a member nobody has heard from loses the seat they are holding", () => { | |
| 105 | const wire = spy(); | |
| 106 | const hub = createHub(wire, { now: 0 }); | |
| 107 | const r = hub.open(opened) as Room; | |
| 108 | hub.enter(r.code, "s1", "sam", 0); | |
| 109 | hub.advance(1000); | |
| 110 | expect(wire.closed.length).toBe(0); | |
| 111 | hub.advance(40_000); | |
| 112 | expect(wire.closed.some((c) => c.id === "s1")).toBe(true); | |
| 113 | // The close is what takes them out, the way a browser closing does. | |
| 114 | hub.exit("s1", 40_000); | |
| 115 | expect(r.members.size).toBe(0); | |
| 116 | expect(r.world.players.every((p) => p.bot)).toBe(true); | |
| 117 | }); | |
| 118 | ||
| 119 | // --- the directory's own documentation ------------------------------------ | |
| 120 | ||
| 121 | const ROOMS = import.meta.dir; | |
| 122 | const ROOT = resolve(ROOMS, "..", ".."); | |
| 123 | ||
| 124 | /** The four modules of this directory, each of which names its own test file. */ | |
| 125 | const MODULES = ["code", "wire", "room", "hub"]; | |
| 126 | ||
| 127 | /** | |
| 128 | * The test files a source names in backticks: either a sibling under this | |
| 129 | * directory (`hub.test.ts`) or a path from the repository root | |
| 130 | * (`src/budget.test.ts`), which are the two ways every docstring here writes | |
| 131 | * one. A name in prose without backticks is not a claim and is not read. | |
| 132 | */ | |
| 133 | export function namedTests(source: string): string[] { | |
| 134 | const out: string[] = []; | |
| 135 | const pattern = /`([A-Za-z0-9_./-]+\.test\.ts)`/g; | |
| 136 | for (let m = pattern.exec(source); m !== null; m = pattern.exec(source)) out.push(m[1] as string); | |
| 137 | return out; | |
| 138 | } | |
| 139 | ||
| 140 | /** Every `.ts` directly under `src/rooms/`, module, test and helper alike. */ | |
| 141 | const roomSources = (): string[] => readdirSync(ROOMS).filter((name) => name.endsWith(".ts")).sort(); | |
| 142 | ||
| 143 | test("every test file a source under src/rooms names is a file that is there", () => { | |
| 144 | const offences: string[] = []; | |
| 145 | for (const file of roomSources()) | |
| 146 | for (const named of namedTests(readFileSync(join(ROOMS, file), "utf8"))) { | |
| 147 | const path = named.includes("/") ? resolve(ROOT, named) : join(ROOMS, named); | |
| 148 | if (!existsSync(path)) offences.push(`${file} names \`${named}\`, which is not a file in this repository`); | |
| 149 | } | |
| 150 | expect(offences).toEqual([]); | |
| 151 | }); | |
| 152 | ||
| 153 | test("each module names the test file that is about it, and that file tests it", () => { | |
| 154 | for (const module of MODULES) { | |
| 155 | const source = readFileSync(join(ROOMS, `${module}.ts`), "utf8"); | |
| 156 | expect(namedTests(source)).toContain(`${module}.test.ts`); | |
| 157 | // And the file it names imports the module it is named for. | |
| 158 | const spec = readFileSync(join(ROOMS, `${module}.test.ts`), "utf8"); | |
| 159 | expect(spec).toContain(`"./${module}.ts"`); | |
| 160 | } | |
| 161 | // The file the four replaced is gone, rather than left beside them saying | |
| 162 | // the same things twice. | |
| 163 | expect(existsSync(join(ROOMS, "rooms.test.ts"))).toBe(false); | |
| 164 | }); | |
| 165 | ||
| 166 | test("the reader reads a name it is handed, and only a name", () => { | |
| 167 | // The names are built at run time so this fixture is not itself a claim the | |
| 168 | // test above would read out of this file and hold against it. | |
| 169 | const ghost = "ghost" + ".test.ts"; | |
| 170 | expect(namedTests(`names \`${ghost}\` and \`src/budget.test.ts\` here`)).toEqual([ghost, "src/budget.test.ts"]); | |
| 171 | // Prose, a sentence and a module are not claims about a test file. | |
| 172 | expect(namedTests("hub.test.ts without backticks")).toEqual([]); | |
| 173 | expect(namedTests("`hub.ts` is the module")).toEqual([]); | |
| 174 | }); |
src/rooms/room.test.ts added +171
| @@ -0,0 +1,171 @@ | ||
| 1 | /** | |
| 2 | * A whole round, played without a network. A seat is one person's: a member | |
| 3 | * takes a bot's body and hands it back on the way out with the money and the | |
| 4 | * guns it had, two friends land on opposite sides, the eleventh person is | |
| 5 | * refused rather than dropped, and an input for a seat nobody holds reaches | |
| 6 | * nothing at all. Then the round itself — keys that move one body, a late frame | |
| 7 | * that is the member's last one, a buy that happens once for one ask, and | |
| 8 | * twenty seconds of bots playing the game and saying what happened. | |
| 9 | * | |
| 10 | * The socket's own half — the upgrade, the same-origin check, the 404 on a | |
| 11 | * plain GET — is in `src/server.test.ts`, where the server is. | |
| 12 | */ | |
| 13 | ||
| 14 | import { expect, test } from "bun:test"; | |
| 15 | import { | |
| 16 | drive, freeSeat, join, leave, logSince, rosterOf, snapshotFor, stepRoom, uniqueName, | |
| 17 | INPUT_HOLD, type Room, | |
| 18 | } from "./room.ts"; | |
| 19 | import { writeInput, SWITCH_PRIMARY, SWITCH_SECONDARY } from "./wire.ts"; | |
| 20 | // The cap is the hub's, because the hub is what enforces it on a socket; a | |
| 21 | // frame of that size is refused here too, and this is where that is read. | |
| 22 | import { MESSAGE_MAX } from "./hub.ts"; | |
| 23 | import { forward, room } from "./stage.test-helper.ts"; | |
| 24 | import { NO_INPUT, type Player } from "../client/world.ts"; | |
| 25 | import { SHORT } from "../client/match.ts"; | |
| 26 | ||
| 27 | // --- seats --------------------------------------------------------------- | |
| 28 | ||
| 29 | test("a member takes a bot's seat and gives it back on the way out", () => { | |
| 30 | const r = room(); | |
| 31 | const seat = freeSeat(r) as number; | |
| 32 | const body = r.world.players[seat] as Player; | |
| 33 | const botName = body.name; | |
| 34 | body.money = 4321; | |
| 35 | const member = join(r, "s1", " Vesper ", 2000); | |
| 36 | expect(member?.seat).toBe(seat); | |
| 37 | expect(body.bot).toBe(false); | |
| 38 | expect(body.name).toBe("Vesper"); | |
| 39 | // The bot's money, guns and position are not reset by an arrival. | |
| 40 | expect(body.money).toBe(4321); | |
| 41 | expect(rosterOf(r).filter((s) => !s.bot).length).toBe(1); | |
| 42 | ||
| 43 | leave(r, "s1", 3000); | |
| 44 | expect(body.bot).toBe(true); | |
| 45 | expect(body.name).toBe(botName); | |
| 46 | expect(body.money).toBe(4321); | |
| 47 | expect(r.members.size).toBe(0); | |
| 48 | }); | |
| 49 | ||
| 50 | test("two friends land on opposite sides, and a name is never two people's", () => { | |
| 51 | const r = room(); | |
| 52 | const first = join(r, "s1", "sam", 1) as NonNullable<ReturnType<typeof join>>; | |
| 53 | const second = join(r, "s2", "sam", 2) as NonNullable<ReturnType<typeof join>>; | |
| 54 | const a = r.world.players[first.seat] as Player; | |
| 55 | const b = r.world.players[second.seat] as Player; | |
| 56 | expect(a.team).not.toBe(b.team); | |
| 57 | expect(second.name).toBe("sam (2)"); | |
| 58 | expect(uniqueName(r, "")).toBe("player"); | |
| 59 | // A member who reconnects with the same session keeps the seat they had. | |
| 60 | expect(join(r, "s1", "sam", 3)?.seat).toBe(first.seat); | |
| 61 | expect(r.members.size).toBe(2); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a room fills up, and the eleventh person is refused rather than dropped", () => { | |
| 65 | const r = room(); | |
| 66 | for (let i = 0; i < 10; i++) expect(join(r, `s${i}`, `p${i}`, i)).not.toBe(null); | |
| 67 | expect(freeSeat(r)).toBe(null); | |
| 68 | expect(join(r, "s10", "late", 11)).toBe(null); | |
| 69 | expect(r.members.size).toBe(10); | |
| 70 | }); | |
| 71 | ||
| 72 | // --- input into the world ------------------------------------------------- | |
| 73 | ||
| 74 | test("a member's keys move their own body, and nobody else's", () => { | |
| 75 | const r = room(); | |
| 76 | r.world.match.phase = "live"; | |
| 77 | r.world.match.timer = SHORT.roundTime; | |
| 78 | const member = join(r, "s1", "sam", 1) as NonNullable<ReturnType<typeof join>>; | |
| 79 | const body = r.world.players[member.seat] as Player; | |
| 80 | const was: [number, number] = [body.pos[0], body.pos[1]]; | |
| 81 | for (let i = 0; i < 32; i++) { | |
| 82 | expect(drive(r, "s1", forward(i, 90))).toBe(true); | |
| 83 | stepRoom(r); | |
| 84 | } | |
| 85 | // It walked, and it walked where the frame said it was looking. | |
| 86 | expect(Math.hypot(body.pos[0] - was[0], body.pos[1] - was[1])).toBeGreaterThan(40); | |
| 87 | expect(body.yaw).toBeCloseTo(90, 2); | |
| 88 | // An input from somebody who is not in this room reaches nothing at all. | |
| 89 | expect(drive(r, "stranger", forward(40, 0))).toBe(false); | |
| 90 | // And a frame of the wrong size is refused and counted. | |
| 91 | expect(drive(r, "s1", new Uint8Array(MESSAGE_MAX))).toBe(false); | |
| 92 | expect(member.dropped).toBe(1); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a late frame is the member's last one, and silence drops the keys", () => { | |
| 96 | const r = room(); | |
| 97 | r.world.match.phase = "live"; | |
| 98 | r.world.match.timer = SHORT.roundTime; | |
| 99 | join(r, "s1", "sam", 1); | |
| 100 | const body = r.world.players[freeSeatOf(r)] as Player; | |
| 101 | drive(r, "s1", forward(1, 0)); | |
| 102 | // Nothing else arrives: the last input is held for INPUT_HOLD ticks and the | |
| 103 | // body keeps walking, then drops the keys and comes to a stop. | |
| 104 | for (let i = 0; i < INPUT_HOLD; i++) stepRoom(r); | |
| 105 | const moving = Math.hypot(body.vel[0], body.vel[1]); | |
| 106 | expect(moving).toBeGreaterThan(50); | |
| 107 | for (let i = 0; i < 64; i++) stepRoom(r); | |
| 108 | expect(Math.hypot(body.vel[0], body.vel[1])).toBeLessThan(1); | |
| 109 | // A frame that overtook another on the way here does not undo it. | |
| 110 | drive(r, "s1", forward(100, 10)); | |
| 111 | expect(drive(r, "s1", forward(50, 200))).toBe(false); | |
| 112 | stepRoom(r); | |
| 113 | expect(body.yaw).toBeCloseTo(10, 2); | |
| 114 | }); | |
| 115 | ||
| 116 | /** The seat the one member of this room is holding. */ | |
| 117 | const freeSeatOf = (r: Room): number => [...r.members.values()][0]?.seat as number; | |
| 118 | ||
| 119 | test("a member buys once for one ask, and only what the money covers", () => { | |
| 120 | const r = room(); | |
| 121 | const member = join(r, "s1", "sam", 1) as NonNullable<ReturnType<typeof join>>; | |
| 122 | const body = r.world.players[member.seat] as Player; | |
| 123 | body.money = 2700; | |
| 124 | const ask = (tick: number, item: "ak47"): void => { | |
| 125 | drive(r, "s1", writeInput({ tick, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: 0, buy: item })); | |
| 126 | }; | |
| 127 | // The key is held for a quarter of a second, which is sixteen frames of the | |
| 128 | // same ask: one rifle, not sixteen. | |
| 129 | for (let i = 0; i < 16; i++) { ask(1, "ak47"); stepRoom(r); } | |
| 130 | expect(body.primary).toBe("ak47"); | |
| 131 | expect(body.money).toBe(2700 - 2700); | |
| 132 | // And with the wallet empty, the next ask changes nothing. | |
| 133 | ask(2, "ak47"); | |
| 134 | stepRoom(r); | |
| 135 | expect(body.money).toBe(0); | |
| 136 | // Holding the rifle is a switch the server resolves against the body. | |
| 137 | drive(r, "s1", writeInput({ tick: 3, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: SWITCH_PRIMARY, buy: null })); | |
| 138 | stepRoom(r); | |
| 139 | expect(body.weapon).toBe("ak47"); | |
| 140 | drive(r, "s1", writeInput({ tick: 4, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: SWITCH_SECONDARY, buy: null })); | |
| 141 | for (let i = 0; i < 64; i++) stepRoom(r); | |
| 142 | expect(body.weapon).toBe(body.secondary); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a room's round is played by its own bots and says what happened", () => { | |
| 146 | const r = room(); | |
| 147 | // Straight into a live round, and then a few seconds of it. | |
| 148 | r.world.match.phase = "live"; | |
| 149 | r.world.match.timer = SHORT.roundTime; | |
| 150 | for (let i = 0; i < 64 * 20; i++) stepRoom(r); | |
| 151 | expect(r.tick).toBe(64 * 20); | |
| 152 | expect(r.world.time).toBeCloseTo(20, 0); | |
| 153 | // Bots left their spawns and somebody shot somebody. | |
| 154 | expect(r.world.players.some((p) => p.kills > 0 || p.damage > 0)).toBe(true); | |
| 155 | const log = logSince(r); | |
| 156 | // The words of the round travel as themselves: the feed and the lines. | |
| 157 | if (log !== null) expect(log.feed.length + log.say.length).toBeGreaterThan(0); | |
| 158 | // Asked twice, nothing is said twice. | |
| 159 | const again = logSince(r); | |
| 160 | expect(again === null || again.feed.length + again.say.length === 0).toBe(true); | |
| 161 | }); | |
| 162 | ||
| 163 | test("one member's snapshot is a snapshot of the room they are in", () => { | |
| 164 | const r = room(); | |
| 165 | const member = join(r, "s1", "sam", 0) as NonNullable<ReturnType<typeof join>>; | |
| 166 | for (let i = 0; i < 64; i++) stepRoom(r); | |
| 167 | const bytes = snapshotFor(r, member); | |
| 168 | expect(bytes.byteLength).toBeGreaterThan(100); | |
| 169 | // Asking again sends only what happened since: the clock moved on. | |
| 170 | expect(member.sentAt).toBeCloseTo(r.world.time, 5); | |
| 171 | }); |
src/rooms/rooms.test.ts deleted −507
| @@ -1,507 +0,0 @@ | ||
| 1 | /** | |
| 2 | * A room, played without a network. Everything in `src/rooms/` is plain data | |
| 3 | * with a world in it, so every claim multiplayer rests on is made here: the | |
| 4 | * code is a code, an input frame is validated at the boundary, a snapshot round | |
| 5 | * trips, a seat is one person's, a leaving member hands the bot its body back, | |
| 6 | * and the caps refuse politely. | |
| 7 | * | |
| 8 | * The socket's own half — the upgrade, the same-origin check, the 404 on a plain | |
| 9 | * GET — is in `src/server.test.ts`, where the server is. | |
| 10 | */ | |
| 11 | ||
| 12 | import { expect, test } from "bun:test"; | |
| 13 | import { ALPHABET, isRoomCode, LENGTH, newRoomCode, roomCodeFrom } from "./code.ts"; | |
| 14 | import { | |
| 15 | createRoom, drive, freeSeat, isIdle, join, leave, logSince, rosterOf, snapshotFor, stepRoom, | |
| 16 | uniqueName, INPUT_HOLD, TICK, type Room, | |
| 17 | } from "./room.ts"; | |
| 18 | import { | |
| 19 | applySnapshot, readInput, writeInput, writeSnapshot, BUY_ITEMS, INPUT_BYTES, SNAPSHOT_MAX, | |
| 20 | SWITCH_PRIMARY, SWITCH_SECONDARY, readDown, forget, | |
| 21 | } from "./wire.ts"; | |
| 22 | import { createHub, CLOSE_FLOOD, CLOSE_TOO_BIG, IDLE_MS, MESSAGE_MAX, ROOM_CAP, type Wire } from "./hub.ts"; | |
| 23 | import { createWorld, NO_INPUT, type Input, type Player } from "../client/world.ts"; | |
| 24 | import { DE_FORGE } from "../client/map.ts"; | |
| 25 | import { SHORT } from "../client/match.ts"; | |
| 26 | import { useShipped } from "../client/shipped.ts"; | |
| 27 | import { pushEffect } from "../engine/index.ts"; | |
| 28 | ||
| 29 | useShipped(); // a room's bots think with the shipped policy, like the game's | |
| 30 | ||
| 31 | const room = (now = 1000): Room => createRoom("ABCDEF", { map: "de_forge", seed: 9, length: "short", now }); | |
| 32 | ||
| 33 | // --- the code ------------------------------------------------------------- | |
| 34 | ||
| 35 | test("a room code is six readable characters, and nothing else is one", () => { | |
| 36 | expect(isRoomCode("ABCDEF")).toBe(true); | |
| 37 | expect(isRoomCode("abcdef")).toBe(false); | |
| 38 | expect(isRoomCode("ABCDE")).toBe(false); | |
| 39 | // The characters a person reads wrong are not in the alphabet at all. | |
| 40 | for (const wrong of ["O", "0", "I", "1", "L", "S", "5"]) expect(ALPHABET.includes(wrong)).toBe(false); | |
| 41 | expect(isRoomCode("ABCDE0")).toBe(false); | |
| 42 | expect(isRoomCode({ code: "ABCDEF" })).toBe(false); | |
| 43 | // The boundary upper-cases what a person typed and refuses what is not a code. | |
| 44 | expect(roomCodeFrom(" abcdef ")).toBe("ABCDEF"); | |
| 45 | expect(roomCodeFrom("../../etc/passwd")).toBe(null); | |
| 46 | expect(roomCodeFrom(null)).toBe(null); | |
| 47 | }); | |
| 48 | ||
| 49 | test("a fresh code is drawn from the alphabet and is the stated length", () => { | |
| 50 | for (let i = 0; i < 50; i++) { | |
| 51 | const code = newRoomCode(); | |
| 52 | expect(code.length).toBe(LENGTH); | |
| 53 | expect(isRoomCode(code)).toBe(true); | |
| 54 | } | |
| 55 | // And it is drawn from the bytes it is handed, so a test can choose one. | |
| 56 | expect(newRoomCode((into) => into.fill(0))).toBe((ALPHABET[0] as string).repeat(LENGTH)); | |
| 57 | }); | |
| 58 | ||
| 59 | // --- the input frame ------------------------------------------------------ | |
| 60 | ||
| 61 | test("an input frame round trips, and every field comes back in range", () => { | |
| 62 | const input: Input = { | |
| 63 | ...NO_INPUT, forward: 1, side: -1, jump: true, fire: true, walk: true, throwing: "smoke", | |
| 64 | }; | |
| 65 | const bytes = writeInput({ tick: 4242, input, yaw: -91.25, pitch: 37.5, switchCode: SWITCH_PRIMARY, buy: "ak47" }); | |
| 66 | expect(bytes.byteLength).toBe(INPUT_BYTES); | |
| 67 | const read = readInput(bytes); | |
| 68 | expect(read).not.toBe(null); | |
| 69 | const got = read as NonNullable<typeof read>; | |
| 70 | expect(got.tick).toBe(4242); | |
| 71 | expect(got.input.forward).toBeCloseTo(1, 2); | |
| 72 | expect(got.input.side).toBeCloseTo(-1, 2); | |
| 73 | expect(got.input.jump).toBe(true); | |
| 74 | expect(got.input.fire).toBe(true); | |
| 75 | expect(got.input.walk).toBe(true); | |
| 76 | expect(got.input.duck).toBe(false); | |
| 77 | expect(got.input.throwing).toBe("smoke"); | |
| 78 | expect(got.yaw).toBeCloseTo(-91.25, 1); | |
| 79 | expect(got.pitch).toBeCloseTo(37.5, 1); | |
| 80 | expect(got.switchCode).toBe(SWITCH_PRIMARY); | |
| 81 | expect(got.buy).toBe("ak47"); | |
| 82 | // A weapon id never crosses this wire: a switch is one of four things the | |
| 83 | // server resolves against the body it is holding. | |
| 84 | expect(BUY_ITEMS.includes("ak47")).toBe(true); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a frame off the wire cannot ask for more than a keyboard can", () => { | |
| 88 | // Every byte set: the widest values this encoding can carry. | |
| 89 | const wild = new Uint8Array(INPUT_BYTES).fill(0xff); | |
| 90 | wild[0] = 1; // still an input frame | |
| 91 | const read = readInput(wild); | |
| 92 | const got = read as NonNullable<typeof read>; | |
| 93 | expect(got.input.forward).toBeGreaterThanOrEqual(-1); | |
| 94 | expect(got.input.side).toBeGreaterThanOrEqual(-1); | |
| 95 | expect(Math.abs(got.pitch)).toBeLessThanOrEqual(89); | |
| 96 | expect(Number.isFinite(got.yaw)).toBe(true); | |
| 97 | // And a frame of the wrong size or the wrong kind is not a frame at all. | |
| 98 | expect(readInput(new Uint8Array(INPUT_BYTES - 1))).toBe(null); | |
| 99 | expect(readInput(new Uint8Array(INPUT_BYTES + 1))).toBe(null); | |
| 100 | expect(readInput(new Uint8Array(INPUT_BYTES))).toBe(null); // first byte is not INPUT_FRAME | |
| 101 | }); | |
| 102 | ||
| 103 | // --- the snapshot --------------------------------------------------------- | |
| 104 | ||
| 105 | /** A round with something in it: a body hurt, a grenade in the air, a plant. */ | |
| 106 | function busy(): Room { | |
| 107 | const r = room(); | |
| 108 | const world = r.world; | |
| 109 | world.match.phase = "live"; | |
| 110 | world.match.timer = SHORT.roundTime; | |
| 111 | for (let i = 0; i < 64 * 3; i++) stepRoom(r); | |
| 112 | const thrower = world.players[2] as Player; | |
| 113 | world.grenades.push({ | |
| 114 | pos: [thrower.pos[0], thrower.pos[1], thrower.pos[2] + 60], vel: [300, 20, 150], | |
| 115 | onGround: false, ducking: false, duckAmount: 0, | |
| 116 | weapon: "he", owner: thrower.id, team: thrower.team, fuse: 1.25, | |
| 117 | }); | |
| 118 | world.clouds.push({ pos: [500, 500, 40], born: world.time - 1, owner: 3 }); | |
| 119 | world.bomb.state = "planted"; | |
| 120 | world.bomb.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 4]; | |
| 121 | world.bomb.site = "A"; | |
| 122 | world.bomb.plantProgress = 1; | |
| 123 | world.bomb.defuseProgress = 0.5; | |
| 124 | return r; | |
| 125 | } | |
| 126 | ||
| 127 | test("a snapshot round trips: every body, the grenades, the smoke, the bomb and the clock", () => { | |
| 128 | const r = busy(); | |
| 129 | const bytes = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX)); | |
| 130 | // The other end has the same ten bodies on the same map from the same seed, | |
| 131 | // and has never stepped the world once. | |
| 132 | const mine = createWorld(r.level, SHORT, "watching", "ct", 5, r.seed); | |
| 133 | const got = applySnapshot(mine, bytes, 0); | |
| 134 | expect(got).not.toBe(null); | |
| 135 | const snap = got as NonNullable<typeof got>; | |
| 136 | expect(snap.tick).toBe(r.tick); | |
| 137 | expect(snap.time).toBeCloseTo(r.world.time, 2); | |
| 138 | expect(snap.own).not.toBe(null); | |
| 139 | ||
| 140 | r.world.players.forEach((p, i) => { | |
| 141 | const q = mine.players[i] as Player; | |
| 142 | // Seat 0 is the reader's own body, whose position is handed back rather | |
| 143 | // than written in: everybody else's is the server's, to a hundredth. | |
| 144 | if (i > 0) { | |
| 145 | expect(q.pos[0]).toBeCloseTo(p.pos[0], 2); | |
| 146 | expect(q.pos[1]).toBeCloseTo(p.pos[1], 2); | |
| 147 | expect(q.pos[2]).toBeCloseTo(p.pos[2], 2); | |
| 148 | expect(q.vel[0]).toBeCloseTo(p.vel[0], 2); | |
| 149 | // An angle is 16 bits of a turn, so it is right to a hundredth of a degree. | |
| 150 | expect(Math.abs(((q.yaw - p.yaw + 540) % 360) - 180)).toBeCloseTo(0, 1); | |
| 151 | } | |
| 152 | expect(q.health).toBe(Math.round(p.health)); | |
| 153 | expect(q.armor).toBe(Math.round(p.armor)); | |
| 154 | expect(q.alive).toBe(p.alive); | |
| 155 | expect(q.team).toBe(p.team); | |
| 156 | expect(q.weapon).toBe(p.weapon); | |
| 157 | expect(q.primary).toBe(p.primary); | |
| 158 | expect(q.money).toBe(p.money); | |
| 159 | expect(q.kills).toBe(p.kills); | |
| 160 | expect(q.deaths).toBe(p.deaths); | |
| 161 | expect(q.ammo[q.weapon]?.mag).toBe(p.ammo[p.weapon]?.mag ?? 0); | |
| 162 | expect(q.duckAmount).toBeCloseTo(p.duckAmount, 2); | |
| 163 | }); | |
| 164 | expect(mine.match.phase).toBe(r.world.match.phase); | |
| 165 | expect(mine.match.round).toBe(r.world.match.round); | |
| 166 | expect(mine.match.timer).toBeCloseTo(r.world.match.timer, 2); | |
| 167 | expect(mine.bomb.state).toBe("planted"); | |
| 168 | expect(mine.bomb.site).toBe("A"); | |
| 169 | expect(mine.bomb.pos[0]).toBeCloseTo(r.world.bomb.pos[0], 2); | |
| 170 | expect(mine.bomb.defuseProgress).toBeCloseTo(0.5, 3); | |
| 171 | expect(mine.grenades.length).toBe(1); | |
| 172 | expect(mine.grenades[0]?.weapon).toBe("he"); | |
| 173 | expect(mine.grenades[0]?.pos[0]).toBeCloseTo(r.world.grenades[0]?.pos[0] as number, 2); | |
| 174 | expect(mine.grenades[0]?.fuse).toBeCloseTo(r.world.grenades[0]?.fuse as number, 1); | |
| 175 | expect(mine.clouds.length).toBe(1); | |
| 176 | expect(mine.clouds[0]?.pos[1]).toBeCloseTo(500, 2); | |
| 177 | // The cloud's age survives, which is what its radius is read off. | |
| 178 | expect(mine.time - (mine.clouds[0]?.born as number)).toBeCloseTo(1, 1); | |
| 179 | }); | |
| 180 | ||
| 181 | test("a snapshot carries what happened, and a member's own shot is their own", () => { | |
| 182 | const r = room(); | |
| 183 | const world = r.world; | |
| 184 | world.match.phase = "live"; | |
| 185 | const at = world.time; | |
| 186 | pushEffect(world.effects, { kind: "shot", at, pos: [1, 2, 3], weapon: "ak47", mine: false, by: 3 }, at); | |
| 187 | pushEffect(world.effects, { kind: "tracer", at, from: [0, 0, 0], to: [10, 0, 0] }, at); | |
| 188 | pushEffect(world.effects, { kind: "sound", at, pos: [4, 5, 6], name: "step", by: 7 }, at); | |
| 189 | const bytes = writeSnapshot(world, r.tick, at - 0.001, new Uint8Array(SNAPSHOT_MAX)); | |
| 190 | const mine = createWorld(r.level, SHORT, "watching", "ct", 5, r.seed); | |
| 191 | forget(mine); | |
| 192 | expect(applySnapshot(mine, bytes, 3)?.events).toBe(3); | |
| 193 | const shot = mine.effects.events.find((e) => e?.kind === "shot"); | |
| 194 | expect(shot?.kind).toBe("shot"); | |
| 195 | if (shot?.kind === "shot") { | |
| 196 | // Seat 3 read this snapshot, and seat 3 fired: it is their own gun. | |
| 197 | expect(shot.mine).toBe(true); | |
| 198 | expect(shot.weapon).toBe("ak47"); | |
| 199 | expect(shot.pos[0]).toBeCloseTo(1, 2); | |
| 200 | } | |
| 201 | const heard = mine.effects.events.find((e) => e?.kind === "sound"); | |
| 202 | expect(heard?.kind === "sound" && heard.by).toBe(7); | |
| 203 | // And the same snapshot read by somebody else is somebody else's gun. | |
| 204 | const theirs = createWorld(r.level, SHORT, "watching", "ct", 5, r.seed); | |
| 205 | forget(theirs); | |
| 206 | applySnapshot(theirs, bytes, 8); | |
| 207 | const other = theirs.effects.events.find((e) => e?.kind === "shot"); | |
| 208 | expect(other?.kind === "shot" && other.mine).toBe(false); | |
| 209 | }); | |
| 210 | ||
| 211 | test("a snapshot of a ten-body round stays inside the bytes the rate was chosen for", () => { | |
| 212 | const r = busy(); | |
| 213 | const bytes = writeSnapshot(r.world, r.tick, r.world.time - 0.0625, new Uint8Array(SNAPSHOT_MAX)); | |
| 214 | // The number the snapshot rate was picked against: 16 a second of this is | |
| 215 | // under 10 kB a second down to each member. A change that doubles it should | |
| 216 | // be read here first, not discovered on somebody's phone. | |
| 217 | expect(bytes.byteLength).toBeLessThan(700); | |
| 218 | console.log(` a snapshot of a busy ten-body round: ${bytes.byteLength} bytes` + | |
| 219 | ` — ${(bytes.byteLength * 16 / 1000).toFixed(1)} kB/s a member at 16 Hz`); | |
| 220 | }); | |
| 221 | ||
| 222 | test("a snapshot that is not one changes nothing", () => { | |
| 223 | const r = room(); | |
| 224 | const mine = createWorld(r.level, SHORT, "watching", "ct", 5, r.seed); | |
| 225 | const was: number[] = [...(mine.players[1] as Player).pos]; | |
| 226 | expect(applySnapshot(mine, new Uint8Array(0), 0)).toBe(null); | |
| 227 | expect(applySnapshot(mine, new Uint8Array([9, 9, 9, 9]), 0)).toBe(null); | |
| 228 | // A frame that is the right kind and then stops: refused, and nothing written. | |
| 229 | const good = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX)); | |
| 230 | expect(applySnapshot(mine, good.slice(0, 30), 0)).toBe(null); | |
| 231 | expect([...(mine.players[1] as Player).pos]).toEqual(was); | |
| 232 | }); | |
| 233 | ||
| 234 | test("a control message off the wire is validated field by field", () => { | |
| 235 | const hello = readDown(JSON.stringify({ | |
| 236 | k: "hello", code: "ABCDEF", seat: 3, map: "de_forge", seed: 9, rules: "short", tick: 10, | |
| 237 | snapshotHz: 16, roster: [{ seat: 0, name: "you", bot: false, team: "ct" }], | |
| 238 | })); | |
| 239 | expect(hello?.k).toBe("hello"); | |
| 240 | expect(readDown("not json")).toBe(null); | |
| 241 | expect(readDown(JSON.stringify({ k: "hello" }))).toBe(null); | |
| 242 | expect(readDown(JSON.stringify({ k: "hello", code: "A", seat: 0, map: "x", seed: Infinity, rules: "short", tick: 0, snapshotHz: 16, roster: [] }))).toBe(null); | |
| 243 | expect(readDown(JSON.stringify({ k: "roster", roster: [{ seat: 0, name: "a", bot: true, team: "zz" }] }))).toBe(null); | |
| 244 | expect(readDown(JSON.stringify({ k: "log", feed: [], say: [{ at: 1, text: "hi" }] }))?.k).toBe("log"); | |
| 245 | expect(readDown(JSON.stringify({ k: "nonsense" }))).toBe(null); | |
| 246 | }); | |
| 247 | ||
| 248 | // --- seats --------------------------------------------------------------- | |
| 249 | ||
| 250 | test("a member takes a bot's seat and gives it back on the way out", () => { | |
| 251 | const r = room(); | |
| 252 | const seat = freeSeat(r) as number; | |
| 253 | const body = r.world.players[seat] as Player; | |
| 254 | const botName = body.name; | |
| 255 | body.money = 4321; | |
| 256 | const member = join(r, "s1", " Vesper ", 2000); | |
| 257 | expect(member?.seat).toBe(seat); | |
| 258 | expect(body.bot).toBe(false); | |
| 259 | expect(body.name).toBe("Vesper"); | |
| 260 | // The bot's money, guns and position are not reset by an arrival. | |
| 261 | expect(body.money).toBe(4321); | |
| 262 | expect(rosterOf(r).filter((s) => !s.bot).length).toBe(1); | |
| 263 | ||
| 264 | leave(r, "s1", 3000); | |
| 265 | expect(body.bot).toBe(true); | |
| 266 | expect(body.name).toBe(botName); | |
| 267 | expect(body.money).toBe(4321); | |
| 268 | expect(r.members.size).toBe(0); | |
| 269 | }); | |
| 270 | ||
| 271 | test("two friends land on opposite sides, and a name is never two people's", () => { | |
| 272 | const r = room(); | |
| 273 | const first = join(r, "s1", "sam", 1) as NonNullable<ReturnType<typeof join>>; | |
| 274 | const second = join(r, "s2", "sam", 2) as NonNullable<ReturnType<typeof join>>; | |
| 275 | const a = r.world.players[first.seat] as Player; | |
| 276 | const b = r.world.players[second.seat] as Player; | |
| 277 | expect(a.team).not.toBe(b.team); | |
| 278 | expect(second.name).toBe("sam (2)"); | |
| 279 | expect(uniqueName(r, "")).toBe("player"); | |
| 280 | // A member who reconnects with the same session keeps the seat they had. | |
| 281 | expect(join(r, "s1", "sam", 3)?.seat).toBe(first.seat); | |
| 282 | expect(r.members.size).toBe(2); | |
| 283 | }); | |
| 284 | ||
| 285 | test("a room fills up, and the eleventh person is refused rather than dropped", () => { | |
| 286 | const r = room(); | |
| 287 | for (let i = 0; i < 10; i++) expect(join(r, `s${i}`, `p${i}`, i)).not.toBe(null); | |
| 288 | expect(freeSeat(r)).toBe(null); | |
| 289 | expect(join(r, "s10", "late", 11)).toBe(null); | |
| 290 | expect(r.members.size).toBe(10); | |
| 291 | }); | |
| 292 | ||
| 293 | // --- input into the world ------------------------------------------------- | |
| 294 | ||
| 295 | /** The bytes a member would send for one tick of holding W. */ | |
| 296 | const forward = (tick: number, yaw: number): Uint8Array => | |
| 297 | writeInput({ tick, input: { ...NO_INPUT, forward: 1 }, yaw, pitch: 0, switchCode: 0, buy: null }); | |
| 298 | ||
| 299 | test("a member's keys move their own body, and nobody else's", () => { | |
| 300 | const r = room(); | |
| 301 | r.world.match.phase = "live"; | |
| 302 | r.world.match.timer = SHORT.roundTime; | |
| 303 | const member = join(r, "s1", "sam", 1) as NonNullable<ReturnType<typeof join>>; | |
| 304 | const body = r.world.players[member.seat] as Player; | |
| 305 | const was: [number, number] = [body.pos[0], body.pos[1]]; | |
| 306 | for (let i = 0; i < 32; i++) { | |
| 307 | expect(drive(r, "s1", forward(i, 90))).toBe(true); | |
| 308 | stepRoom(r); | |
| 309 | } | |
| 310 | // It walked, and it walked where the frame said it was looking. | |
| 311 | expect(Math.hypot(body.pos[0] - was[0], body.pos[1] - was[1])).toBeGreaterThan(40); | |
| 312 | expect(body.yaw).toBeCloseTo(90, 2); | |
| 313 | // An input from somebody who is not in this room reaches nothing at all. | |
| 314 | expect(drive(r, "stranger", forward(40, 0))).toBe(false); | |
| 315 | // And a frame of the wrong size is refused and counted. | |
| 316 | expect(drive(r, "s1", new Uint8Array(MESSAGE_MAX))).toBe(false); | |
| 317 | expect(member.dropped).toBe(1); | |
| 318 | }); | |
| 319 | ||
| 320 | test("a late frame is the member's last one, and silence drops the keys", () => { | |
| 321 | const r = room(); | |
| 322 | r.world.match.phase = "live"; | |
| 323 | r.world.match.timer = SHORT.roundTime; | |
| 324 | join(r, "s1", "sam", 1); | |
| 325 | const body = r.world.players[freeSeatOf(r)] as Player; | |
| 326 | drive(r, "s1", forward(1, 0)); | |
| 327 | // Nothing else arrives: the last input is held for INPUT_HOLD ticks and the | |
| 328 | // body keeps walking, then drops the keys and comes to a stop. | |
| 329 | for (let i = 0; i < INPUT_HOLD; i++) stepRoom(r); | |
| 330 | const moving = Math.hypot(body.vel[0], body.vel[1]); | |
| 331 | expect(moving).toBeGreaterThan(50); | |
| 332 | for (let i = 0; i < 64; i++) stepRoom(r); | |
| 333 | expect(Math.hypot(body.vel[0], body.vel[1])).toBeLessThan(1); | |
| 334 | // A frame that overtook another on the way here does not undo it. | |
| 335 | drive(r, "s1", forward(100, 10)); | |
| 336 | expect(drive(r, "s1", forward(50, 200))).toBe(false); | |
| 337 | stepRoom(r); | |
| 338 | expect(body.yaw).toBeCloseTo(10, 2); | |
| 339 | }); | |
| 340 | ||
| 341 | /** The seat the one member of this room is holding. */ | |
| 342 | const freeSeatOf = (r: Room): number => [...r.members.values()][0]?.seat as number; | |
| 343 | ||
| 344 | test("a member buys once for one ask, and only what the money covers", () => { | |
| 345 | const r = room(); | |
| 346 | const member = join(r, "s1", "sam", 1) as NonNullable<ReturnType<typeof join>>; | |
| 347 | const body = r.world.players[member.seat] as Player; | |
| 348 | body.money = 2700; | |
| 349 | const ask = (tick: number, item: "ak47"): void => { | |
| 350 | drive(r, "s1", writeInput({ tick, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: 0, buy: item })); | |
| 351 | }; | |
| 352 | // The key is held for a quarter of a second, which is sixteen frames of the | |
| 353 | // same ask: one rifle, not sixteen. | |
| 354 | for (let i = 0; i < 16; i++) { ask(1, "ak47"); stepRoom(r); } | |
| 355 | expect(body.primary).toBe("ak47"); | |
| 356 | expect(body.money).toBe(2700 - 2700); | |
| 357 | // And with the wallet empty, the next ask changes nothing. | |
| 358 | ask(2, "ak47"); | |
| 359 | stepRoom(r); | |
| 360 | expect(body.money).toBe(0); | |
| 361 | // Holding the rifle is a switch the server resolves against the body. | |
| 362 | drive(r, "s1", writeInput({ tick: 3, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: SWITCH_PRIMARY, buy: null })); | |
| 363 | stepRoom(r); | |
| 364 | expect(body.weapon).toBe("ak47"); | |
| 365 | drive(r, "s1", writeInput({ tick: 4, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: SWITCH_SECONDARY, buy: null })); | |
| 366 | for (let i = 0; i < 64; i++) stepRoom(r); | |
| 367 | expect(body.weapon).toBe(body.secondary); | |
| 368 | }); | |
| 369 | ||
| 370 | test("a room's round is played by its own bots and says what happened", () => { | |
| 371 | const r = room(); | |
| 372 | // Straight into a live round, and then a few seconds of it. | |
| 373 | r.world.match.phase = "live"; | |
| 374 | r.world.match.timer = SHORT.roundTime; | |
| 375 | for (let i = 0; i < 64 * 20; i++) stepRoom(r); | |
| 376 | expect(r.tick).toBe(64 * 20); | |
| 377 | expect(r.world.time).toBeCloseTo(20, 0); | |
| 378 | // Bots left their spawns and somebody shot somebody. | |
| 379 | expect(r.world.players.some((p) => p.kills > 0 || p.damage > 0)).toBe(true); | |
| 380 | const log = logSince(r); | |
| 381 | // The words of the round travel as themselves: the feed and the lines. | |
| 382 | if (log !== null) expect(log.feed.length + log.say.length).toBeGreaterThan(0); | |
| 383 | // Asked twice, nothing is said twice. | |
| 384 | const again = logSince(r); | |
| 385 | expect(again === null || again.feed.length + again.say.length === 0).toBe(true); | |
| 386 | }); | |
| 387 | ||
| 388 | // --- the hub ------------------------------------------------------------- | |
| 389 | ||
| 390 | /** A wire that remembers instead of sending. */ | |
| 391 | function spy(): Wire & { sent: Map<string, (Uint8Array | string)[]>; closed: { id: string; code: number }[] } { | |
| 392 | const sent = new Map<string, (Uint8Array | string)[]>(); | |
| 393 | const closed: { id: string; code: number }[] = []; | |
| 394 | return { | |
| 395 | sent, closed, | |
| 396 | deliver(id, frame) { | |
| 397 | const list = sent.get(id) ?? []; | |
| 398 | list.push(frame); | |
| 399 | sent.set(id, list); | |
| 400 | }, | |
| 401 | close(id, code) { closed.push({ id, code }); }, | |
| 402 | }; | |
| 403 | } | |
| 404 | ||
| 405 | const opened = { map: "de_forge" as const, seed: 5, length: "short" as const, now: 0 }; | |
| 406 | ||
| 407 | test("a hub holds its rooms, hears one member and never the other room", () => { | |
| 408 | const wire = spy(); | |
| 409 | const hub = createHub(wire, { now: 0 }); | |
| 410 | const first = hub.open({ ...opened, now: 0 }); | |
| 411 | const second = hub.open({ ...opened, seed: 6, now: 0 }); | |
| 412 | expect(first).not.toBe(null); | |
| 413 | expect(second).not.toBe(null); | |
| 414 | const a = hub.enter((first as Room).code, "s1", "sam", 0); | |
| 415 | const b = hub.enter((second as Room).code, "s2", "kim", 0); | |
| 416 | expect(a).not.toBe(null); | |
| 417 | expect(b).not.toBe(null); | |
| 418 | // Each was told which body is theirs, on which map, with who else is here. | |
| 419 | const hello = wire.sent.get("s1")?.[0] as string; | |
| 420 | expect(JSON.parse(hello).k).toBe("hello"); | |
| 421 | expect(JSON.parse(hello).code).toBe((first as Room).code); | |
| 422 | ||
| 423 | // A quarter of a second: sixteen ticks a room, four snapshots a member. | |
| 424 | for (let ms = 10; ms <= 250; ms += 10) hub.advance(ms); | |
| 425 | const snapshots = (id: string): number => | |
| 426 | (wire.sent.get(id) ?? []).filter((f) => f instanceof Uint8Array).length; | |
| 427 | expect(snapshots("s1")).toBeGreaterThanOrEqual(3); | |
| 428 | expect(snapshots("s1")).toBeLessThanOrEqual(5); | |
| 429 | // The two rooms stepped their own worlds and nobody else's. | |
| 430 | expect((first as Room).tick).toBeGreaterThan(8); | |
| 431 | expect((first as Room).world.players[0]?.pos).not.toEqual((second as Room).world.players[0]?.pos); | |
| 432 | expect(hub.members()).toBe(2); | |
| 433 | expect(hub.lines(250).length).toBe(2); | |
| 434 | ||
| 435 | // One member's input reaches their own room's world, and the other room's | |
| 436 | // member is in no way touched by it. | |
| 437 | const before = [...((second as Room).world.players[(b as { member: { seat: number } }).member.seat] as Player).pos]; | |
| 438 | expect(hub.frame("s1", forward(1, 0), 260)).toBe(true); | |
| 439 | hub.advance(300); | |
| 440 | expect((second as Room).world.players[(b as { member: { seat: number } }).member.seat]?.pos).not.toBe(before); | |
| 441 | }); | |
| 442 | ||
| 443 | test("a frame over the cap or over the limiter closes the connection", () => { | |
| 444 | const wire = spy(); | |
| 445 | const hub = createHub(wire, { now: 0 }); | |
| 446 | const r = hub.open(opened) as Room; | |
| 447 | hub.enter(r.code, "s1", "sam", 0); | |
| 448 | expect(hub.frame("s1", new Uint8Array(MESSAGE_MAX + 1), 1)).toBe(false); | |
| 449 | expect(wire.closed[0]).toEqual({ id: "s1", code: CLOSE_TOO_BIG }); | |
| 450 | // And a caller that sends far more than a page can: closed, and the process | |
| 451 | // is still here to say so. | |
| 452 | const wire2 = spy(); | |
| 453 | const hub2 = createHub(wire2, { now: 0 }); | |
| 454 | const r2 = hub2.open(opened) as Room; | |
| 455 | hub2.enter(r2.code, "s2", "kim", 0); | |
| 456 | let refused = 0; | |
| 457 | for (let i = 0; i < 400; i++) if (!hub2.frame("s2", forward(i, 0), 1)) refused += 1; | |
| 458 | expect(refused).toBeGreaterThan(200); | |
| 459 | expect(wire2.closed.some((c) => c.code === CLOSE_FLOOD)).toBe(true); | |
| 460 | }); | |
| 461 | ||
| 462 | test("the room cap refuses politely, and an empty room is collected", () => { | |
| 463 | const wire = spy(); | |
| 464 | const hub = createHub(wire, { now: 0 }); | |
| 465 | for (let i = 0; i < ROOM_CAP; i++) expect(hub.open({ ...opened, seed: i })).not.toBe(null); | |
| 466 | expect(hub.open(opened)).toBe(null); | |
| 467 | expect(hub.rooms.size).toBe(ROOM_CAP); | |
| 468 | // Nobody ever joined any of them, so they all go on the first sweep past the | |
| 469 | // idle time — and the cap is a cap again. | |
| 470 | hub.advance(IDLE_MS + 1000); | |
| 471 | expect(hub.rooms.size).toBe(0); | |
| 472 | const r = hub.open({ ...opened, now: IDLE_MS + 1000 }) as Room; | |
| 473 | hub.enter(r.code, "s1", "sam", IDLE_MS + 1000); | |
| 474 | // A room with somebody in it is never idle, however long the match runs. | |
| 475 | hub.advance(IDLE_MS * 3); | |
| 476 | expect(hub.rooms.size).toBe(1); | |
| 477 | expect(isIdle(r, IDLE_MS * 3, IDLE_MS)).toBe(false); | |
| 478 | // They leave, and it is collected on the sweep after the idle time. | |
| 479 | hub.exit("s1", IDLE_MS * 3); | |
| 480 | hub.advance(IDLE_MS * 3 + IDLE_MS + 1); | |
| 481 | expect(hub.rooms.size).toBe(0); | |
| 482 | }); | |
| 483 | ||
| 484 | test("a member nobody has heard from loses the seat they are holding", () => { | |
| 485 | const wire = spy(); | |
| 486 | const hub = createHub(wire, { now: 0 }); | |
| 487 | const r = hub.open(opened) as Room; | |
| 488 | hub.enter(r.code, "s1", "sam", 0); | |
| 489 | hub.advance(1000); | |
| 490 | expect(wire.closed.length).toBe(0); | |
| 491 | hub.advance(40_000); | |
| 492 | expect(wire.closed.some((c) => c.id === "s1")).toBe(true); | |
| 493 | // The close is what takes them out, the way a browser closing does. | |
| 494 | hub.exit("s1", 40_000); | |
| 495 | expect(r.members.size).toBe(0); | |
| 496 | expect(r.world.players.every((p) => p.bot)).toBe(true); | |
| 497 | }); | |
| 498 | ||
| 499 | test("one member's snapshot is a snapshot of the room they are in", () => { | |
| 500 | const r = room(); | |
| 501 | const member = join(r, "s1", "sam", 0) as NonNullable<ReturnType<typeof join>>; | |
| 502 | for (let i = 0; i < 64; i++) stepRoom(r); | |
| 503 | const bytes = snapshotFor(r, member); | |
| 504 | expect(bytes.byteLength).toBeGreaterThan(100); | |
| 505 | // Asking again sends only what happened since: the clock moved on. | |
| 506 | expect(member.sentAt).toBeCloseTo(r.world.time, 5); | |
| 507 | }); |
src/rooms/stage.test-helper.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | /** | |
| 2 | * The stage a room is played on in a test: the fixtures the four test files | |
| 3 | * beside this one would otherwise each keep a copy of. | |
| 4 | * | |
| 5 | * Both games do it this way — `src/client/stage.test-helper.ts` and | |
| 6 | * `src/range/stage.test-helper.ts` hold the stub page their tests boot — and | |
| 7 | * this is the same habit with a world in place of a page, because nothing under | |
| 8 | * `src/rooms/` has a page at all. What is genuinely shared is four things: a | |
| 9 | * room on a fixed map and a fixed seed, a *busy* round with a body hurt, a | |
| 10 | * grenade in the air, smoke standing and the bomb planted, the bytes a member | |
| 11 | * sends for one tick of holding W, and a `Wire` that remembers what it was | |
| 12 | * handed instead of sending it. | |
| 13 | * | |
| 14 | * It ships in no bundle: only `code.test.ts`, `wire.test.ts`, `room.test.ts` | |
| 15 | * and `hub.test.ts` import it. | |
| 16 | */ | |
| 17 | ||
| 18 | import { DE_FORGE } from "../client/map.ts"; | |
| 19 | import { SHORT } from "../client/match.ts"; | |
| 20 | import { useShipped } from "../client/shipped.ts"; | |
| 21 | import { createWorld, NO_INPUT, type Player, type World } from "../client/world.ts"; | |
| 22 | import { createRoom, stepRoom, type Room } from "./room.ts"; | |
| 23 | import { writeInput } from "./wire.ts"; | |
| 24 | import type { Wire } from "./hub.ts"; | |
| 25 | ||
| 26 | useShipped(); // a room's bots think with the shipped policy, like the game's | |
| 27 | ||
| 28 | /** A room nobody is in yet, on the map and the seed every test here plays. */ | |
| 29 | export const room = (now = 1000): Room => | |
| 30 | createRoom("ABCDEF", { map: "de_forge", seed: 9, length: "short", now }); | |
| 31 | ||
| 32 | /** A round with something in it: a body hurt, a grenade in the air, a plant. */ | |
| 33 | export function busy(): Room { | |
| 34 | const r = room(); | |
| 35 | const world = r.world; | |
| 36 | world.match.phase = "live"; | |
| 37 | world.match.timer = SHORT.roundTime; | |
| 38 | for (let i = 0; i < 64 * 3; i++) stepRoom(r); | |
| 39 | const thrower = world.players[2] as Player; | |
| 40 | world.grenades.push({ | |
| 41 | pos: [thrower.pos[0], thrower.pos[1], thrower.pos[2] + 60], vel: [300, 20, 150], | |
| 42 | onGround: false, ducking: false, duckAmount: 0, | |
| 43 | weapon: "he", owner: thrower.id, team: thrower.team, fuse: 1.25, | |
| 44 | }); | |
| 45 | world.clouds.push({ pos: [500, 500, 40], born: world.time - 1, owner: 3 }); | |
| 46 | world.bomb.state = "planted"; | |
| 47 | world.bomb.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 4]; | |
| 48 | world.bomb.site = "A"; | |
| 49 | world.bomb.plantProgress = 1; | |
| 50 | world.bomb.defuseProgress = 0.5; | |
| 51 | return r; | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * The other end of the wire: the same ten bodies on the same map from the same | |
| 56 | * seed, which has never stepped the world once. It is what a member's browser | |
| 57 | * has before the first snapshot arrives. | |
| 58 | */ | |
| 59 | export const watcher = (r: Room): World => createWorld(r.level, SHORT, "watching", "ct", 5, r.seed); | |
| 60 | ||
| 61 | /** The bytes a member would send for one tick of holding W. */ | |
| 62 | export const forward = (tick: number, yaw: number): Uint8Array => | |
| 63 | writeInput({ tick, input: { ...NO_INPUT, forward: 1 }, yaw, pitch: 0, switchCode: 0, buy: null }); | |
| 64 | ||
| 65 | /** The room a hub in a test opens. */ | |
| 66 | export const opened = { map: "de_forge" as const, seed: 5, length: "short" as const, now: 0 }; | |
| 67 | ||
| 68 | /** A wire that remembers instead of sending. */ | |
| 69 | export type SpyWire = Wire & { | |
| 70 | /** Every frame delivered, by member, in the order they were handed over. */ | |
| 71 | sent: Map<string, (Uint8Array | string)[]>; | |
| 72 | /** Every close, with the code it carried. */ | |
| 73 | closed: { id: string; code: number }[]; | |
| 74 | }; | |
| 75 | ||
| 76 | export function spy(): SpyWire { | |
| 77 | const sent = new Map<string, (Uint8Array | string)[]>(); | |
| 78 | const closed: { id: string; code: number }[] = []; | |
| 79 | return { | |
| 80 | sent, closed, | |
| 81 | deliver(id, frame) { | |
| 82 | const list = sent.get(id) ?? []; | |
| 83 | list.push(frame); | |
| 84 | sent.set(id, list); | |
| 85 | }, | |
| 86 | close(id, code) { closed.push({ id, code }); }, | |
| 87 | }; | |
| 88 | } |
src/rooms/wire.test.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
| 2 | * The codec, read from both ends with neither end running. Bytes in, plain data | |
| 3 | * out: an input frame round trips and comes back in range whatever was in it, a | |
| 4 | * snapshot of a busy ten-body round arrives on a world that has never been | |
| 5 | * stepped and is that round, a member's own shot is their own, a frame that is | |
| 6 | * not a frame changes nothing, and a control message is validated field by | |
| 7 | * field. | |
| 8 | * | |
| 9 | * The size of a snapshot is measured here and printed, because that number — | |
| 10 | * 544 bytes, 8.7 kB/s a member at 16 Hz — is the whole argument for the rate in | |
| 11 | * `src/rooms/room.ts` and should be read in a diff rather than discovered on | |
| 12 | * somebody's phone. | |
| 13 | */ | |
| 14 | ||
| 15 | import { expect, test } from "bun:test"; | |
| 16 | import { | |
| 17 | applySnapshot, readInput, writeInput, writeSnapshot, BUY_ITEMS, INPUT_BYTES, SNAPSHOT_MAX, | |
| 18 | SWITCH_PRIMARY, readDown, forget, | |
| 19 | } from "./wire.ts"; | |
| 20 | import { busy, room, watcher } from "./stage.test-helper.ts"; | |
| 21 | import { NO_INPUT, type Input, type Player } from "../client/world.ts"; | |
| 22 | import { pushEffect } from "../engine/index.ts"; | |
| 23 | ||
| 24 | // --- the input frame ------------------------------------------------------ | |
| 25 | ||
| 26 | test("an input frame round trips, and every field comes back in range", () => { | |
| 27 | const input: Input = { | |
| 28 | ...NO_INPUT, forward: 1, side: -1, jump: true, fire: true, walk: true, throwing: "smoke", | |
| 29 | }; | |
| 30 | const bytes = writeInput({ tick: 4242, input, yaw: -91.25, pitch: 37.5, switchCode: SWITCH_PRIMARY, buy: "ak47" }); | |
| 31 | expect(bytes.byteLength).toBe(INPUT_BYTES); | |
| 32 | const read = readInput(bytes); | |
| 33 | expect(read).not.toBe(null); | |
| 34 | const got = read as NonNullable<typeof read>; | |
| 35 | expect(got.tick).toBe(4242); | |
| 36 | expect(got.input.forward).toBeCloseTo(1, 2); | |
| 37 | expect(got.input.side).toBeCloseTo(-1, 2); | |
| 38 | expect(got.input.jump).toBe(true); | |
| 39 | expect(got.input.fire).toBe(true); | |
| 40 | expect(got.input.walk).toBe(true); | |
| 41 | expect(got.input.duck).toBe(false); | |
| 42 | expect(got.input.throwing).toBe("smoke"); | |
| 43 | expect(got.yaw).toBeCloseTo(-91.25, 1); | |
| 44 | expect(got.pitch).toBeCloseTo(37.5, 1); | |
| 45 | expect(got.switchCode).toBe(SWITCH_PRIMARY); | |
| 46 | expect(got.buy).toBe("ak47"); | |
| 47 | // A weapon id never crosses this wire: a switch is one of four things the | |
| 48 | // server resolves against the body it is holding. | |
| 49 | expect(BUY_ITEMS.includes("ak47")).toBe(true); | |
| 50 | }); | |
| 51 | ||
| 52 | test("a frame off the wire cannot ask for more than a keyboard can", () => { | |
| 53 | // Every byte set: the widest values this encoding can carry. | |
| 54 | const wild = new Uint8Array(INPUT_BYTES).fill(0xff); | |
| 55 | wild[0] = 1; // still an input frame | |
| 56 | const read = readInput(wild); | |
| 57 | const got = read as NonNullable<typeof read>; | |
| 58 | expect(got.input.forward).toBeGreaterThanOrEqual(-1); | |
| 59 | expect(got.input.side).toBeGreaterThanOrEqual(-1); | |
| 60 | expect(Math.abs(got.pitch)).toBeLessThanOrEqual(89); | |
| 61 | expect(Number.isFinite(got.yaw)).toBe(true); | |
| 62 | // And a frame of the wrong size or the wrong kind is not a frame at all. | |
| 63 | expect(readInput(new Uint8Array(INPUT_BYTES - 1))).toBe(null); | |
| 64 | expect(readInput(new Uint8Array(INPUT_BYTES + 1))).toBe(null); | |
| 65 | expect(readInput(new Uint8Array(INPUT_BYTES))).toBe(null); // first byte is not INPUT_FRAME | |
| 66 | }); | |
| 67 | ||
| 68 | // --- the snapshot --------------------------------------------------------- | |
| 69 | ||
| 70 | test("a snapshot round trips: every body, the grenades, the smoke, the bomb and the clock", () => { | |
| 71 | const r = busy(); | |
| 72 | const bytes = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX)); | |
| 73 | // The other end has the same ten bodies on the same map from the same seed, | |
| 74 | // and has never stepped the world once. | |
| 75 | const mine = watcher(r); | |
| 76 | const got = applySnapshot(mine, bytes, 0); | |
| 77 | expect(got).not.toBe(null); | |
| 78 | const snap = got as NonNullable<typeof got>; | |
| 79 | expect(snap.tick).toBe(r.tick); | |
| 80 | expect(snap.time).toBeCloseTo(r.world.time, 2); | |
| 81 | expect(snap.own).not.toBe(null); | |
| 82 | ||
| 83 | r.world.players.forEach((p, i) => { | |
| 84 | const q = mine.players[i] as Player; | |
| 85 | // Seat 0 is the reader's own body, whose position is handed back rather | |
| 86 | // than written in: everybody else's is the server's, to a hundredth. | |
| 87 | if (i > 0) { | |
| 88 | expect(q.pos[0]).toBeCloseTo(p.pos[0], 2); | |
| 89 | expect(q.pos[1]).toBeCloseTo(p.pos[1], 2); | |
| 90 | expect(q.pos[2]).toBeCloseTo(p.pos[2], 2); | |
| 91 | expect(q.vel[0]).toBeCloseTo(p.vel[0], 2); | |
| 92 | // An angle is 16 bits of a turn, so it is right to a hundredth of a degree. | |
| 93 | expect(Math.abs(((q.yaw - p.yaw + 540) % 360) - 180)).toBeCloseTo(0, 1); | |
| 94 | } | |
| 95 | expect(q.health).toBe(Math.round(p.health)); | |
| 96 | expect(q.armor).toBe(Math.round(p.armor)); | |
| 97 | expect(q.alive).toBe(p.alive); | |
| 98 | expect(q.team).toBe(p.team); | |
| 99 | expect(q.weapon).toBe(p.weapon); | |
| 100 | expect(q.primary).toBe(p.primary); | |
| 101 | expect(q.money).toBe(p.money); | |
| 102 | expect(q.kills).toBe(p.kills); | |
| 103 | expect(q.deaths).toBe(p.deaths); | |
| 104 | expect(q.ammo[q.weapon]?.mag).toBe(p.ammo[p.weapon]?.mag ?? 0); | |
| 105 | expect(q.duckAmount).toBeCloseTo(p.duckAmount, 2); | |
| 106 | }); | |
| 107 | expect(mine.match.phase).toBe(r.world.match.phase); | |
| 108 | expect(mine.match.round).toBe(r.world.match.round); | |
| 109 | expect(mine.match.timer).toBeCloseTo(r.world.match.timer, 2); | |
| 110 | expect(mine.bomb.state).toBe("planted"); | |
| 111 | expect(mine.bomb.site).toBe("A"); | |
| 112 | expect(mine.bomb.pos[0]).toBeCloseTo(r.world.bomb.pos[0], 2); | |
| 113 | expect(mine.bomb.defuseProgress).toBeCloseTo(0.5, 3); | |
| 114 | expect(mine.grenades.length).toBe(1); | |
| 115 | expect(mine.grenades[0]?.weapon).toBe("he"); | |
| 116 | expect(mine.grenades[0]?.pos[0]).toBeCloseTo(r.world.grenades[0]?.pos[0] as number, 2); | |
| 117 | expect(mine.grenades[0]?.fuse).toBeCloseTo(r.world.grenades[0]?.fuse as number, 1); | |
| 118 | expect(mine.clouds.length).toBe(1); | |
| 119 | expect(mine.clouds[0]?.pos[1]).toBeCloseTo(500, 2); | |
| 120 | // The cloud's age survives, which is what its radius is read off. | |
| 121 | expect(mine.time - (mine.clouds[0]?.born as number)).toBeCloseTo(1, 1); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a snapshot carries what happened, and a member's own shot is their own", () => { | |
| 125 | const r = room(); | |
| 126 | const world = r.world; | |
| 127 | world.match.phase = "live"; | |
| 128 | const at = world.time; | |
| 129 | pushEffect(world.effects, { kind: "shot", at, pos: [1, 2, 3], weapon: "ak47", mine: false, by: 3 }, at); | |
| 130 | pushEffect(world.effects, { kind: "tracer", at, from: [0, 0, 0], to: [10, 0, 0] }, at); | |
| 131 | pushEffect(world.effects, { kind: "sound", at, pos: [4, 5, 6], name: "step", by: 7 }, at); | |
| 132 | const bytes = writeSnapshot(world, r.tick, at - 0.001, new Uint8Array(SNAPSHOT_MAX)); | |
| 133 | const mine = watcher(r); | |
| 134 | forget(mine); | |
| 135 | expect(applySnapshot(mine, bytes, 3)?.events).toBe(3); | |
| 136 | const shot = mine.effects.events.find((e) => e?.kind === "shot"); | |
| 137 | expect(shot?.kind).toBe("shot"); | |
| 138 | if (shot?.kind === "shot") { | |
| 139 | // Seat 3 read this snapshot, and seat 3 fired: it is their own gun. | |
| 140 | expect(shot.mine).toBe(true); | |
| 141 | expect(shot.weapon).toBe("ak47"); | |
| 142 | expect(shot.pos[0]).toBeCloseTo(1, 2); | |
| 143 | } | |
| 144 | const heard = mine.effects.events.find((e) => e?.kind === "sound"); | |
| 145 | expect(heard?.kind === "sound" && heard.by).toBe(7); | |
| 146 | // And the same snapshot read by somebody else is somebody else's gun. | |
| 147 | const theirs = watcher(r); | |
| 148 | forget(theirs); | |
| 149 | applySnapshot(theirs, bytes, 8); | |
| 150 | const other = theirs.effects.events.find((e) => e?.kind === "shot"); | |
| 151 | expect(other?.kind === "shot" && other.mine).toBe(false); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a snapshot of a ten-body round stays inside the bytes the rate was chosen for", () => { | |
| 155 | const r = busy(); | |
| 156 | const bytes = writeSnapshot(r.world, r.tick, r.world.time - 0.0625, new Uint8Array(SNAPSHOT_MAX)); | |
| 157 | // The number the snapshot rate was picked against: 16 a second of this is | |
| 158 | // under 10 kB a second down to each member. A change that doubles it should | |
| 159 | // be read here first, not discovered on somebody's phone. | |
| 160 | expect(bytes.byteLength).toBeLessThan(700); | |
| 161 | console.log(` a snapshot of a busy ten-body round: ${bytes.byteLength} bytes` + | |
| 162 | ` — ${(bytes.byteLength * 16 / 1000).toFixed(1)} kB/s a member at 16 Hz`); | |
| 163 | }); | |
| 164 | ||
| 165 | test("a snapshot that is not one changes nothing", () => { | |
| 166 | const r = room(); | |
| 167 | const mine = watcher(r); | |
| 168 | const was: number[] = [...(mine.players[1] as Player).pos]; | |
| 169 | expect(applySnapshot(mine, new Uint8Array(0), 0)).toBe(null); | |
| 170 | expect(applySnapshot(mine, new Uint8Array([9, 9, 9, 9]), 0)).toBe(null); | |
| 171 | // A frame that is the right kind and then stops: refused, and nothing written. | |
| 172 | const good = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX)); | |
| 173 | expect(applySnapshot(mine, good.slice(0, 30), 0)).toBe(null); | |
| 174 | expect([...(mine.players[1] as Player).pos]).toEqual(was); | |
| 175 | }); | |
| 176 | ||
| 177 | test("a control message off the wire is validated field by field", () => { | |
| 178 | const hello = readDown(JSON.stringify({ | |
| 179 | k: "hello", code: "ABCDEF", seat: 3, map: "de_forge", seed: 9, rules: "short", tick: 10, | |
| 180 | snapshotHz: 16, roster: [{ seat: 0, name: "you", bot: false, team: "ct" }], | |
| 181 | })); | |
| 182 | expect(hello?.k).toBe("hello"); | |
| 183 | expect(readDown("not json")).toBe(null); | |
| 184 | expect(readDown(JSON.stringify({ k: "hello" }))).toBe(null); | |
| 185 | expect(readDown(JSON.stringify({ k: "hello", code: "A", seat: 0, map: "x", seed: Infinity, rules: "short", tick: 0, snapshotHz: 16, roster: [] }))).toBe(null); | |
| 186 | expect(readDown(JSON.stringify({ k: "roster", roster: [{ seat: 0, name: "a", bot: true, team: "zz" }] }))).toBe(null); | |
| 187 | expect(readDown(JSON.stringify({ k: "log", feed: [], say: [{ at: 1, text: "hi" }] }))?.k).toBe("log"); | |
| 188 | expect(readDown(JSON.stringify({ k: "nonsense" }))).toBe(null); | |
| 189 | }); |
new above
offline