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