Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,34 @@ 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,
}),
);
const matchmaking = document.querySelector("matchmaking-modal") as
| (HTMLElement & { requeue?: () => boolean })
| null;
matchmaking?.requeue?.();
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("kick_reason.match_cancelled"),
color: "red",
duration: 5000,
},
}),
);
} else {
showErrorModal(
message.error,
Expand Down
22 changes: 22 additions & 0 deletions src/client/Matchmaking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,28 @@ 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.reconnectAttempts = 0;
this.connect();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.
Expand Down
4 changes: 3 additions & 1 deletion src/server/GameManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -1057,6 +1058,32 @@ 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 {
if (this.gameConfig.rankedType === undefined) {
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;
Expand Down
8 changes: 7 additions & 1 deletion src/server/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
12 changes: 12 additions & 0 deletions tests/matchmaking/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading