Files
OpenFrontIO/tests/ResolveCosmetics.test.ts
T
a639a6aa6b 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>
2026-07-15 15:54:01 -07:00

470 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
groupCosmeticVariants,
resolveCosmetics,
ResolvedCosmetic,
} from "../src/client/Cosmetics";
import { UserMeResponse } from "../src/core/ApiSchemas";
import { Cosmetics } from "../src/core/CosmeticSchemas";
const product = { productId: "prod_1", priceId: "price_1", price: "$4.99" };
function makeCosmetics(overrides: Partial<Cosmetics> = {}): Cosmetics {
return {
patterns: {},
flags: {},
colorPalettes: {},
...overrides,
} as Cosmetics;
}
function makeUserMe(flares: string[] = []): UserMeResponse {
return {
user: {},
player: {
publicId: "test",
adfree: false,
unlimitedRanked: false,
canCreatePublicLobbies: false,
flares,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
},
} as UserMeResponse;
}
describe("resolveCosmetics", () => {
test("returns empty array for null cosmetics", () => {
expect(resolveCosmetics(null, false, null)).toEqual([]);
});
test("always includes default pattern as first item, owned", () => {
const result = resolveCosmetics(makeCosmetics(), false, null);
expect(result[0]).toEqual({
type: "pattern",
cosmetic: null,
colorPalette: null,
relationship: "owned",
key: "pattern:default",
});
});
describe("patterns", () => {
const pattern = {
type: "pattern" as const,
name: "stripes",
pattern: "AAAAAA",
affiliateCode: null,
product,
priceSoft: undefined,
priceHard: undefined,
rarity: "common",
colorPalettes: [
{ name: "red", isArchived: false },
{ name: "blue", isArchived: false },
],
};
const colorPalettes = {
red: { name: "red", primaryColor: "#ff0000", secondaryColor: "#000000" },
blue: {
name: "blue",
primaryColor: "#0000ff",
secondaryColor: "#ffffff",
},
};
test("expands pattern × colorPalettes + null palette", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, false, null);
// default + red + blue + null-palette
const patternItems = result.filter((r) =>
r.key.startsWith("pattern:stripes"),
);
expect(patternItems).toHaveLength(3);
expect(patternItems.map((r) => r.key)).toEqual([
"pattern:stripes:red",
"pattern:stripes:blue",
"pattern:stripes",
]);
});
test("resolves color palette from cosmetics.colorPalettes", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, false, null);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.colorPalette).toEqual(colorPalettes.red);
});
test("null palette entry has null colorPalette", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, false, null);
const nullPaletteItem = result.find((r) => r.key === "pattern:stripes");
expect(nullPaletteItem?.colorPalette).toBeNull();
});
test("pattern with no colorPalettes produces single null-palette entry", () => {
const noPalettePattern = { ...pattern, colorPalettes: undefined };
const cosmetics = makeCosmetics({
patterns: { stripes: noPalettePattern as any },
});
const result = resolveCosmetics(cosmetics, false, null);
const patternItems = result.filter((r) =>
r.key.startsWith("pattern:stripes"),
);
expect(patternItems).toHaveLength(1);
expect(patternItems[0].key).toBe("pattern:stripes");
});
test("purchasable when user has no flares and product exists", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("purchasable");
});
test("owned when user has specific flare", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["pattern:stripes:red"]),
null,
);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("owned");
});
test("owned when user has wildcard flare", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["pattern:*"]),
null,
);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("owned");
});
test("blocked when affiliate code mismatch", () => {
const affiliatePattern = { ...pattern, affiliateCode: "partner1" };
const cosmetics = makeCosmetics({
patterns: { stripes: affiliatePattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("blocked");
});
test("purchasable when affiliate code matches", () => {
const affiliatePattern = { ...pattern, affiliateCode: "partner1" };
const cosmetics = makeCosmetics({
patterns: { stripes: affiliatePattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, makeUserMe(), "partner1");
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("purchasable");
});
test("archived palette is blocked unless owned", () => {
const archivedPattern = {
...pattern,
colorPalettes: [{ name: "old", isArchived: true }],
};
const cosmetics = makeCosmetics({
patterns: { stripes: archivedPattern as any },
colorPalettes: {
old: {
name: "old",
primaryColor: "#111",
secondaryColor: "#222",
},
},
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const oldItem = result.find((r) => r.key === "pattern:stripes:old");
expect(oldItem?.relationship).toBe("blocked");
});
test("archived palette is owned when user has specific flare", () => {
const archivedPattern = {
...pattern,
colorPalettes: [{ name: "old", isArchived: true }],
};
const cosmetics = makeCosmetics({
patterns: { stripes: archivedPattern as any },
colorPalettes: {
old: {
name: "old",
primaryColor: "#111",
secondaryColor: "#222",
},
},
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["pattern:stripes:old"]),
null,
);
const oldItem = result.find((r) => r.key === "pattern:stripes:old");
expect(oldItem?.relationship).toBe("owned");
});
});
describe("flags", () => {
const flag = {
type: "flag" as const,
name: "cool_flag",
url: "https://example.com/cool.png",
affiliateCode: null,
product,
priceSoft: undefined,
priceHard: undefined,
rarity: "rare",
};
test("includes flags with correct key", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(cosmetics, false, null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem).toBeDefined();
expect(flagItem?.cosmetic).toEqual(flag);
expect(flagItem?.colorPalette).toBeNull();
});
test("purchasable when not logged in and product exists", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(cosmetics, false, null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("purchasable");
});
test("owned with wildcard flare", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(cosmetics, makeUserMe(["flag:*"]), null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("owned");
});
test("owned with specific flare", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["flag:cool_flag"]),
null,
);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("owned");
});
test("blocked with no product", () => {
const freeFlag = { ...flag, product: null };
const cosmetics = makeCosmetics({
flags: { cool_flag: freeFlag as any },
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("blocked");
});
});
describe("crowns", () => {
const crown = {
name: "gold_crown",
url: "http://localhost:8787/public/cosmetics/crown/gold",
affiliateCode: null,
product,
priceSoft: undefined,
priceHard: 5,
artist: "sadfas",
rarity: "common",
};
test("includes crowns with correct key", () => {
const cosmetics = makeCosmetics({
crowns: { gold_crown: crown as any },
});
const result = resolveCosmetics(cosmetics, false, null);
const crownItem = result.find((r) => r.key === "crown:gold_crown");
expect(crownItem).toBeDefined();
expect(crownItem?.cosmetic).toEqual(crown);
expect(crownItem?.colorPalette).toBeNull();
});
test("purchasable when user has no flares and priceHard exists", () => {
const cosmetics = makeCosmetics({
crowns: { gold_crown: crown as any },
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const crownItem = result.find((r) => r.key === "crown:gold_crown");
expect(crownItem?.relationship).toBe("purchasable");
});
test("owned with wildcard flare", () => {
const cosmetics = makeCosmetics({
crowns: { gold_crown: crown as any },
});
const result = resolveCosmetics(cosmetics, makeUserMe(["crown:*"]), null);
const crownItem = result.find((r) => r.key === "crown:gold_crown");
expect(crownItem?.relationship).toBe("owned");
});
test("owned with specific flare", () => {
const cosmetics = makeCosmetics({
crowns: { gold_crown: crown as any },
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["crown:gold_crown"]),
null,
);
const crownItem = result.find((r) => r.key === "crown:gold_crown");
expect(crownItem?.relationship).toBe("owned");
});
test("blocked with no product and no price", () => {
const freeCrown = {
...crown,
product: null,
priceHard: undefined,
};
const cosmetics = makeCosmetics({
crowns: { gold_crown: freeCrown as any },
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const crownItem = result.find((r) => r.key === "crown:gold_crown");
expect(crownItem?.relationship).toBe("blocked");
});
});
describe("groupCosmeticVariants", () => {
const patternVariant = (
patternName: string,
paletteName: string | null,
): ResolvedCosmetic => ({
type: "pattern",
cosmetic: { name: patternName } as any,
colorPalette: paletteName
? { name: paletteName, primaryColor: "#fff", secondaryColor: "#000" }
: null,
relationship: "purchasable",
key: paletteName
? `pattern:${patternName}:${paletteName}`
: `pattern:${patternName}`,
});
const skinVariant = (name: string): ResolvedCosmetic => ({
type: "skin",
cosmetic: { name } as any,
colorPalette: null,
relationship: "purchasable",
key: `skin:${name}`,
});
test("collapses colour variants of the same pattern into one group", () => {
const groups = groupCosmeticVariants([
patternVariant("stripes", "red"),
patternVariant("stripes", "blue"),
patternVariant("stripes", "green"),
]);
expect(groups).toHaveLength(1);
expect(groups[0].map((r) => r.key)).toEqual([
"pattern:stripes:red",
"pattern:stripes:blue",
"pattern:stripes:green",
]);
});
test("keeps distinct patterns in separate groups, first-seen order", () => {
const groups = groupCosmeticVariants([
patternVariant("stripes", "red"),
patternVariant("dots", "red"),
patternVariant("stripes", "blue"),
]);
expect(groups).toHaveLength(2);
expect(groups[0].map((r) => r.key)).toEqual([
"pattern:stripes:red",
"pattern:stripes:blue",
]);
expect(groups[1].map((r) => r.key)).toEqual(["pattern:dots:red"]);
});
test("skins are never grouped — one group each", () => {
const groups = groupCosmeticVariants([
skinVariant("mountain"),
skinVariant("ocean"),
patternVariant("stripes", "red"),
]);
expect(groups).toHaveLength(3);
expect(groups.map((g) => g.length)).toEqual([1, 1, 1]);
});
});
describe("mixed cosmetics", () => {
test("returns all types in order: default, patterns, flags", () => {
const cosmetics = makeCosmetics({
patterns: {
stripes: {
type: "pattern" as const,
name: "stripes",
pattern: "AAAAAA",
affiliateCode: null,
product,
priceSoft: null,
priceHard: null,
rarity: "common",
} as any,
},
flags: {
heart: {
type: "flag" as const,
name: "heart",
url: "/flags/heart.svg",
affiliateCode: null,
product,
priceSoft: null,
priceHard: null,
rarity: "common",
} as any,
},
});
const result = resolveCosmetics(cosmetics, false, null);
const keys = result.map((r) => r.key);
expect(keys[0]).toBe("pattern:default");
expect(keys).toContain("pattern:stripes");
expect(keys).toContain("flag:heart");
// patterns come before flags
const patternIdx = keys.indexOf("pattern:stripes");
const flagIdx = keys.indexOf("flag:heart");
expect(patternIdx).toBeLessThan(flagIdx);
});
});
});