pike/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

Merge pull request #7 from pike/6-bug-check-on-main-hunt-what-the-tests-ar

Bug check on main: hunt what the tests are not catching
benjivers committed edbb432 merge Browse files
7 files changed +263 −23
README.md +6 −1
@@ -52,7 +52,12 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
5252 spike, ability zones — is a flat billboard scaled by distance and clipped
5353 against the walls.
5454 - One map, one match at a time, ten seats. There is no matchmaking, no
55 ranking and no account: your seat belongs to a session cookie.
55 ranking and no account: your seat belongs to a session cookie, and a
56 playing seat is capped one per client address so a cookie — free to mint
57 again — cannot buy a second one on the other team. A second machine on a
58 second network is still a second person as far as the server can tell,
59 the same as any anonymous game without accounts; a socket that does not
60 get a seat is sent a spectator view with no position in it, never a side.
5661 - No overtime — first to 13 ends it at 12–12 too.
5762 - Sight is a full circle rather than a view cone — you are drawn what is in
5863 front of you, but the server reveals anything you have line of sight to,
src/arena.ts +92 −8
@@ -31,10 +31,16 @@ import { isWeaponId, WEAPONS } from "./weapons.ts";
3131 export const TICK = 1 / 30;
3232 const BROADCAST_EVERY = 2;
3333
34 export type Seat = { playerId: string; send: (data: string) => void };
34 export type Seat = { playerId: string; token: string; address: string; send: (data: string) => void };
3535
3636 let state: MatchState = newMatch();
3737 const seats = new Map<string, Seat>();
38 // A session is a cookie, and a cookie costs one HTTP request — free for
39 // anyone to mint as many of as they like. The address is the one thing here
40 // a single machine cannot hand itself two of, so it is what a playing seat
41 // is actually rationed by; the session on top of it is only what lets a
42 // reload find its way back to the same seat rather than a fresh one.
43 const addressSeats = new Map<string, string>();
3844 let ticks = 0;
3945 let lastResult: string | null = null;
4046 // The kill feed is read off the match a tick at a time; the rules keep no
@@ -65,14 +71,34 @@ function freeName(want: string, exceptId: string): string {
6571 return want;
6672 }
6773
68 /** Sit somebody down in the emptiest team, replacing a bot where it stands. */
69 export function takeSeat(session: string, name: string): Player | null {
74 /**
75 * Whether this address already plays under a different session. Checked
76 * before a socket is even offered a seat, so the caller can hand it a
77 * spectator view instead of upgrading it into a second side's vision.
78 */
79 export function addressCapped(address: string, session: string): boolean {
80 const holder = addressSeats.get(address);
81 return holder !== undefined && holder !== session;
82 }
83
84 /**
85 * Sit somebody down in the emptiest team, replacing a bot where it stands.
86 *
87 * Both claims go in right here, synchronously, before the caller ever opens
88 * a socket. A websocket upgrade completes on a later tick, so two requests
89 * arriving close together both used to read the maps before either had
90 * written to them — one client, two seats, opposite sides, the vision of
91 * both. Claiming inline closes that window regardless of how the two
92 * requests were related.
93 */
94 export function takeSeat(address: string, session: string, name: string): Player | null {
7095 const existing = seats.get(session);
7196 const held = existing ? findPlayer(existing.playerId) : undefined;
7297 if (held) {
7398 held.name = freeName(name, held.id);
7499 return held;
75100 }
101 if (addressCapped(address, session)) return null;
76102 const humans = { red: 0, blue: 0 };
77103 for (const player of state.players) if (!player.bot) humans[player.team] += 1;
78104 const wanted = humans.red <= humans.blue ? "red" : "blue";
@@ -83,18 +109,28 @@ export function takeSeat(session: string, name: string): Player | null {
83109 seat.name = freeName(name, seat.id);
84110 seat.think = 0;
85111 seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null };
112 addressSeats.set(address, session);
113 seats.set(session, { playerId: seat.id, token: "", address, send: () => {} });
86114 return seat;
87115 }
88116
89 export function attach(session: string, playerId: string, send: (data: string) => void): void {
90 seats.set(session, { playerId, send });
117 /** The socket that actually opened fills in how to reach it and its token. */
118 export function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void {
119 seats.set(session, { playerId, token, address, send });
91120 }
92121
93 /** A person leaving gives the seat back to a bot, mid-round and all. */
94 export function release(session: string): void {
122 /**
123 * A person leaving gives the seat back to a bot, mid-round and all — but only
124 * if this is still the socket the session is on. A reload opens the new
125 * socket before the old one's close event arrives; without the token, that
126 * late close would delete the reconnect's seat entry and hand a live human's
127 * seat to a bot mid-round.
128 */
129 export function release(session: string, token: string): void {
95130 const seat = seats.get(session);
131 if (!seat || seat.token !== token) return;
96132 seats.delete(session);
97 if (!seat) return;
133 if (addressSeats.get(seat.address) === session) addressSeats.delete(seat.address);
98134 const player = findPlayer(seat.playerId);
99135 if (player && !stillHeld(seat.playerId)) {
100136 player.bot = true;
@@ -104,11 +140,52 @@ export function release(session: string): void {
104140 }
105141 }
106142
143 /**
144 * Test-only hygiene: give back whatever this address currently holds. The
145 * arena is one match shared by the whole test run, over real HTTP, from one
146 * loopback address — this is how a test that needs that address free gets it
147 * free, rather than depending on every other test in the file to clean up
148 * after itself.
149 */
150 export function releaseAddress(address: string): void {
151 const session = addressSeats.get(address);
152 if (session !== undefined) release(session, seats.get(session)?.token ?? "");
153 }
154
107155 const stillHeld = (playerId: string): boolean =>
108156 [...seats.values()].some((s) => s.playerId === playerId);
109157
110158 export const seatCount = (): number => seats.size;
111159
160 // -------------------------------------------------------------- spectators
161
162 // A connection that never carried a session the page minted gets no side and
163 // no seat: only the public state everyone already sees on the page itself.
164 const spectators = new Map<string, (data: string) => void>();
165
166 export function spectatorSnapshot(): string {
167 return JSON.stringify({
168 t: "s",
169 spectator: true,
170 phase: state.phase,
171 clock: round2(Math.max(0, state.clock)),
172 round: state.round,
173 score: state.score,
174 attackers: state.attackers,
175 result: state.result,
176 events: state.events.slice(0, 5).map((e) => e.text),
177 });
178 }
179
180 export function watch(token: string, send: (data: string) => void): void {
181 spectators.set(token, send);
182 send(spectatorSnapshot());
183 }
184
185 export function unwatch(token: string): void {
186 spectators.delete(token);
187 }
188
112189 // ------------------------------------------------------------- the commands
113190
114191 export type Command = { t: string; [key: string]: unknown };
@@ -291,6 +368,13 @@ function step(): void {
291368 // handler gives the seat back.
292369 }
293370 }
371 for (const send of spectators.values()) {
372 try {
373 send(spectatorSnapshot());
374 } catch {
375 // Same story: the close handler drops it from the set.
376 }
377 }
294378 }
295379
296380 let timer: ReturnType<typeof setInterval> | null = null;
src/client.test.ts +24 −1
@@ -149,6 +149,17 @@ const realFetch = globalThis.fetch;
149149 globalThis.fetch = ((input: string, init?: RequestInit) =>
150150 realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch;
151151
152 // A real browser attaches the session cookie to the websocket handshake on
153 // its own; this stub is not a browser, so it carries the cookie the page's
154 // own fetch was handed, the way one actually would.
155 let sessionCookie = "";
156 let openedSocket: WebSocket | null = null;
157 const RealWebSocket = globalThis.WebSocket;
158 globalThis.WebSocket = function (url: string) {
159 openedSocket = new RealWebSocket(url, { headers: { cookie: sessionCookie } } as unknown as string[]);
160 return openedSocket;
161 } as unknown as typeof WebSocket;
162
152163 const runFrame = () => {
153164 const pending = frames.splice(0, frames.length);
154165 for (const fn of pending) fn(performance.now());
@@ -166,7 +177,9 @@ const until = async (what: () => boolean, ms = 4000) => {
166177 // ------------------------------------------------------------------ the run
167178
168179 test("the bundled client connects, paints the HUD and draws a frame", async () => {
169 const page = await (await fetch(base + "/")).text();
180 const first = await fetch(base + "/");
181 sessionCookie = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
182 const page = await first.text();
170183 const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!;
171184 const script = await (await fetch(base + src)).text();
172185 const path = join(process.env.FORGE_APP_DATA!, "client.mjs");
@@ -174,6 +187,10 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
174187
175188 await import(path);
176189 expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true);
190 // The one socket this module opens is already under way; put the global
191 // back so another test file's raw `new WebSocket(...)` is not silently
192 // handed a cookie this test happened to pick up.
193 globalThis.WebSocket = RealWebSocket;
177194 expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true);
178195
179196 // The HUD says what the match says.
@@ -256,4 +273,10 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
256273
257274 // And the socket really took a seat in the running match.
258275 expect(seat.bot).toBe(false);
276
277 // This test's loopback address is shared with every other test in the
278 // run; close the socket so the address is free for the next real
279 // connection rather than left holding a seat for the rest of the suite.
280 openedSocket?.close();
281 await Bun.sleep(50);
259282 }, 15000);
src/match.test.ts +12
@@ -12,6 +12,7 @@ import {
1212 newMatch,
1313 PLANT_TIME,
1414 ROUND_TIME,
15 setInput,
1516 SWAP_AFTER,
1617 tick,
1718 visible,
@@ -393,6 +394,17 @@ test("a seat made on its own still gets a name of its own", () => {
393394 expect(names.size).toBe(10);
394395 });
395396
397 test("an aim angle from a client is kept in range, not taken as given", () => {
398 const state = newMatch(9);
399 const player = state.players[0]!;
400 setInput(player, { angle: 99999 });
401 expect(player.input.angle).toBeGreaterThanOrEqual(-Math.PI);
402 expect(player.input.angle).toBeLessThanOrEqual(Math.PI);
403 // A plain, already-sane angle is unchanged in any way that matters.
404 setInput(player, { angle: 1.2 });
405 expect(player.input.angle).toBeCloseTo(1.2, 9);
406 });
407
396408 test("the deal is the seeded shuffle, so the same match replays the same", () => {
397409 expect(newMatch(77).players.map((p) => p.name)).toEqual(newMatch(77).players.map((p) => p.name));
398410 expect(newMatch(77).players.map((p) => p.name)).not.toEqual(newMatch(78).players.map((p) => p.name));
src/match.ts +5 −1
@@ -670,11 +670,15 @@ export function visible(state: MatchState, viewer: Player, other: Player): boole
670670 return !sightBlocked(viewer, other, state.zones);
671671 }
672672
673 /** Wraps to (-pi, pi]: a client's own claim of where it looks, kept in range
674 * rather than left to grow without bound and lose precision against it. */
675 const normalizeAngle = (angle: number): number => ((angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
676
673677 export function setInput(player: Player, input: Partial<Input>): void {
674678 const target = player.input;
675679 if (typeof input.mx === "number" && Number.isFinite(input.mx)) target.mx = Math.max(-1, Math.min(1, input.mx));
676680 if (typeof input.my === "number" && Number.isFinite(input.my)) target.my = Math.max(-1, Math.min(1, input.my));
677 if (typeof input.angle === "number" && Number.isFinite(input.angle)) target.angle = input.angle;
681 if (typeof input.angle === "number" && Number.isFinite(input.angle)) target.angle = normalizeAngle(input.angle);
678682 if (typeof input.fire === "boolean") target.fire = input.fire;
679683 if (typeof input.use === "boolean") target.use = input.use;
680684 if (typeof input.reload === "boolean") target.reload = input.reload;
src/server.test.ts +85 −3
@@ -6,7 +6,7 @@ import { join } from "node:path";
66 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
77 process.env.PORT = "0";
88 const { server } = await import("./server.ts");
9 const { match, snapshot, advance, takeSeat } = await import("./arena.ts");
9 const { match, snapshot, advance, takeSeat, release, releaseAddress } = await import("./arena.ts");
1010 const base = `http://${server.hostname}:${server.port}`;
1111
1212 test("serves a live match on the first paint, with no script needed", async () => {
@@ -47,6 +47,13 @@ test("a name is taken from a same-origin form and sanitised", async () => {
4747 expect(match().players.every((p) => !p.name.includes("<"))).toBe(true);
4848 // The seat that name took is a real player in the running match.
4949 expect(match().players.some((p) => !p.bot && p.name.includes("Wren"))).toBe(true);
50
51 // A join never opens a socket to close, so nothing ever frees the address
52 // it claimed on its own; every other test in this run shares this same
53 // loopback address, so free it explicitly rather than starve them of it.
54 const sessionCookie = posted.headers.getSetCookie().find((c) => c.startsWith("val_session="));
55 const session = sessionCookie?.split(";")[0]?.split("=")[1];
56 if (session) release(session, "");
5057 });
5158
5259 test("refuses a cross-site post", async () => {
@@ -207,11 +214,86 @@ test("the page carries the first-run card and the prompt line", async () => {
207214 expect(body).toContain('id="viewtoggle"');
208215 });
209216
217 test("a stale socket closing does not evict a live reconnect's seat", async () => {
218 // A reload opens the new socket before the old one's close event lands.
219 // The old close must not turn a player who is still connected into a bot.
220 const { attach, release } = await import("./arena.ts");
221 const player = takeSeat("203.0.113.1", "reload-session", "Reload")!;
222 attach("reload-session", player.id, "203.0.113.1", "first", () => {});
223 attach("reload-session", player.id, "203.0.113.1", "second", () => {});
224 release("reload-session", "first");
225 expect(player.bot).toBe(false);
226 });
227
228 test("one session cannot claim two seats on opposite teams", () => {
229 // A websocket upgrade finishes on a later tick than the request that asked
230 // for it, so two requests for the same session used to both read the seat
231 // map before either had written to it — one client, two seats, opposite
232 // sides, the vision of both. Nothing here waits for a socket to open:
233 // the claim has to land the moment a seat is handed out.
234 const first = takeSeat("203.0.113.2", "cheat-session", "Cheat")!;
235 const second = takeSeat("203.0.113.2", "cheat-session", "Cheat")!;
236 expect(second.id).toBe(first.id);
237 expect(match().players.filter((p) => !p.bot && p.name === "Cheat").length).toBe(1);
238 });
239
240 test("one address cannot hold seats on both sides through a second session", async () => {
241 // Three GETs of "/" mint three different, equally legitimate-looking
242 // cookies. A session names a browser tab, not a person, so capping by
243 // session alone still lets one machine sit on both teams and read both
244 // snapshots — every position on the map, no forged traffic required.
245 const { addressCapped } = await import("./arena.ts");
246 const first = takeSeat("198.51.100.9", "session-a", "OneMachine")!;
247 expect(addressCapped("198.51.100.9", "session-b")).toBe(true);
248 const second = takeSeat("198.51.100.9", "session-b", "OneMachine");
249 expect(second).toBe(null);
250 // The same session reconnecting is not a second claim, and is let through.
251 expect(addressCapped("198.51.100.9", "session-a")).toBe(false);
252 expect(takeSeat("198.51.100.9", "session-a", "OneMachine")!.id).toBe(first.id);
253 });
254
255 test("a real second connection from one address gets a spectator view, positions and all excluded", async () => {
256 // The end-to-end path: two sockets from this test's own loopback address,
257 // one cookie each. The second must come back with no seat and nothing in
258 // its snapshot that carries a coordinate — score and clock, not a map.
259 // This loopback address is shared with every other test that runs a real
260 // request; start from a clean slate rather than trust none of them left it
261 // holding a seat.
262 releaseAddress("127.0.0.1");
263 const first = await fetch(base + "/");
264 const cookieOne = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
265 const second = await fetch(base + "/");
266 const cookieTwo = (second.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
267 expect(cookieOne).not.toBe(cookieTwo);
268
269 const wsUrl = base.replace(/^http/, "ws") + "/ws";
270 const wsOne = new WebSocket(wsUrl, { headers: { cookie: cookieOne } } as unknown as string[]);
271 const snapOne = await new Promise<{ spectator?: boolean; me?: unknown }>((resolve, reject) => {
272 wsOne.onmessage = (event) => resolve(JSON.parse(String(event.data)));
273 wsOne.onerror = reject;
274 });
275 // The first connection from this address is the one that actually plays.
276 expect(snapOne.spectator).toBeUndefined();
277 expect(snapOne.me).toBeDefined();
278
279 const wsTwo = new WebSocket(wsUrl, { headers: { cookie: cookieTwo } } as unknown as string[]);
280 const snapTwo = await new Promise<unknown>((resolve, reject) => {
281 wsTwo.onmessage = (event) => resolve(JSON.parse(String(event.data)));
282 wsTwo.onerror = reject;
283 });
284 expect(snapTwo).toMatchObject({ t: "s", spectator: true });
285 // No coordinate anywhere, not under "me", not under a different key either.
286 expect(JSON.stringify(snapTwo)).not.toMatch(/"x"|"y"/);
287 wsOne.close();
288 wsTwo.close();
289 await Bun.sleep(50);
290 });
291
210292 test("two people who pick the same name do not end up with the same name", () => {
211293 // Everything that addresses a player — the kill feed, the round log, the
212294 // scoreboard — does it by name, so the match may never hold two of one.
213 const first = takeSeat("clash-a", "Twin")!;
214 const second = takeSeat("clash-b", "Twin")!;
295 const first = takeSeat("198.51.100.10", "clash-a", "Twin")!;
296 const second = takeSeat("198.51.100.11", "clash-b", "Twin")!;
215297 expect(first.name).toBe("Twin");
216298 expect(second.name).not.toBe("Twin");
217299 expect(second.name.startsWith("Twin")).toBe(true);
src/server.ts +39 −9
@@ -10,7 +10,20 @@
1010 import { createHash, randomUUID } from "node:crypto";
1111 import { join } from "node:path";
1212 import { AGENTS, SLOTS } from "./abilities.ts";
13 import { attach, command, findPlayer, match, release, snapshot, startArena, takeSeat, type Command } from "./arena.ts";
13 import {
14 addressCapped,
15 attach,
16 command,
17 findPlayer,
18 match,
19 release,
20 snapshot,
21 startArena,
22 takeSeat,
23 unwatch,
24 watch,
25 type Command,
26 } from "./arena.ts";
1427 import { recentResults } from "./db.ts";
1528 import { gridRows, TILE } from "./map.ts";
1629 import { gamePage } from "./pages/game.ts";
@@ -145,7 +158,7 @@ const cleanName = (raw: unknown): string => {
145158 const setCookie = (name: string, value: string, httpOnly: boolean): string =>
146159 `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=604800${httpOnly ? "; HttpOnly" : ""}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
147160
148 type SocketData = { session: string; playerId: string };
161 type SocketData = { session: string; playerId: string; address: string; token: string; spectator: boolean };
149162
150163 // ---------------------------------------------------------------- routing
151164
@@ -157,6 +170,10 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
157170 const jar = cookies(request);
158171 const session = jar[SESSION] ?? randomUUID();
159172 const fresh = jar[SESSION] === undefined;
173 // A cookie costs one HTTP request, so it names a browser, not a person —
174 // the address is the one thing here a single machine cannot mint a
175 // second one of, which is what a playing seat is actually rationed by.
176 const address = self?.requestIP?.(request)?.address ?? "";
160177
161178 if (request.method === "POST" && !sameOrigin(request))
162179 return new Response("forbidden", { status: 403, headers: SECURITY });
@@ -169,18 +186,28 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
169186 headers.append("set-cookie", setCookie(SESSION, session, true));
170187 if (name) {
171188 headers.append("set-cookie", setCookie(NAME, name, false));
172 takeSeat(session, name);
189 takeSeat(address, session, name);
173190 }
174191 return new Response(null, { status: 303, headers });
175192 }
176193
177 // The socket: one seat per session, taken from a bot where it stands.
194 // The socket: one playing seat per address, taken from a bot where it
195 // stands. A connection with no session the page minted, or one whose
196 // address already plays under a different session, gets no seat at all —
197 // it watches instead, with a view built for no side, rather than being
198 // handed the vision a seat would otherwise give it on top of the one it
199 // already has.
178200 if (url.pathname === "/ws") {
179201 if (!sameOrigin(request)) return new Response("forbidden", { status: 403, headers: SECURITY });
202 const token = randomUUID();
203 if (fresh || addressCapped(address, session)) {
204 const upgraded = self.upgrade(request, { data: { session, playerId: "", address, token, spectator: true } });
205 return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY });
206 }
180207 const name = cleanName(jar[NAME]) || `Guest-${session.slice(0, 4)}`;
181 const player = takeSeat(session, name);
208 const player = takeSeat(address, session, name);
182209 if (!player) return new Response("no seats", { status: 503, headers: SECURITY });
183 const upgraded = self.upgrade(request, { data: { session, playerId: player.id } });
210 const upgraded = self.upgrade(request, { data: { session, playerId: player.id, address, token, spectator: false } });
184211 return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY });
185212 }
186213
@@ -264,11 +291,13 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
264291 websocket: {
265292 maxPayloadLength: 4 * 1024,
266293 open(socket) {
267 const { session, playerId } = socket.data;
268 attach(session, playerId, (data) => socket.send(data));
294 const { session, playerId, address, token, spectator } = socket.data;
295 if (spectator) return void watch(token, (data) => socket.send(data));
296 attach(session, playerId, address, token, (data) => socket.send(data));
269297 socket.send(snapshot(playerId));
270298 },
271299 message(socket, raw) {
300 if (socket.data.spectator) return; // a watcher has no seat to drive
272301 // Anything a client sends is a claim: it is parsed, bounded and given
273302 // to the arena, which decides what it is allowed to mean.
274303 if (typeof raw !== "string" || raw.length > 4096) return;
@@ -282,7 +311,8 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
282311 command(socket.data.playerId, parsed as Command);
283312 },
284313 close(socket) {
285 release(socket.data.session);
314 if (socket.data.spectator) return void unwatch(socket.data.token);
315 release(socket.data.session, socket.data.token);
286316 },
287317 },
288318
↑↓ navigate ↵ open esc close

Keyboard shortcuts