Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
25 changes: 25 additions & 0 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,31 @@ 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,
}),
);
document.dispatchEvent(new CustomEvent("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
24 changes: 24 additions & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ declare global {
toggle_game_start_timer: CustomEvent;
"join-changed": CustomEvent;
"open-matchmaking": CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>;
"matchmaking-requeue": CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>;
userMeResponse: CustomEvent<UserMeResponse | false>;
"leave-lobby": CustomEvent;
"game-starting": CustomEvent;
Expand Down Expand Up @@ -414,6 +415,10 @@ class Client {
"open-matchmaking",
this.handleOpenMatchmaking.bind(this),
);
document.addEventListener(
"matchmaking-requeue",
this.handleMatchmakingRequeue.bind(this),
);

const hlpModal = document.querySelector("help-modal") as HelpModal;
if (!hlpModal || !(hlpModal instanceof HelpModal)) {
Expand Down Expand Up @@ -1116,6 +1121,25 @@ class Client {
crazyGamesSDK.gameplayStop();
}

// Puts the player back into the ranked queue. From a pre-start match
// cancellation the matchmaking modal is still open and rejoins in place,
// keeping its mode. From a finished game (WinModal passes the mode) the
// page needs the reload teardown, so navigate home with the requeue
// param and let consumeRequeueUrl() reopen the queue. A modeless
// dispatch with no open modal (the player closed it mid-wait) stays a
// no-op — don't force them back into a queue they left.
private handleMatchmakingRequeue(
event: CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>,
) {
if (this.matchmakingModal?.requeue()) {
return;
}
if (event.detail?.mode !== undefined) {
window.location.href =
event.detail.mode === "2v2" ? "/?requeue=2v2" : "/?requeue";
}
}

private handleOpenMatchmaking(
event: CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>,
) {
Expand Down
41 changes: 41 additions & 0 deletions src/client/Matchmaking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,29 @@ 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.queueSize = null;
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 Expand Up @@ -156,6 +179,24 @@ export class MatchmakingModal extends BaseModal {
clearTimeout(this.connectTimeout);
this.connectTimeout = null;
}
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
// Nor may the previous socket itself: requeue()/onOpen() reset
// intentionalClose and gameID before reconnecting, so a delayed close
// event from the old socket would look unexpected and schedule a
// duplicate connection — the server would then kick this one as
// "replaced by newer connection".
if (this.socket) {
this.socket.onopen = null;
this.socket.onmessage = null;
this.socket.onerror = null;
this.socket.onclose = null;
if (this.socket.readyState !== WebSocket.CLOSED) {
this.socket.close();
}
}
this.socket = new WebSocket(
`${ClientEnv.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(ClientEnv.instanceId())}&mode=${this.mode}`,
);
Expand Down
19 changes: 13 additions & 6 deletions src/client/hud/layers/WinModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,19 @@ export class WinModal extends LitElement implements Controller {

private _handleRequeue() {
this.hide();
// Navigate to homepage and open matchmaking modal for the same mode
const requeue =
this.game.config().gameConfig().rankedType === RankedType.TwoVTwo
? "/?requeue=2v2"
: "/?requeue";
window.location.href = requeue;
// Requeue for the same mode; Main owns the mechanism (currently a
// reload with the requeue param, which reopens the queue after the
// page teardown).
document.dispatchEvent(
new CustomEvent("matchmaking-requeue", {
detail: {
mode:
this.game.config().gameConfig().rankedType === RankedType.TwoVTwo
? ("2v2" as const)
: ("1v1" as const),
},
}),
);
}

init() {}
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
35 changes: 34 additions & 1 deletion src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { z } from "zod";
import { anonAnimalName } from "../core/AnonAnimals";
import { isAdminRole } from "../core/ApiSchemas";
import { GameEnv } from "../core/configuration/Config";
import { GameMode, GameType } from "../core/game/Game";
import { GameMode, GameType, RankedType } from "../core/game/Game";
import {
ClientID,
ClientMessageSchema,
Expand Down 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,38 @@ 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 {
// Explicitly 1v1/2v2 only — a future ranked type must opt in rather
// than inherit pre-start cancellation.
const rankedType = this.gameConfig.rankedType;
if (
rankedType !== RankedType.OneVOne &&
rankedType !== RankedType.TwoVTwo
) {
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