Gate public lobby listing on the canCreatePublicLobbies entitlement (#4620)

## What

The API now returns an explicit `canCreatePublicLobbies` boolean on
`/users/@me` (player) and on each subscription tier in `cosmetics.json`.
This PR gates public lobby listing on that entitlement instead of
inferring it from subscription status (`active`/`trialing`), and
advertises the perk on the store's subscription tiles.

## Changes

**Entitlement check**
- `UserMeResponseSchema` gains a required `canCreatePublicLobbies`
boolean (same pattern as `unlimitedRanked`)
- The worker's listing endpoint and `HostLobbyModal`'s Public toggle
both check the boolean directly; the Dev bypass is unchanged
- `hasActiveSubscription()` is removed — these were its only call sites
- The server error stays `subscription_required` (subscribing is still
how you get the entitlement; renaming would ripple through i18n for no
behavior change)

**Store**
- `SubscriptionSchema` (cosmetics catalog) gains a required
`canCreatePublicLobbies` boolean
- Subscription tiles show a "Create public lobbies" badge below
"Unlimited ranked" when the tier grants it (`cosmetics.public_lobbies`
added to en.json)

**Tests**
- Schema tests for both new fields (accepts true, rejects missing,
rejects non-boolean); `UserMeResponse` fixtures updated

## Deploy ordering

Both new schema fields are required, so this client must deploy after
the API serves them (same constraint as `unlimitedRanked` / infra#427).
The API already returns both fields.

## Verified

Typecheck, ESLint, and the affected test files pass (165 tests).
Confirmed the live payloads against a local API: `/users/@me` returns
`player.canCreatePublicLobbies` and both subscription tiers in
`cosmetics.json` carry the field.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-15 15:54:01 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 766fda991a
commit a639a6aa6b
10 changed files with 80 additions and 72 deletions
+1
View File
@@ -397,6 +397,7 @@
"hard": "Plutonium",
"legendary": "Legendary",
"per_day": "/day",
"public_lobbies": "Create public lobbies",
"rare": "Rare",
"search": "Search...",
"soft": "Caps",
+5 -5
View File
@@ -8,7 +8,6 @@ import {
showToast,
translateText,
} from "../client/Utils";
import { hasActiveSubscription } from "../core/ApiSchemas";
import { GameEnv } from "../core/configuration/Config";
import { EventBus } from "../core/EventBus";
import { DoomsdayClockSpeed } from "../core/game/DoomsdayClock";
@@ -108,8 +107,9 @@ export class HostLobbyModal extends BaseModal {
@state() private lobbyCreatorClientID: string = "";
@state() private lobbyStartAt: number | null = null;
@state() private serverTimeOffset: number = 0;
// Whether this user may actually make the lobby public (subscribers, or
// anyone in dev). The toggle itself is always shown.
// Whether this user may actually make the lobby public (the API's
// canCreatePublicLobbies entitlement, or anyone in dev). The toggle itself
// is always shown.
@state() private canListPublicly: boolean = false;
@state() private publiclyListed: boolean = false;
@state() private showSubscriptionRequired: boolean = false;
@@ -653,11 +653,11 @@ export class HostLobbyModal extends BaseModal {
this.leaveLobbyOnClose = true;
this.startLobbyUpdates();
void getUserMe().then((userMe) => {
// Dev skips the subscription gate (matching the server) so the
// Dev skips the entitlement gate (matching the server) so the
// listing flow is testable locally.
this.canListPublicly =
ClientEnv.env() === GameEnv.Dev ||
(userMe !== false && hasActiveSubscription(userMe));
(userMe !== false && userMe.player.canCreatePublicLobbies);
});
// Attach mode: the server already minted this successor lobby with us as
+6
View File
@@ -301,6 +301,12 @@ export class CosmeticButton extends LitElement {
>${translateText("cosmetics.unlimited_ranked")}</span
>`
: nothing}
${sub.canCreatePublicLobbies
? html`<span
class="text-[10px] font-bold text-purple-300 uppercase tracking-wide"
>${translateText("cosmetics.public_lobbies")}</span
>`
: nothing}
</div>
</div>`;
}
+3 -7
View File
@@ -120,6 +120,9 @@ export const UserMeResponseSchema = z.object({
// True when the player's active subscription tier exempts them from the
// free-ranked-play limits.
unlimitedRanked: z.boolean(),
// True when the player may list a custom lobby publicly. The API decides
// which subscriptions/grants confer this.
canCreatePublicLobbies: z.boolean(),
flares: z.string().array().optional(),
achievements: z.object({
singleplayerMap: z.array(SingleplayerMapAchievementSchema),
@@ -187,13 +190,6 @@ export type UserSubscription = NonNullable<
NonNullable<UserMeResponse["player"]["subscription"]>
>;
// Whether the player currently has subscriber entitlements (e.g. may list a
// private lobby publicly). Trialing counts; past_due/canceled do not.
export function hasActiveSubscription(userMe: UserMeResponse): boolean {
const status = userMe.player.subscription?.status;
return status === "active" || status === "trialing";
}
export const PlayerStatsLeafSchema = z.object({
wins: BigIntStringSchema,
losses: BigIntStringSchema,
+3
View File
@@ -361,6 +361,9 @@ export const SubscriptionSchema = CosmeticSchema.extend({
// Whether this tier exempts subscribers from the free-ranked-play limits
// (advertised on the store tile).
unlimitedRanked: z.boolean(),
// Whether this tier lets subscribers list custom lobbies publicly
// (advertised on the store tile).
canCreatePublicLobbies: z.boolean(),
});
// Schema for resources/cosmetics/cosmetics.json
+1 -2
View File
@@ -7,7 +7,6 @@ import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
import { z } from "zod";
import { hasActiveSubscription } from "../core/ApiSchemas";
import { GameEnv } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import {
@@ -309,7 +308,7 @@ export async function startWorker() {
);
return res.status(403).json({ error: "subscription_required" });
}
if (!hasActiveSubscription(userMe.response)) {
if (!userMe.response.player.canCreatePublicLobbies) {
return res.status(403).json({ error: "subscription_required" });
}
}
+23 -58
View File
@@ -3,7 +3,6 @@ import {
ClaimRewardResponseSchema,
GoogleUser,
GoogleUserSchema,
hasActiveSubscription,
PlayerGameModeFilterSchema,
PlayerGameResultSchema,
PlayerGameTypeFilterSchema,
@@ -11,7 +10,6 @@ import {
PublicPlayerGameSchema,
PublicPlayerGamesResponseSchema,
RewardSchema,
UserMeResponse,
UserMeResponseSchema,
} from "../src/core/ApiSchemas";
@@ -257,6 +255,7 @@ describe("UserMeResponseSchema rewards", () => {
publicId: "p1",
adfree: false,
unlimitedRanked: false,
canCreatePublicLobbies: false,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
@@ -295,6 +294,7 @@ describe("UserMeResponseSchema unlimitedRanked", () => {
const basePlayer = {
publicId: "p1",
adfree: false,
canCreatePublicLobbies: false,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
@@ -365,65 +365,30 @@ describe("claim response schemas", () => {
});
});
describe("hasActiveSubscription", () => {
function userMeWith(
subscription: UserMeResponse["player"]["subscription"],
): UserMeResponse {
return {
describe("UserMeResponseSchema canCreatePublicLobbies", () => {
const basePlayer = {
publicId: "p1",
adfree: false,
unlimitedRanked: false,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
};
it("accepts a player allowed to list lobbies publicly", () => {
const result = UserMeResponseSchema.safeParse({
user: {},
player: {
publicId: "p1",
adfree: false,
unlimitedRanked: false,
achievements: { singleplayerMap: [] },
friends: [],
subscription,
},
};
}
it("is true for an active subscription", () => {
expect(
hasActiveSubscription(
userMeWith({
tier: "supporter",
status: "active",
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
}),
),
).toBe(true);
});
it("is true while trialing", () => {
expect(
hasActiveSubscription(
userMeWith({
tier: "supporter",
status: "trialing",
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
}),
),
).toBe(true);
});
it("is false for canceled or past_due subscriptions", () => {
for (const status of ["canceled", "past_due", "incomplete"]) {
expect(
hasActiveSubscription(
userMeWith({
tier: "supporter",
status,
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
}),
),
).toBe(false);
player: { ...basePlayer, canCreatePublicLobbies: true },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.player.canCreatePublicLobbies).toBe(true);
}
});
it("is false without a subscription", () => {
expect(hasActiveSubscription(userMeWith(null))).toBe(false);
it("rejects a response without canCreatePublicLobbies", () => {
expect(
UserMeResponseSchema.safeParse({ user: {}, player: basePlayer }).success,
).toBe(false);
});
});
+36
View File
@@ -841,6 +841,7 @@ describe("SubscriptionSchema unlimitedRanked", () => {
priceMonthly: 5,
dailySoftCurrency: 100,
dailyHardCurrency: 10,
canCreatePublicLobbies: false,
};
it("rejects a tier without unlimitedRanked", () => {
@@ -864,3 +865,38 @@ describe("SubscriptionSchema unlimitedRanked", () => {
).toBe(false);
});
});
describe("SubscriptionSchema canCreatePublicLobbies", () => {
const base = {
name: "gold",
product: null,
rarity: "epic",
description: "Gold tier",
priceMonthly: 5,
dailySoftCurrency: 100,
dailyHardCurrency: 10,
unlimitedRanked: false,
};
it("rejects a tier without canCreatePublicLobbies", () => {
expect(SubscriptionSchema.safeParse(base).success).toBe(false);
});
it("accepts a tier with canCreatePublicLobbies", () => {
const result = SubscriptionSchema.safeParse({
...base,
canCreatePublicLobbies: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.canCreatePublicLobbies).toBe(true);
}
});
it("rejects a non-boolean canCreatePublicLobbies", () => {
expect(
SubscriptionSchema.safeParse({ ...base, canCreatePublicLobbies: "yes" })
.success,
).toBe(false);
});
});
+1
View File
@@ -24,6 +24,7 @@ function makeUserMe(flares: string[] = []): UserMeResponse {
publicId: "test",
adfree: false,
unlimitedRanked: false,
canCreatePublicLobbies: false,
flares,
achievements: { singleplayerMap: [] },
friends: [],
+1
View File
@@ -29,6 +29,7 @@ const userWithClans = (tags: string[]): UserMeResponse =>
publicId: "p1",
adfree: false,
unlimitedRanked: false,
canCreatePublicLobbies: false,
flares: [],
achievements: { singleplayerMap: [] },
friends: [],