diff --git a/resources/lang/en.json b/resources/lang/en.json index 6ecacb4df2..1ac02a328f 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -558,7 +558,7 @@ "choose_spawn": "Choose a starting location" }, "territory_patterns": { - "title": "Select Territory Pattern", + "title": "Select Territory Skin", "purchase": "Purchase", "blocked": { "login": "You must be logged in to access this pattern.", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 7645bdaadd..b5eb03f082 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -5,6 +5,7 @@ import { GameID, GameRecord, GameStartInfo, + PlayerPattern, PlayerRecord, ServerMessage, } from "../core/Schemas"; @@ -47,7 +48,7 @@ import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; export interface LobbyConfig { serverConfig: ServerConfig; - patternName: string | undefined; + pattern: PlayerPattern | undefined; flag: string; playerName: string; clientID: ClientID; diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 0b2f919a76..c0a501d8bc 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -1,38 +1,17 @@ import { UserMeResponse } from "../core/ApiSchemas"; -import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas"; +import { + ColorPalette, + Cosmetics, + CosmeticsSchema, + Pattern, +} from "../core/CosmeticSchemas"; import { getApiBase, getAuthHeader } from "./jwt"; import { getPersistentID } from "./Main"; -export async function fetchPatterns( - userMe: UserMeResponse | null, -): Promise> { - const cosmetics = await getCosmetics(); - - if (cosmetics === undefined) { - return new Map(); - } - - const patterns: Map = new Map(); - const playerFlares = new Set(userMe?.player?.flares ?? []); - const hasAllPatterns = playerFlares.has("pattern:*"); - - for (const name in cosmetics.patterns) { - const patternData = cosmetics.patterns[name]; - const hasAccess = hasAllPatterns || playerFlares.has(`pattern:${name}`); - if (hasAccess) { - // Remove product info because player already has access. - patternData.product = null; - patterns.set(name, patternData); - } else if (patternData.product !== null) { - // Player doesn't have access, but product is available for purchase. - patterns.set(name, patternData); - } - // If player doesn't have access and product is null, don't show it. - } - return patterns; -} - -export async function handlePurchase(pattern: Pattern) { +export async function handlePurchase( + pattern: Pattern, + colorPalette: ColorPalette | null, +) { if (pattern.product === null) { alert("This pattern is not available for purchase."); return; @@ -50,6 +29,7 @@ export async function handlePurchase(pattern: Pattern) { body: JSON.stringify({ priceId: pattern.product.priceId, hostname: window.location.origin, + colorPaletteName: colorPalette?.name, }), }, ); @@ -72,20 +52,65 @@ export async function handlePurchase(pattern: Pattern) { window.location.href = url; } -export async function getCosmetics(): Promise { +export async function fetchCosmetics(): Promise { try { const response = await fetch(`${getApiBase()}/cosmetics.json`); if (!response.ok) { console.error(`HTTP error! status: ${response.status}`); - return; + return null; } const result = CosmeticsSchema.safeParse(await response.json()); if (!result.success) { console.error(`Invalid cosmetics: ${result.error.message}`); - return; + return null; } return result.data; } catch (error) { console.error("Error getting cosmetics:", error); + return null; + } +} + +export function patternRelationship( + pattern: Pattern, + colorPalette: { name: string; isArchived?: boolean } | null, + userMeResponse: UserMeResponse | null, + affiliateCode: string | null, +): "owned" | "purchasable" | "blocked" { + const flares = userMeResponse?.player.flares ?? []; + if (flares.includes("pattern:*")) { + return "owned"; } + + if (colorPalette === null) { + // For backwards compatibility only show non-colored patterns if they are owned. + if (flares.includes(`pattern:${pattern.name}`)) { + return "owned"; + } + return "blocked"; + } + + const requiredFlare = `pattern:${pattern.name}:${colorPalette.name}`; + + if (flares.includes(requiredFlare)) { + return "owned"; + } + + if (pattern.product === null) { + // We don't own it and it's not for sale, so don't show it. + return "blocked"; + } + + if (colorPalette?.isArchived) { + // We don't own the color palette, and it's archived, so don't show it. + return "blocked"; + } + + if (affiliateCode !== pattern.affiliateCode) { + // Pattern is for sale, but it's not the right store to show it on. + return "blocked"; + } + + // Patterns is for sale, and it's the right store to show it on. + return "purchasable"; } diff --git a/src/client/Main.ts b/src/client/Main.ts index 1d435eea65..9d4f1eba83 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -7,6 +7,7 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; import { joinLobby } from "./ClientGameRunner"; +import { fetchCosmetics } from "./Cosmetics"; import "./DarkModeButton"; import { DarkModeButton } from "./DarkModeButton"; import "./FlagInput"; @@ -508,7 +509,9 @@ class Client { { gameID: lobby.gameID, serverConfig: config, - patternName: this.userSettings.getSelectedPatternName(), + pattern: + this.userSettings.getSelectedPatternName(await fetchCosmetics()) ?? + undefined, flag: this.flagInput === null || this.flagInput.getCurrentFlag() === "xx" ? "" diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index d2b4ee56ee..2ee83323e6 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -21,7 +21,7 @@ import "./components/baseComponents/Modal"; import "./components/Difficulties"; import { DifficultyDescription } from "./components/Difficulties"; import "./components/Maps"; -import { getCosmetics } from "./Cosmetics"; +import { fetchCosmetics } from "./Cosmetics"; import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; @@ -425,13 +425,12 @@ export class SinglePlayerModal extends LitElement { if (!flagInput) { console.warn("Flag input element not found"); } - const patternName = this.userSettings.getSelectedPatternName(); - let pattern: string | undefined = undefined; - if (this.userSettings.getDevOnlyPattern()) { - pattern = this.userSettings.getDevOnlyPattern(); - } else if (patternName) { - pattern = (await getCosmetics())?.patterns[patternName]?.pattern; - } + const cosmetics = await fetchCosmetics(); + let selectedPattern = this.userSettings.getSelectedPatternName(cosmetics); + selectedPattern ??= cosmetics + ? (this.userSettings.getDevOnlyPattern() ?? null) + : null; + this.dispatchEvent( new CustomEvent("join-lobby", { detail: { @@ -443,11 +442,13 @@ export class SinglePlayerModal extends LitElement { { clientID, username: usernameInput.getCurrentUsername(), - flag: - flagInput.getCurrentFlag() === "xx" - ? "" - : flagInput.getCurrentFlag(), - pattern: pattern, + cosmetics: { + flag: + flagInput.getCurrentFlag() === "xx" + ? "" + : flagInput.getCurrentFlag(), + pattern: selectedPattern ?? undefined, + }, }, ], config: { diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 53357f7927..b0a9b45b46 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -2,12 +2,17 @@ import type { TemplateResult } from "lit"; import { html, LitElement, render } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; -import { Pattern } from "../core/CosmeticSchemas"; +import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas"; import { UserSettings } from "../core/game/UserSettings"; +import { PlayerPattern } from "../core/Schemas"; import "./components/Difficulties"; import "./components/PatternButton"; import { renderPatternPreview } from "./components/PatternButton"; -import { fetchPatterns, handlePurchase } from "./Cosmetics"; +import { + fetchCosmetics, + handlePurchase, + patternRelationship, +} from "./Cosmetics"; import { translateText } from "./Utils"; @customElement("territory-patterns-modal") @@ -19,9 +24,9 @@ export class TerritoryPatternsModal extends LitElement { public previewButton: HTMLElement | null = null; - @state() private selectedPattern: Pattern | null; + @state() private selectedPattern: PlayerPattern | null; - private patterns: Map = new Map(); + private cosmetics: Cosmetics | null = null; private userSettings: UserSettings = new UserSettings(); @@ -29,6 +34,8 @@ export class TerritoryPatternsModal extends LitElement { private affiliateCode: string | null = null; + private userMeResponse: UserMeResponse | null = null; + constructor() { super(); } @@ -38,11 +45,12 @@ export class TerritoryPatternsModal extends LitElement { this.userSettings.setSelectedPatternName(undefined); this.selectedPattern = null; } - this.patterns = await fetchPatterns(userMeResponse); - const storedPatternName = this.userSettings.getSelectedPatternName(); - if (storedPatternName) { - this.selectedPattern = this.patterns.get(storedPatternName) ?? null; - } + this.userMeResponse = userMeResponse; + this.cosmetics = await fetchCosmetics(); + this.selectedPattern = + this.cosmetics !== null + ? this.userSettings.getSelectedPatternName(this.cosmetics) + : null; this.refresh(); } @@ -52,25 +60,31 @@ export class TerritoryPatternsModal extends LitElement { private renderPatternGrid(): TemplateResult { const buttons: TemplateResult[] = []; - for (const [name, pattern] of this.patterns) { - if (this.affiliateCode === null) { - if (pattern.affiliateCode !== null && pattern.product !== null) { - // Patterns with affiliate code are not for sale by default. - continue; - } - } else { - if (pattern.affiliateCode !== this.affiliateCode) { + for (const pattern of Object.values(this.cosmetics?.patterns ?? {})) { + const colorPalettes = [...(pattern.colorPalettes ?? []), null]; + for (const colorPalette of colorPalettes) { + const rel = patternRelationship( + pattern, + colorPalette, + this.userMeResponse, + this.affiliateCode, + ); + if (rel === "blocked") { continue; } + buttons.push(html` + this.selectPattern(p)} + .onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) => + handlePurchase(p, colorPalette)} + > + `); } - - buttons.push(html` - this.selectPattern(p)} - .onPurchase=${(p: Pattern) => handlePurchase(p)} - > - `); } return html` @@ -115,19 +129,24 @@ export class TerritoryPatternsModal extends LitElement { this.modalEl?.close(); } - private selectPattern(pattern: Pattern | null) { - this.userSettings.setSelectedPatternName(pattern?.name); + private selectPattern(pattern: PlayerPattern | 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.close(); } public async refresh() { - const preview = renderPatternPreview( - this.selectedPattern?.pattern ?? null, - 48, - 48, - ); + const preview = renderPatternPreview(this.selectedPattern ?? null, 48, 48); this.requestUpdate(); // Wait for the DOM to be updated and the o-modal element to be available diff --git a/src/client/Transport.ts b/src/client/Transport.ts index b49419881d..12e720d0f9 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -368,8 +368,11 @@ export class Transport { lastTurn: numTurns, token: this.lobbyConfig.token, username: this.lobbyConfig.playerName, - flag: this.lobbyConfig.flag, - patternName: this.lobbyConfig.patternName, + cosmetics: { + flag: this.lobbyConfig.flag, + patternName: this.lobbyConfig.pattern?.name, + patternColorPaletteName: this.lobbyConfig.pattern?.colorPalette?.name, + }, } satisfies ClientJoinMessage); } diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts index 5579671075..1b7146b138 100644 --- a/src/client/components/PatternButton.ts +++ b/src/client/components/PatternButton.ts @@ -1,8 +1,14 @@ +import { Colord } from "colord"; import { base64url } from "jose"; import { html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { Pattern } from "../../core/CosmeticSchemas"; +import { + ColorPalette, + DefaultPattern, + Pattern, +} from "../../core/CosmeticSchemas"; import { PatternDecoder } from "../../core/PatternDecoder"; +import { PlayerPattern } from "../../core/Schemas"; import { translateText } from "../Utils"; export const BUTTON_WIDTH = 150; @@ -12,17 +18,23 @@ export class PatternButton extends LitElement { @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: Pattern | null) => void; + onSelect?: (pattern: PlayerPattern | null) => void; @property({ type: Function }) - onPurchase?: (pattern: Pattern) => void; + onPurchase?: (pattern: Pattern, colorPalette: ColorPalette | null) => void; createRenderRoot() { return this; } - private translatePatternName(prefix: string, patternName: string): string { + private translateCosmetic(prefix: string, patternName: string): string { const translation = translateText(`${prefix}.${patternName}`); if (translation.startsWith(prefix)) { return patternName @@ -35,55 +47,75 @@ export class PatternButton extends LitElement { } private handleClick() { - const isDefaultPattern = this.pattern === null; - if (isDefaultPattern || this.pattern?.product === null) { - this.onSelect?.(this.pattern); + 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(e: Event) { e.stopPropagation(); if (this.pattern?.product) { - this.onPurchase?.(this.pattern); + this.onPurchase?.(this.pattern, this.colorPalette ?? null); } } render() { const isDefaultPattern = this.pattern === null; - const isPurchasable = !isDefaultPattern && this.pattern?.product !== null; return html`
- ${isPurchasable + ${this.requiresPurchase ? html`