Add crown cosmetic type (#4618)

## What

Adds **crowns** as a new cosmetic category, wired end-to-end like
flags/skins, plus a Crowns tab in the lobby cosmetics modal (#4617).

Catalog shape in cosmetics.json:

```json
"crowns": {
  "gold_crown": {
    "name": "gold_crown",
    "url": "...",
    "affiliateCode": null,
    "product": null,
    "priceHard": 5,
    "artist": "...",
    "rarity": "common"
  }
}
```

## Changes

- **Core**: `CrownSchema` + optional `crowns` record in
`CosmeticsSchema`; `crownName` in `PlayerCosmeticRefs`; resolved `crown:
{ name, url }` in `PlayerCosmetics`
- **Server**: `isCrownAllowed` in the privilege checker — requires a
`crown:*` or `crown:<name>` flare, resolves the ref to the catalog URL
- **Client**: crown selection persisted under the `crown` localStorage
key (stale/unowned selections self-clear), `crownRelationship` +
resolve/refs wiring, `"crown"` purchase type, Crowns tab in the store,
and a Crowns tab in the cosmetics modal (owned crowns + Default tile;
selecting persists and keeps the modal open)
- **i18n**: `store.crowns`, `store.no_crowns`
- Tests for schema parsing, privilege checks, and cosmetic resolution

## Not in this PR

- In-game rendering of `player.cosmetics.crown` (name pass /
leaderboards / player panel)
- API-side support (`/shop/purchase` accepting `cosmeticType: "crown"`,
granting `crown:<name>` flares, serving `crowns` in cosmetics.json)

🤖 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 12:49:45 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 7636f4770c
commit e05dce3437
13 changed files with 547 additions and 14 deletions
+1 -1
View File
@@ -192,7 +192,7 @@ export async function setMarketingConsent(
}
export async function purchaseWithCurrency(
cosmeticType: "pattern" | "skin" | "flag" | "effect",
cosmeticType: "pattern" | "skin" | "flag" | "crown" | "effect",
cosmeticName: string,
currencyType: "hard" | "soft",
colorPaletteName?: string,
+84 -7
View File
@@ -4,6 +4,7 @@ import {
ColorPalette,
Cosmetics,
CosmeticsSchema,
Crown,
Effect,
findEffectForSlot,
Flag,
@@ -165,10 +166,14 @@ export async function purchaseCosmetic(
const currencyName = translateText(
method === "hard" ? "cosmetics.hard" : "cosmetics.soft",
);
const itemName =
resolved.type === "flag"
? translateCosmetic("flags", c.name)
: translateCosmetic("territory_patterns.pattern", c.name);
let itemName: string;
if (resolved.type === "flag") {
itemName = translateCosmetic("flags", c.name);
} else if (resolved.type === "crown") {
itemName = translateCosmetic("crowns", c.name);
} else {
itemName = translateCosmetic("territory_patterns.pattern", c.name);
}
return {
currency: currencyName,
shortfall: price - balance,
@@ -178,7 +183,12 @@ export async function purchaseCosmetic(
};
}
const cosmeticType = resolved.type as "pattern" | "skin" | "flag" | "effect";
const cosmeticType = resolved.type as
| "pattern"
| "skin"
| "flag"
| "crown"
| "effect";
const success = await purchaseWithCurrency(
cosmeticType,
c.name,
@@ -360,6 +370,25 @@ export function flagRelationship(
);
}
export function crownRelationship(
crown: Crown,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
return cosmeticRelationship(
{
wildcardFlare: "crown:*",
requiredFlare: `crown:${crown.name}`,
product: crown.product,
priceSoft: crown.priceSoft,
priceHard: crown.priceHard,
affiliateCode,
itemAffiliateCode: crown.affiliateCode ?? null,
},
userMeResponse,
);
}
export function skinRelationship(
skin: Skin,
userMeResponse: UserMeResponse | false,
@@ -399,8 +428,15 @@ export function effectRelationship(
}
export type ResolvedCosmetic = {
type: "pattern" | "skin" | "flag" | "effect" | "pack" | "subscription";
cosmetic: Pattern | Skin | Flag | Effect | Pack | Subscription | null;
type:
| "pattern"
| "skin"
| "flag"
| "crown"
| "effect"
| "pack"
| "subscription";
cosmetic: Pattern | Skin | Flag | Crown | Effect | Pack | Subscription | null;
colorPalette: ColorPalette | null;
relationship: "owned" | "purchasable" | "blocked";
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "skin:mountain" */
@@ -468,6 +504,18 @@ export function resolveCosmetics(
});
}
// Crowns
for (const [crownKey, crown] of Object.entries(cosmetics.crowns ?? {})) {
const rel = crownRelationship(crown, userMeResponse, affiliateCode);
result.push({
type: "crown",
cosmetic: crown,
colorPalette: null,
relationship: rel,
key: `crown:${crownKey}`,
});
}
// Skins (image-based territory cosmetics). No separate "default" entry —
// the pattern default doubles as "no skin": selecting it clears both.
for (const [skinKey, skin] of Object.entries(cosmetics.skins ?? {})) {
@@ -645,6 +693,27 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
}
}
let crownName = userSettings.getSelectedCrownName() ?? undefined;
if (crownName) {
const crown = cosmetics?.crowns?.[crownName];
if (cosmetics && !crown) {
// Cosmetics loaded but the saved crown no longer exists.
crownName = undefined;
} else if (crown) {
const userMe = await getUserMe();
if (userMe) {
const flares = userMe.player.flares ?? [];
const hasWildcard = flares.includes("crown:*");
if (!hasWildcard && !flares.includes(`crown:${crown.name}`)) {
crownName = undefined;
}
}
}
if (crownName === undefined) {
userSettings.setSelectedCrownName(undefined);
}
}
// Effects: a per-slot map (slot -> effect name). A slot is the effectType for
// trails and the nukeType for nuke explosions (see effectTypeForSlot). Drop any
// entry whose effect no longer exists, doesn't fit the slot, or the user can't
@@ -677,6 +746,7 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
skinName,
crownName,
effects: Object.keys(effects).length > 0 ? effects : undefined,
};
}
@@ -720,6 +790,13 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
}
}
if (refs.crownName && cosmetics) {
const crown = cosmetics.crowns?.[refs.crownName];
if (crown) {
result.crown = { name: refs.crownName, url: crown.url };
}
}
if (refs.effects && cosmetics) {
const effects: Record<string, PlayerEffect> = {};
for (const [slot, name] of Object.entries(refs.effects)) {
+69 -5
View File
@@ -2,8 +2,9 @@ import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { Cosmetics, Skin } from "../core/CosmeticSchemas";
import { Cosmetics, Crown, Skin } from "../core/CosmeticSchemas";
import {
CROWN_KEY,
PATTERN_KEY,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
@@ -24,9 +25,9 @@ import {
import { translateText } from "./Utils";
/**
* One modal for every non-flag cosmetic: a Skins tab (patterns + image skins)
* and an Effects tab (all effect types via the tabbed effects-grid). Opened
* from the lobby's "Cosmetics" button.
* One modal for every non-flag cosmetic: a Skins tab (patterns + image skins),
* a Crowns tab, and an Effects tab (all effect types via the tabbed
* effects-grid). Opened from the lobby's "Cosmetics" button.
*/
@customElement("cosmetics-modal")
export class CosmeticsModal extends BaseModal {
@@ -45,6 +46,7 @@ export class CosmeticsModal extends BaseModal {
return {
tabs: [
{ key: "skins", label: translateText("store.patterns") },
{ key: "crowns", label: translateText("store.crowns") },
{ key: "effects", label: translateText("store.effects") },
],
};
@@ -67,6 +69,10 @@ export class CosmeticsModal extends BaseModal {
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onCosmeticSelected,
);
window.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${CROWN_KEY}`,
this._onCosmeticSelected,
);
}
disconnectedCallback() {
@@ -75,6 +81,10 @@ export class CosmeticsModal extends BaseModal {
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onCosmeticSelected,
);
window.removeEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${CROWN_KEY}`,
this._onCosmeticSelected,
);
}
private async updateFromSettings() {
@@ -146,6 +156,52 @@ export class CosmeticsModal extends BaseModal {
`;
}
/** Owned crowns + a Default (none) tile; selecting persists to UserSettings. */
private renderCrownGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
null,
).filter(
(r) =>
r.type === "crown" &&
r.relationship === "owned" &&
r.cosmetic !== null &&
this.includedInSearch(r.cosmetic.name),
);
// The Default tile has no name to match — hide it while searching.
const noneTile: ResolvedCosmetic = {
type: "crown",
cosmetic: null,
colorPalette: null,
relationship: "owned",
key: "crown:none",
};
const tiles = this.search ? items : [noneTile, ...items];
const selectedCrown = this.userSettings.getSelectedCrownName();
return html`
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${tiles.map((r) => {
const name = (r.cosmetic as Crown | null)?.name ?? null;
const isSelected =
(name === null && selectedCrown === null) ||
(name !== null && selectedCrown === name);
return html`
<cosmetic-button
.resolved=${r}
.selected=${isSelected}
.onSelect=${() => this.selectCrown(name)}
></cosmetic-button>
`;
})}
</div>
`;
}
protected renderHeaderSlot() {
return html`
<div
@@ -176,7 +232,9 @@ export class CosmeticsModal extends BaseModal {
protected renderBody(tab: string) {
let grid: TemplateResult;
if (tab === "effects") {
if (tab === "crowns") {
grid = this.renderCrownGrid();
} else if (tab === "effects") {
grid = html`<effects-grid
mode="select"
tabbed
@@ -230,6 +288,12 @@ export class CosmeticsModal extends BaseModal {
this.refresh();
}
private selectCrown(crownName: string | null) {
this.userSettings.setSelectedCrownName(crownName ?? undefined);
// Stay open — the tile highlight moves to the new selection.
this.refresh();
}
private selectPattern(pattern: PlayerPattern | null) {
this.selectedColor = null;
if (pattern === null) {
+49 -1
View File
@@ -19,7 +19,13 @@ import {
} from "./Cosmetics";
import { translateText } from "./Utils";
type StoreTab = "patterns" | "flags" | "effects" | "packs" | "subscriptions";
type StoreTab =
| "patterns"
| "flags"
| "crowns"
| "effects"
| "packs"
| "subscriptions";
@customElement("store-modal")
export class StoreModal extends BaseModal {
@@ -39,6 +45,7 @@ export class StoreModal extends BaseModal {
{ key: "subscriptions", label: translateText("store.subscriptions") },
{ key: "patterns", label: translateText("store.patterns") },
{ key: "flags", label: translateText("store.flags") },
{ key: "crowns", label: translateText("store.crowns") },
{ key: "effects", label: translateText("store.effects") },
],
};
@@ -158,6 +165,44 @@ export class StoreModal extends BaseModal {
`;
}
private renderCrownGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter(
(r) =>
r.type === "crown" &&
r.relationship !== "blocked" &&
r.relationship !== "owned",
);
if (items.length === 0) {
return html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_crowns")}
</div>`;
}
const selectedCrown = new UserSettings().getSelectedCrownName() ?? "";
return html`
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.selected=${`crown:${selectedCrown}` === r.key}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
`;
}
private renderEffectGrid(): TemplateResult {
// A sub-tab per effectType (Boat Trail / Nuke Trail); each tab opens that
// type's grid. Tabs are always present, even when a type has nothing to buy.
@@ -249,6 +294,8 @@ export class StoreModal extends BaseModal {
return this.renderPatternGrid();
case "flags":
return this.renderFlagGrid();
case "crowns":
return this.renderCrownGrid();
case "effects":
return this.renderEffectGrid();
case "subscriptions":
@@ -269,6 +316,7 @@ export class StoreModal extends BaseModal {
(r.type === "pattern" ||
r.type === "skin" ||
r.type === "flag" ||
r.type === "crown" ||
r.type === "effect" ||
r.type === "pack") &&
r.relationship === "purchasable",
+23
View File
@@ -1,6 +1,7 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import {
Crown,
Effect,
Flag,
isNukeExplosionEffect,
@@ -91,6 +92,9 @@ export class CosmeticButton extends LitElement {
if (this.activeResolved.type === "effect") {
return translateCosmetic("effects", c.name);
}
if (this.activeResolved.type === "crown") {
return translateCosmetic("crowns", c.name);
}
return translateCosmetic("flags", c.name);
}
@@ -209,6 +213,25 @@ export class CosmeticButton extends LitElement {
></trail-swatch>`;
}
if (this.activeResolved.type === "crown") {
const c = this.activeResolved.cosmetic as Crown | null;
if (c === null) {
// "Default" (none) tile — selecting it clears the crown.
return html`<div
class="w-full h-full flex items-center justify-center text-white/40 text-xs uppercase"
>
${translateText("territory_patterns.pattern.default")}
</div>`;
}
return html`<img
src=${c.url}
alt=${c.name}
class="w-full h-full object-contain pointer-events-none"
draggable="false"
loading="lazy"
/>`;
}
if (this.activeResolved.type === "pack") {
const pack = this.activeResolved.cosmetic as Pack;
const isHard = pack.currency === "hard";
+6
View File
@@ -6,6 +6,7 @@ import { PlayerPattern } from "./Schemas";
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
export type Pattern = z.infer<typeof PatternSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type Crown = z.infer<typeof CrownSchema>;
export type Skin = z.infer<typeof SkinSchema>;
export type Pack = z.infer<typeof PackSchema>;
export type Subscription = z.infer<typeof SubscriptionSchema>;
@@ -96,6 +97,10 @@ export const FlagSchema = CosmeticSchema.extend({
url: z.string(),
});
export const CrownSchema = CosmeticSchema.extend({
url: z.string(),
});
export const SkinSchema = CosmeticSchema.extend({
url: z.string(),
});
@@ -363,6 +368,7 @@ export const CosmeticsSchema = z.object({
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
patterns: z.record(z.string(), PatternSchema),
flags: z.record(z.string(), FlagSchema),
crowns: z.record(z.string(), CrownSchema).optional(),
skins: z.record(z.string(), SkinSchema).optional(),
// Grouped by effectType. Each effect also carries its own effectType (matching
// this outer key) so an Effect stands alone and EffectSchema can discriminate
+8
View File
@@ -145,6 +145,7 @@ export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type PlayerSkin = z.infer<typeof PlayerSkinSchema>;
export type PlayerCrown = z.infer<typeof PlayerCrownSchema>;
export type PlayerEffect = z.infer<typeof PlayerEffectSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
export type GameInfo = z.infer<typeof GameInfoSchema>;
@@ -653,6 +654,7 @@ export const PlayerCosmeticRefsSchema = z.object({
patternName: CosmeticNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
skinName: CosmeticNameSchema.optional(),
crownName: CosmeticNameSchema.optional(),
// One selected effect per slot: key = slot (effectType for trails, nukeType for
// nuke explosions — see effectTypeForSlot), value = effect name.
effects: z.record(z.string(), CosmeticNameSchema).optional(),
@@ -663,6 +665,11 @@ export const PlayerSkinSchema = z.object({
url: z.string(),
});
export const PlayerCrownSchema = z.object({
name: CosmeticNameSchema,
url: z.string(),
});
// A resolved effect is just an identity: which effect, of which type. Its
// attributes (the visual style) are resolved from the cosmetics catalog by
// (effectType, name), so this needs no per-type variants — a new effectType
@@ -678,6 +685,7 @@ export const PlayerCosmeticsSchema = z.object({
pattern: PlayerPatternSchema.optional(),
color: PlayerColorSchema.optional(),
skin: PlayerSkinSchema.optional(),
crown: PlayerCrownSchema.optional(),
// Resolved effects keyed by slot (effectType for trails, nukeType for nuke
// explosions).
effects: z.record(z.string(), PlayerEffectSchema).optional(),
+14
View File
@@ -54,6 +54,7 @@ export const USER_SETTINGS_CHANGED_EVENT = "event:user-settings-changed";
*/
export const PATTERN_KEY = "territoryPattern";
export const FLAG_KEY = "flag";
export const CROWN_KEY = "crown";
export const COLOR_KEY = "settings.territoryColor";
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export const KEYBINDS_KEY = "settings.keybinds";
@@ -301,6 +302,19 @@ export class UserSettings {
return data.startsWith(skinPrefix) ? data.slice(skinPrefix.length) : null;
}
/** Returns the selected crown name, or null if none is selected. */
getSelectedCrownName(): string | null {
return this.getCached(CROWN_KEY);
}
setSelectedCrownName(name: string | undefined): void {
if (name === undefined) {
this.removeCached(CROWN_KEY);
} else {
this.setCached(CROWN_KEY, name);
}
}
getFlag(): string | null {
let flag = this.getCached(FLAG_KEY);
if (!flag) return null;
+18
View File
@@ -17,6 +17,7 @@ import {
PlayerColor,
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerCrown,
PlayerEffect,
PlayerPattern,
PlayerSkin,
@@ -258,6 +259,14 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
return { type: "forbidden", reason: "invalid skin: " + message };
}
}
if (refs.crownName) {
try {
cosmetics.crown = this.isCrownAllowed(flares, refs.crownName);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { type: "forbidden", reason: "invalid crown: " + message };
}
}
if (refs.effects) {
for (const [slot, name] of Object.entries(refs.effects)) {
try {
@@ -300,6 +309,15 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
throw new Error(`No flares for skin ${name}`);
}
isCrownAllowed(flares: string[], name: string): PlayerCrown {
const found = this.cosmetics.crowns?.[name];
if (!found) throw new Error(`Crown ${name} not found`);
if (flares.includes("crown:*") || flares.includes(`crown:${found.name}`)) {
return { name: found.name, url: found.url };
}
throw new Error(`No flares for crown ${name}`);
}
isPatternAllowed(
flares: readonly string[],
name: string,