Skip to content
Draft
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
6 changes: 0 additions & 6 deletions packages/engine-api/src/game/GameTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/CloseCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 2 additions & 3 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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": {
Expand All @@ -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.",
Expand Down
28 changes: 4 additions & 24 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 5 additions & 9 deletions src/client/Matchmaking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`<span class="${DEFAULT_TITLE_CLASS}">${title}</span>
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions src/client/RequeueUrl.ts
Original file line number Diff line number Diff line change
@@ -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<Location, "search" | "pathname" | "hash">,
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;
}
25 changes: 10 additions & 15 deletions src/client/components/RankedModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
}

Expand All @@ -92,7 +89,6 @@ export class RankedModal extends BaseModal {
this.showTrustRequired = false;
this.elo = "...";
this.elo2v2 = "...";
this.eloFfa = "...";
this.errorMessage = null;

try {
Expand All @@ -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();
Expand Down Expand Up @@ -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,
)}
</div>
<p class="mt-6 text-xs text-white/60 leading-relaxed text-center">
Expand All @@ -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()
Expand All @@ -188,7 +181,9 @@ export class RankedModal extends BaseModal {
return html`
<button
@click=${onClick}
class="relative flex flex-col w-full h-28 sm:h-32 rounded-2xl bg-malibu-blue border-0 transition-all duration-200 hover:bg-aquarius hover:scale-[1.03] hover:shadow-[var(--shadow-action-card-hover)] active:bg-malibu-blue/80 active:scale-[0.98] p-6 items-center justify-center gap-3"
class="${wide
? "sm:col-span-2 "
: ""}relative flex flex-col w-full h-28 sm:h-32 rounded-2xl bg-malibu-blue border-0 transition-all duration-200 hover:bg-aquarius hover:scale-[1.03] hover:shadow-[var(--shadow-action-card-hover)] active:bg-malibu-blue/80 active:scale-[0.98] p-6 items-center justify-center gap-3"
>
<div class="flex flex-col items-center gap-1 text-center">
<h3
Expand Down
35 changes: 16 additions & 19 deletions src/client/hud/layers/WinModal.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -31,6 +31,7 @@ import {
} from "../../Cosmetics";
import { isDesktopShell } from "../../DesktopShell";
import { gamePortal } from "../../GamePortal";
import type { MatchmakingMode } from "../../Matchmaking";
import {
gameShareMoment,
momentShareLabel,
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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`<profile-share
data-win-share
class="flex ${placement}"
Expand Down Expand Up @@ -606,8 +611,9 @@ export class WinModal extends LitElement implements Controller {

async show() {
gamePortal.gameplayStop();
this.isRankedGame =
this.game.config().gameConfig().rankedType !== undefined;
this.requeueMode = requeueModeForRankedType(
this.game.config().gameConfig().rankedType,
);
this.isVisible = true;
this.requestUpdate();
try {
Expand Down Expand Up @@ -929,23 +935,14 @@ export class WinModal extends LitElement implements Controller {
}

private _handleRequeue() {
if (this.requeueMode === null) return;
this.hide();
// Requeue for the same mode; Main owns the mechanism (currently a
// reload with the requeue param, which reopens the queue after the
// page teardown).
const rankedType = this.game.config().gameConfig().rankedType;
document.dispatchEvent(
new CustomEvent("matchmaking-requeue", {
detail: {
mode:
rankedType === RankedType.TwoVTwo
? ("2v2" as const)
: rankedType === RankedType.FreeForAll
? ("ffa" as const)
: rankedType === RankedType.ClanWars
? ("clanwars" as const)
: ("1v1" as const),
},
detail: { mode: this.requeueMode },
}),
);
}
Expand Down
Loading
Loading