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
+158
View File
@@ -88,6 +88,37 @@ const skinChecker = new PrivilegeCheckerImpl(
bannedWords,
);
const crownCosmetics = {
patterns: {},
colorPalettes: {},
flags: {},
crowns: {
gold_crown: {
name: "gold_crown",
url: "https://example.com/gold.png",
affiliateCode: null,
product: null,
priceSoft: undefined,
priceHard: 5,
rarity: "common",
},
silver_crown: {
name: "silver_crown",
url: "https://example.com/silver.png",
affiliateCode: null,
product: null,
priceSoft: undefined,
priceHard: undefined,
rarity: "rare",
},
},
};
const crownChecker = new PrivilegeCheckerImpl(
crownCosmetics,
mockDecoder,
bannedWords,
);
const effectCosmetics = {
patterns: {},
colorPalettes: {},
@@ -591,6 +622,133 @@ describe("Skin validation", () => {
});
});
describe("Crown validation", () => {
describe("isCrownAllowed (direct)", () => {
test("returns crown when user has wildcard flare", () => {
const result = crownChecker.isCrownAllowed(["crown:*"], "gold_crown");
expect(result).toEqual({
name: "gold_crown",
url: "https://example.com/gold.png",
});
});
test("returns crown when user has exact-match flare", () => {
const result = crownChecker.isCrownAllowed(
["crown:gold_crown"],
"gold_crown",
);
expect(result).toEqual({
name: "gold_crown",
url: "https://example.com/gold.png",
});
});
test("ignores unrelated flares", () => {
expect(() =>
crownChecker.isCrownAllowed(
["crown:silver_crown", "skin:*", "flag:*"],
"gold_crown",
),
).toThrow(/No flares for crown gold_crown/);
});
test("throws when user has no crown flares", () => {
expect(() => crownChecker.isCrownAllowed([], "gold_crown")).toThrow(
/No flares for crown gold_crown/,
);
});
test("throws when crown does not exist in cosmetics", () => {
expect(() =>
crownChecker.isCrownAllowed(["crown:*"], "nonexistent"),
).toThrow(/Crown nonexistent not found/);
});
test("throws when crown does not exist even with exact-match flare", () => {
// Forged refs.crownName must not bypass the existence check.
expect(() =>
crownChecker.isCrownAllowed(["crown:nonexistent"], "nonexistent"),
).toThrow(/Crown nonexistent not found/);
});
test("throws when checker has no crowns map at all", () => {
// checker is constructed with mockCosmetics (no crowns key).
expect(() => checker.isCrownAllowed(["crown:*"], "anything")).toThrow(
/Crown anything not found/,
);
});
});
describe("isAllowed integration", () => {
test("allows valid crown with wildcard flare", () => {
const result = crownChecker.isAllowed(["crown:*"], {
crownName: "gold_crown",
});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.crown).toEqual({
name: "gold_crown",
url: "https://example.com/gold.png",
});
}
});
test("allows valid crown with exact-match flare", () => {
const result = crownChecker.isAllowed(["crown:silver_crown"], {
crownName: "silver_crown",
});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.crown).toEqual({
name: "silver_crown",
url: "https://example.com/silver.png",
});
}
});
test("rejects crown when user lacks flare", () => {
const result = crownChecker.isAllowed([], { crownName: "gold_crown" });
expect(result.type).toBe("forbidden");
if (result.type === "forbidden") {
expect(result.reason).toMatch(/invalid crown/);
}
});
test("rejects crown when flare is for a different crown", () => {
const result = crownChecker.isAllowed(["crown:silver_crown"], {
crownName: "gold_crown",
});
expect(result.type).toBe("forbidden");
});
test("rejects nonexistent crown", () => {
const result = crownChecker.isAllowed(["crown:*"], {
crownName: "ghost",
});
expect(result.type).toBe("forbidden");
if (result.type === "forbidden") {
expect(result.reason).toMatch(/Crown ghost not found/);
}
});
test("no crown in refs leaves cosmetics.crown undefined", () => {
const result = crownChecker.isAllowed(["crown:*"], {});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.crown).toBeUndefined();
}
});
test("invalid crown short-circuits and does not return other cosmetics", () => {
const result = crownChecker.isAllowed(["color:red"], {
color: "red",
crownName: "gold_crown",
});
expect(result.type).toBe("forbidden");
});
});
});
describe("Effect validation in isAllowed", () => {
test("allows valid effect with wildcard flare", () => {
const result = effectChecker.isAllowed(["effect:*"], {