From a26585a47b17c0a4bee96b368a8f79ba5321cd87 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 18 Sep 2025 20:00:15 -0700 Subject: [PATCH 1/5] Add support for colored patterns (#2062) ## Description: Add support for colored territory patterns/skins * Refactored & updated territory pattern rendering to render colored skins * rename public from pattern to skin (keep pattern name internally, too difficult to rename) * Moved all territory color logic to PlayerView * Updated WinModal to show colored skins * Refactored decode logic into a separate function: decodePatternData * Refactored/updated how cosmetics are sent to server. Players now send a PlayerCosmeticRefsSchema in the ClientJoinMessage. PlayerCosmeticRefsSchema just contains names of the cosmetics, and the server replaces the names/references with actual cosmetic data * Refactored PastelThemeDark: have it extend Pastel theme so duplicate logic can be removed. * ## 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 --- resources/lang/en.json | 2 +- src/client/ClientGameRunner.ts | 3 +- src/client/Cosmetics.ts | 93 +++++++----- src/client/Main.ts | 5 +- src/client/SinglePlayerModal.ts | 27 ++-- src/client/TerritoryPatternsModal.ts | 83 ++++++---- src/client/Transport.ts | 7 +- src/client/components/PatternButton.ts | 114 ++++++++++---- src/client/graphics/AnimatedSpriteLoader.ts | 4 +- src/client/graphics/SpriteLoader.ts | 5 +- src/client/graphics/layers/RailroadLayer.ts | 2 +- .../graphics/layers/StructureIconsLayer.ts | 10 +- src/client/graphics/layers/StructureLayer.ts | 6 +- src/client/graphics/layers/TerritoryLayer.ts | 63 ++------ src/client/graphics/layers/UILayer.ts | 4 +- src/client/graphics/layers/UnitLayer.ts | 18 +-- src/client/graphics/layers/WinModal.ts | 49 ++++-- src/core/CosmeticSchemas.ts | 37 ++++- src/core/PatternDecoder.ts | 76 ++++++---- src/core/Schemas.ts | 57 ++++--- src/core/configuration/Config.ts | 7 +- src/core/configuration/PastelTheme.ts | 37 ++--- src/core/configuration/PastelThemeDark.ts | 143 ++---------------- src/core/game/GameView.ts | 82 ++++++++-- src/core/game/UserSettings.ts | 35 ++++- src/server/Client.ts | 5 +- src/server/GameServer.ts | 3 +- src/server/Privilege.ts | 41 +++-- src/server/Worker.ts | 127 ++++++++++------ 29 files changed, 650 insertions(+), 495 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 6ecacb4df..1ac02a328 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 7645bdaad..b5eb03f08 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 0b2f919a7..c0a501d8b 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 1d435eea6..9d4f1eba8 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 d2b4ee56e..2ee83323e 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 53357f792..b0a9b45b4 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 b49419881..12e720d0f 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 557967107..1b7146b13 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: Function }) - onSelect?: (pattern: Pattern | null) => void; + @property({ type: Object }) + colorPalette: ColorPalette | null = null; + + @property({ type: Boolean }) + requiresPurchase: boolean = false; @property({ type: Function }) - onPurchase?: (pattern: Pattern) => void; + onSelect?: (pattern: PlayerPattern | null) => void; + + @property({ type: Function }) + 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` - `, - )} + > + { + const img = e.currentTarget as HTMLImageElement; + const fallback = "/flags/xx.svg"; + if (img.src && !img.src.endsWith(fallback)) { + img.src = fallback; + } + }} + /> + ${country.name} + + `, + ) + : html``}
`; @@ -85,9 +89,11 @@ export class FlagInputModal extends LitElement { } public open() { + this.isModalOpen = true; this.modalEl?.open(); } public close() { + this.isModalOpen = false; this.modalEl?.close(); }