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`${translateText("cosmetics.unlimited_ranked")}` + : nothing} `; } diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index b242ee0bc..5e236e982 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -117,6 +117,9 @@ export const UserMeResponseSchema = z.object({ player: z.object({ publicId: z.string(), adfree: z.boolean(), + // True when the player's active subscription tier exempts them from the + // free-ranked-play limits. + unlimitedRanked: z.boolean(), flares: z.string().array().optional(), achievements: z.object({ singleplayerMap: z.array(SingleplayerMapAchievementSchema), diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index 0293ffd43..cf2029b19 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -353,6 +353,9 @@ export const SubscriptionSchema = CosmeticSchema.extend({ priceMonthly: z.number(), dailySoftCurrency: z.number(), dailyHardCurrency: z.number(), + // Whether this tier exempts subscribers from the free-ranked-play limits + // (advertised on the store tile). + unlimitedRanked: z.boolean(), }); // Schema for resources/cosmetics/cosmetics.json diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts index 4a15fefb6..daf499355 100644 --- a/tests/ApiSchemas.test.ts +++ b/tests/ApiSchemas.test.ts @@ -256,6 +256,7 @@ describe("UserMeResponseSchema rewards", () => { const basePlayer = { publicId: "p1", adfree: false, + unlimitedRanked: false, achievements: { singleplayerMap: [] }, friends: [], subscription: null, @@ -290,6 +291,33 @@ describe("UserMeResponseSchema rewards", () => { }); }); +describe("UserMeResponseSchema unlimitedRanked", () => { + const basePlayer = { + publicId: "p1", + adfree: false, + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + }; + + it("accepts a player exempt from ranked play limits", () => { + const result = UserMeResponseSchema.safeParse({ + user: {}, + player: { ...basePlayer, unlimitedRanked: true }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.player.unlimitedRanked).toBe(true); + } + }); + + it("rejects a response without unlimitedRanked", () => { + expect( + UserMeResponseSchema.safeParse({ user: {}, player: basePlayer }).success, + ).toBe(false); + }); +}); + describe("claim response schemas", () => { it("coerces claim balances from bigint strings to numbers", () => { const result = ClaimRewardResponseSchema.safeParse({ @@ -346,6 +374,7 @@ describe("hasActiveSubscription", () => { player: { publicId: "p1", adfree: false, + unlimitedRanked: false, achievements: { singleplayerMap: [] }, friends: [], subscription, diff --git a/tests/CosmeticSchemas.test.ts b/tests/CosmeticSchemas.test.ts index c68cc730b..fde25cc23 100644 --- a/tests/CosmeticSchemas.test.ts +++ b/tests/CosmeticSchemas.test.ts @@ -10,6 +10,7 @@ import { isNukeExplosionEffect, isTrailEffect, NukeExplosionAttributesSchema, + SubscriptionSchema, TrailEffectAttributesSchema, } from "../src/core/CosmeticSchemas"; import { PlayerEffectSchema } from "../src/core/Schemas"; @@ -784,3 +785,36 @@ describe("effect selection slots", () => { expect(findEffectForSlot(null, "atom", "atom_boom")).toBeUndefined(); }); }); + +describe("SubscriptionSchema unlimitedRanked", () => { + const base = { + name: "gold", + product: null, + rarity: "epic", + description: "Gold tier", + priceMonthly: 5, + dailySoftCurrency: 100, + dailyHardCurrency: 10, + }; + + it("rejects a tier without unlimitedRanked", () => { + expect(SubscriptionSchema.safeParse(base).success).toBe(false); + }); + + it("accepts a tier with unlimitedRanked", () => { + const result = SubscriptionSchema.safeParse({ + ...base, + unlimitedRanked: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.unlimitedRanked).toBe(true); + } + }); + + it("rejects a non-boolean unlimitedRanked", () => { + expect( + SubscriptionSchema.safeParse({ ...base, unlimitedRanked: "yes" }).success, + ).toBe(false); + }); +}); diff --git a/tests/ResolveCosmetics.test.ts b/tests/ResolveCosmetics.test.ts index 1bc2f9c44..aa051ff3c 100644 --- a/tests/ResolveCosmetics.test.ts +++ b/tests/ResolveCosmetics.test.ts @@ -23,6 +23,7 @@ function makeUserMe(flares: string[] = []): UserMeResponse { player: { publicId: "test", adfree: false, + unlimitedRanked: false, flares, achievements: { singleplayerMap: [] }, friends: [], diff --git a/tests/client/clan/ClanApiQueries.test.ts b/tests/client/clan/ClanApiQueries.test.ts index 0254db1df..039f6977c 100644 --- a/tests/client/clan/ClanApiQueries.test.ts +++ b/tests/client/clan/ClanApiQueries.test.ts @@ -28,6 +28,7 @@ const userWithClans = (tags: string[]): UserMeResponse => player: { publicId: "p1", adfree: false, + unlimitedRanked: false, flares: [], achievements: { singleplayerMap: [] }, friends: [],