mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 15:40:26 +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:
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user