From c995bf1cdf8a86c152b8a44cdb57ee934d841f2a Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 17:29:47 +0100 Subject: [PATCH 1/6] fix(client): ?requeue=ffa no longer opens 1v1 matchmaking Co-Authored-By: Claude Opus 5.5 --- src/client/Main.ts | 28 +++------------ src/client/RequeueUrl.ts | 53 ++++++++++++++++++++++++++++ tests/client/RequeueUrl.test.ts | 61 +++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 24 deletions(-) create mode 100644 src/client/RequeueUrl.ts create mode 100644 tests/client/RequeueUrl.test.ts diff --git a/src/client/Main.ts b/src/client/Main.ts index 5ccf7a144a..55d63ac381 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -103,6 +103,7 @@ import { } from "./PresenceGroup"; import { fetchMyGameXp, fetchProgressionConfig } from "./ProgressionApi"; import { startProvisionalProgress } from "./ProvisionalProgress"; +import { consumeRequeueUrl } from "./RequeueUrl"; import type { RewardsModal } from "./RewardsModal"; import { ensureServerList, @@ -1576,7 +1577,9 @@ class Client { window.location.href = homeHref(); } - const requeueMode = this.consumeRequeueUrl(); + const requeueMode = consumeRequeueUrl(window.location, (url) => + history.replaceState(null, "", url), + ); if (requeueMode !== null) { document.dispatchEvent( new CustomEvent("open-matchmaking", { @@ -1608,29 +1611,6 @@ class Client { if (customElements.get("store-modal")) this.storeModal?.refresh(); } - // Returns the requeue mode ("/?requeue" = 1v1, "/?requeue=2v2" = 2v2, - // "/?requeue=ffa" = FFA, "/?requeue=clanwars" = clan wars), or null when - // the URL has no requeue param. - private consumeRequeueUrl(): MatchmakingMode | null { - const searchParams = new URLSearchParams(window.location.search); - if (!searchParams.has("requeue")) { - return null; - } - const param = searchParams.get("requeue"); - const mode = - param === "2v2" || param === "ffa" || param === "clanwars" - ? param - : "1v1"; - - searchParams.delete("requeue"); - const newUrl = - window.location.pathname + - (searchParams.toString() ? `?${searchParams.toString()}` : "") + - window.location.hash; - history.replaceState(null, "", newUrl); - return mode; - } - private desktopUpdateState: DesktopUpdateState | null = null; // See the call site in handleUrl. True when a navigation was issued. diff --git a/src/client/RequeueUrl.ts b/src/client/RequeueUrl.ts new file mode 100644 index 0000000000..b3a02b1015 --- /dev/null +++ b/src/client/RequeueUrl.ts @@ -0,0 +1,53 @@ +import { RankedType } from "@openfront/engine-api/game/GameTypes"; +import type { MatchmakingMode } from "./Matchmaking"; + +// Kept tiny and free of game-client imports: Main (the homepage chunk) loads +// it statically. + +// The queue a `/?requeue=` value reopens: "" (a bare `/?requeue`) = 1v1, +// "2v2" = 2v2, "clanwars" = clan wars. Ranked FFA has no queue any more, so +// "ffa" (a stale link or bookmark) reopens nothing; any other value falls +// back to 1v1. +export function requeueModeFromParam(param: string): MatchmakingMode | null { + if (param === "ffa") return null; + return param === "2v2" || param === "clanwars" ? param : "1v1"; +} + +// The queue the win modal's "play again" reopens for a finished game, or null +// when there is none (an unranked game, or a ranked FFA whose queue is gone). +export function requeueModeForRankedType( + rankedType: RankedType | undefined, +): MatchmakingMode | null { + switch (rankedType) { + case RankedType.OneVOne: + return "1v1"; + case RankedType.TwoVTwo: + return "2v2"; + case RankedType.ClanWars: + return "clanwars"; + default: + return null; + } +} + +// Reads and strips the `requeue` param from the URL. Returns the mode to +// reopen, or null when the URL has no requeue param or its mode has no queue +// (the param is stripped in the second case too). +export function consumeRequeueUrl( + location: Pick, + replaceUrl: (url: string) => void, +): MatchmakingMode | null { + const searchParams = new URLSearchParams(location.search); + if (!searchParams.has("requeue")) { + return null; + } + const mode = requeueModeFromParam(searchParams.get("requeue") ?? ""); + + searchParams.delete("requeue"); + const newUrl = + location.pathname + + (searchParams.toString() ? `?${searchParams.toString()}` : "") + + location.hash; + replaceUrl(newUrl); + return mode; +} diff --git a/tests/client/RequeueUrl.test.ts b/tests/client/RequeueUrl.test.ts new file mode 100644 index 0000000000..c3c9a97954 --- /dev/null +++ b/tests/client/RequeueUrl.test.ts @@ -0,0 +1,61 @@ +import { RankedType } from "@openfront/engine-api/game/GameTypes"; +import { describe, expect, it, vi } from "vitest"; +import { + consumeRequeueUrl, + requeueModeForRankedType, + requeueModeFromParam, +} from "../../src/client/RequeueUrl"; + +const loc = (search: string, hash = "") => ({ search, pathname: "/", hash }); + +describe("requeueModeFromParam", () => { + it.each([ + ["", "1v1"], + ["2v2", "2v2"], + ["clanwars", "clanwars"], + ["bogus", "1v1"], + ] as const)("maps %j to %s", (param, mode) => { + expect(requeueModeFromParam(param)).toBe(mode); + }); + + it("does not requeue ranked FFA, whose queue is gone", () => { + expect(requeueModeFromParam("ffa")).toBeNull(); + }); +}); + +describe("consumeRequeueUrl", () => { + it("leaves a URL without the param alone", () => { + const replace = vi.fn(); + expect(consumeRequeueUrl(loc("?foo=1"), replace)).toBeNull(); + expect(replace).not.toHaveBeenCalled(); + }); + + it("?requeue=ffa opens no matchmaking, not 1v1, and still strips the param", () => { + const replace = vi.fn(); + expect( + consumeRequeueUrl(loc("?requeue=ffa&foo=1", "#x"), replace), + ).toBeNull(); + expect(replace).toHaveBeenCalledWith("/?foo=1#x"); + }); + + it("keeps a bare ?requeue as 1v1", () => { + const replace = vi.fn(); + expect(consumeRequeueUrl(loc("?requeue"), replace)).toBe("1v1"); + expect(replace).toHaveBeenCalledWith("/"); + }); +}); + +describe("requeueModeForRankedType", () => { + it.each([ + [RankedType.OneVOne, "1v1"], + [RankedType.TwoVTwo, "2v2"], + [RankedType.ClanWars, "clanwars"], + ] as const)("requeues %s as %s", (rankedType, mode) => { + expect(requeueModeForRankedType(rankedType)).toBe(mode); + }); + + it("offers no requeue for ranked FFA or an unranked game", () => { + expect(requeueModeForRankedType(RankedType.FreeForAll)).toBeNull(); + expect(requeueModeForRankedType(undefined)).toBeNull(); + }); +}); From e51c944f6fe746f2671907dd2f944d29f91b001d Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 17:36:05 +0100 Subject: [PATCH 2/6] fix(client): no Play Again after a ranked FFA game Co-Authored-By: Claude Opus 5.5 --- src/client/hud/layers/WinModal.ts | 35 +++++----- tests/client/graphics/layers/WinModal.test.ts | 69 ++++++++++++++----- 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index 7a290973da..cb522c2a28 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -1,4 +1,4 @@ -import { GameType, RankedType } from "@openfront/engine-api/game/GameTypes"; +import { GameType } from "@openfront/engine-api/game/GameTypes"; import { GameUpdateType } from "@openfront/engine-api/game/GameUpdates"; import type { AllPlayersStats, Winner } from "@openfront/engine-api/Schemas"; import type { GameXpEligible, Progress } from "@openfront/shared/ApiSchemas"; @@ -31,6 +31,7 @@ import { } from "../../Cosmetics"; import { isDesktopShell } from "../../DesktopShell"; import { gamePortal } from "../../GamePortal"; +import type { MatchmakingMode } from "../../Matchmaking"; import { gameShareMoment, momentShareLabel, @@ -54,6 +55,7 @@ import { provisionalXpRules, reconcileXp, } from "../../ProvisionalXpAtDeath"; +import { requeueModeForRankedType } from "../../RequeueUrl"; import { PlaySoundEffectEvent } from "../../sound/Sounds"; import { steamSDK } from "../../SteamSDK"; import { SendWinnerEvent } from "../../Transport"; @@ -104,8 +106,10 @@ export class WinModal extends LitElement implements Controller { @state() private isWin = false; + // The queue to rejoin from "Play Again"; null when the game has none (an + // unranked game, or a ranked FFA game, which has no queue any more). @state() - private isRankedGame = false; + private requeueMode: MatchmakingMode | null = null; @state() private patternContent: TemplateResult | null = null; @@ -195,7 +199,7 @@ export class WinModal extends LitElement implements Controller { "exit", )} ${this.renderShare()} - ${this.isRankedGame + ${this.requeueMode !== null ? this.actionButton( "main", translateText("win_modal.requeue"), @@ -306,9 +310,10 @@ export class WinModal extends LitElement implements Controller { if (url === null) return null; // Three labelled buttons fit side by side from md up; below that (and // beside a ranked game's requeue button) it takes a row of its own. - const placement = this.isRankedGame - ? "order-first basis-full" - : "order-first basis-full md:order-none md:basis-0 md:flex-1"; + const placement = + this.requeueMode !== null + ? "order-first basis-full" + : "order-first basis-full md:order-none md:basis-0 md:flex-1"; return html` ({ translateText: vi.fn((key: string) => { @@ -77,24 +78,6 @@ describe("WinModal Requeue", () => { vi.restoreAllMocks(); }); - describe("isRankedGame detection", () => { - it("should detect ranked 1v1 game", () => { - const gameConfig = { - rankedType: RankedType.OneVOne, - }; - const isRankedGame = gameConfig.rankedType === RankedType.OneVOne; - expect(isRankedGame).toBe(true); - }); - - it("should not detect non-ranked game", () => { - const gameConfig = { - rankedType: undefined, - }; - const isRankedGame = gameConfig.rankedType === RankedType.OneVOne; - expect(isRankedGame).toBe(false); - }); - }); - describe("requeue navigation", () => { it("should navigate to /?requeue when requeue is triggered", () => { // Simulate the _handleRequeue behavior @@ -134,6 +117,54 @@ describe("WinModal Requeue", () => { }); }); +describe("WinModal Play Again", () => { + const stubGame = (rankedType?: RankedType) => + ({ + config: () => ({ + gameConfig: () => ({ rankedType, gameType: GameType.Public }), + isReplay: () => false, + }), + myPlayer: () => null, + }) as unknown as GameView; + + async function shownWith(rankedType?: RankedType): Promise { + const modal = document.createElement("win-modal") as WinModal; + modal.game = stubGame(rankedType); + document.body.appendChild(modal); + await modal.show(); + await modal.updateComplete; + return modal; + } + + it("offers no Play Again after a ranked FFA game", async () => { + const modal = await shownWith(RankedType.FreeForAll); + expect(modal.querySelector('[data-win-action="requeue"]')).toBeNull(); + modal.remove(); + }); + + it.each([ + [RankedType.OneVOne, "1v1"], + [RankedType.TwoVTwo, "2v2"], + [RankedType.ClanWars, "clanwars"], + ] as const)("requeues a ranked %s game as %s", async (rankedType, mode) => { + const modal = await shownWith(rankedType); + const requeue = vi.fn(); + document.addEventListener("matchmaking-requeue", requeue); + modal + .querySelector('[data-win-action="requeue"]')! + .click(); + expect(requeue.mock.calls[0][0].detail).toEqual({ mode }); + document.removeEventListener("matchmaking-requeue", requeue); + modal.remove(); + }); + + it("offers no Play Again after an unranked game", async () => { + const modal = await shownWith(undefined); + expect(modal.querySelector('[data-win-action="requeue"]')).toBeNull(); + modal.remove(); + }); +}); + describe("WinModal pattern promotion", () => { let modal: WinModal | undefined; From 6ab6e085281f4427191508fa0513c11d3eef4e16 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 17:41:36 +0100 Subject: [PATCH 3/6] feat(client): remove the ranked FFA queue card and mode Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 4 +--- src/client/Matchmaking.ts | 14 +++++--------- src/client/components/RankedModal.ts | 25 ++++++++++--------------- tests/client/RankedModalTrust.test.ts | 17 ++++++++++------- 4 files changed, 26 insertions(+), 34 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 675a0d9f40..3ef5c8b445 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1688,7 +1688,6 @@ "title": "1v1 Ranked Matchmaking (ALPHA)", "title_2v2": "2v2 Ranked Matchmaking (ALPHA)", "title_clanwars": "Clan Wars Ranked Matchmaking (ALPHA)", - "title_ffa": "FFA Ranked Matchmaking (ALPHA)", "waiting_for_game": "Waiting for game to start..." }, "mode_selector": { @@ -1698,8 +1697,7 @@ "ranked_2v2_title": "2v2", "ranked_clanwars_subtitle": "Your clan vs theirs", "ranked_clanwars_title": "Clan Wars", - "ranked_ffa_title": "FFA", - "ranked_pairing_note": "In team modes, clanmates and friends in the queue are placed on the same team where possible. Friends are paired first, then clanmates. If your ratings are too far apart you'll be matched separately to keep games fair. In FFA, 4 to 10 players are matched by rating, and friends and clanmates are never placed in the same game. In Clan Wars, every team is one clan: you play only alongside members of the clan you queue for, and the clan's rating rises or falls with the result.", + "ranked_pairing_note": "In team modes, clanmates and friends in the queue are placed on the same team where possible. Friends are paired first, then clanmates. If your ratings are too far apart you'll be matched separately to keep games fair. In Clan Wars, every team is one clan: you play only alongside members of the clan you queue for, and the clan's rating rises or falls with the result.", "ranked_title": "Ranked", "ranked_trust_required_body": "Ranked only accepts trusted accounts. To become trusted, play more games or make any purchase.", "ranked_trust_required_body_crazygames": "Ranked only accepts trusted accounts. To become trusted, play more games.", diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index f7aafe2284..9c4ccf4e14 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -24,7 +24,7 @@ import { translateText } from "./Utils"; // The ranked queues. Same strings as RankedType, the API's `mode` param and // the `/?requeue=` value. -export type MatchmakingMode = "1v1" | "2v2" | "ffa" | "clanwars"; +export type MatchmakingMode = "1v1" | "2v2" | "clanwars"; type MatchmakingJoin = { type: "join"; @@ -75,11 +75,9 @@ export class MatchmakingModal extends BaseModal { const title = translateText( this.mode === "2v2" ? "matchmaking_modal.title_2v2" - : this.mode === "ffa" - ? "matchmaking_modal.title_ffa" - : this.mode === "clanwars" - ? "matchmaking_modal.title_clanwars" - : "matchmaking_modal.title", + : this.mode === "clanwars" + ? "matchmaking_modal.title_clanwars" + : "matchmaking_modal.title", ); return modalHeader({ titleContent: html`${title} @@ -517,9 +515,7 @@ export class MatchmakingModal extends BaseModal { const row = this.mode === "2v2" ? userMe.player.leaderboard?.twoVtwo - : this.mode === "ffa" - ? userMe.player.leaderboard?.ffa - : userMe.player.leaderboard?.oneVone; + : userMe.player.leaderboard?.oneVone; this.elo = row?.elo ?? translateText("matchmaking_modal.no_elo"); this.selectedClanTag = this.selectedClanFrom(userMe); // Clan wars is played for a clan: without one of the player's own clans diff --git a/src/client/components/RankedModal.ts b/src/client/components/RankedModal.ts index 2ec09011be..415bdab25c 100644 --- a/src/client/components/RankedModal.ts +++ b/src/client/components/RankedModal.ts @@ -21,7 +21,6 @@ export class RankedModal extends BaseModal { @state() private elo: number | string = "..."; @state() private elo2v2: number | string = "..."; - @state() private eloFfa: number | string = "..."; @state() private userMeResponse: UserMeResponse | false = false; @state() private errorMessage: string | null = null; // Hides the trust lock until /users/@me answers, so a trusted player @@ -72,7 +71,6 @@ export class RankedModal extends BaseModal { if (this.errorMessage) { this.elo = translateText("map_component.error"); this.elo2v2 = translateText("map_component.error"); - this.eloFfa = translateText("map_component.error"); return; } @@ -83,7 +81,6 @@ export class RankedModal extends BaseModal { const noElo = translateText("matchmaking_modal.no_elo"); this.elo = leaderboard?.oneVone?.elo ?? noElo; this.elo2v2 = leaderboard?.twoVtwo?.elo ?? noElo; - this.eloFfa = leaderboard?.ffa?.elo ?? noElo; } } @@ -92,7 +89,6 @@ export class RankedModal extends BaseModal { this.showTrustRequired = false; this.elo = "..."; this.elo2v2 = "..."; - this.eloFfa = "..."; this.errorMessage = null; try { @@ -107,7 +103,6 @@ export class RankedModal extends BaseModal { this.errorMessage = translateText("map_component.error"); this.elo = translateText("map_component.error"); this.elo2v2 = translateText("map_component.error"); - this.eloFfa = translateText("map_component.error"); } finally { this.loading = false; this.updateElo(); @@ -146,19 +141,12 @@ export class RankedModal extends BaseModal { : translateText("mode_selector.ranked_title")), () => this.handleRanked("2v2"), )} - ${this.renderCard( - translateText("mode_selector.ranked_ffa_title"), - this.errorMessage ?? - (this.isRankedEligible() - ? translateText("matchmaking_modal.elo", { elo: this.eloFfa }) - : translateText("mode_selector.ranked_title")), - () => this.handleRanked("ffa"), - )} ${this.renderCard( translateText("mode_selector.ranked_clanwars_title"), this.errorMessage ?? translateText("mode_selector.ranked_clanwars_subtitle"), () => this.handleRanked("clanwars"), + true, )}

@@ -175,7 +163,12 @@ export class RankedModal extends BaseModal { `; } - private renderCard(title: string, subtitle: string, onClick: () => void) { + private renderCard( + title: string, + subtitle: string, + onClick: () => void, + wide = false, + ) { // Ranked admits trusted accounts only: a green open lock when the // player's account is trusted, a red closed one otherwise. const lock = this.trustKnown() @@ -188,7 +181,9 @@ export class RankedModal extends BaseModal { return html`