diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 0268d21d2..7b3f42ed4 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -15,7 +15,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"; diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 0bf0b4ad1..f2a704982 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -1,5 +1,6 @@ import { UserMeResponse } from "../core/ApiSchemas"; import { + ColorPalette, Cosmetics, CosmeticsSchema, Flag, @@ -187,6 +188,73 @@ export function flagRelationship( ); } +export type ResolvedCosmetic = { + 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({ + 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({ + 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({ + cosmetic: flag, + colorPalette: null, + relationship: rel, + key: `flag:${flagKey}`, + }); + } + + return result; +} + export async function getPlayerCosmeticsRefs(): Promise { const userSettings = new UserSettings(); const cosmetics = await fetchCosmetics(); diff --git a/src/client/FlagInputModal.ts b/src/client/FlagInputModal.ts index 8ad0e3856..70f8324db 100644 --- a/src/client/FlagInputModal.ts +++ b/src/client/FlagInputModal.ts @@ -2,16 +2,31 @@ import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; import Countries from "resources/countries.json" with { type: "json" }; import { UserMeResponse } from "../core/ApiSchemas"; -import { Cosmetics } from "../core/CosmeticSchemas"; +import { Cosmetics, Flag } from "../core/CosmeticSchemas"; import { UserSettings } from "../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 { + type: "flag" as const, + name, + url: `/flags/${code}.svg`, + product: null, + rarity: "common", + affiliateCode: null, + }; +} + @customElement("flag-input-modal") export class FlagInputModal extends BaseModal { @state() private search = ""; @@ -26,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]) => { @@ -37,28 +48,42 @@ export class FlagInputModal extends BaseModal { return false; return flagRelationship(flag, this.userMe, null) === "owned"; }) - .map( - ([key, flag]) => html` - { + const r: ResolvedCosmetic = { + cosmetic: flag, + colorPalette: null, + relationship: "owned", + key: `flag:${key}`, + }; + return html` + - `, - ); + .onSelect=${() => { + this.setFlag(`flag:${key}`); + this.close(); + }} + > + `; + }); + const noFlagResolved: ResolvedCosmetic = { + cosmetic: countryFlag("None", "xx"), + colorPalette: null, + relationship: "owned", + key: "country:xx", + }; const noFlag = this.search ? null : html` - + .onSelect=${() => { + this.setFlag("country:xx"); + this.close(); + }} + > `; const countryFlags = Countries.filter( @@ -66,19 +91,24 @@ export class FlagInputModal extends BaseModal { country.code !== "xx" && !country.restricted && this.includedInSearch(country), - ).map( - (country) => html` - { + const r: ResolvedCosmetic = { + cosmetic: countryFlag(country.name, country.code), + colorPalette: null, + relationship: "owned", + key: `country:${country.code}`, + }; + return html` + - `, - ); + .onSelect=${() => { + this.setFlag(`country:${country.code}`); + this.close(); + }} + > + `; + }); return html`
this.selectPattern(p)} - .onPurchase=${(p: Pattern, cp: ColorPalette | null) => - handlePurchase(p.product!, cp?.name)} - > - `); - } - } + const items = resolveCosmetics( + this.cosmetics, + this.userMeResponse, + this.affiliateCode, + ).filter( + (r) => + (r.cosmetic === null || r.cosmetic.type === "pattern") && + r.relationship !== "blocked" && + r.relationship !== "owned", + ); - if (buttons.length === 0) { + if (items.length === 0) { return html`
@@ -161,33 +125,40 @@ export class StoreModal extends BaseModal {
- ${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` + this.selectCosmetic(rc)} + .onPurchase=${(rc: ResolvedCosmetic) => + handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)} + > + `; + })}
`; } 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` - handlePurchase(flag.product!)} - > - `); - } + const items = resolveCosmetics( + this.cosmetics, + this.userMeResponse, + this.affiliateCode, + ).filter( + (r) => + r.cosmetic?.type === "flag" && + r.relationship !== "blocked" && + r.relationship !== "owned", + ); - if (buttons.length === 0) { + if (items.length === 0) { return html`
@@ -195,11 +166,21 @@ export class StoreModal extends BaseModal {
`; } + const selectedFlag = new UserSettings().getFlag() ?? ""; return html`
- ${buttons} + ${items.map( + (r) => html` + + handlePurchase(rc.cosmetic!.product!)} + > + `, + )}
`; } @@ -261,6 +242,22 @@ export class StoreModal extends BaseModal { super.close(); } + private selectCosmetic(resolved: ResolvedCosmetic) { + const c = resolved.cosmetic; + if (c === null) { + this.selectPattern(null); + return; + } + if (c.type === "pattern") { + const pattern: PlayerPattern = { + name: c.name, + patternData: c.pattern, + colorPalette: resolved.colorPalette ?? undefined, + }; + this.selectPattern(pattern); + } + } + private selectPattern(pattern: PlayerPattern | null) { this.selectedColor = null; this.userSettings.setSelectedColor(undefined); diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 9f8c9f6d9..35d676754 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -2,17 +2,18 @@ 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 { UserSettings } 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, } from "./Cosmetics"; import { translateText } from "./Utils"; @@ -68,56 +69,36 @@ 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) { - 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` - this.selectPattern(p)} - > - `); - } - } + ).filter( + (r) => + (r.cosmetic === null || r.cosmetic.type === "pattern") && + r.relationship === "owned", + ); return html`
- ${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` + this.selectCosmetic(rc)} + > + `; + })}
`; @@ -176,6 +157,22 @@ export class TerritoryPatternsModal extends BaseModal { await this.refresh(); } + private selectCosmetic(resolved: ResolvedCosmetic) { + const c = resolved.cosmetic; + if (c === null) { + this.selectPattern(null); + return; + } + if (c.type === "pattern") { + const pattern: PlayerPattern = { + name: c.name, + patternData: c.pattern, + colorPalette: resolved.colorPalette ?? undefined, + }; + this.selectPattern(pattern); + } + } + private selectPattern(pattern: PlayerPattern | null) { this.selectedColor = null; this.userSettings.setSelectedColor(undefined); diff --git a/src/client/TokenLoginModal.ts b/src/client/TokenLoginModal.ts index 5cc4039a6..9c968e414 100644 --- a/src/client/TokenLoginModal.ts +++ b/src/client/TokenLoginModal.ts @@ -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"; diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts new file mode 100644 index 000000000..2dab7fabd --- /dev/null +++ b/src/client/components/CosmeticButton.ts @@ -0,0 +1,116 @@ +import { html, LitElement, nothing, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +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() { + if (this.resolved.relationship === "purchasable") { + this.onPurchase?.(this.resolved); + return; + } + this.onSelect?.(this.resolved); + } + + private get displayName(): string { + const c = this.resolved.cosmetic; + if (c === null) { + return translateText("territory_patterns.pattern.default"); + } + if (c.type === "pattern") { + return translateCosmetic("territory_patterns.pattern", c.name); + } + return translateCosmetic("flags", c.name); + } + + private renderPreview(): TemplateResult { + const c = this.resolved.cosmetic; + if (c === null || c.type === "pattern") { + const playerPattern: PlayerPattern | null = + c === null + ? null + : { + name: c.name, + patternData: c.pattern, + colorPalette: this.resolved.colorPalette ?? undefined, + }; + return renderPatternPreview(playerPattern, 150, 150); + } + + return html`${c.name} { + 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 = c === null || c.type === "pattern"; + const sizeClass = isPattern ? "gap-2 p-3 w-48" : "gap-1 p-1.5 w-36"; + + return html` + this.onPurchase?.(this.resolved)} + .name=${this.displayName} + > + + + `; + } +} diff --git a/src/client/components/CosmeticInfo.ts b/src/client/components/CosmeticInfo.ts index 150df5155..88a9aba6d 100644 --- a/src/client/components/CosmeticInfo.ts +++ b/src/client/components/CosmeticInfo.ts @@ -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}
` : nothing} -
- ${translateText("cosmetics.adfree")} -
+ ${this.showAdFree + ? html`
+ ${translateText("cosmetics.adfree")} +
` + : nothing} ${this.colorPalette ? html`
${translateText("cosmetics.color_label")} diff --git a/src/client/components/FlagButton.ts b/src/client/components/FlagButton.ts deleted file mode 100644 index 2e209991c..000000000 --- a/src/client/components/FlagButton.ts +++ /dev/null @@ -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` - this.onPurchase?.()} - .name=${translateCosmetic("flags", this.flag.name)} - > - - - `; - } -} diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts deleted file mode 100644 index 6f52633ca..000000000 --- a/src/client/components/PatternButton.ts +++ /dev/null @@ -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` - this.handlePurchase()} - .name=${isDefaultPattern - ? translateText("territory_patterns.pattern.default") - : translateCosmetic("territory_patterns.pattern", this.pattern!.name)} - > - - - `; - } -} - -export function renderPatternPreview( - pattern: PlayerPattern | null, - width: number, - height: number, -): TemplateResult { - if (pattern === null) { - return renderBlankPreview(width, height); - } - return html`Pattern preview`; -} - -function renderBlankPreview(width: number, height: number): TemplateResult { - return html` -
-
-
-
-
-
-
-
- - `; -} - -const patternCache = new Map(); -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; -} diff --git a/src/client/components/PatternPreview.ts b/src/client/components/PatternPreview.ts new file mode 100644 index 000000000..8060d0ef3 --- /dev/null +++ b/src/client/components/PatternPreview.ts @@ -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`Pattern preview`; +} + +function renderBlankPreview(): TemplateResult { + return html` +
+
+
+
+
+
+
+
+ + `; +} + +const patternCache = new Map(); +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; +} diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index e967776db..94b7516a8 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -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.cosmetic?.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`
- ${selectedPatterns.map( - ({ pattern, colorPalette }) => html` - {}} - .onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) => - handlePurchase(p.product!, colorPalette?.name)} - > + ${selected.map( + (r) => html` + + handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)} + > `, )}
diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index bd808cdcc..dd60abab7 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -10,6 +10,7 @@ export type PatternName = z.infer; export type Product = z.infer; export type ColorPalette = z.infer; export type PatternData = z.infer; +export type Cosmetic = Pattern | Flag; export const ProductSchema = z.object({ productId: z.string(), @@ -51,7 +52,7 @@ export const ColorPaletteSchema = z.object({ secondaryColor: z.string(), }); -const CosmeticSchema = z.object({ +const CosmeticBaseSchema = z.object({ name: CosmeticNameSchema, affiliateCode: z.string().nullable(), product: ProductSchema.nullable(), @@ -61,7 +62,8 @@ const CosmeticSchema = z.object({ .or(z.string()), }); -export const PatternSchema = CosmeticSchema.extend({ +export const PatternSchema = CosmeticBaseSchema.extend({ + type: z.literal("pattern").default("pattern"), pattern: PatternDataSchema, colorPalettes: z .object({ @@ -72,10 +74,16 @@ export const PatternSchema = CosmeticSchema.extend({ .optional(), }); -export const FlagSchema = CosmeticSchema.extend({ +export const FlagSchema = CosmeticBaseSchema.extend({ + type: z.literal("flag").default("flag"), url: z.string(), }); +export const CosmeticSchema = z.discriminatedUnion("type", [ + PatternSchema, + FlagSchema, +]); + // Schema for resources/cosmetics/cosmetics.json export const CosmeticsSchema = z.object({ colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(), diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index bedfc797b..ce3b93b57 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -25,7 +25,7 @@ const bannedWords = [ const matcher = createMatcher(bannedWords); // Create a minimal PrivilegeCheckerImpl for testing censorUsername -const mockCosmetics = { patterns: {}, colorPalettes: {}, flags: {} }; +const mockCosmetics = { patterns: {}, colorPalettes: {}, flags: {}, packs: {} }; const mockDecoder = () => new Uint8Array(); const checker = new PrivilegeCheckerImpl( mockCosmetics, @@ -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, @@ -46,6 +47,7 @@ const flagCosmetics = { rarity: "common", }, }, + packs: {}, }; const flagChecker = new PrivilegeCheckerImpl( flagCosmetics, diff --git a/tests/ResolveCosmetics.test.ts b/tests/ResolveCosmetics.test.ts new file mode 100644 index 000000000..fdf882524 --- /dev/null +++ b/tests/ResolveCosmetics.test.ts @@ -0,0 +1,319 @@ +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 { + 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({ + 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); + }); + }); +});