diff --git a/resources/lang/en.json b/resources/lang/en.json
index 13eb59a36..4fa528e46 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -399,6 +399,7 @@
"rare": "Rare",
"soft": "Caps",
"uncommon": "Uncommon",
+ "unlimited_ranked": "Unlimited ranked",
"usd_value": "Value: {usd}"
},
"difficulty": {
@@ -1073,6 +1074,9 @@
"matchmaking_modal": {
"connecting": "Connecting to matchmaking server...",
"elo": "Your ELO: {elo}",
+ "limit_reached": "You're out of free ranked matches.",
+ "limit_reached_info": "Free matches refill daily. Subscribe for unlimited ranked play.",
+ "limit_upsell": "Get unlimited ranked",
"no_elo": "No ELO yet",
"replaced": "You joined matchmaking from another tab or window.",
"searching": "Searching for game...",
diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts
index 9e4effdb9..2d12a5748 100644
--- a/src/client/Matchmaking.ts
+++ b/src/client/Matchmaking.ts
@@ -24,6 +24,7 @@ export class MatchmakingModal extends BaseModal {
@state() private connected = false;
@state() private socket: WebSocket | null = null;
@state() private gameID: string | null = null;
+ @state() private limitReached = false;
private elo: number | string = "...";
constructor() {
@@ -61,6 +62,24 @@ export class MatchmakingModal extends BaseModal {
}
private renderInner() {
+ if (this.limitReached) {
+ return html`
+
+
+ ${translateText("matchmaking_modal.limit_reached")}
+
+
+ ${translateText("matchmaking_modal.limit_reached_info")}
+
+
+
+ `;
+ }
if (!this.connected) {
return this.renderLoadingSpinner(
translateText("matchmaking_modal.connecting"),
@@ -80,6 +99,13 @@ export class MatchmakingModal extends BaseModal {
}
}
+ 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.
+ this.close();
+ window.location.hash = "modal=store&tab=subscriptions";
+ };
+
private async connect() {
// A pending join timer from a previous socket must not fire on this one.
if (this.connectTimeout) {
@@ -131,6 +157,14 @@ export class MatchmakingModal extends BaseModal {
if (this.intentionalClose || this.gameID !== null) {
return;
}
+ // 1008 is also used for auth failures ("Invalid session"), so match on
+ // the reason. Out of free ranked plays — the server will keep refusing
+ // until the next UTC day (or a subscription), so don't reconnect.
+ if (event.code === 1008 && event.reason === "ranked_limit_reached") {
+ this.connected = false;
+ this.limitReached = true;
+ return;
+ }
if (event.code === 1000) {
// A newer connection for this account (e.g. a second tab) took the
// queue slot; this socket was replaced. Do not retry.
@@ -200,6 +234,7 @@ export class MatchmakingModal extends BaseModal {
this.connected = false;
this.gameID = null;
this.intentionalClose = false;
+ this.limitReached = false;
this.reconnectAttempts = 0;
this.connect();
}
diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts
index 8f04104ff..cec4ac5fa 100644
--- a/src/client/components/CosmeticButton.ts
+++ b/src/client/components/CosmeticButton.ts
@@ -272,6 +272,12 @@ export class CosmeticButton extends LitElement {
>${translateText("cosmetics.per_day")}
+ ${sub.unlimitedRanked
+ ? html`