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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/320-src-rooms-is-held-by-this-repository-s-o → main 1 commit fork

  • Merged. torque/320-src-rooms-is-held-by-this-repository-s-o was merged into main as fe1d98e.

Review

Sign in to review this change.
8 files changed +745 −516
src/independence.test.ts +83 −9
@@ -51,16 +51,27 @@ const SHELL = join(SRC, "pages", "shell.ts");
5151 const TOOLS = [resolve(ROOT, "scripts", "build.ts"), resolve(ROOT, "scripts", "bundle.test-helper.ts")];
5252
5353 /**
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.
6273 */
63 const ALSO: Record<string, readonly string[]> = { app: ["src/rl"] };
74 const ALSO: Record<string, readonly string[]> = { app: ["src/rl", "src/rooms"] };
6475
6576 /**
6677 * 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", () =
96107 const of = (game: string): string[] => files.filter((f) => gameOf(f) === game);
97108 expect(of("app")).toContain("src/client/world.ts");
98109 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");
99116 expect(of("range")).toContain("src/range/targets.ts");
100117 expect(of("range")).toContain("src/range/main.ts");
101118 // 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", () => {
137154 `src/rl/arena.ts imports "../range/targets.ts"; a game may not import another game`,
138155 );
139156 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/);
140165 // And the third game is held to it from the moment it was written down.
141166 expect(importsAnotherGame("src/example/main.ts", "../client/world.ts")).toMatch(/another game/);
142167 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
161186 // The training and the shooter are one game between them, both ways.
162187 expect(importsAnotherGame("src/rl/arena.ts", "../client/world.ts")).toBeNull();
163188 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();
164196 // The app is served both games and is held to neither rule.
165197 expect(importsAnotherGame("src/server.ts", "./pages/range.ts")).toBeNull();
166198 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", () =>
184216 ]);
185217 });
186218
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
187261 /**
188262 * The range's own rule, which is stricter than the one above and is why the
189263 * 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 @@
1414 * room about once in ten million tries, against a limiter that allows nowhere
1515 * near that many. The code is drawn from `crypto.getRandomValues`, not from
1616 * `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.
1721 */
1822
1923 /** 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs