cosmetic refactor (#3628)

## Description:

The motivation is to have a single "cosmetic-button" element, so we can
abstract out the cosmetic types. This will make it much easier to add
new cosmetic types in the future.

Unifies PatternButton and FlagButton into a single CosmeticButton
component. Extracts a resolveCosmetics() function that flattens patterns
× color palettes + flags into a ResolvedCosmetic[] with relationship
status pre-computed, replacing duplicated resolution logic across four
callers.

* New CosmeticButton — renders patterns or flags based on
ResolvedCosmetic.type
* New resolveCosmetics() — centralizes ownership/purchase/blocked
resolution
* Extracted PatternPreview — canvas rendering split into its own module
* Added type: "pattern" | "flag" discriminator to Zod cosmetic schemas
* Deleted FlagButton.ts and PatternButton.ts
* Added 320-line test suite for resolveCosmetics


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-04-09 21:07:07 -07:00
committed by GitHub
parent f0b3c490b1
commit d5a2cc0fca
16 changed files with 826 additions and 594 deletions
-1
View File
@@ -16,7 +16,6 @@ import "./components/baseComponents/stats/PlayerStatsTree";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import "./components/Difficulties";
import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
+85
View File
@@ -1,6 +1,7 @@
import { assetUrl } from "src/core/AssetUrls";
import { UserMeResponse } from "../core/ApiSchemas";
import {
ColorPalette,
Cosmetics,
CosmeticsSchema,
Flag,
@@ -188,6 +189,90 @@ export function flagRelationship(
);
}
export type ResolvedCosmetic = {
type: "pattern" | "flag";
cosmetic: Pattern | Flag | null;
colorPalette: ColorPalette | null;
relationship: "owned" | "purchasable" | "blocked";
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "flag:cool_flag" */
key: string;
};
/**
* Resolves all cosmetics into a flat display-ready list with relationship
* status and resolved color palettes. Callers can filter by relationship.
*/
export function resolveCosmetics(
cosmetics: Cosmetics | null,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): ResolvedCosmetic[] {
if (!cosmetics) return [];
const result: ResolvedCosmetic[] = [];
// Default pattern (always owned)
result.push({
type: "pattern",
cosmetic: null,
colorPalette: null,
relationship: "owned",
key: "pattern:default",
});
// Patterns × color palettes
for (const [patternKey, pattern] of Object.entries(cosmetics.patterns)) {
const colorPalettes = [...(pattern.colorPalettes ?? []), null];
for (const cp of colorPalettes) {
const rel = patternRelationship(
pattern,
cp,
userMeResponse,
affiliateCode,
);
const resolvedPalette = cp
? (cosmetics.colorPalettes?.[cp.name] ?? null)
: null;
const key = cp
? `pattern:${patternKey}:${cp.name}`
: `pattern:${patternKey}`;
result.push({
type: "pattern",
cosmetic: pattern,
colorPalette: resolvedPalette,
relationship: rel,
key,
});
}
}
// Flags
for (const [flagKey, flag] of Object.entries(cosmetics.flags)) {
const rel = flagRelationship(flag, userMeResponse, affiliateCode);
result.push({
type: "flag",
cosmetic: flag,
colorPalette: null,
relationship: rel,
key: `flag:${flagKey}`,
});
}
return result;
}
export function resolvedToPlayerPattern(
resolved: ResolvedCosmetic,
): PlayerPattern | null {
if (resolved.type !== "pattern") return null;
const c = resolved.cosmetic;
if (c === null) return null;
return {
name: c.name,
patternData: (c as Pattern).pattern,
colorPalette: resolved.colorPalette ?? undefined,
};
}
export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
const userSettings = new UserSettings();
const cosmetics = await fetchCosmetics();
+67 -35
View File
@@ -3,16 +3,30 @@ import { customElement, state } from "lit/decorators.js";
import Countries from "resources/countries.json" with { type: "json" };
import { UserMeResponse } from "src/core/ApiSchemas";
import { assetUrl } from "src/core/AssetUrls";
import { Cosmetics } from "src/core/CosmeticSchemas";
import { Cosmetics, Flag } from "src/core/CosmeticSchemas";
import { UserSettings } from "src/core/game/UserSettings";
import { getUserMe } from "./Api";
import { fetchCosmetics, flagRelationship } from "./Cosmetics";
import {
fetchCosmetics,
flagRelationship,
ResolvedCosmetic,
} from "./Cosmetics";
import { translateText } from "./Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/FlagButton";
import "./components/CosmeticButton";
import "./components/NotLoggedInWarning";
import { modalHeader } from "./components/ui/ModalHeader";
function countryFlag(name: string, code: string): Flag {
return {
name,
url: assetUrl(`/flags/${code}.svg`),
product: null,
rarity: "common",
affiliateCode: null,
};
}
@customElement("flag-input-modal")
export class FlagInputModal extends BaseModal {
@state() private search = "";
@@ -27,10 +41,6 @@ export class FlagInputModal extends BaseModal {
private renderFlags() {
const userSettings = new UserSettings();
const selectedFlag = userSettings.getFlag() ?? "";
const onSelect = (flagKey: string) => {
this.setFlag(flagKey);
this.close();
};
const cosmeticFlags = Object.entries(this.cosmetics?.flags ?? {})
.filter(([, flag]) => {
@@ -38,28 +48,44 @@ export class FlagInputModal extends BaseModal {
return false;
return flagRelationship(flag, this.userMe, null) === "owned";
})
.map(
([key, flag]) => html`
<flag-button
.flag=${{ ...flag, key: `flag:${key}` }}
.map(([key, flag]) => {
const r: ResolvedCosmetic = {
type: "flag",
cosmetic: flag,
colorPalette: null,
relationship: "owned",
key: `flag:${key}`,
};
return html`
<cosmetic-button
.resolved=${r}
.selected=${selectedFlag === `flag:${key}`}
.onSelect=${onSelect}
></flag-button>
`,
);
.onSelect=${() => {
this.setFlag(`flag:${key}`);
this.close();
}}
></cosmetic-button>
`;
});
const noFlagResolved: ResolvedCosmetic = {
type: "flag",
cosmetic: countryFlag("None", "xx"),
colorPalette: null,
relationship: "owned",
key: "country:xx",
};
const noFlag = this.search
? null
: html`
<flag-button
.flag=${{
key: "country:xx",
name: "None",
url: assetUrl("/flags/xx.svg"),
}}
<cosmetic-button
.resolved=${noFlagResolved}
.selected=${selectedFlag === "" || selectedFlag === "country:xx"}
.onSelect=${onSelect}
></flag-button>
.onSelect=${() => {
this.setFlag("country:xx");
this.close();
}}
></cosmetic-button>
`;
const countryFlags = Countries.filter(
@@ -67,19 +93,25 @@ export class FlagInputModal extends BaseModal {
country.code !== "xx" &&
!country.restricted &&
this.includedInSearch(country),
).map(
(country) => html`
<flag-button
.flag=${{
key: `country:${country.code}`,
name: country.name,
url: assetUrl(`/flags/${country.code}.svg`),
}}
).map((country) => {
const r: ResolvedCosmetic = {
type: "flag",
cosmetic: countryFlag(country.name, country.code),
colorPalette: null,
relationship: "owned",
key: `country:${country.code}`,
};
return html`
<cosmetic-button
.resolved=${r}
.selected=${selectedFlag === `country:${country.code}`}
.onSelect=${onSelect}
></flag-button>
`,
);
.onSelect=${() => {
this.setFlag(`country:${country.code}`);
this.close();
}}
></cosmetic-button>
`;
});
return html`
<div
-1
View File
@@ -6,7 +6,6 @@ import { getUserMe, hasLinkedAccount } from "./Api";
import { getPlayToken } from "./Auth";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import { JoinLobbyEvent } from "./Main";
import { translateText } from "./Utils";
+1 -1
View File
@@ -5,7 +5,7 @@ import {
USER_SETTINGS_CHANGED_EVENT,
} from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { renderPatternPreview } from "./components/PatternButton";
import { renderPatternPreview } from "./components/PatternPreview";
import { getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { translateText } from "./Utils";
+47 -144
View File
@@ -2,31 +2,22 @@ import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
import {
PATTERN_KEY,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
} from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { Cosmetics } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { BaseModal } from "./components/BaseModal";
import "./components/FlagButton";
import "./components/CosmeticButton";
import "./components/NotLoggedInWarning";
import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
flagRelationship,
getPlayerCosmetics,
handlePurchase,
patternRelationship,
resolveCosmetics,
ResolvedCosmetic,
} from "./Cosmetics";
import { translateText } from "./Utils";
@customElement("store-modal")
export class StoreModal extends BaseModal {
@state() private selectedPattern: PlayerPattern | null;
@state() private selectedColor: string | null = null;
@state() private activeTab: "patterns" | "flags" = "patterns";
private cosmetics: Cosmetics | null = null;
@@ -35,11 +26,6 @@ export class StoreModal extends BaseModal {
private affiliateCode: string | null = null;
private userMeResponse: UserMeResponse | false = false;
private _onPatternSelected = async () => {
await this.updateFromSettings();
this.refresh();
};
connectedCallback() {
super.connectedCallback();
document.addEventListener(
@@ -48,30 +34,11 @@ export class StoreModal extends BaseModal {
this.onUserMe(event.detail);
},
);
window.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
);
}
private async updateFromSettings() {
const cosmetics = await getPlayerCosmetics();
this.selectedPattern = cosmetics.pattern ?? null;
this.selectedColor = cosmetics.color?.color ?? null;
}
async onUserMe(userMeResponse: UserMeResponse | false) {
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
await this.updateFromSettings();
this.refresh();
}
@@ -107,53 +74,18 @@ export class StoreModal extends BaseModal {
}
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
const patterns: (Pattern | null)[] = [
null,
...Object.values(this.cosmetics?.patterns ?? {}),
];
for (const pattern of patterns) {
const colorPalettes = pattern
? [...(pattern.colorPalettes ?? []), null]
: [null];
for (const colorPalette of colorPalettes) {
let rel = "owned";
if (pattern) {
rel = patternRelationship(
pattern,
colorPalette,
this.userMeResponse,
this.affiliateCode,
);
}
if (rel === "blocked" || rel === "owned") {
continue;
}
const isDefaultPattern = pattern === null;
const isSelected =
(isDefaultPattern && this.selectedPattern === null) ||
(!isDefaultPattern &&
this.selectedPattern &&
this.selectedPattern.name === pattern?.name &&
(this.selectedPattern.colorPalette?.name ?? null) ===
(colorPalette?.name ?? null));
buttons.push(html`
<pattern-button
.pattern=${pattern}
.colorPalette=${this.cosmetics?.colorPalettes?.[
colorPalette?.name ?? ""
] ?? null}
.requiresPurchase=${rel === "purchasable"}
.selected=${isSelected}
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
.onPurchase=${(p: Pattern, cp: ColorPalette | null) =>
handlePurchase(p.product!, cp?.name)}
></pattern-button>
`);
}
}
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter(
(r) =>
r.type === "pattern" &&
r.relationship !== "blocked" &&
r.relationship !== "owned",
);
if (buttons.length === 0) {
if (items.length === 0) {
return html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
@@ -165,33 +97,32 @@ export class StoreModal extends BaseModal {
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${buttons}
${items.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${(rc: ResolvedCosmetic) =>
handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)}
></cosmetic-button>
`,
)}
</div>
`;
}
private renderFlagGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
const flags = Object.entries(this.cosmetics?.flags ?? {});
for (const [key, flag] of flags) {
const rel = flagRelationship(
flag,
this.userMeResponse,
this.affiliateCode,
);
if (rel === "blocked" || rel === "owned") continue;
const selectedFlag = new UserSettings().getFlag() ?? "";
buttons.push(html`
<flag-button
.flag=${{ ...flag, key: `flag:${key}` }}
.selected=${selectedFlag === `flag:${key}`}
.requiresPurchase=${rel === "purchasable"}
.onPurchase=${() => handlePurchase(flag.product!)}
></flag-button>
`);
}
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter(
(r) =>
r.type === "flag" &&
r.relationship !== "blocked" &&
r.relationship !== "owned",
);
if (buttons.length === 0) {
if (items.length === 0) {
return html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
@@ -199,11 +130,21 @@ export class StoreModal extends BaseModal {
</div>`;
}
const selectedFlag = new UserSettings().getFlag() ?? "";
return html`
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${buttons}
${items.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.selected=${selectedFlag === r.key}
.onPurchase=${(rc: ResolvedCosmetic) =>
handlePurchase(rc.cosmetic!.product!)}
></cosmetic-button>
`,
)}
</div>
`;
}
@@ -265,44 +206,6 @@ export class StoreModal extends BaseModal {
super.close();
}
private selectPattern(pattern: PlayerPattern | null) {
this.selectedColor = null;
if (pattern === null) {
this.userSettings.setSelectedPatternName(undefined);
} else {
const name =
pattern.colorPalette?.name === undefined
? pattern.name
: `${pattern.name}:${pattern.colorPalette.name}`;
this.userSettings.setSelectedPatternName(`pattern:${name}`);
}
this.selectedPattern = pattern;
this.refresh();
this.showSelectedPopup(pattern);
this.close();
}
private showSelectedPopup(pattern: PlayerPattern | null) {
let skinName = translateText("territory_patterns.pattern.default");
if (pattern && pattern.name) {
skinName = pattern.name
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
if (pattern.colorPalette && pattern.colorPalette.name) {
skinName += ` (${pattern.colorPalette.name})`;
}
}
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: `${skinName} ${translateText("territory_patterns.selected")}`,
duration: 2000,
},
}),
);
}
public async refresh() {
this.requestUpdate();
}
+36 -52
View File
@@ -2,7 +2,7 @@ import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { Cosmetics, Pattern } from "../core/CosmeticSchemas";
import { Cosmetics } from "../core/CosmeticSchemas";
import {
PATTERN_KEY,
USER_SETTINGS_CHANGED_EVENT,
@@ -10,13 +10,15 @@ import {
} from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { BaseModal } from "./components/BaseModal";
import "./components/CosmeticButton";
import "./components/NotLoggedInWarning";
import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
getPlayerCosmetics,
patternRelationship,
resolveCosmetics,
ResolvedCosmetic,
resolvedToPlayerPattern,
} from "./Cosmetics";
import { translateText } from "./Utils";
@@ -82,62 +84,39 @@ export class TerritoryPatternsModal extends BaseModal {
}
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
const patterns: (Pattern | null)[] = [
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
null,
...Object.values(this.cosmetics?.patterns ?? {}),
];
for (const pattern of patterns) {
if (pattern === null && this.search) {
continue;
}
if (pattern !== null && !this.includedInSearch(pattern.name)) {
continue;
}
const colorPalettes = pattern
? [...(pattern.colorPalettes ?? []), null]
: [null];
for (const colorPalette of colorPalettes) {
let rel = "owned";
if (pattern) {
rel = patternRelationship(
pattern,
colorPalette,
this.userMeResponse,
null,
);
}
if (rel !== "owned") {
continue;
}
const isDefaultPattern = pattern === null;
const isSelected =
(isDefaultPattern && this.selectedPattern === null) ||
(!isDefaultPattern &&
this.selectedPattern &&
this.selectedPattern.name === pattern?.name &&
(this.selectedPattern.colorPalette?.name ?? null) ===
(colorPalette?.name ?? null));
buttons.push(html`
<pattern-button
.pattern=${pattern}
.colorPalette=${this.cosmetics?.colorPalettes?.[
colorPalette?.name ?? ""
] ?? null}
.requiresPurchase=${false}
.selected=${isSelected}
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
></pattern-button>
`);
}
}
).filter(
(r) =>
r.type === "pattern" &&
r.relationship === "owned" &&
(r.cosmetic === null
? !this.search
: this.includedInSearch(r.cosmetic.name)),
);
return html`
<div class="flex flex-col">
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${buttons}
${items.map((r) => {
const isSelected =
(r.cosmetic === null && this.selectedPattern === null) ||
(r.cosmetic !== null &&
this.selectedPattern?.name === r.cosmetic.name &&
(this.selectedPattern?.colorPalette?.name ?? null) ===
(r.colorPalette?.name ?? null));
return html`
<cosmetic-button
.resolved=${r}
.selected=${isSelected}
.onSelect=${(rc: ResolvedCosmetic) => this.selectCosmetic(rc)}
></cosmetic-button>
`;
})}
</div>
</div>
`;
@@ -213,6 +192,11 @@ export class TerritoryPatternsModal extends BaseModal {
this.search = "";
}
private selectCosmetic(resolved: ResolvedCosmetic) {
if (resolved.type !== "pattern") return;
this.selectPattern(resolvedToPlayerPattern(resolved));
}
private selectPattern(pattern: PlayerPattern | null) {
this.selectedColor = null;
if (pattern === null) {
-1
View File
@@ -3,7 +3,6 @@ import { customElement } from "lit/decorators.js";
import { tempTokenLogin } from "./Auth";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
+115
View File
@@ -0,0 +1,115 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Flag, Pattern } from "../../core/CosmeticSchemas";
import { PlayerPattern } from "../../core/Schemas";
import { ResolvedCosmetic, translateCosmetic } from "../Cosmetics";
import { translateText } from "../Utils";
import "./CosmeticContainer";
import "./CosmeticInfo";
import { renderPatternPreview } from "./PatternPreview";
@customElement("cosmetic-button")
export class CosmeticButton extends LitElement {
@property({ type: Object })
resolved!: ResolvedCosmetic;
@property({ type: Boolean })
selected: boolean = false;
@property({ type: Function })
onSelect?: (resolved: ResolvedCosmetic) => void;
@property({ type: Function })
onPurchase?: (resolved: ResolvedCosmetic) => void;
createRenderRoot() {
return this;
}
private handleClick() {
this.onSelect?.(this.resolved);
}
private get displayName(): string {
const c = this.resolved.cosmetic;
if (c === null) {
return translateText("territory_patterns.pattern.default");
}
if (this.resolved.type === "pattern") {
return translateCosmetic("territory_patterns.pattern", c.name);
}
return translateCosmetic("flags", c.name);
}
private renderPreview(): TemplateResult {
if (this.resolved.type === "pattern") {
const c = this.resolved.cosmetic;
const playerPattern: PlayerPattern | null =
c === null
? null
: {
name: c.name,
patternData: (c as Pattern).pattern,
colorPalette: this.resolved.colorPalette ?? undefined,
};
return renderPatternPreview(playerPattern, 150, 150);
}
const c = this.resolved.cosmetic as Flag;
return html`<img
src=${c.url}
alt=${c.name}
class="w-full h-full object-contain pointer-events-none"
draggable="false"
loading="lazy"
@error=${(e: Event) => {
const img = e.currentTarget as HTMLImageElement;
const fallback = "/flags/xx.svg";
if (img.src && !img.src.endsWith(fallback)) {
img.src = fallback;
}
}}
/>`;
}
render() {
const c = this.resolved.cosmetic;
const isPurchasable = this.resolved.relationship === "purchasable";
const isPattern = this.resolved.type === "pattern";
const sizeClass = isPattern ? "gap-2 p-3 w-48" : "gap-1 p-1.5 w-36";
const crazygamesClass = isPattern ? "no-crazygames " : "";
return html`
<cosmetic-container
class="${crazygamesClass}flex flex-col items-center justify-between ${sizeClass} h-full"
.rarity=${c?.rarity ?? "common"}
.selected=${this.selected}
.product=${isPurchasable && c?.product ? c.product : null}
.onPurchase=${() => this.onPurchase?.(this.resolved)}
.name=${this.displayName}
>
<button
class="group relative flex flex-col items-center w-full ${isPattern
? "gap-2"
: "gap-1"} rounded-lg cursor-pointer transition-all duration-200 flex-1"
@click=${() => this.handleClick()}
>
${c?.product
? html`<cosmetic-info
.artist=${c.artist}
.rarity=${c.rarity}
.colorPalette=${this.resolved.colorPalette?.name}
.showAdFree=${isPurchasable}
></cosmetic-info>`
: nothing}
<div
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
>
${this.renderPreview()}
</div>
</button>
</cosmetic-container>
`;
}
}
+8 -3
View File
@@ -22,6 +22,9 @@ export class CosmeticInfo extends LitElement {
@property({ type: String })
colorPalette?: string;
@property({ type: Boolean })
showAdFree: boolean = false;
createRenderRoot() {
return this;
}
@@ -53,9 +56,11 @@ export class CosmeticInfo extends LitElement {
${translateText(`cosmetics.${this.rarity}`) || this.rarity}
</div>`
: nothing}
<div class="text-green-400 font-bold">
${translateText("cosmetics.adfree")}
</div>
${this.showAdFree
? html`<div class="text-green-400 font-bold">
${translateText("cosmetics.adfree")}
</div>`
: nothing}
${this.colorPalette
? html`<div>
${translateText("cosmetics.color_label")}
-82
View File
@@ -1,82 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Flag } from "../../core/CosmeticSchemas";
import { translateCosmetic } from "../Cosmetics";
import "./CosmeticContainer";
import "./CosmeticInfo";
export type FlagItem = Flag & { key: string };
@customElement("flag-button")
export class FlagButton extends LitElement {
@property({ type: Boolean })
selected: boolean = false;
@property({ type: Object })
flag!: FlagItem;
@property({ type: Boolean })
requiresPurchase: boolean = false;
@property({ type: Function })
onSelect?: (flagKey: string) => void;
@property({ type: Function })
onPurchase?: () => void;
createRenderRoot() {
return this;
}
private handleClick() {
if (this.requiresPurchase) {
this.onPurchase?.();
return;
}
this.onSelect?.(this.flag.key);
}
render() {
return html`
<cosmetic-container
class="flex flex-col items-center justify-between gap-1 p-1.5 w-36 h-full"
.rarity=${this.flag.rarity ?? "common"}
.selected=${this.selected}
.product=${this.requiresPurchase && this.flag.product
? this.flag.product
: null}
.onPurchase=${() => this.onPurchase?.()}
.name=${translateCosmetic("flags", this.flag.name)}
>
<button
class="group relative flex flex-col items-center w-full gap-1 rounded-lg cursor-pointer transition-all duration-200 flex-1"
@click=${this.handleClick}
>
<cosmetic-info
.artist=${this.flag.artist}
.rarity=${this.flag.rarity}
></cosmetic-info>
<div
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
>
<img
src=${this.flag.url}
alt=${this.flag.name}
class="w-full h-full object-contain pointer-events-none"
draggable="false"
loading="lazy"
@error=${(e: Event) => {
const img = e.currentTarget as HTMLImageElement;
const fallback = "/flags/xx.svg";
if (img.src && !img.src.endsWith(fallback)) {
img.src = fallback;
}
}}
/>
</div>
</button>
</cosmetic-container>
`;
}
}
-234
View File
@@ -1,234 +0,0 @@
import { Colord } from "colord";
import { base64url } from "jose";
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import {
ColorPalette,
DefaultPattern,
Pattern,
} from "../../core/CosmeticSchemas";
import { PatternDecoder } from "../../core/PatternDecoder";
import { PlayerPattern } from "../../core/Schemas";
import { translateCosmetic } from "../Cosmetics";
import { translateText } from "../Utils";
import "./CosmeticContainer";
import "./CosmeticInfo";
export const BUTTON_WIDTH = 150;
@customElement("pattern-button")
export class PatternButton extends LitElement {
@property({ type: Boolean })
selected: boolean = false;
@property({ type: Object })
pattern: Pattern | null = null;
@property({ type: Object })
colorPalette: ColorPalette | null = null;
@property({ type: Boolean })
requiresPurchase: boolean = false;
@property({ type: Function })
onSelect?: (pattern: PlayerPattern | null) => void;
@property({ type: Function })
onPurchase?: (pattern: Pattern, colorPalette: ColorPalette | null) => void;
createRenderRoot() {
return this;
}
private handleClick() {
if (this.requiresPurchase) {
this.handlePurchase();
return;
}
if (this.pattern === null) {
this.onSelect?.(null);
return;
}
this.onSelect?.({
name: this.pattern!.name,
patternData: this.pattern!.pattern,
colorPalette: this.colorPalette ?? undefined,
} satisfies PlayerPattern);
}
private handlePurchase() {
if (this.pattern?.product) {
this.onPurchase?.(this.pattern, this.colorPalette ?? null);
}
}
render() {
const isDefaultPattern = this.pattern === null;
return html`
<cosmetic-container
class="no-crazygames flex flex-col items-center justify-between gap-2 p-3 w-48 h-full"
.rarity=${this.pattern?.rarity ?? "common"}
.selected=${this.selected}
.product=${this.requiresPurchase && this.pattern?.product
? this.pattern.product
: null}
.onPurchase=${() => this.handlePurchase()}
.name=${isDefaultPattern
? translateText("territory_patterns.pattern.default")
: translateCosmetic("territory_patterns.pattern", this.pattern!.name)}
>
<button
class="group relative flex flex-col items-center w-full gap-2 rounded-lg cursor-pointer transition-all duration-200 flex-1"
@click=${this.handleClick}
>
<cosmetic-info
.artist=${this.pattern?.artist}
.rarity=${this.pattern?.rarity}
.colorPalette=${this.colorPalette?.name ?? undefined}
></cosmetic-info>
<div
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
>
${renderPatternPreview(
this.pattern !== null
? ({
name: this.pattern!.name,
patternData: this.pattern!.pattern,
colorPalette: this.colorPalette ?? undefined,
} satisfies PlayerPattern)
: DefaultPattern,
BUTTON_WIDTH,
BUTTON_WIDTH,
)}
</div>
</button>
</cosmetic-container>
`;
}
}
export function renderPatternPreview(
pattern: PlayerPattern | null,
width: number,
height: number,
): TemplateResult {
if (pattern === null) {
return renderBlankPreview(width, height);
}
return html`<img
src="${generatePreviewDataUrl(pattern, width, height)}"
alt="Pattern preview"
class="w-full h-full object-contain [image-rendering:pixelated] pointer-events-none"
draggable="false"
/>`;
}
function renderBlankPreview(width: number, height: number): TemplateResult {
return html`
<div
class="md:hidden flex items-center justify-center h-full w-full bg-white rounded overflow-hidden relative border border-[#ccc] box-border"
>
<div
class="grid grid-cols-2 grid-rows-2 gap-0 w-[calc(100%-1px)] h-[calc(100%-2px)] box-border"
>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
</div>
</div>
<div
class="hidden md:flex items-center justify-center h-full w-full rounded overflow-hidden relative text-center p-1"
>
<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full"
>
${translateText("territory_patterns.select_skin")}
</span>
</div>
`;
}
const patternCache = new Map<string, string>();
const DEFAULT_PRIMARY = new Colord("#ffffff").toRgb(); // White
const DEFAULT_SECONDARY = new Colord("#000000").toRgb(); // Black
function generatePreviewDataUrl(
pattern?: PlayerPattern,
width?: number,
height?: number,
): string {
pattern ??= DefaultPattern;
const patternLookupKey = [
pattern.name,
pattern.colorPalette?.primaryColor ?? "undefined",
pattern.colorPalette?.secondaryColor ?? "undefined",
width,
height,
].join("-");
if (patternCache.has(patternLookupKey)) {
return patternCache.get(patternLookupKey)!;
}
// Calculate canvas size
let decoder: PatternDecoder;
try {
decoder = new PatternDecoder(
{
name: pattern.name,
patternData: pattern.patternData,
colorPalette: pattern.colorPalette,
},
base64url.decode,
);
} catch (e) {
console.error("Error decoding pattern", e);
return "";
}
const scaledWidth = decoder.scaledWidth();
const scaledHeight = decoder.scaledHeight();
width =
width === undefined
? scaledWidth
: Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth;
height =
height === undefined
? scaledHeight
: Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight;
// Create the canvas
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D context not supported");
// Create an image
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
const primary = pattern.colorPalette?.primaryColor
? new Colord(pattern.colorPalette.primaryColor).toRgb()
: DEFAULT_PRIMARY;
const secondary = pattern.colorPalette?.secondaryColor
? new Colord(pattern.colorPalette.secondaryColor).toRgb()
: DEFAULT_SECONDARY;
let i = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const rgba = decoder.isPrimary(x, y) ? primary : secondary;
data[i++] = rgba.r;
data[i++] = rgba.g;
data[i++] = rgba.b;
data[i++] = 255; // Alpha
}
}
// Create a data URL
ctx.putImageData(imageData, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
patternCache.set(patternLookupKey, dataUrl);
return dataUrl;
}
+129
View File
@@ -0,0 +1,129 @@
import { Colord } from "colord";
import { base64url } from "jose";
import { html, TemplateResult } from "lit";
import { DefaultPattern } from "../../core/CosmeticSchemas";
import { PatternDecoder } from "../../core/PatternDecoder";
import { PlayerPattern } from "../../core/Schemas";
import { translateText } from "../Utils";
export function renderPatternPreview(
pattern: PlayerPattern | null,
width: number,
height: number,
): TemplateResult {
if (pattern === null) {
return renderBlankPreview();
}
return html`<img
src="${generatePreviewDataUrl(pattern, width, height)}"
alt="Pattern preview"
class="w-full h-full object-contain [image-rendering:pixelated] pointer-events-none"
draggable="false"
/>`;
}
function renderBlankPreview(): TemplateResult {
return html`
<div
class="md:hidden flex items-center justify-center h-full w-full bg-white rounded overflow-hidden relative border border-[#ccc] box-border"
>
<div
class="grid grid-cols-2 grid-rows-2 gap-0 w-[calc(100%-1px)] h-[calc(100%-2px)] box-border"
>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
</div>
</div>
<div
class="hidden md:flex items-center justify-center h-full w-full rounded overflow-hidden relative text-center p-1"
>
<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full"
>
${translateText("territory_patterns.select_skin")}
</span>
</div>
`;
}
const patternCache = new Map<string, string>();
const DEFAULT_PRIMARY = new Colord("#ffffff").toRgb();
const DEFAULT_SECONDARY = new Colord("#000000").toRgb();
export function generatePreviewDataUrl(
pattern?: PlayerPattern,
width?: number,
height?: number,
): string {
pattern ??= DefaultPattern;
const patternLookupKey = [
pattern.name,
pattern.colorPalette?.primaryColor ?? "undefined",
pattern.colorPalette?.secondaryColor ?? "undefined",
width,
height,
].join("-");
if (patternCache.has(patternLookupKey)) {
return patternCache.get(patternLookupKey)!;
}
let decoder: PatternDecoder;
try {
decoder = new PatternDecoder(
{
name: pattern.name,
patternData: pattern.patternData,
colorPalette: pattern.colorPalette,
},
base64url.decode,
);
} catch (e) {
console.error("Error decoding pattern", e);
return "";
}
const scaledWidth = decoder.scaledWidth();
const scaledHeight = decoder.scaledHeight();
width =
width === undefined
? scaledWidth
: Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth;
height =
height === undefined
? scaledHeight
: Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D context not supported");
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
const primary = pattern.colorPalette?.primaryColor
? new Colord(pattern.colorPalette.primaryColor).toRgb()
: DEFAULT_PRIMARY;
const secondary = pattern.colorPalette?.secondaryColor
? new Colord(pattern.colorPalette.secondaryColor).toRgb()
: DEFAULT_SECONDARY;
let i = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const rgba = decoder.isPrimary(x, y) ? primary : secondary;
data[i++] = rgba.r;
data[i++] = rgba.g;
data[i++] = rgba.b;
data[i++] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
patternCache.set(patternLookupKey, dataUrl);
return dataUrl;
}
+17 -40
View File
@@ -6,17 +6,17 @@ import {
translateText,
TUTORIAL_VIDEO_URL,
} from "../../../client/Utils";
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
import { RankedType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { getUserMe } from "../../Api";
import "../../components/PatternButton";
import "../../components/CosmeticButton";
import {
fetchCosmetics,
handlePurchase,
patternRelationship,
resolveCosmetics,
ResolvedCosmetic,
} from "../../Cosmetics";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { Platform } from "../../Platform";
@@ -157,54 +157,31 @@ export class WinModal extends LitElement implements Layer {
async loadPatternContent() {
const me = await getUserMe();
const patterns = await fetchCosmetics();
const cosmetics = await fetchCosmetics();
const purchasablePatterns: {
pattern: Pattern;
colorPalette: ColorPalette;
}[] = [];
const purchasable = resolveCosmetics(cosmetics, me, null).filter(
(r) => r.type === "pattern" && r.relationship === "purchasable",
);
for (const pattern of Object.values(patterns?.patterns ?? {})) {
for (const colorPalette of pattern.colorPalettes ?? []) {
if (
patternRelationship(pattern, colorPalette, me, null) === "purchasable"
) {
const palette = patterns?.colorPalettes?.[colorPalette.name];
if (palette) {
purchasablePatterns.push({
pattern,
colorPalette: palette,
});
}
}
}
}
if (purchasablePatterns.length === 0) {
if (purchasable.length === 0) {
this.patternContent = html``;
return;
}
// Shuffle the array and take patterns based on screen size
const shuffled = [...purchasablePatterns].sort(() => Math.random() - 0.5);
const shuffled = [...purchasable].sort(() => Math.random() - 0.5);
const maxPatterns = Platform.isMobileWidth ? 1 : 3;
const selectedPatterns = shuffled.slice(
0,
Math.min(maxPatterns, shuffled.length),
);
const selected = shuffled.slice(0, Math.min(maxPatterns, shuffled.length));
this.patternContent = html`
<div class="flex gap-4 flex-wrap justify-start">
${selectedPatterns.map(
({ pattern, colorPalette }) => html`
<pattern-button
.pattern=${pattern}
.colorPalette=${colorPalette}
.requiresPurchase=${true}
.onSelect=${(p: Pattern | null) => {}}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
handlePurchase(p.product!, colorPalette?.name)}
></pattern-button>
${selected.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${(rc: ResolvedCosmetic) =>
handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)}
></cosmetic-button>
`,
)}
</div>
+1
View File
@@ -39,6 +39,7 @@ const flagCosmetics = {
colorPalettes: {},
flags: {
cool_flag: {
type: "flag" as const,
name: "cool_flag",
url: "https://example.com/cool.png",
affiliateCode: null,
+320
View File
@@ -0,0 +1,320 @@
import { resolveCosmetics } 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",
flares,
achievements: { singleplayerMap: [] },
},
} 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,
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,
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("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,
rarity: "common",
} as any,
},
flags: {
heart: {
type: "flag" as const,
name: "heart",
url: "/flags/heart.svg",
affiliateCode: null,
product,
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);
});
});
});