diff --git a/packages/engine-api/src/game/GameTypes.ts b/packages/engine-api/src/game/GameTypes.ts
index 7f85120a13..c53a698833 100644
--- a/packages/engine-api/src/game/GameTypes.ts
+++ b/packages/engine-api/src/game/GameTypes.ts
@@ -140,12 +140,6 @@ export enum RankedType {
ClanWars = "clanwars",
}
-// Ranked FFA lobby size. The API's matcher assigns between these many players
-// and only rates a game whose roster is in this range, so a match that ends
-// up with fewer is cancelled before it starts.
-export const RANKED_FFA_MIN_PLAYERS = 4;
-export const RANKED_FFA_MAX_PLAYERS = 10;
-
export const isGameMode = (value: unknown): value is GameMode =>
isEnumValue(GameMode, value);
diff --git a/packages/shared/src/CloseCodes.ts b/packages/shared/src/CloseCodes.ts
index 827854abd2..83a071c67e 100644
--- a/packages/shared/src/CloseCodes.ts
+++ b/packages/shared/src/CloseCodes.ts
@@ -16,6 +16,7 @@ export const CloseCode = {
InvalidClan: 4101,
ClanVerificationFailed: 4102,
NotTrusted: 4103,
+ ModeRetired: 4104,
} as const;
export type CloseCode = (typeof CloseCode)[keyof typeof CloseCode];
@@ -44,6 +45,7 @@ export const CloseReason = {
CannotJoin: "close_reason.cannot_join",
NotAllowlisted: "close_reason.not_allowlisted",
NotTrusted: "close_reason.not_trusted",
+ ModeRetired: "close_reason.mode_retired",
LobbyFull: "close_reason.lobby_full",
WrongWorker: "close_reason.wrong_worker",
InternalError: "close_reason.internal_error",
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 675a0d9f40..a277487271 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -579,6 +579,7 @@
"invalid_token": "Your session token was rejected.",
"lobby_full": "The lobby is full.",
"login_required": "You must be logged in to play on this server.",
+ "mode_retired": "This queue has been retired. Pick another mode.",
"no_heartbeat": "The server stopped hearing from you.",
"not_allowlisted": "You are not on this lobby's allowlist.",
"not_trusted": "This lobby is limited to trusted accounts. You can still join any other lobby.",
@@ -1688,7 +1689,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 +1698,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/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/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/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/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`