diff --git a/eslint.config.js b/eslint.config.js index 201abad09..3ac29c3e3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -33,6 +33,7 @@ export default [ "__mocks__/fileMock.js", "eslint.config.js", "scripts/sync-assets.mjs", + "tests/matchmaking/*.mjs", ], }, tsconfigRootDir: import.meta.dirname, diff --git a/package.json b/package.json index 5635c7245..0ff687aa3 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "docs:map-generator": "cd map-generator && go doc -cmd -u -all", "tunnel": "npm run build-prod && npm run start:server", "test": "vitest run && vitest run tests/server", + "test:matchmaking": "node tests/matchmaking/contained.mjs", + "test:matchmaking:e2e": "node tests/matchmaking/e2e.mjs", "perf": "npx tsx tests/perf/run-all.ts", "perf:game": "npx tsx tests/perf/fullgame/FullGamePerf.ts", "perf:client": "npx tsx tests/perf/client/ClientUpdatePerf.ts", diff --git a/resources/lang/en.json b/resources/lang/en.json index 9ffddf027..b353aae80 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1074,6 +1074,7 @@ "connecting": "Connecting to matchmaking server...", "elo": "Your ELO: {elo}", "no_elo": "No ELO yet", + "replaced": "You joined matchmaking from another tab or window.", "searching": "Searching for game...", "title": "1v1 Ranked Matchmaking (ALPHA)", "waiting_for_game": "Waiting for game to start..." diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index c65f092fd..9f2484241 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -15,6 +15,9 @@ import { translateText } from "./Utils"; export class MatchmakingModal extends BaseModal { private gameCheckInterval: ReturnType | null = null; private connectTimeout: ReturnType | null = null; + private reconnectTimeout: ReturnType | null = null; + private reconnectAttempts = 0; + private intentionalClose = false; @state() private connected = false; @state() private socket: WebSocket | null = null; @state() private gameID: string | null = null; @@ -71,6 +74,11 @@ export class MatchmakingModal extends BaseModal { } private async connect() { + // A pending join timer from a previous socket must not fire on this one. + if (this.connectTimeout) { + clearTimeout(this.connectTimeout); + this.connectTimeout = null; + } this.socket = new WebSocket( `${ClientEnv.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(ClientEnv.instanceId())}`, ); @@ -99,6 +107,7 @@ export class MatchmakingModal extends BaseModal { console.log(event.data); const data = JSON.parse(event.data); if (data.type === "match-assignment") { + this.intentionalClose = true; this.socket?.close(); console.log(`matchmaking: got game ID: ${data.gameId}`); this.gameID = data.gameId; @@ -108,8 +117,35 @@ export class MatchmakingModal extends BaseModal { this.socket.onerror = (event: Event) => { console.error("WebSocket error occurred:", event); }; - this.socket.onclose = () => { - console.log("Matchmaking server closed connection"); + this.socket.onclose = (event: CloseEvent) => { + console.log( + `Matchmaking server closed connection: code=${event.code} reason=${event.reason}`, + ); + if (this.intentionalClose || this.gameID !== null) { + return; + } + if (event.code === 1000) { + // A newer connection for this account (e.g. a second tab) took the + // queue slot; this socket was replaced. Do not retry. + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: translateText("matchmaking_modal.replaced"), + color: "red", + duration: 5000, + }, + }), + ); + this.close(); + return; + } + // 1008: the jwt was rejected — getPlayToken() refreshes expired tokens, + // so rejoining sends a fresh one. Anything else is a server + // restart/deploy; the queue is in-memory only, so rejoin. Back off in + // case the failure repeats. + this.connected = false; + const delay = Math.min(1000 * 2 ** this.reconnectAttempts++, 15000); + this.reconnectTimeout = setTimeout(() => this.connect(), delay); }; } @@ -154,16 +190,23 @@ export class MatchmakingModal extends BaseModal { this.connected = false; this.gameID = null; + this.intentionalClose = false; + this.reconnectAttempts = 0; this.connect(); } protected onClose(): void { this.connected = false; + this.intentionalClose = true; this.socket?.close(); if (this.connectTimeout) { clearTimeout(this.connectTimeout); this.connectTimeout = null; } + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } if (this.gameCheckInterval) { clearInterval(this.gameCheckInterval); this.gameCheckInterval = null; diff --git a/tests/matchmaking/README.md b/tests/matchmaking/README.md new file mode 100644 index 000000000..65bd2866a --- /dev/null +++ b/tests/matchmaking/README.md @@ -0,0 +1,41 @@ +# Matchmaking integration harnesses + +Two harnesses for the matchmaking client integration (the `matchmaking-modal` +close-code/reconnect contract from the matchmaking API handoff). Neither runs +as part of `npm test` — they drive a real headless browser. + +## Contained — `npm run test:matchmaking` + +Integration test against a **fake matchmaking server** (`fakeServer.mjs`, an +in-process `ws` server speaking the documented protocol). The browser's +`WebSocket` for `/matchmaking/join` is redirected to it, so real close-code +semantics apply. Covers: + +| Scenario | Expected client behavior | +| ----------------------------------- | ------------------------------- | +| join after connect | `{type:"join", jwt}` sent | +| abrupt drop (deploy/restart, 1006) | reconnect + rejoin (backoff) | +| 1008 `Invalid session` | reconnect + rejoin, fresh token | +| `match-assignment` | gameId recorded, socket done | +| 1000 `Replaced by newer connection` | message shown, **no** retry | +| intentional close (user backs out) | no retry, no message | + +Prerequisite: `npm run dev` (app on :9000). No API worker needed. + +## E2E — `npm run test:matchmaking:e2e` + +Real integration against the **API worker on `localhost:8787`**. Two browser +players join the real queue through the real modal; the dev game server's +`/matchmaking/checkin` long-poll receives the assignment and creates the +game; the test asserts both players get the same `gameId` and dispatch +`join-lobby` once the game exists. + +Prerequisites: + +- `npm run dev` (app + game server on :9000; the game server polls checkin + on :8787 out of the box in dev) +- the API worker running locally: `wrangler dev` in the API repo (port 8787) + +On failure the harness dumps both players' browser consoles — close code +1008 means the worker rejected the play token; no assignment usually means +the worker rejected the game server's `x-api-key` checkin. diff --git a/tests/matchmaking/contained.mjs b/tests/matchmaking/contained.mjs new file mode 100644 index 000000000..f169c26e8 --- /dev/null +++ b/tests/matchmaking/contained.mjs @@ -0,0 +1,139 @@ +// Contained matchmaking integration test: drives the real matchmaking modal +// in the real app against a fake matchmaking server (fakeServer.mjs) that +// speaks the documented protocol. Covers the close-code contract: +// - unexpected close (deploy) -> reconnect + rejoin +// - 1008 Invalid session -> reconnect + rejoin (fresh token) +// - 1000 Replaced by newer connection-> message shown, NO retry +// - intentional close (user backs out or assignment received) -> no retry +// +// Prerequisite: the dev app must be running (`npm run dev`, port 9000). +// Run: npm run test:matchmaking + +import { + gotoHome, + launch, +} from "../../.claude/skills/run-openfront/driver.mjs"; +import { startFakeMatchmakingServer } from "./fakeServer.mjs"; +import { isUp, makeChecker, waitFor } from "./util.mjs"; + +if (!(await isUp("http://localhost:9000"))) { + console.error( + "Dev app is not running on :9000 — start it with `npm run dev`.", + ); + process.exit(1); +} + +const fake = await startFakeMatchmakingServer(); +const control = async (path, body) => { + const res = await fetch(`${fake.controlUrl}/${path}`, { + method: "POST", + body: body ? JSON.stringify(body) : undefined, + }); + return res.json(); +}; +const joins = async () => { + const res = await fetch(`${fake.controlUrl}/state`); + return (await res.json()).joins; +}; +const joinCountReaches = (n, timeoutMs) => + waitFor(async () => (await joins()).length >= n, { + timeoutMs, + label: `join #${n} to reach the fake server`, + }); + +const { browser, page } = await launch(); +const c = makeChecker(); +try { + await gotoHome(page); + + // Redirect the modal's /matchmaking/join socket to the fake server while + // keeping real browser WebSocket (and close-code) semantics. + await page.evaluate((wsUrl) => { + const Real = window.WebSocket; + window.WebSocket = class extends Real { + constructor(url, protocols) { + const s = String(url); + if (s.includes("/matchmaking/join")) { + super( + `${wsUrl}/matchmaking/join?${s.split("?")[1] ?? ""}`, + protocols, + ); + } else { + super(url, protocols); + } + } + }; + window.__mmMessages = []; + window.addEventListener("show-message", (e) => + window.__mmMessages.push(e.detail?.message), + ); + }, fake.wsUrl); + + const modal = (body) => + page.evaluate(`(() => { + const el = document.querySelector("matchmaking-modal"); + ${body} + })()`); + const resetAndConnect = () => + modal(`el.gameID = null; + el.intentionalClose = false; + el.reconnectAttempts = 0; + el.connect();`); + + // 1. Joining the queue: connect -> join arrives (after the modal's 2s delay) + await resetAndConnect(); + await joinCountReaches(1, 8000); + c.check("join sent after connect", true); + + // 2. Deploy/restart: server drops the socket abruptly -> reconnect + rejoin + await control("kill"); + await joinCountReaches(2, 10000); + c.check("unexpected close -> reconnected and rejoined", true); + + // 3. Invalid session: next join is closed 1008 -> client retries and rejoins + await control("reject-next"); + await control("kill"); // forces the reconnect whose join gets 1008 + await joinCountReaches(4, 20000); // join 3 rejected, join 4 accepted + c.check("1008 -> reconnected and rejoined with fresh token", true); + + // 4. Assignment: modal records the gameId + await control("assign", { gameId: "FakeGame1" }); + await waitFor(() => modal(`return el.gameID === "FakeGame1";`), { + timeoutMs: 5000, + label: "modal to receive match-assignment", + }); + c.check("match-assignment received", true); + await modal(`el.onClose();`); // stop the game-exists polling + + // 5. Replaced by newer connection: message shown, no retry + await resetAndConnect(); + await joinCountReaches(5, 8000); + await control("replace"); + await waitFor(() => page.evaluate(() => window.__mmMessages.length > 0), { + timeoutMs: 5000, + label: "replaced message", + }); + const msg = await page.evaluate(() => window.__mmMessages.at(-1)); + c.check( + `replaced -> message shown ("${msg}")`, + typeof msg === "string" && !msg.includes("matchmaking_modal."), + ); + await new Promise((r) => setTimeout(r, 3500)); + c.check("replaced -> no retry", (await joins()).length === 5); + + // 6. Intentional close (user backs out): no retry, no message + await resetAndConnect(); + await joinCountReaches(6, 8000); + const msgsBefore = await page.evaluate(() => window.__mmMessages.length); + await modal(`el.onClose();`); + await new Promise((r) => setTimeout(r, 3500)); + c.check("intentional close -> no retry", (await joins()).length === 6); + c.check( + "intentional close -> no message", + (await page.evaluate(() => window.__mmMessages.length)) === msgsBefore, + ); +} finally { + await browser.close(); + await fake.close(); +} +c.finish(); diff --git a/tests/matchmaking/e2e.mjs b/tests/matchmaking/e2e.mjs new file mode 100644 index 000000000..5396f041c --- /dev/null +++ b/tests/matchmaking/e2e.mjs @@ -0,0 +1,116 @@ +// End-to-end matchmaking integration test. Requires the REAL stack: +// - the API worker running on localhost:8787 (wrangler dev in the API repo) +// - the dev app running on localhost:9000 (`npm run dev` — its game server +// long-polls the worker's /matchmaking/checkin out of the box in dev) +// +// Drives two real browser players through the real matchmaking modal: +// both join the queue on the worker, the matcher pairs them, the local game +// server receives the checkin assignment and creates the game, and both +// clients see the game exist and dispatch join-lobby. +// +// Run: npm run test:matchmaking:e2e + +import { + gotoHome, + launch, +} from "../../.claude/skills/run-openfront/driver.mjs"; +import { isUp, makeChecker, waitFor } from "./util.mjs"; + +if (!(await isUp("http://localhost:9000"))) { + console.error( + "Dev app is not running on :9000 — start it with `npm run dev`.", + ); + process.exit(1); +} +if (!(await isUp("http://localhost:8787"))) { + console.error( + "API worker is not running on :8787 — start it with `wrangler dev` in the API repo.", + ); + process.exit(1); +} + +const { browser, page: page1 } = await launch(); +const context2 = await browser.newContext({ + viewport: { width: 1400, height: 1000 }, +}); +const page2 = await context2.newPage(); + +const pages = [page1, page2]; +const consoles = pages.map(() => []); +pages.forEach((p, i) => p.on("console", (msg) => consoles[i].push(msg.text()))); + +const dumpConsoles = () => { + pages.forEach((_, i) => { + console.error(`\n--- player ${i + 1} console (last 25 lines) ---`); + for (const line of consoles[i].slice(-25)) console.error(line); + }); + console.error( + "\nHints: close code 1008 means the worker rejected the play token; " + + "no assignment at all usually means the worker isn't accepting the " + + "game server's checkin (x-api-key) or the matcher isn't running.", + ); +}; + +const c = makeChecker(); +try { + // Two contexts = separate localStorage = two distinct players. + for (const p of pages) { + await gotoHome(p); + await p.evaluate(() => { + window.__joinLobby = null; + document.addEventListener( + "join-lobby", + (e) => (window.__joinLobby = e.detail ?? {}), + ); + document.querySelector("matchmaking-modal").connect(); + }); + } + + const gameIdOf = (p) => + p.evaluate(() => document.querySelector("matchmaking-modal").gameID); + + let gameIds; + try { + gameIds = await waitFor( + async () => { + const ids = await Promise.all(pages.map(gameIdOf)); + return ids.every(Boolean) ? ids : null; + }, + { + timeoutMs: 90000, + intervalMs: 1000, + label: "both players to receive a match-assignment", + }, + ); + } catch (err) { + dumpConsoles(); + throw err; + } + c.check(`both players received an assignment (${gameIds[0]})`, true); + c.check("both players got the same gameId", gameIds[0] === gameIds[1]); + + // The modal polls the game server until the assigned game exists, then + // dispatches join-lobby — this proves the checkin/creation side worked. + try { + await waitFor( + async () => { + const details = await Promise.all( + pages.map((p) => p.evaluate(() => window.__joinLobby)), + ); + return details.every((d) => d?.gameID === gameIds[0]); + }, + { + timeoutMs: 45000, + intervalMs: 1000, + label: "both players to dispatch join-lobby for the created game", + }, + ); + c.check("game created on the game server and joinable by both", true); + } catch (err) { + dumpConsoles(); + throw err; + } +} finally { + await browser.close(); +} +c.finish(); diff --git a/tests/matchmaking/fakeServer.mjs b/tests/matchmaking/fakeServer.mjs new file mode 100644 index 000000000..4a0dd80b3 --- /dev/null +++ b/tests/matchmaking/fakeServer.mjs @@ -0,0 +1,101 @@ +// Fake matchmaking server implementing the API worker's documented protocol +// (see the matchmaking integration handoff): WS /matchmaking/join, the +// {type:"join", jwt} message, {type:"match-assignment", gameId}, and the +// close-code contract (1008 invalid session, 1000 replaced by newer +// connection). A control API lets tests trigger each server-side behavior. + +import http from "node:http"; +import { WebSocketServer } from "ws"; + +export async function startFakeMatchmakingServer() { + const state = { + joins: [], // every {type:"join"} ever received: { jwt, at } + sockets: new Map(), // jwt -> ws holding the queue slot + rejectNextJoin: false, + }; + + const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + const send = (obj) => { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(obj)); + }; + switch (new URL(req.url, "http://localhost").pathname) { + case "/control/state": + return send({ + joins: state.joins, + queued: [...state.sockets.keys()], + }); + case "/control/reject-next": // next join gets 1008 Invalid session + state.rejectNextJoin = true; + return send({ ok: true }); + case "/control/kill": // abrupt drop (deploy/restart) -> client sees 1006 + for (const ws of state.sockets.values()) ws.terminate(); + state.sockets.clear(); + return send({ ok: true }); + case "/control/replace": // queue slot taken by a newer connection + for (const ws of state.sockets.values()) { + ws.close(1000, "Replaced by newer connection"); + } + state.sockets.clear(); + return send({ ok: true }); + case "/control/assign": { + const { gameId } = JSON.parse(body || "{}"); + for (const ws of state.sockets.values()) { + ws.send(JSON.stringify({ type: "match-assignment", gameId })); + } + return send({ ok: true }); + } + default: + res.statusCode = 404; + return res.end(); + } + }); + }); + + const wss = new WebSocketServer({ + server, + // Real worker rejects a missing instance_id with HTTP 400 pre-upgrade. + verifyClient: ({ req }) => + new URL(req.url, "http://localhost").searchParams.has("instance_id"), + }); + + wss.on("connection", (ws) => { + ws.on("message", (raw) => { + let msg; + try { + msg = JSON.parse(raw.toString()); + } catch { + return; + } + if (msg.type !== "join") return; + state.joins.push({ jwt: msg.jwt, at: Date.now() }); + if (state.rejectNextJoin) { + state.rejectNextJoin = false; + ws.close(1008, "Invalid session"); + return; + } + const prev = state.sockets.get(msg.jwt); + if (prev && prev !== ws) { + prev.close(1000, "Replaced by newer connection"); + } + state.sockets.set(msg.jwt, ws); + }); + ws.on("close", () => { + for (const [jwt, s] of state.sockets) { + if (s === ws) state.sockets.delete(jwt); + } + }); + }); + + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = server.address().port; + return { + port, + wsUrl: `ws://127.0.0.1:${port}`, + controlUrl: `http://127.0.0.1:${port}/control`, + close: () => new Promise((r) => server.close(r)), + }; +} diff --git a/tests/matchmaking/util.mjs b/tests/matchmaking/util.mjs new file mode 100644 index 000000000..c9721857a --- /dev/null +++ b/tests/matchmaking/util.mjs @@ -0,0 +1,44 @@ +// Shared helpers for the matchmaking harnesses (contained + e2e). + +export async function waitFor( + fn, + { timeoutMs = 10000, intervalMs = 200, label = "condition" } = {}, +) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) { + throw new Error(`Timed out after ${timeoutMs}ms waiting for: ${label}`); + } + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +// Any HTTP response (including 404) counts as "up"; only a network error +// (nothing listening) counts as down. +export async function isUp(url) { + try { + await fetch(url, { signal: AbortSignal.timeout(2000) }); + return true; + } catch { + return false; + } +} + +export function makeChecker() { + const results = []; + return { + check(name, cond) { + results.push({ name, pass: !!cond }); + console.log(`${cond ? "PASS" : "FAIL"} ${name}`); + }, + finish() { + const failed = results.filter((r) => !r.pass); + console.log( + `\n${results.length - failed.length}/${results.length} checks passed`, + ); + process.exit(failed.length > 0 ? 1 : 0); + }, + }; +}