mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 14:36:34 +00:00
Handle ranked play limits in the client (#4602)
## Summary
Client-side handling for the new server-enforced ranked play limits
(infra#427): free players get a lifetime allowance of ranked matches,
then a small daily allowance; subscribers on tiers with the
`unlimitedRanked` entitlement are never limited.
- **Queue rejection**: the matchmaking socket closing with code 1008 and
reason `ranked_limit_reached` now renders a limit-reached view in the
matchmaking modal (message + "Get unlimited ranked" button that opens
the store on the subscriptions tab) and does **not** auto-reconnect.
Auth-failure 1008 (`Invalid session`) is disambiguated by the reason
string and keeps the existing reconnect-with-refreshed-token path; code
1000 ("replaced by another tab") is unchanged. Both 1v1 and 2v2 go
through the same modal, so both queues get identical handling.
- **`/users/@me`**: added required `player.unlimitedRanked: boolean` to
`UserMeResponseSchema`.
- **cosmetics.json**: added required `unlimitedRanked: boolean` to
`SubscriptionSchema`; store subscription tiles now show an "Unlimited
ranked" perk line for tiers that include it.
Display copy avoids hardcoding the limit numbers since the server
constants may be tuned.
## ⚠️ Deploy ordering
Both new schema fields are **required**, so this must ship **after** the
API starts serving them (infra#427). Against the current API,
`/users/@me` parsing fails (players appear logged out) and a catalog
without the subscription field fails the whole cosmetics parse.
## Testing
- Schema tests for both new fields (present / rejected-when-missing) in
`tests/ApiSchemas.test.ts` and `tests/CosmeticSchemas.test.ts`; full
suite, tsc, ESLint, Prettier all pass.
- Verified in the running app (headless Chromium): forced the modal into
the limit-reached state, confirmed the rendered view, and confirmed the
upsell click closes matchmaking and opens the store on the subscriptions
tab.
- Not verifiable locally (needs deployed infra#427): the real close
frame, the midnight-UTC reset, and the perk line against a real catalog
— covered by the handoff QA checklist.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<p class="text-white font-bold">
|
||||
${translateText("matchmaking_modal.limit_reached")}
|
||||
</p>
|
||||
<p class="text-sm text-white/60">
|
||||
${translateText("matchmaking_modal.limit_reached_info")}
|
||||
</p>
|
||||
<button
|
||||
@click=${this.openSubscriptions}
|
||||
class="px-6 py-3 bg-purple-600 hover:bg-purple-500 text-white font-bold uppercase tracking-wider rounded-xl transition-colors"
|
||||
>
|
||||
${translateText("matchmaking_modal.limit_upsell")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -272,6 +272,12 @@ export class CosmeticButton extends LitElement {
|
||||
>${translateText("cosmetics.per_day")}</span
|
||||
>
|
||||
</div>
|
||||
${sub.unlimitedRanked
|
||||
? html`<span
|
||||
class="text-[10px] font-bold text-purple-300 uppercase tracking-wide"
|
||||
>${translateText("cosmetics.unlimited_ranked")}</span
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user