mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-22 22:58:06 +00:00
feat: cancel ranked 2v2 matches that don't fill or fully spawn (#4655)
## Problem A ranked 2v2 that starts with a missing player, or where a matched player idles through the spawn phase, plays out as a lopsided 2v1 — and records a ranked result (ELO) for a match that was never fair. ## Solution Void the match from inside the sim: `WinCheckExecution` runs a one-time check on the first check tick after the spawn phase ends. For `rankedType === "2v2"`, if fewer than `maxPlayers` humans have spawned, the game ends immediately with **no winner**. A player who never joined isn't in the game start info at all, so the single spawn-count check covers both the missing-at-start and never-spawned cases. - `setWinner` now accepts `null` and emits a `WinUpdate` with `winner: undefined` — the wire schema (`WinnerSchema`) already allowed an absent winner. - `WinModal`'s previously-empty `winner === undefined` branch shows "Match cancelled — not all players spawned" and still votes the winnerless result to the server, so the winner-vote consensus is reached and the record archives promptly. - The archived record simply has no `winner`; the API voids winnerless ranked records (same shape any ranked game already produces when the winner vote never reaches consensus). - Only server change: winner votes are keyed with `JSON.stringify(winner ?? null)`, since `JSON.stringify(undefined)` is not a string and would break the `VoteRound` key contract. The check counts via `allPlayers()` rather than `players()`, since the latter filters out tile-less players — which is exactly what a never-spawned player is. Ranked 1v1 is deliberately untouched (the existing last-human-connected walkover still applies). ## Tests `tests/Ranked2v2Cancel.test.ts`: - 3 of 4 players spawn → game ends with a winnerless `WinUpdate` - a 4th player never joined (absent from start info) → same - all 4 spawn → no cancellation - unranked team game short a player → no cancellation Full suite (2094 tests), tsc, ESLint, and Prettier all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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!",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<GameConfig>): Promise<Game> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user