diff --git a/resources/lang/en.json b/resources/lang/en.json index 9ab07da4d..ba3290ba5 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1639,6 +1639,7 @@ "join_discord": "Join Our Discord Community!", "join_server": "Join Server", "keep": "Keep Playing", + "match_cancelled": "Match cancelled — not all players spawned", "nation_won": "Nation {nation} has won!", "new_lobby": "New Lobby", "other_team": "{team} team has won!", diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index 8e95c876e..2ac37325f 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -277,7 +277,14 @@ export class WinModal extends LitElement implements Controller { const winUpdates = updates !== null ? updates[GameUpdateType.Win] : []; winUpdates.forEach((wu) => { if (wu.winner === undefined) { - // ... + // Match cancelled (e.g. a ranked 2v2 that didn't fill or fully + // spawn): the game ends with no winner. Still vote the result to the + // server so the record is archived winnerless (never ranked). + this.eventBus.emit(new SendWinnerEvent(undefined, wu.allPlayersStats)); + this._title = translateText("win_modal.match_cancelled"); + this.isWin = false; + history.replaceState(null, "", `${window.location.pathname}?replay`); + this.show(); } else if (wu.winner[0] === "team") { this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats)); if (wu.winner[1] === this.game.myPlayer()?.team()) { diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 8b8664353..b8d510183 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -19,6 +19,8 @@ export class WinCheckExecution implements Execution { private mg: Game | null = null; + private checkedRankedSpawns = false; + // Hard time limit (in seconds) to force a winner before the server's // maxGameDuration hard kill. 170mins (10 mins before 3hrs) private static readonly HARD_TIME_LIMIT_SECONDS = 170 * 60; @@ -35,6 +37,10 @@ export class WinCheckExecution implements Execution { } if (this.mg === null) throw new Error("Not initialized"); + if (this.checkRanked2v2Cancelled()) { + return; + } + if (this.mg.config().gameConfig().gameMode === GameMode.FFA) { this.checkWinnerFFA(); } else { @@ -42,6 +48,39 @@ export class WinCheckExecution implements Execution { } } + // A ranked 2v2 match is void unless all four matched players actually + // spawned — a player who never joined isn't in the game at all, and one who + // idled through the spawn phase never placed a spawn. Either way the match + // would be lopsided, so end it with no winner (the record is archived + // winnerless and never ranked). Runs once, on the first check after the + // spawn phase ends (this execution is inactive during the spawn phase). + private checkRanked2v2Cancelled(): boolean { + if (this.mg === null) throw new Error("Not initialized"); + if (this.checkedRankedSpawns) { + return false; + } + this.checkedRankedSpawns = true; + const gameConfig = this.mg.config().gameConfig(); + if (gameConfig.rankedType !== RankedType.TwoVTwo) { + return false; + } + // allPlayers: players() hides tile-less players, which is exactly what a + // never-spawned player is. + const spawned = this.mg + .allPlayers() + .filter((p) => p.type() === PlayerType.Human && p.hasSpawned()).length; + const expected = gameConfig.maxPlayers ?? 0; + if (spawned >= expected) { + return false; + } + console.log( + `ranked 2v2 cancelled: only ${spawned}/${expected} players spawned`, + ); + this.mg.setWinner(null, this.mg.stats().stats()); + this.active = false; + return true; + } + checkWinnerFFA(): void { if (this.mg === null) throw new Error("Not initialized"); const sorted = this.mg diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 9fd7cc4ed..854c81d8a 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -762,7 +762,12 @@ export interface Game extends GameMap { drainPackedMotionPlans(): Uint32Array | null; drainPackedPlayerUpdates(): Float64Array | null; drainPackedAttackUpdates(): Float64Array | null; - setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void; + // null ends the game with no winner (a cancelled match, e.g. a ranked game + // that didn't fill): the record is archived winnerless and never ranked. + setWinner( + winner: Player | Team | null, + allPlayersStats: AllPlayersStats, + ): void; getWinner(): Player | Team | null; config(): Config; isPaused(): boolean; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index aae76222d..bfb93de78 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -890,7 +890,10 @@ export class GameImpl implements Game { }); } - setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void { + setWinner( + winner: Player | Team | null, + allPlayersStats: AllPlayersStats, + ): void { this._winner = winner; // OFM: snapshot final tiles for standings (bots skipped in recordFinalTiles). for (const player of this.players()) { @@ -898,7 +901,7 @@ export class GameImpl implements Game { } this.addUpdate({ type: GameUpdateType.Win, - winner: this.makeWinner(winner), + winner: winner === null ? undefined : this.makeWinner(winner), allPlayersStats, }); } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index dca022bde..7617402b2 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1591,8 +1591,9 @@ export class GameServer { } client.reportedWinner = clientMsg.winner; - // Add client vote - const winnerKey = JSON.stringify(clientMsg.winner); + // Add client vote. A cancelled match ends with winner omitted; + // JSON.stringify(undefined) is not a string, so key those votes as "null". + const winnerKey = JSON.stringify(clientMsg.winner ?? null); const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip)).size; const votes = this.winnerVotes.add(winnerKey, clientMsg, client.ip); diff --git a/tests/Ranked2v2Cancel.test.ts b/tests/Ranked2v2Cancel.test.ts new file mode 100644 index 000000000..8920f636f --- /dev/null +++ b/tests/Ranked2v2Cancel.test.ts @@ -0,0 +1,103 @@ +import { WinCheckExecution } from "../src/core/execution/WinCheckExecution"; +import { + Game, + GameMode, + PlayerInfo, + PlayerType, + RankedType, +} from "../src/core/game/Game"; +import { GameUpdateType, WinUpdate } from "../src/core/game/GameUpdates"; +import { GameConfig } from "../src/core/Schemas"; +import { setup } from "./util/Setup"; + +async function setupTeamGame(config: Partial): Promise { + const players = [1, 2, 3, 4].map( + (n) => + new PlayerInfo(`player${n}`, PlayerType.Human, `client${n}`, `p${n}_id`), + ); + return setup( + "plains", + { + gameMode: GameMode.Team, + playerTeams: 2, + maxPlayers: 4, + ...config, + }, + players, + ); +} + +function spawnPlayers(game: Game, count: number) { + for (let n = 1; n <= count; n++) { + const player = game.player(`p${n}_id`); + player.setSpawnTile(game.map().ref(n, n)); + } +} + +// The check fires on the first WinCheckExecution tick after the spawn phase +// (ticks divisible by 10), so 11 ticks is always enough to reach it. +function collectWinUpdates(game: Game): WinUpdate[] { + game.addExecution(new WinCheckExecution()); + const wins: WinUpdate[] = []; + for (let i = 0; i < 11; i++) { + wins.push(...game.executeNextTick()[GameUpdateType.Win]); + } + return wins; +} + +describe("Ranked 2v2 cancellation", () => { + it("ends the game with no winner when only 3 of 4 players spawned", async () => { + const game = await setupTeamGame({ rankedType: RankedType.TwoVTwo }); + spawnPlayers(game, 3); + + const wins = collectWinUpdates(game); + + expect(wins).toHaveLength(1); + expect(wins[0].winner).toBeUndefined(); + expect(game.getWinner()).toBeNull(); + }); + + it("ends the game with no winner when a player never joined", async () => { + // Only 3 players are in the game at all (the 4th never connected, so it + // isn't in the start info), even though all present players spawned. + const players = [1, 2, 3].map( + (n) => + new PlayerInfo( + `player${n}`, + PlayerType.Human, + `client${n}`, + `p${n}_id`, + ), + ); + const game = await setup( + "plains", + { + gameMode: GameMode.Team, + playerTeams: 2, + maxPlayers: 4, + rankedType: RankedType.TwoVTwo, + }, + players, + ); + spawnPlayers(game, 3); + + const wins = collectWinUpdates(game); + + expect(wins).toHaveLength(1); + expect(wins[0].winner).toBeUndefined(); + }); + + it("does not cancel when all 4 players spawned", async () => { + const game = await setupTeamGame({ rankedType: RankedType.TwoVTwo }); + spawnPlayers(game, 4); + + expect(collectWinUpdates(game)).toHaveLength(0); + }); + + it("does not cancel unranked team games that are short a player", async () => { + const game = await setupTeamGame({}); + spawnPlayers(game, 3); + + expect(collectWinUpdates(game)).toHaveLength(0); + }); +});