From e05dce34370cb4b5e50a9b9ce9c7972d975cfb94 Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 15 Jul 2026 12:49:45 -0700 Subject: [PATCH] Add crown cosmetic type (#4618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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:` 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:` flares, serving `crowns` in cosmetics.json) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- resources/lang/en.json | 2 + src/client/Api.ts | 2 +- src/client/Cosmetics.ts | 91 ++++++++++++-- src/client/CosmeticsModal.ts | 74 ++++++++++- src/client/Store.ts | 50 +++++++- src/client/components/CosmeticButton.ts | 23 ++++ src/core/CosmeticSchemas.ts | 6 + src/core/Schemas.ts | 8 ++ src/core/game/UserSettings.ts | 14 +++ src/server/Privilege.ts | 18 +++ tests/CosmeticSchemas.test.ts | 46 +++++++ tests/Privilege.test.ts | 158 ++++++++++++++++++++++++ tests/ResolveCosmetics.test.ts | 69 +++++++++++ 13 files changed, 547 insertions(+), 14 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index d4b9be29e..6f2f6153b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1358,6 +1358,7 @@ "confirm_purchase_body": "Buy {item} for {amount, number} {currency}?", "confirm_purchase_title": "Confirm Purchase", "confirm_upgrade": "Upgrade to {tier}? You'll be charged the prorated difference now.", + "crowns": "Crowns", "currency_pack_purchase_success": "Currency pack purchase successful!", "custom_amount": "Choose Amount", "custom_currency_purchase_success": "Plutonium purchase successful!", @@ -1366,6 +1367,7 @@ "insufficient_currency_body": "You need {amount, number} more {currency} to buy {item}.", "insufficient_currency_title": "Insufficient {currency}", "login_required": "You must be logged in to purchase with currency.", + "no_crowns": "No crowns available. Check back later for new items.", "no_effects": "No effects available. Check back later for new items.", "no_flags": "No flags available. Check back later for new items.", "no_skins": "No skins available. Check back later for new items.", diff --git a/src/client/Api.ts b/src/client/Api.ts index b3eecec4e..86c84964e 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -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, diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index c623f8c84..5a3690ad0 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -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 { } } + 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 { 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 { } } + 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 = {}; for (const [slot, name] of Object.entries(refs.effects)) { diff --git a/src/client/CosmeticsModal.ts b/src/client/CosmeticsModal.ts index 1b9fe529f..5925a5296 100644 --- a/src/client/CosmeticsModal.ts +++ b/src/client/CosmeticsModal.ts @@ -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` +
+ ${tiles.map((r) => { + const name = (r.cosmetic as Crown | null)?.name ?? null; + const isSelected = + (name === null && selectedCrown === null) || + (name !== null && selectedCrown === name); + return html` + this.selectCrown(name)} + > + `; + })} +
+ `; + } + protected renderHeaderSlot() { return html`
+ r.type === "crown" && + r.relationship !== "blocked" && + r.relationship !== "owned", + ); + + if (items.length === 0) { + return html`
+ ${translateText("store.no_crowns")} +
`; + } + + const selectedCrown = new UserSettings().getSelectedCrownName() ?? ""; + return html` +
+ ${items.map( + (r) => html` + + `, + )} +
+ `; + } + 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", diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts index cec4ac5fa..5fd9f1150 100644 --- a/src/client/components/CosmeticButton.ts +++ b/src/client/components/CosmeticButton.ts @@ -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 { >`; } + 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`
+ ${translateText("territory_patterns.pattern.default")} +
`; + } + return html`${c.name}`; + } + if (this.activeResolved.type === "pack") { const pack = this.activeResolved.cosmetic as Pack; const isHard = pack.currency === "hard"; diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index cf2029b19..01b229913 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -6,6 +6,7 @@ import { PlayerPattern } from "./Schemas"; export type Cosmetics = z.infer; export type Pattern = z.infer; export type Flag = z.infer; +export type Crown = z.infer; export type Skin = z.infer; export type Pack = z.infer; export type Subscription = z.infer; @@ -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 diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 50ee54ae0..a6a6af28c 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -145,6 +145,7 @@ export type PlayerCosmeticRefs = z.infer; export type PlayerPattern = z.infer; export type PlayerColor = z.infer; export type PlayerSkin = z.infer; +export type PlayerCrown = z.infer; export type PlayerEffect = z.infer; export type GameStartInfo = z.infer; export type GameInfo = z.infer; @@ -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(), diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index fd3e2a075..3fc9e3a17 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -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; diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 35b3fe9a5..b40882816 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -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, diff --git a/tests/CosmeticSchemas.test.ts b/tests/CosmeticSchemas.test.ts index fde25cc23..7b1dd1de5 100644 --- a/tests/CosmeticSchemas.test.ts +++ b/tests/CosmeticSchemas.test.ts @@ -786,6 +786,52 @@ describe("effect selection slots", () => { }); }); +describe("crowns in the cosmetics catalog", () => { + const goldCrown = { + name: "gold_crown", + url: "http://localhost:8787/public/cosmetics/crown/gold", + affiliateCode: null, + product: null, + priceHard: 5, + artist: "sadfas", + rarity: "common", + }; + + it("parses a crowns catalog entry", () => { + const result = CosmeticsSchema.safeParse({ + patterns: {}, + flags: {}, + crowns: { gold_crown: goldCrown }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.crowns?.gold_crown?.name).toBe("gold_crown"); + expect(result.data.crowns?.gold_crown?.url).toBe( + "http://localhost:8787/public/cosmetics/crown/gold", + ); + } + }); + + it("parses a catalog without crowns (older cosmetics.json)", () => { + const result = CosmeticsSchema.safeParse({ patterns: {}, flags: {} }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.crowns).toBeUndefined(); + } + }); + + it("rejects a crown without a url", () => { + const noUrl = { ...goldCrown, url: undefined }; + expect( + CosmeticsSchema.safeParse({ + patterns: {}, + flags: {}, + crowns: { gold_crown: noUrl }, + }).success, + ).toBe(false); + }); +}); + describe("SubscriptionSchema unlimitedRanked", () => { const base = { name: "gold", diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index 8951d1f55..47615d2bc 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -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:*"], { diff --git a/tests/ResolveCosmetics.test.ts b/tests/ResolveCosmetics.test.ts index aa051ff3c..77d11c9a1 100644 --- a/tests/ResolveCosmetics.test.ts +++ b/tests/ResolveCosmetics.test.ts @@ -294,6 +294,75 @@ describe("resolveCosmetics", () => { }); }); + 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,