diff --git a/docs/API.md b/docs/API.md index d37707957..1497312de 100644 --- a/docs/API.md +++ b/docs/API.md @@ -25,7 +25,7 @@ GET https://api.openfront.io/public/games - `end` (required): ISO 8601 timestamp - `type` (optional): Game type, must be one of `[Private, Public, Singleplayer]` - `mode` (optional): Game mode, must be one of `[Free For All, Team]` -- `rankedType` (optional): Ranked type, must be one of `[unranked, 1v1]` +- `rankedType` (optional): Ranked type, must be one of `[unranked, 1v1, 2v2]` - `playerTeams` (optional): Player team configuration (e.g. `Duos`) - `limit` (optional): Number of results (max 1000, default 50) - `offset` (optional): Pagination offset diff --git a/resources/lang/en.json b/resources/lang/en.json index 1af29f0c9..13eb59a36 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1077,6 +1077,7 @@ "replaced": "You joined matchmaking from another tab or window.", "searching": "Searching for game...", "title": "1v1 Ranked Matchmaking (ALPHA)", + "title_2v2": "2v2 Ranked Matchmaking (ALPHA)", "waiting_for_game": "Waiting for game to start..." }, "mode_selector": { @@ -1208,6 +1209,7 @@ "public": "Public", "ranked": "Ranked", "ranked_1v1": "1v1", + "ranked_2v2": "2v2", "solo": "Solo", "stats_games_played": "Games Played", "stats_losses": "Losses", diff --git a/src/client/Main.ts b/src/client/Main.ts index 4e5c49b7b..a74f14ac1 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -233,7 +233,7 @@ declare global { "kick-player": CustomEvent; toggle_game_start_timer: CustomEvent; "join-changed": CustomEvent; - "open-matchmaking": CustomEvent; + "open-matchmaking": CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>; userMeResponse: CustomEvent; "leave-lobby": CustomEvent; "update-game-config": CustomEvent; @@ -854,16 +854,24 @@ class Client { window.location.href = "/"; } - if (this.consumeRequeueUrl()) { - document.dispatchEvent(new CustomEvent("open-matchmaking")); + const requeueMode = this.consumeRequeueUrl(); + if (requeueMode !== null) { + document.dispatchEvent( + new CustomEvent("open-matchmaking", { + detail: { mode: requeueMode }, + }), + ); } } - private consumeRequeueUrl(): boolean { + // Returns the requeue mode ("/?requeue" = 1v1, "/?requeue=2v2" = 2v2), or + // null when the URL has no requeue param. + private consumeRequeueUrl(): "1v1" | "2v2" | null { const searchParams = new URLSearchParams(window.location.search); if (!searchParams.has("requeue")) { - return false; + return null; } + const mode = searchParams.get("requeue") === "2v2" ? "2v2" : "1v1"; searchParams.delete("requeue"); const newUrl = @@ -871,7 +879,7 @@ class Client { (searchParams.toString() ? `?${searchParams.toString()}` : "") + window.location.hash; history.replaceState(null, "", newUrl); - return true; + return mode; } private async handleJoinLobby(event: CustomEvent) { @@ -1066,8 +1074,14 @@ class Client { crazyGamesSDK.gameplayStop(); } - private handleOpenMatchmaking(_event: CustomEvent) { - this.matchmakingModal?.open(); + private handleOpenMatchmaking( + event: CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>, + ) { + if (!this.matchmakingModal) return; + // Always set the mode: dispatchers without a detail (homepage button, + // requeue URL) mean 1v1 and must reset a lingering 2v2 selection. + this.matchmakingModal.mode = event.detail?.mode === "2v2" ? "2v2" : "1v1"; + this.matchmakingModal.open(); } private handleKickPlayer(event: CustomEvent) { diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index 9f2484241..9e4effdb9 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -18,6 +18,9 @@ export class MatchmakingModal extends BaseModal { private reconnectTimeout: ReturnType | null = null; private reconnectAttempts = 0; private intentionalClose = false; + // Which queue to join; set by Main from the open-matchmaking event + // before the modal opens. + public mode: "1v1" | "2v2" = "1v1"; @state() private connected = false; @state() private socket: WebSocket | null = null; @state() private gameID: string | null = null; @@ -34,7 +37,11 @@ export class MatchmakingModal extends BaseModal { protected renderHeaderSlot() { return modalHeader({ - title: translateText("matchmaking_modal.title"), + title: translateText( + this.mode === "2v2" + ? "matchmaking_modal.title_2v2" + : "matchmaking_modal.title", + ), onBack: () => this.close(), ariaLabel: translateText("common.back"), }); @@ -80,7 +87,7 @@ export class MatchmakingModal extends BaseModal { this.connectTimeout = null; } this.socket = new WebSocket( - `${ClientEnv.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(ClientEnv.instanceId())}`, + `${ClientEnv.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(ClientEnv.instanceId())}&mode=${this.mode}`, ); this.socket.onopen = async () => { console.log("Connected to matchmaking server"); @@ -184,9 +191,11 @@ export class MatchmakingModal extends BaseModal { return; } - this.elo = - userMe.player.leaderboard?.oneVone?.elo ?? - translateText("matchmaking_modal.no_elo"); + const row = + this.mode === "2v2" + ? userMe.player.leaderboard?.twoVtwo + : userMe.player.leaderboard?.oneVone; + this.elo = row?.elo ?? translateText("matchmaking_modal.no_elo"); this.connected = false; this.gameID = null; diff --git a/src/client/components/RankedModal.ts b/src/client/components/RankedModal.ts index 94cf987b1..d377546fe 100644 --- a/src/client/components/RankedModal.ts +++ b/src/client/components/RankedModal.ts @@ -13,6 +13,7 @@ export class RankedModal extends BaseModal { protected routerName = "ranked"; @state() private elo: number | string = "..."; + @state() private elo2v2: number | string = "..."; @state() private userMeResponse: UserMeResponse | false = false; @state() private errorMessage: string | null = null; // CrazyGames players authenticate through the SDK, not a linked @@ -56,20 +57,23 @@ export class RankedModal extends BaseModal { private updateElo() { if (this.errorMessage) { this.elo = translateText("map_component.error"); + this.elo2v2 = translateText("map_component.error"); return; } if (this.isRankedEligible()) { - this.elo = - this.userMeResponse && - this.userMeResponse.player.leaderboard?.oneVone?.elo - ? this.userMeResponse.player.leaderboard.oneVone.elo - : translateText("matchmaking_modal.no_elo"); + const leaderboard = this.userMeResponse + ? this.userMeResponse.player.leaderboard + : undefined; + const noElo = translateText("matchmaking_modal.no_elo"); + this.elo = leaderboard?.oneVone?.elo ?? noElo; + this.elo2v2 = leaderboard?.twoVtwo?.elo ?? noElo; } } protected override async onOpen(): Promise { this.elo = "..."; + this.elo2v2 = "..."; this.errorMessage = null; try { @@ -83,6 +87,7 @@ export class RankedModal extends BaseModal { this.userMeResponse = false; this.errorMessage = translateText("map_component.error"); this.elo = translateText("map_component.error"); + this.elo2v2 = translateText("map_component.error"); } finally { this.updateElo(); } @@ -110,11 +115,15 @@ export class RankedModal extends BaseModal { (this.isRankedEligible() ? translateText("matchmaking_modal.elo", { elo: this.elo }) : translateText("mode_selector.ranked_title")), - () => this.handleRanked(), + () => this.handleRanked("1v1"), )} - ${this.renderDisabledCard( + ${this.renderCard( translateText("mode_selector.ranked_2v2_title"), - translateText("mode_selector.coming_soon"), + this.errorMessage ?? + (this.isRankedEligible() + ? translateText("matchmaking_modal.elo", { elo: this.elo2v2 }) + : translateText("mode_selector.ranked_title")), + () => this.handleRanked("2v2"), )} ${this.renderDisabledCard( translateText("mode_selector.coming_soon"), @@ -133,7 +142,7 @@ export class RankedModal extends BaseModal { return html`