mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-17 00:10:54 +00:00
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:
@@ -397,6 +397,7 @@
|
||||
"hard": "Plutonium",
|
||||
"legendary": "Legendary",
|
||||
"per_day": "/day",
|
||||
"public_lobbies": "Create public lobbies",
|
||||
"rare": "Rare",
|
||||
"search": "Search...",
|
||||
"soft": "Caps",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ function makeUserMe(flares: string[] = []): UserMeResponse {
|
||||
publicId: "test",
|
||||
adfree: false,
|
||||
unlimitedRanked: false,
|
||||
canCreatePublicLobbies: false,
|
||||
flares,
|
||||
achievements: { singleplayerMap: [] },
|
||||
friends: [],
|
||||
|
||||
@@ -29,6 +29,7 @@ const userWithClans = (tags: string[]): UserMeResponse =>
|
||||
publicId: "p1",
|
||||
adfree: false,
|
||||
unlimitedRanked: false,
|
||||
canCreatePublicLobbies: false,
|
||||
flares: [],
|
||||
achievements: { singleplayerMap: [] },
|
||||
friends: [],
|
||||
|
||||
Reference in New Issue
Block a user