mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 17:50:50 +00:00
## 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>
104 lines
2.8 KiB
TypeScript
104 lines
2.8 KiB
TypeScript
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);
|
|
});
|
|
});
|