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
+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) {