diff --git a/package.json b/package.json index d8854e7a74..ccf18091cc 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test": "vitest run && vitest run tests/server", "test:matchmaking": "node tests/matchmaking/contained.mjs", "test:matchmaking:e2e": "node tests/matchmaking/e2e.mjs", + "test:matchmaking:cancel": "node tests/matchmaking/e2e-cancel.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 f1ece5eaec..4fce1a86c1 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -890,7 +890,8 @@ "admin": "Kicked by an admin", "duplicate_session": "Kicked from game (you may have been playing on another tab)", "host_left": "The host has left the lobby.", - "lobby_creator": "Kicked by lobby creator" + "lobby_creator": "Kicked by lobby creator", + "match_cancelled": "Match cancelled: a player failed to connect. You have not lost any ELO." }, "lang": { "en": "English", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 9f99bd91ca..05ea5dd97a 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -229,6 +229,31 @@ export function joinLobby( }), ); }); + } else if (message.error === "kick_reason.match_cancelled") { + // A matched player never connected and the server cancelled the game + // pre-start. Tear down the dead lobby, then put the matchmaking + // modal — still open on "waiting for game" (it only closes at + // prestart) — straight back into the queue so the players who did + // connect don't have to requeue by hand. A non-blocking toast + // explains why; an alert here would keep them out of the queue + // until dismissed. + document.dispatchEvent( + new CustomEvent("leave-lobby", { + detail: { lobby: lobbyConfig.gameID, cause: "match-cancelled" }, + bubbles: true, + composed: true, + }), + ); + document.dispatchEvent(new CustomEvent("matchmaking-requeue")); + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: translateText("kick_reason.match_cancelled"), + color: "red", + duration: 5000, + }, + }), + ); } else { showErrorModal( message.error, diff --git a/src/client/Main.ts b/src/client/Main.ts index 9207802ced..04e5c0065a 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -242,6 +242,7 @@ declare global { toggle_game_start_timer: CustomEvent; "join-changed": CustomEvent; "open-matchmaking": CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>; + "matchmaking-requeue": CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>; userMeResponse: CustomEvent; "leave-lobby": CustomEvent; "game-starting": CustomEvent; @@ -414,6 +415,10 @@ class Client { "open-matchmaking", this.handleOpenMatchmaking.bind(this), ); + document.addEventListener( + "matchmaking-requeue", + this.handleMatchmakingRequeue.bind(this), + ); const hlpModal = document.querySelector("help-modal") as HelpModal; if (!hlpModal || !(hlpModal instanceof HelpModal)) { @@ -1116,6 +1121,25 @@ class Client { crazyGamesSDK.gameplayStop(); } + // Puts the player back into the ranked queue. From a pre-start match + // cancellation the matchmaking modal is still open and rejoins in place, + // keeping its mode. From a finished game (WinModal passes the mode) the + // page needs the reload teardown, so navigate home with the requeue + // param and let consumeRequeueUrl() reopen the queue. A modeless + // dispatch with no open modal (the player closed it mid-wait) stays a + // no-op — don't force them back into a queue they left. + private handleMatchmakingRequeue( + event: CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>, + ) { + if (this.matchmakingModal?.requeue()) { + return; + } + if (event.detail?.mode !== undefined) { + window.location.href = + event.detail.mode === "2v2" ? "/?requeue=2v2" : "/?requeue"; + } + } + private handleOpenMatchmaking( event: CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>, ) { diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index 088c27e0d8..5c995e4875 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -112,6 +112,29 @@ export class MatchmakingModal extends BaseModal { } } + // Re-enter the queue after a pre-start match cancellation (a matched + // player never connected to the game server). The modal is normally still + // open on "waiting for game" at that point — reset back to searching and + // reconnect. Returns false when the modal was closed in the meantime, so + // the caller knows nothing was rejoined. + public requeue(): boolean { + if (!this.isModalOpen) { + return false; + } + if (this.gameCheckInterval) { + clearInterval(this.gameCheckInterval); + this.gameCheckInterval = null; + } + this.connected = false; + this.gameID = null; + this.intentionalClose = false; + this.limitReached = false; + this.queueSize = null; + this.reconnectAttempts = 0; + this.connect(); + return true; + } + private openSubscriptions = () => { // The matchmaking modal isn't registered with the modal router, so it // won't be closed by the store opening from the hash change. @@ -156,6 +179,24 @@ export class MatchmakingModal extends BaseModal { clearTimeout(this.connectTimeout); this.connectTimeout = null; } + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + // Nor may the previous socket itself: requeue()/onOpen() reset + // intentionalClose and gameID before reconnecting, so a delayed close + // event from the old socket would look unexpected and schedule a + // duplicate connection — the server would then kick this one as + // "replaced by newer connection". + if (this.socket) { + this.socket.onopen = null; + this.socket.onmessage = null; + this.socket.onerror = null; + this.socket.onclose = null; + if (this.socket.readyState !== WebSocket.CLOSED) { + this.socket.close(); + } + } this.socket = new WebSocket( `${ClientEnv.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(ClientEnv.instanceId())}&mode=${this.mode}`, ); diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index b401fc654f..a74bd7a51a 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -256,12 +256,19 @@ export class WinModal extends LitElement implements Controller { private _handleRequeue() { this.hide(); - // Navigate to homepage and open matchmaking modal for the same mode - const requeue = - this.game.config().gameConfig().rankedType === RankedType.TwoVTwo - ? "/?requeue=2v2" - : "/?requeue"; - window.location.href = requeue; + // Requeue for the same mode; Main owns the mechanism (currently a + // reload with the requeue param, which reopens the queue after the + // page teardown). + document.dispatchEvent( + new CustomEvent("matchmaking-requeue", { + detail: { + mode: + this.game.config().gameConfig().rankedType === RankedType.TwoVTwo + ? ("2v2" as const) + : ("1v1" as const), + }, + }), + ); } init() {} diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 246a7dbc80..2ca6e96c3f 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -138,7 +138,9 @@ export class GameManager { game.maybeAutoStartListed(); } if (phase === GamePhase.Active) { - if (!game.hasStarted()) { + // A matchmade game missing a player at the start deadline is + // cancelled instead of started short-handed. + if (!game.hasStarted() && !game.cancelShortHandedMatch()) { // Prestart tells clients to start loading the game. game.prestart(); // Start game on delay to allow time for clients to connect. diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 5fc6666f01..5eff62ca03 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { anonAnimalName } from "../core/AnonAnimals"; import { isAdminRole } from "../core/ApiSchemas"; import { GameEnv } from "../core/configuration/Config"; -import { GameMode, GameType } from "../core/game/Game"; +import { GameMode, GameType, RankedType } from "../core/game/Game"; import { ClientID, ClientMessageSchema, @@ -80,6 +80,7 @@ const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session"; const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator"; const KICK_REASON_ADMIN = "kick_reason.admin"; const KICK_REASON_HOST_LEFT = "kick_reason.host_left"; +const KICK_REASON_MATCH_CANCELLED = "kick_reason.match_cancelled"; const KICK_REASON_TOO_MUCH_DATA = "kick_reason.too_much_data"; const KICK_REASON_INVALID_MESSAGE = "kick_reason.invalid_message"; @@ -1057,6 +1058,38 @@ export class GameServer { return this.outOfSyncClients.size; } + // Matchmade ranked games (1v1/2v2) must start with full attendance: the + // roster freezes at start(), so a game missing a player would run + // short-handed only to be voided by the sim (2v2) or hand out a walkover + // the absent player never contested (1v1). Called at the start deadline; + // cancels the game and returns true when a matched player never connected. + public cancelShortHandedMatch(): boolean { + // Explicitly 1v1/2v2 only — a future ranked type must opt in rather + // than inherit pre-start cancellation. + const rankedType = this.gameConfig.rankedType; + if ( + rankedType !== RankedType.OneVOne && + rankedType !== RankedType.TwoVTwo + ) { + return false; + } + const expected = this.gameConfig.maxPlayers; + if (expected === undefined || this.activeClients.length >= expected) { + return false; + } + this.log.info("cancelling matchmade game, missing players at deadline", { + gameID: this.id, + connected: this.activeClients.length, + expected, + }); + for (const c of [...this.activeClients]) { + this.kickClient(c.clientID, KICK_REASON_MATCH_CANCELLED); + } + // phase() reports Finished once ended, so GameManager's next tick prunes. + this._hasEnded = true; + return true; + } + public prestart() { if (this.hasStarted()) { return; diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 39fee2eafc..5b9379a817 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -862,7 +862,13 @@ function startMatchmakingLoop(gm: GameManager, mode: "1v1" | "2v2") { ? { ...baseConfig, allowedPublicIds: parsed.data.players } : baseConfig, undefined, - Date.now() + 7000, + // Deadline for the slowest player: after match-assignment the + // client still has to poll game existence, pass Turnstile, and + // clear join auth; anyone not connected when start() fires is + // left out of the roster and the ranked game starts short-handed. + // A full lobby is NOT delayed by this — hasReachedMaxPlayerCount + // flips the phase to Active as soon as everyone has joined. + Date.now() + 15000, undefined, parsed.success ? parsed.data.teams : undefined, ); diff --git a/tests/matchmaking/README.md b/tests/matchmaking/README.md index cc84978e59..cf8a9665b8 100644 --- a/tests/matchmaking/README.md +++ b/tests/matchmaking/README.md @@ -47,3 +47,15 @@ Prerequisites: 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. + +## Cancellation — `npm run test:matchmaking:cancel` + +Same stack (and prerequisites) as the e2e harness. Four players queue for +2v2; one has its `/exists` polls blocked, so it never connects to the +created game — simulating a client that fails to connect in time. Asserts +that at the start deadline the server cancels the game instead of starting +it 3-handed, that the three connected players get the +`kick_reason.match_cancelled` toast, dispatch `leave-lobby`, and are +automatically requeued (modal back in the searching state), that the +no-show is left untouched, and that the cancelled game is pruned without +an archive. diff --git a/tests/matchmaking/e2e-cancel.mjs b/tests/matchmaking/e2e-cancel.mjs new file mode 100644 index 0000000000..febcade643 --- /dev/null +++ b/tests/matchmaking/e2e-cancel.mjs @@ -0,0 +1,242 @@ +// E2E test for the short-handed matchmade game cancellation + auto-requeue +// (PR #4762). Same stack as e2e.mjs (dev app on :9000, API worker on :8787). +// +// Four browser players queue for 2v2. One of them (the "no-show") has its +// /exists polls blocked, so it never dispatches join-lobby and never +// connects to the game server — simulating a client that fails to connect +// in time. Expected: at the 15s start deadline the server cancels the game +// instead of starting it 3-handed, the three connected players get the +// kick_reason.match_cancelled toast, dispatch leave-lobby, and their +// matchmaking modals rejoin the queue automatically. +// +// Run: node tests/matchmaking/e2e-cancel.mjs + +import { readFileSync } from "node:fs"; +import { + gotoHome, + launch, +} from "../../.claude/skills/run-openfront/driver.mjs"; +import { isUp, makeChecker, waitFor } from "./util.mjs"; + +const MODE = "2v2"; +const PLAYER_COUNT = 4; +const NO_SHOW = 3; // index of the player that never reaches the game + +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 rafThrottle = (interval) => { + let last = 0; + window.requestAnimationFrame = (cb) => { + const now = performance.now(); + const wait = Math.max(0, interval - (now - last)); + return setTimeout(() => { + last = performance.now(); + cb(last); + }, wait); + }; + window.cancelAnimationFrame = (id) => clearTimeout(id); +}; + +const { browser } = await launch(); +const pages = []; +for (let i = 0; i < PLAYER_COUNT; i++) { + const context = await browser.newContext({ + viewport: { width: 1400, height: 1000 }, + }); + await context.addInitScript(rafThrottle, 2000); + pages.push(await context.newPage()); +} + +// The no-show never learns the game exists, so it never joins it. +await pages[NO_SHOW].route("**/api/game/*/exists", (route) => route.abort()); + +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); + }); +}; + +const fetchGameInfo = async (gameId) => { + for (const worker of ["w0", "w1"]) { + const res = await fetch( + `http://localhost:9000/${worker}/api/game/${gameId}`, + ); + if (res.ok) return res.json(); + } + return null; +}; + +const c = makeChecker(); +try { + for (const p of pages) { + await gotoHome(p); + await p.evaluate((mode) => { + window.__joinLobby = null; + window.__leaveLobby = null; + window.__toasts = []; + document.addEventListener( + "join-lobby", + (e) => (window.__joinLobby = e.detail ?? {}), + ); + document.addEventListener( + "leave-lobby", + (e) => (window.__leaveLobby = e.detail ?? {}), + ); + window.addEventListener("show-message", (e) => + window.__toasts.push(e.detail?.message ?? ""), + ); + const el = document.querySelector("matchmaking-modal"); + // requeue() refuses to rejoin a closed modal; in the real flow the + // modal is open (it only closes at prestart). Open it the same way + // Main's modal-close loop toggles it. + el.isModalOpen = true; + el.mode = mode; + el.connect(); + }, MODE); + } + console.log(`${PLAYER_COUNT} players queued (${MODE}), one will no-show...`); + + 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: "every player to receive a match-assignment", + }, + ); + } catch (err) { + dumpConsoles(); + throw err; + } + const gameId = gameIds[0]; + const assignedAt = Date.now(); + c.check( + `all ${PLAYER_COUNT} players received an assignment (${gameId})`, + gameIds.every((id) => id === gameId), + ); + + // The three live players join the game; the no-show must not. + try { + await waitFor( + async () => { + const info = await fetchGameInfo(gameId); + return (info?.clients?.length ?? 0) === PLAYER_COUNT - 1; + }, + { + timeoutMs: 20000, + intervalMs: 500, + label: "the three live players to join the game", + }, + ); + c.check("three live players joined the game", true); + } catch (err) { + dumpConsoles(); + throw err; + } + c.check( + "no-show never dispatched join-lobby", + (await pages[NO_SHOW].evaluate(() => window.__joinLobby)) === null, + ); + + // The 15s deadline passes -> the server must cancel instead of starting. + // Give it the deadline + tick granularity + margin. + let finalStates; + try { + finalStates = await waitFor( + async () => { + const states = await Promise.all( + pages.map((p) => + p.evaluate(() => { + const el = document.querySelector("matchmaking-modal"); + return { + gameID: el.gameID, + connected: el.connected, + toasts: window.__toasts, + leaveLobby: window.__leaveLobby, + }; + }), + ), + ); + const live = states.filter((_, i) => i !== NO_SHOW); + // Requeued = cancellation toast seen, left the dead lobby, modal + // back in the queue (gameID cleared, join re-sent after the 2s + // connect delay). + const done = live.every( + (s) => + s.toasts.length > 0 && + s.leaveLobby !== null && + s.gameID === null && + s.connected === true, + ); + return done ? states : null; + }, + { + timeoutMs: 40000, + intervalMs: 1000, + label: "the three live players to be cancelled and requeued", + }, + ); + } catch (err) { + dumpConsoles(); + throw err; + } + const secs = ((Date.now() - assignedAt) / 1000).toFixed(1); + c.check( + `live players cancelled + requeued (${secs}s after assignment)`, + true, + ); + + const liveStates = finalStates.filter((_, i) => i !== NO_SHOW); + c.check( + "cancellation toast mentions the failed connect", + liveStates.every((s) => s.toasts.some((t) => /cancelled/i.test(t))), + ); + c.check( + 'leave-lobby cause is "match-cancelled"', + liveStates.every((s) => s.leaveLobby?.cause === "match-cancelled"), + ); + c.check( + "no-show got no toast and kept its stale assignment", + finalStates[NO_SHOW].toasts.length === 0 && + finalStates[NO_SHOW].gameID === gameId, + ); + + // The cancelled game must be gone from the game server (pruned, never + // started, nothing archived). + await new Promise((r) => setTimeout(r, 2000)); + c.check( + "cancelled game pruned from the server", + (await fetchGameInfo(gameId)) === null, + ); + + const devLog = readFileSync("/tmp/dev.log", "utf8"); + c.check( + "server logged the cancellation", + devLog.includes("cancelling matchmade game"), + ); +} finally { + await browser.close(); +} +c.finish(); diff --git a/tests/server/MatchmakingCancel.test.ts b/tests/server/MatchmakingCancel.test.ts new file mode 100644 index 0000000000..2ed38d855d --- /dev/null +++ b/tests/server/MatchmakingCancel.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GameType, RankedType } from "../../src/core/game/Game"; +import { Client } from "../../src/server/Client"; +import { GamePhase, GameServer } from "../../src/server/GameServer"; + +function makeMockWs() { + return { + on: () => {}, + removeAllListeners: () => {}, + send: vi.fn(), + close: vi.fn(), + readyState: 1, + }; +} + +function makeClient(clientID: string, persistentID: string): Client { + return new Client( + clientID, + persistentID, + null, + null, + undefined, + "127.0.0.1", + "TestUser", + null, + makeMockWs() as any, + undefined, + `pub-${persistentID}`, + [], + ); +} + +describe("GameServer - short-handed matchmade game cancellation", () => { + let mockLogger: any; + + beforeEach(() => { + vi.useFakeTimers(); + mockLogger = { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + function makeRankedGame(rankedType: RankedType, maxPlayers: number) { + return new GameServer( + "test-game", + mockLogger, + Date.now(), + { gameType: GameType.Public, rankedType, maxPlayers } as any, + undefined, + Date.now() + 15000, + ); + } + + it("cancels a 2v2 game missing a player: kicks the connected, ends the game", () => { + const game = makeRankedGame(RankedType.TwoVTwo, 4); + const clients = [ + makeClient("c1", "p1"), + makeClient("c2", "p2"), + makeClient("c3", "p3"), + ]; + clients.forEach((c) => expect(game.joinClient(c)).toBe("joined")); + + expect(game.cancelShortHandedMatch()).toBe(true); + + for (const c of clients) { + expect(c.ws.send).toHaveBeenCalledWith( + JSON.stringify({ + type: "error", + error: "kick_reason.match_cancelled", + }), + ); + expect(c.ws.close).toHaveBeenCalled(); + } + expect(game.phase()).toBe(GamePhase.Finished); + }); + + it("cancels a 1v1 game where the opponent never connected", () => { + const game = makeRankedGame(RankedType.OneVOne, 2); + expect(game.joinClient(makeClient("c1", "p1"))).toBe("joined"); + + expect(game.cancelShortHandedMatch()).toBe(true); + expect(game.phase()).toBe(GamePhase.Finished); + }); + + it("does not cancel a ranked game with full attendance", () => { + const game = makeRankedGame(RankedType.OneVOne, 2); + expect(game.joinClient(makeClient("c1", "p1"))).toBe("joined"); + expect(game.joinClient(makeClient("c2", "p2"))).toBe("joined"); + + expect(game.cancelShortHandedMatch()).toBe(false); + expect(game.phase()).not.toBe(GamePhase.Finished); + }); + + it("does not cancel unknown ranked types — new modes must opt in", () => { + const game = new GameServer( + "test-game", + mockLogger, + Date.now(), + { gameType: GameType.Public, rankedType: "3v3", maxPlayers: 6 } as any, + undefined, + Date.now() + 15000, + ); + expect(game.joinClient(makeClient("c1", "p1"))).toBe("joined"); + + expect(game.cancelShortHandedMatch()).toBe(false); + }); + + it("does not cancel non-ranked games, even short-handed", () => { + const game = new GameServer( + "test-game", + mockLogger, + Date.now(), + { gameType: GameType.Public, maxPlayers: 4 } as any, + undefined, + Date.now() + 15000, + ); + expect(game.joinClient(makeClient("c1", "p1"))).toBe("joined"); + + expect(game.cancelShortHandedMatch()).toBe(false); + }); +});