From 39ad547c0419207a8108fcd4f66ab4d4bbd2b5b9 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 23 Mar 2026 17:09:18 -0700 Subject: [PATCH] support for unlockable flags (#3479) ## Description: Add support for purchasable/gated flags. * Create a new "Store" modal that renders both skins & flags * move all store related logic out of TerritoryPatternsModal * use nation:code for existing nation flags & flag:key for gated flags * check if user has the appropriate flags before purchasing ## 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 --- index.html | 4 +- resources/cosmetics/cosmetics.json | 531 ------------------ resources/lang/en.json | 10 +- src/client/Api.ts | 2 +- src/client/Auth.ts | 3 + src/client/Cosmetics.ts | 185 ++++-- src/client/FlagInput.ts | 82 +-- src/client/FlagInputModal.ts | 134 +++-- src/client/LangSelector.ts | 1 + src/client/Main.ts | 69 +-- src/client/PatternInput.ts | 18 +- src/client/Store.ts | 327 +++++++++++ src/client/TerritoryPatternsModal.ts | 207 +------ src/client/UserSettingModal.ts | 45 -- src/client/components/FlagButton.ts | 97 ++++ src/client/components/PatternButton.ts | 39 +- src/client/components/PlayPage.ts | 5 + src/client/components/PurchaseButton.ts | 37 ++ src/client/graphics/layers/NameLayer.ts | 19 +- .../graphics/layers/PerformanceOverlay.ts | 14 +- .../graphics/layers/PlayerInfoOverlay.ts | 21 +- src/client/graphics/layers/WinModal.ts | 2 +- src/core/CosmeticSchemas.ts | 34 +- src/core/CustomFlag.ts | 80 --- src/core/Schemas.ts | 34 +- src/core/game/GameView.ts | 2 +- src/core/game/UserSettings.ts | 32 +- src/server/Privilege.ts | 38 +- tests/CosmeticRelationship.test.ts | 147 +++++ tests/Privilege.test.ts | 80 ++- 30 files changed, 1144 insertions(+), 1155 deletions(-) delete mode 100644 resources/cosmetics/cosmetics.json create mode 100644 src/client/Store.ts create mode 100644 src/client/components/FlagButton.ts create mode 100644 src/client/components/PurchaseButton.ts delete mode 100644 src/core/CustomFlag.ts create mode 100644 tests/CosmeticRelationship.test.ts diff --git a/index.html b/index.html index 5db9deb74..c15810e54 100644 --- a/index.html +++ b/index.html @@ -204,11 +204,11 @@ inline class="hidden w-full h-full page-content relative z-50" > - + > { export async function createCheckoutSession( priceId: string, - colorPaletteName: string | null, + colorPaletteName?: string, ): Promise { try { const response = await fetch( diff --git a/src/client/Auth.ts b/src/client/Auth.ts index afd47908c..be0899146 100644 --- a/src/client/Auth.ts +++ b/src/client/Auth.ts @@ -1,4 +1,5 @@ import { decodeJwt } from "jose"; +import { UserSettings } from "src/core/game/UserSettings"; import { z } from "zod"; import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; import { base64urlToUuid } from "../core/Base64"; @@ -63,6 +64,8 @@ export async function logOut(allSessions: boolean = false): Promise { } finally { __jwt = null; localStorage.removeItem(PERSISTENT_ID_KEY); + new UserSettings().clearFlag(); + new UserSettings().setSelectedPatternName(undefined); } } diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index a09470d33..0bf0b4ad1 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -1,9 +1,10 @@ import { UserMeResponse } from "../core/ApiSchemas"; import { - ColorPalette, Cosmetics, CosmeticsSchema, + Flag, Pattern, + Product, } from "../core/CosmeticSchemas"; import { PlayerCosmeticRefs, @@ -12,34 +13,26 @@ import { } from "../core/Schemas"; import { UserSettings } from "../core/game/UserSettings"; import { createCheckoutSession, getApiBase, getUserMe } from "./Api"; +import { translateText } from "./Utils"; export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute -export async function handlePurchase( - pattern: Pattern, - colorPalette: ColorPalette | null, -) { - if (pattern.product === null) { - alert("This pattern is not available for purchase."); - return; - } +let __cosmetics: Promise | null = null; +let __cosmeticsHash: string | null = null; - const url = await createCheckoutSession( - pattern.product.priceId, - colorPalette?.name ?? null, - ); +export async function handlePurchase( + product: Product, + colorPaletteName?: string, +) { + const url = await createCheckoutSession(product.priceId, colorPaletteName); if (url === false) { alert("Failed to create checkout session."); return; } - // Redirect to Stripe checkout window.location.href = url; } -let __cosmetics: Promise | null = null; -let __cosmeticsHash: string | null = null; - function simpleHash(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -80,54 +73,118 @@ export async function fetchCosmetics(): Promise { return __cosmetics; } +export async function resolveFlagUrl( + flagRef: string, +): Promise { + if (flagRef.startsWith("flag:")) { + const key = flagRef.slice("flag:".length); + const cosmetics = await fetchCosmetics(); + const flagData = cosmetics?.flags?.[key]; + return flagData?.url; + } + if (flagRef.startsWith("country:")) { + const code = flagRef.slice("country:".length); + return `/flags/${code}.svg`; + } + return undefined; +} + export async function getCosmeticsHash(): Promise { await fetchCosmetics(); return __cosmeticsHash; } +export function cosmeticRelationship( + opts: { + wildcardFlare: string; + requiredFlare: string; + product: Product | null; + affiliateCode: string | null; + itemAffiliateCode: string | null; + }, + userMeResponse: UserMeResponse | false, +): "owned" | "purchasable" | "blocked" { + const flares = + userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); + + if (flares.includes(opts.wildcardFlare)) { + return "owned"; + } + + if (flares.includes(opts.requiredFlare)) { + return "owned"; + } + + if (opts.product === null) { + return "blocked"; + } + + if (opts.affiliateCode !== opts.itemAffiliateCode) { + return "blocked"; + } + + return "purchasable"; +} + export function patternRelationship( pattern: Pattern, colorPalette: { name: string; isArchived?: boolean } | null, userMeResponse: UserMeResponse | false, affiliateCode: string | null, ): "owned" | "purchasable" | "blocked" { - const flares = - userMeResponse === false ? [] : (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}`)) { + const flares = + userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); + if ( + flares.includes("pattern:*") || + 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. + if (colorPalette.isArchived) { + // Check ownership first β€” if owned, show it even if archived. + const flares = + userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); + if ( + flares.includes("pattern:*") || + flares.includes(`pattern:${pattern.name}:${colorPalette.name}`) + ) { + return "owned"; + } return "blocked"; } - if (colorPalette?.isArchived) { - // We don't own the color palette, and it's archived, so don't show it. - return "blocked"; - } + return cosmeticRelationship( + { + wildcardFlare: "pattern:*", + requiredFlare: `pattern:${pattern.name}:${colorPalette.name}`, + product: pattern.product, + affiliateCode, + itemAffiliateCode: pattern.affiliateCode, + }, + userMeResponse, + ); +} - 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"; +export function flagRelationship( + flag: Flag, + userMeResponse: UserMeResponse | false, + affiliateCode: string | null, +): "owned" | "purchasable" | "blocked" { + return cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: `flag:${flag.name}`, + product: flag.product, + affiliateCode, + itemAffiliateCode: flag.affiliateCode, + }, + userMeResponse, + ); } export async function getPlayerCosmeticsRefs(): Promise { @@ -154,8 +211,34 @@ export async function getPlayerCosmeticsRefs(): Promise { } } + let flag = userSettings.getFlag(); + if (flag?.startsWith("flag:")) { + const key = flag.slice("flag:".length); + const flagData = cosmetics?.flags?.[key]; + if (!flagData) { + // Only clear if cosmetics loaded successfully but the key is missing + if (cosmetics) { + flag = null; + } + } else { + const userMe = await getUserMe(); + if (!userMe) { + flag = null; + } else { + const flares = userMe.player.flares ?? []; + const hasWildcard = flares.includes("flag:*"); + if (!hasWildcard && !flares.includes(`flag:${flagData.name}`)) { + flag = null; + } + } + } + } + if (flag === null) { + userSettings.clearFlag(); + } + return { - flag: userSettings.getFlag(), + flag: flag ?? undefined, color: userSettings.getSelectedColor() ?? undefined, patternName: pattern?.name ?? undefined, patternColorPaletteName: pattern?.colorPalette?.name ?? undefined, @@ -169,7 +252,7 @@ export async function getPlayerCosmetics(): Promise { const result: PlayerCosmetics = {}; if (refs.flag) { - result.flag = refs.flag; + result.flag = await resolveFlagUrl(refs.flag); } if (refs.color) { @@ -191,3 +274,15 @@ export async function getPlayerCosmetics(): Promise { return result; } + +export function translateCosmetic(prefix: string, name: string): string { + const translation = translateText(`${prefix}.${name}`); + if (translation.startsWith(prefix)) { + return name + .split("_") + .filter((word) => word.length > 0) + .map((word) => word[0].toUpperCase() + word.substring(1)) + .join(" "); + } + return translation; +} diff --git a/src/client/FlagInput.ts b/src/client/FlagInput.ts index 0622937cf..edcdb06b1 100644 --- a/src/client/FlagInput.ts +++ b/src/client/FlagInput.ts @@ -1,11 +1,10 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { renderPlayerFlag } from "../core/CustomFlag"; -import { FlagSchema } from "../core/Schemas"; +import { FlagName } from "../core/Schemas"; +import { UserSettings } from "../core/game/UserSettings"; +import { resolveFlagUrl } from "./Cosmetics"; import { translateText } from "./Utils"; -const flagKey: string = "flag"; - @customElement("flag-input") export class FlagInput extends LitElement { @state() public flag: string = ""; @@ -14,36 +13,16 @@ export class FlagInput extends LitElement { public showSelectLabel: boolean = false; private isDefaultFlagValue(flag: string): boolean { - return !flag || flag === "xx"; + return !flag || flag === "xx" || flag === "country:xx"; } - public getCurrentFlag(): string { - return this.flag; - } - - private getStoredFlag(): string { - const storedFlag = localStorage.getItem(flagKey); - if (storedFlag) { - return storedFlag; + private updateFlag = (e: CustomEvent) => { + const parsed = FlagName.safeParse(e.detail); + if (!parsed.success) { + console.warn(`error parsing flag ${e.detail.value}, ${parsed.error}`); } - return ""; - } - - private dispatchFlagEvent() { - this.dispatchEvent( - new CustomEvent("flag-change", { - detail: { flag: this.flag }, - bubbles: true, - composed: true, - }), - ); - } - - private updateFlag = (ev: Event) => { - const e = ev as CustomEvent<{ flag: string }>; - if (!FlagSchema.safeParse(e.detail.flag).success) return; - if (this.flag !== e.detail.flag) { - this.flag = e.detail.flag; + if (this.flag !== e.detail) { + this.flag = e.detail; } }; @@ -60,14 +39,19 @@ export class FlagInput extends LitElement { connectedCallback() { super.connectedCallback(); - this.flag = this.getStoredFlag(); - this.dispatchFlagEvent(); - window.addEventListener("flag-change", this.updateFlag as EventListener); + this.flag = new UserSettings().getFlag() ?? ""; + window.addEventListener( + "event:user-settings-changed:flag", + this.updateFlag as EventListener, + ); } disconnectedCallback() { super.disconnectedCallback(); - window.removeEventListener("flag-change", this.updateFlag as EventListener); + window.removeEventListener( + "event:user-settings-changed:flag", + this.updateFlag as EventListener, + ); } createRenderRoot() { @@ -94,7 +78,7 @@ export class FlagInput extends LitElement { > ${showSelect ? html` ${translateText("flag_input.title")} ` @@ -103,32 +87,26 @@ export class FlagInput extends LitElement { `; } - updated() { + async updated() { const preview = this.renderRoot.querySelector( "#flag-preview", ) as HTMLElement; if (!preview) return; - if (this.showSelectLabel && this.isDefaultFlagValue(this.flag)) { + if (this.isDefaultFlagValue(this.flag)) { preview.innerHTML = ""; return; } preview.innerHTML = ""; - if (this.flag?.startsWith("!")) { - renderPlayerFlag(this.flag, preview); - } else { - const img = document.createElement("img"); - img.src = this.flag ? `/flags/${this.flag}.svg` : `/flags/xx.svg`; - img.className = "w-full h-full object-cover pointer-events-none"; - img.draggable = false; - img.onerror = () => { - if (!img.src.endsWith("/flags/xx.svg")) { - img.src = "/flags/xx.svg"; - } - }; - preview.appendChild(img); - } + const url = await resolveFlagUrl(this.flag); + if (!url) return; + + const img = document.createElement("img"); + img.src = url; + img.className = "w-full h-full object-cover pointer-events-none"; + img.draggable = false; + preview.appendChild(img); } } diff --git a/src/client/FlagInputModal.ts b/src/client/FlagInputModal.ts index 8451b4e5d..c51fc2036 100644 --- a/src/client/FlagInputModal.ts +++ b/src/client/FlagInputModal.ts @@ -1,21 +1,93 @@ import { html } from "lit"; -import { customElement, query, state } from "lit/decorators.js"; +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 { UserSettings } from "../core/game/UserSettings"; +import { getUserMe } from "./Api"; +import { fetchCosmetics, flagRelationship } from "./Cosmetics"; import { translateText } from "./Utils"; import { BaseModal } from "./components/BaseModal"; +import "./components/FlagButton"; import { modalHeader } from "./components/ui/ModalHeader"; @customElement("flag-input-modal") export class FlagInputModal extends BaseModal { - @query("#flag-input-modal") private modalRef!: HTMLElement; - @state() private search = ""; + @state() private cosmetics: Cosmetics | null = null; + @state() private userMe: UserMeResponse | false = false; public returnTo = ""; updated(changedProperties: Map) { super.updated(changedProperties); } + 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]) => { + if (!this.includedInSearch({ name: flag.name, code: flag.name })) + return false; + return flagRelationship(flag, this.userMe, null) === "owned"; + }) + .map( + ([key, flag]) => html` + + `, + ); + + const noFlag = this.search + ? null + : html` + + `; + + const countryFlags = Countries.filter( + (country) => + country.code !== "xx" && + !country.restricted && + this.includedInSearch(country), + ).map( + (country) => html` + + `, + ); + + return html` +
+ ${noFlag} ${cosmeticFlags} ${countryFlags} +
+ `; + } + render() { const content = html`
@@ -35,6 +107,7 @@ export class FlagInputModal extends BaseModal { focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-white placeholder-white/30 transition-all" type="text" placeholder=${translateText("flag_input.search_flag")} + .value=${this.search} @change=${this.handleSearch} @keyup=${this.handleSearch} /> @@ -42,43 +115,9 @@ export class FlagInputModal extends BaseModal {
-
- ${Countries.filter( - (country) => - !country.restricted && this.includedInSearch(country), - ).map( - (country) => html` - - `, - )} -
+ ${this.renderFlags()}
`; @@ -112,21 +151,18 @@ export class FlagInputModal extends BaseModal { } private setFlag(flag: string) { - localStorage.setItem("flag", flag); - this.dispatchEvent( - new CustomEvent("flag-change", { - detail: { flag }, - bubbles: true, - composed: true, - }), - ); + new UserSettings().setFlag(flag); } - protected onOpen(): void { - // No custom logic needed + protected async onOpen(): Promise { + [this.cosmetics, this.userMe] = await Promise.all([ + fetchCosmetics(), + getUserMe().then((r) => r || (false as const)), + ]); } protected onClose(): void { + this.search = ""; if (this.returnTo) { const returnEl = document.querySelector(this.returnTo) as any; if (returnEl?.open) { diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 2ba0ee498..cff2e079e 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -223,6 +223,7 @@ export class LangSelector extends LitElement { "o-modal", "o-button", "territory-patterns-modal", + "store-modal", "pattern-input", "fluent-slider", "news-modal", diff --git a/src/client/Main.ts b/src/client/Main.ts index 07fda48cd..6a134573f 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -41,6 +41,8 @@ import { initNavigation } from "./Navigation"; import "./NewsModal"; import "./PatternInput"; import "./SinglePlayerModal"; +import { StoreModal } from "./Store"; +import "./TerritoryPatternsModal"; import { TerritoryPatternsModal } from "./TerritoryPatternsModal"; import { TokenLoginModal } from "./TokenLoginModal"; import { @@ -247,7 +249,7 @@ class Client { private joinModal: JoinLobbyModal; private gameModeSelector: GameModeSelector; private userSettings: UserSettings = new UserSettings(); - private patternsModal: TerritoryPatternsModal; + private storeModal: StoreModal; private tokenLoginModal: TokenLoginModal; private matchmakingModal: MatchmakingModal; @@ -361,30 +363,22 @@ class Client { }); }); - this.patternsModal = document.getElementById( + this.storeModal = document.getElementById("page-item-store") as StoreModal; + if (!this.storeModal || !(this.storeModal instanceof StoreModal)) { + console.warn("Store modal element not found"); + } + + const patternsModal = document.getElementById( "territory-patterns-modal", ) as TerritoryPatternsModal; - if ( - !this.patternsModal || - !(this.patternsModal instanceof TerritoryPatternsModal) - ) { - console.warn("Territory patterns modal element not found"); + if (!patternsModal || !(patternsModal instanceof TerritoryPatternsModal)) { + console.warn("Patterns modal element not found"); } // Attach listener to any pattern-input component document.querySelectorAll("pattern-input").forEach((patternInput) => { patternInput.addEventListener("pattern-input-click", () => { - // Open the Store page which contains the patterns UI - window.showPage?.("page-item-store"); - const skinStoreModal = document.getElementById( - "page-item-store", - ) as HTMLElement & { open?: (opts: any) => void }; - if (skinStoreModal) { - skinStoreModal.classList.remove("hidden"); - if (typeof skinStoreModal.open === "function") { - skinStoreModal.open({ showOnlyOwned: true }); - } - } + patternsModal.open(); }); }); @@ -393,29 +387,20 @@ class Client { if (mobilePat) mobilePat.style.display = "none"; } - if ( - !this.patternsModal || - !(this.patternsModal instanceof TerritoryPatternsModal) - ) { - console.warn("Territory patterns modal element not found"); + if (!this.storeModal || !(this.storeModal instanceof StoreModal)) { + console.warn("Store modal element not found"); } // We no longer need to manually manage the preview button as PatternInput handles it component-side. // However, we still want to ensure the modal can be opened. // The setupPatternInput above handles the click event for the new buttons. - this.patternsModal.refresh(); - - // Listen for pattern selection to update any other listeners if needed, - // though PatternInput handles its own updates via window event. - this.patternsModal.addEventListener("pattern-selected", () => { - // PatternInput components will update themselves. - }); + this.storeModal.refresh(); window.addEventListener("showPage", (e: any) => { if (typeof e?.detail === "string" && e.detail === "page-play") { setTimeout(() => { - this.patternsModal.refresh(); + this.storeModal.refresh(); }, 50); } }); @@ -647,14 +632,20 @@ class Client { return; } - const patternName = params.get("cosmetic"); - if (!patternName) { + const cosmeticName = params.get("cosmetic"); + if (!cosmeticName) { alert("Something went wrong. Please contact support."); console.error("purchase-completed but no pattern name"); return; } - this.userSettings.setSelectedPatternName(patternName); + const setCosmetic = () => { + if (cosmeticName.startsWith("pattern:")) { + this.userSettings.setSelectedPatternName(cosmeticName); + } else if (cosmeticName.startsWith("flag:")) { + this.userSettings.setFlag(cosmeticName); + } + }; const token = params.get("login-token"); if (token) { @@ -662,12 +653,13 @@ class Client { window.addEventListener("beforeunload", () => { // The page reloads after token login, so we need to save the pattern name // in case it is unset during reload. - this.userSettings.setSelectedPatternName(patternName); + setCosmetic(); }); this.tokenLoginModal.openWithToken(token); } else { - alertAndStrip(`purchase succeeded: ${patternName}`); - this.patternsModal.refresh(); + alertAndStrip(`purchase succeeded: ${cosmeticName}`); + setCosmetic(); + this.storeModal.refresh(); } return; } @@ -702,7 +694,7 @@ class Client { const affiliateCode = decodedHash.replace("#affiliate=", ""); strip(); if (affiliateCode) { - this.patternsModal?.open(affiliateCode); + this.storeModal?.open(affiliateCode); } } if (decodedHash.startsWith("#refresh")) { @@ -781,6 +773,7 @@ class Client { "user-setting", "troubleshooting-modal", "territory-patterns-modal", + "store-modal", "language-modal", "news-modal", "flag-input-modal", diff --git a/src/client/PatternInput.ts b/src/client/PatternInput.ts index 008eadb55..46910c486 100644 --- a/src/client/PatternInput.ts +++ b/src/client/PatternInput.ts @@ -46,9 +46,13 @@ export class PatternInput extends LitElement { this.pattern = cosmetics.pattern ?? null; if (!this.isConnected) return; this.isLoading = false; - window.addEventListener("pattern-selected", this._onPatternSelected, { - signal: this._abortController.signal, - }); + window.addEventListener( + "event:user-settings-changed:pattern", + this._onPatternSelected, + { + signal: this._abortController.signal, + }, + ); } disconnectedCallback() { @@ -79,10 +83,10 @@ export class PatternInput extends LitElement { } const showSelect = this.showSelectLabel && this.getIsDefaultPattern(); - this.style.setProperty("height", "3rem"); + this.style.setProperty("height", "2.5rem"); this.style.setProperty( "width", - showSelect ? "clamp(6.5rem, 28vw, 9.5rem)" : "3rem", + showSelect ? "clamp(3.25rem, 14vw, 4.75rem)" : "2.5rem", ); } @@ -136,7 +140,9 @@ export class PatternInput extends LitElement { ${showSelect ? html` ${translateText("territory_patterns.select_skin")} ` diff --git a/src/client/Store.ts b/src/client/Store.ts new file mode 100644 index 000000000..29c23d54f --- /dev/null +++ b/src/client/Store.ts @@ -0,0 +1,327 @@ +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 { UserSettings } from "../core/game/UserSettings"; +import { PlayerPattern } from "../core/Schemas"; +import { hasLinkedAccount } from "./Api"; +import { BaseModal } from "./components/BaseModal"; +import "./components/FlagButton"; +import "./components/PatternButton"; +import { modalHeader } from "./components/ui/ModalHeader"; +import { + fetchCosmetics, + flagRelationship, + getPlayerCosmetics, + handlePurchase, + patternRelationship, +} 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; + private userSettings: UserSettings = new UserSettings(); + private isActive = false; + private affiliateCode: string | null = null; + private userMeResponse: UserMeResponse | false = false; + + private _onPatternSelected = async () => { + await this.updateFromSettings(); + this.refresh(); + }; + + connectedCallback() { + super.connectedCallback(); + document.addEventListener( + "userMeResponse", + (event: CustomEvent) => { + this.onUserMe(event.detail); + }, + ); + window.addEventListener( + "event:user-settings-changed:pattern", + this._onPatternSelected, + ); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener( + "event:user-settings-changed:pattern", + 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(); + } + + private renderHeader(): TemplateResult { + return html` + ${modalHeader({ + title: translateText("store.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + rightContent: !hasLinkedAccount(this.userMeResponse) + ? html`
+ ${this.renderNotLoggedInWarning()} +
` + : undefined, + })} +
+ + +
+ `; + } + + 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` + this.selectPattern(p)} + .onPurchase=${(p: Pattern, cp: ColorPalette | null) => + handlePurchase(p.product!, cp?.name)} + > + `); + } + } + + if (buttons.length === 0) { + return html`
+ ${translateText("store.no_skins")} +
`; + } + + return html` +
+ ${buttons} +
+ `; + } + + 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!)} + > + `); + } + + if (buttons.length === 0) { + return html`
+ ${translateText("store.no_flags")} +
`; + } + + return html` +
+ ${buttons} +
+ `; + } + + private renderNotLoggedInWarning(): TemplateResult { + return html``; + } + + render() { + if (!this.isActive && !this.inline) return html``; + + const content = html` +
+ ${this.renderHeader()} +
+ ${this.activeTab === "patterns" + ? this.renderPatternGrid() + : this.renderFlagGrid()} +
+
+ `; + + if (this.inline) { + return content; + } + + return html` + + ${content} + + `; + } + + public async open(options?: string | { affiliateCode?: string }) { + if (this.isModalOpen) return; + this.isActive = true; + if (typeof options === "string") { + this.affiliateCode = options; + } else if ( + options !== null && + typeof options === "object" && + !Array.isArray(options) + ) { + this.affiliateCode = options.affiliateCode ?? null; + } else { + this.affiliateCode = null; + } + + this.cosmetics ??= await fetchCosmetics(); + await this.refresh(); + super.open(); + } + + public close() { + this.isActive = false; + this.affiliateCode = null; + super.close(); + } + + private selectPattern(pattern: PlayerPattern | null) { + this.selectedColor = null; + this.userSettings.setSelectedColor(undefined); + 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(); + } +} diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 1f87891fd..c4349f113 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -2,18 +2,16 @@ 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 { Cosmetics, Pattern } from "../core/CosmeticSchemas"; import { UserSettings } from "../core/game/UserSettings"; import { PlayerPattern } from "../core/Schemas"; import { hasLinkedAccount } from "./Api"; import { BaseModal } from "./components/BaseModal"; -import "./components/Difficulties"; import "./components/PatternButton"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics, getPlayerCosmetics, - handlePurchase, patternRelationship, } from "./Cosmetics"; import { translateText } from "./Utils"; @@ -25,17 +23,9 @@ export class TerritoryPatternsModal extends BaseModal { @state() private selectedPattern: PlayerPattern | null; @state() private selectedColor: string | null = null; - @state() private activeTab: "patterns" | "colors" = "patterns"; - @state() private showOnlyOwned: boolean = false; - private cosmetics: Cosmetics | null = null; - private userSettings: UserSettings = new UserSettings(); - private isActive = false; - - private affiliateCode: string | null = null; - private userMeResponse: UserMeResponse | false = false; private _onPatternSelected = async () => { @@ -43,10 +33,6 @@ export class TerritoryPatternsModal extends BaseModal { this.refresh(); }; - constructor() { - super(); - } - connectedCallback() { super.connectedCallback(); document.addEventListener( @@ -55,12 +41,18 @@ export class TerritoryPatternsModal extends BaseModal { this.onUserMe(event.detail); }, ); - window.addEventListener("pattern-selected", this._onPatternSelected); + window.addEventListener( + "event:user-settings-changed:pattern", + this._onPatternSelected, + ); } disconnectedCallback() { super.disconnectedCallback(); - window.removeEventListener("pattern-selected", this._onPatternSelected); + window.removeEventListener( + "event:user-settings-changed:pattern", + this._onPatternSelected, + ); } private async updateFromSettings() { @@ -76,42 +68,6 @@ export class TerritoryPatternsModal extends BaseModal { this.refresh(); } - private renderTabNavigation(): TemplateResult { - return html` - ${modalHeader({ - title: translateText("territory_patterns.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - rightContent: !hasLinkedAccount(this.userMeResponse) - ? html`
- ${this.renderNotLoggedInWarning()} -
` - : undefined, - })} - - `; - } - private renderPatternGrid(): TemplateResult { const buttons: TemplateResult[] = []; const patterns: (Pattern | null)[] = [ @@ -129,19 +85,12 @@ export class TerritoryPatternsModal extends BaseModal { pattern, colorPalette, this.userMeResponse, - this.affiliateCode, + null, ); } - if (rel === "blocked") { + if (rel !== "owned") { continue; } - if (this.showOnlyOwned) { - if (rel !== "owned") continue; - } else { - // Store mode: hide owned items - if (rel === "owned") continue; - } - // Determine if this pattern/color is selected const isDefaultPattern = pattern === null; const isSelected = (isDefaultPattern && this.selectedPattern === null) || @@ -156,11 +105,9 @@ export class TerritoryPatternsModal extends BaseModal { .colorPalette=${this.cosmetics?.colorPalettes?.[ colorPalette?.name ?? "" ] ?? null} - .requiresPurchase=${rel === "purchasable"} + .requiresPurchase=${false} .selected=${isSelected} .onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)} - .onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) => - handlePurchase(p, colorPalette)} > `); } @@ -168,42 +115,15 @@ export class TerritoryPatternsModal extends BaseModal { return html`
-
- ${hasLinkedAccount(this.userMeResponse) - ? this.renderMySkinsButton() - : html``} +
+ ${buttons}
- ${!this.showOnlyOwned && buttons.length === 0 - ? html`
- ${translateText("territory_patterns.all_owned")} -
` - : html` -
- ${buttons} -
- `}
`; } - private renderMySkinsButton(): TemplateResult { - return html``; - } - private renderNotLoggedInWarning(): TemplateResult { return html`
- `; - } - public async refresh() { this.requestUpdate(); } diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 7bf6612d3..c6a692de2 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -10,14 +10,8 @@ import "./components/baseComponents/setting/SettingSlider"; import "./components/baseComponents/setting/SettingToggle"; import { BaseModal } from "./components/BaseModal"; import { modalHeader } from "./components/ui/ModalHeader"; -import "./FlagInputModal"; import { Platform } from "./Platform"; -interface FlagInputModalElement extends HTMLElement { - open(): void; - returnTo?: string; -} - const isMac = Platform.isMac; const DefaultKeybinds: Record = { @@ -396,16 +390,6 @@ export class UserSettingModal extends BaseModal { this.userSettings.set("settings.performanceOverlay", enabled); } - private openFlagSelector = () => { - const flagInputModal = - document.querySelector("#flag-input-modal"); - if (flagInputModal?.open) { - this.close(); - flagInputModal.returnTo = "#" + (this.id || "page-settings"); - flagInputModal.open(); - } - }; - render() { const activeContent = this.activeTab === "basic" @@ -786,35 +770,6 @@ export class UserSettingModal extends BaseModal { private renderBasicSettings() { return html` - -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - this.openFlagSelector(); - } - }} - > -
-
- ${translateText("flag_input.title")} -
-
- ${translateText("flag_input.button_title")} -
-
- -
- -
-
- void; + + @property({ type: Function }) + onPurchase?: () => void; + + createRenderRoot() { + return this; + } + + private handleClick() { + this.onSelect?.(this.flag.key); + } + + render() { + return html` +
+ + + ${this.requiresPurchase && this.flag.product + ? html` + this.onPurchase?.()} + > + ` + : null} +
+ `; + } +} diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts index 06dffdc69..e2cd99205 100644 --- a/src/client/components/PatternButton.ts +++ b/src/client/components/PatternButton.ts @@ -9,7 +9,9 @@ import { } from "../../core/CosmeticSchemas"; import { PatternDecoder } from "../../core/PatternDecoder"; import { PlayerPattern } from "../../core/Schemas"; +import { translateCosmetic } from "../Cosmetics"; import { translateText } from "../Utils"; +import "./PurchaseButton"; export const BUTTON_WIDTH = 150; @@ -36,18 +38,6 @@ export class PatternButton extends LitElement { return this; } - private translateCosmetic(prefix: string, patternName: string): string { - const translation = translateText(`${prefix}.${patternName}`); - if (translation.startsWith(prefix)) { - return patternName - .split("_") - .filter((word) => word.length > 0) - .map((word) => word[0].toUpperCase() + word.substring(1)) - .join(" "); - } - return translation; - } - private handleClick() { if (this.pattern === null) { this.onSelect?.(null); @@ -60,8 +50,7 @@ export class PatternButton extends LitElement { } satisfies PlayerPattern); } - private handlePurchase(e: Event) { - e.stopPropagation(); + private handlePurchase() { if (this.pattern?.product) { this.onPurchase?.(this.pattern, this.colorPalette ?? null); } @@ -91,14 +80,14 @@ export class PatternButton extends LitElement { : ""}" title="${isDefaultPattern ? translateText("territory_patterns.pattern.default") - : this.translateCosmetic( + : translateCosmetic( "territory_patterns.pattern", this.pattern!.name, )}" > ${isDefaultPattern ? translateText("territory_patterns.pattern.default") - : this.translateCosmetic( + : translateCosmetic( "territory_patterns.pattern", this.pattern!.name, )} @@ -111,7 +100,7 @@ export class PatternButton extends LitElement { ? "opacity-50" : ""}" > - ${this.translateCosmetic( + ${translateCosmetic( "territory_patterns.color_palette", this.colorPalette!.name, )} @@ -139,18 +128,10 @@ export class PatternButton extends LitElement { ${this.requiresPurchase && this.pattern?.product ? html` -
- -
+ this.handlePurchase()} + > ` : null} diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts index 02c77e93d..9417b5fd2 100644 --- a/src/client/components/PlayPage.ts +++ b/src/client/components/PlayPage.ts @@ -121,6 +121,11 @@ export class PlayPage extends LitElement { adaptive-size class="shrink-0 lg:hidden" > + diff --git a/src/client/components/PurchaseButton.ts b/src/client/components/PurchaseButton.ts new file mode 100644 index 000000000..47bacf4b1 --- /dev/null +++ b/src/client/components/PurchaseButton.ts @@ -0,0 +1,37 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { Product } from "../../core/CosmeticSchemas"; +import { translateText } from "../Utils"; + +@customElement("purchase-button") +export class PurchaseButton extends LitElement { + @property({ type: Object }) + product!: Product; + + @property({ type: Function }) + onPurchase?: () => void; + + createRenderRoot() { + return this; + } + + private handleClick(e: Event) { + e.stopPropagation(); + this.onPurchase?.(); + } + + render() { + return html` +
+ +
+ `; + } +} diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 9f49f6f26..167d85c75 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -1,4 +1,3 @@ -import { renderPlayerFlag } from "../../../core/CustomFlag"; import { EventBus } from "../../../core/EventBus"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { Theme } from "../../../core/configuration/Config"; @@ -210,22 +209,14 @@ export class NameLayer implements Layer { element.classList.add("player-flag"); element.style.opacity = "0.8"; element.style.zIndex = "1"; - element.style.aspectRatio = "3/4"; + element.style.objectFit = "contain"; }; if (player.cosmetics.flag) { - const flag = player.cosmetics.flag; - if (flag !== undefined && flag !== null && flag.startsWith("!")) { - const flagWrapper = document.createElement("div"); - applyFlagStyles(flagWrapper); - renderPlayerFlag(flag, flagWrapper); - nameDiv.appendChild(flagWrapper); - } else if (flag !== undefined && flag !== null) { - const flagImg = document.createElement("img"); - applyFlagStyles(flagImg); - flagImg.src = "/flags/" + flag + ".svg"; - nameDiv.appendChild(flagImg); - } + const flagImg = document.createElement("img"); + applyFlagStyles(flagImg); + flagImg.src = player.cosmetics.flag; + nameDiv.appendChild(flagImg); } nameDiv.classList.add("player-name"); nameDiv.style.color = this.theme.textColor(player); diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index ec08d024b..9dd097ab9 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -476,14 +476,8 @@ export class PerformanceOverlay extends LitElement implements Layer { this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay); }; - private onUserSettingsChanged = (event: Event) => { - const customEvent = event as CustomEvent<{ - key?: string; - value?: unknown; - }>; - if (customEvent.detail?.key !== "settings.performanceOverlay") return; - - const nextVisible = customEvent.detail.value === true; + private onUserSettingsChanged = (event: CustomEvent) => { + const nextVisible = (event.detail as boolean) === true; if (this.isVisible === nextVisible) return; this.setVisible(nextVisible); }; @@ -511,7 +505,7 @@ export class PerformanceOverlay extends LitElement implements Layer { if (!this.isUserSettingsListenerAttached) { globalThis.addEventListener( - "user-settings-changed", + "event:user-settings-changed:settings.performanceOverlay", this.onUserSettingsChanged, ); this.isUserSettingsListenerAttached = true; @@ -523,7 +517,7 @@ export class PerformanceOverlay extends LitElement implements Layer { if (this.isUserSettingsListenerAttached) { globalThis.removeEventListener( - "user-settings-changed", + "event:user-settings-changed:settings.performanceOverlay", this.onUserSettingsChanged, ); this.isUserSettingsListenerAttached = false; diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index fe9ed185a..43236d3a0 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -1,7 +1,5 @@ import { LitElement, TemplateResult, html } from "lit"; -import { ref } from "lit-html/directives/ref.js"; import { customElement, property, state } from "lit/decorators.js"; -import { renderPlayerFlag } from "../../../core/CustomFlag"; import { EventBus } from "../../../core/EventBus"; import { PlayerProfile, @@ -364,21 +362,10 @@ export class PlayerInfoOverlay extends LitElement implements Layer { )}" > ${player.cosmetics.flag - ? player.cosmetics.flag!.startsWith("!") - ? html`
{ - if (el instanceof HTMLElement) { - requestAnimationFrame(() => { - renderPlayerFlag(player.cosmetics.flag!, el); - }); - } - })} - >
` - : html`` + ? html`` : html``} ${player.name()} ${playerTeam !== "" && player.type() !== PlayerType.Bot diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index e4fcd3286..e967776db 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -203,7 +203,7 @@ export class WinModal extends LitElement implements Layer { .requiresPurchase=${true} .onSelect=${(p: Pattern | null) => {}} .onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) => - handlePurchase(p, colorPalette)} + handlePurchase(p.product!, colorPalette?.name)} > `, )} diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index a4bcd6762..39405f96d 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -5,7 +5,8 @@ import { PlayerPattern } from "./Schemas"; export type Cosmetics = z.infer; export type Pattern = z.infer; -export type PatternName = z.infer; +export type Flag = z.infer; +export type PatternName = z.infer; export type Product = z.infer; export type ColorPalette = z.infer; export type PatternData = z.infer; @@ -16,7 +17,7 @@ export const ProductSchema = z.object({ price: z.string(), }); -export const PatternNameSchema = z +export const CosmeticNameSchema = z .string() .regex(/^[a-z0-9_]+$/) .max(32); @@ -51,7 +52,7 @@ export const ColorPaletteSchema = z.object({ }); export const PatternSchema = z.object({ - name: PatternNameSchema, + name: CosmeticNameSchema, pattern: PatternDataSchema, colorPalettes: z .object({ @@ -64,29 +65,18 @@ export const PatternSchema = z.object({ product: ProductSchema.nullable(), }); +export const FlagSchema = z.object({ + name: CosmeticNameSchema, + url: z.string(), + affiliateCode: z.string().nullable(), + product: ProductSchema.nullable(), +}); + // Schema for resources/cosmetics/cosmetics.json export const CosmeticsSchema = z.object({ colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(), patterns: z.record(z.string(), PatternSchema), - flag: z - .object({ - layers: z.record( - z.string(), - z.object({ - name: z.string(), - flares: z.array(z.string()).optional(), - }), - ), - color: z.record( - z.string(), - z.object({ - color: z.string(), - name: z.string(), - flares: z.array(z.string()).optional(), - }), - ), - }) - .optional(), + flags: z.record(z.string(), FlagSchema), }); export const DefaultPattern = { diff --git a/src/core/CustomFlag.ts b/src/core/CustomFlag.ts deleted file mode 100644 index 3347e5e8f..000000000 --- a/src/core/CustomFlag.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Cosmetics } from "./CosmeticSchemas"; - -const ANIMATION_DURATIONS: Record = { - rainbow: 4000, - "bright-rainbow": 4000, - "copper-glow": 3000, - "silver-glow": 3000, - "gold-glow": 3000, - neon: 3000, - lava: 6000, - water: 6200, -}; - -// TODO: Pass in cosmetics as a parameter when -// remote cosmetics are implemented for custom flags -export function renderPlayerFlag( - flag: string, - target: HTMLElement, - cosmetics: Cosmetics | undefined = undefined, -) { - if (cosmetics === undefined) { - console.warn("No cosmetics provided for flag", flag); - return; - } - - if (!flag.startsWith("!")) return; - - const code = flag.slice("!".length); - const layers = code.split("_").map((segment) => { - const [layerKey, colorKey] = segment.split("-"); - return { layerKey, colorKey }; - }); - - target.innerHTML = ""; - target.style.overflow = "hidden"; - target.style.position = "relative"; - target.style.aspectRatio = "3/4"; - - for (const { layerKey, colorKey } of layers) { - const layerName = cosmetics?.flag?.layers[layerKey]?.name ?? layerKey; - - const mask = `/flags/custom/${layerName}.svg`; - if (!mask) continue; - - const layer = document.createElement("div"); - layer.style.position = "absolute"; - layer.style.top = "0"; - layer.style.left = "0"; - layer.style.width = "100%"; - layer.style.height = "100%"; - - const colorValue = cosmetics?.flag?.color[colorKey]?.color ?? colorKey; - const isSpecial = - !colorValue.startsWith("#") && - !/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorValue); - - if (isSpecial) { - const duration = ANIMATION_DURATIONS[colorValue] ?? 5000; - const now = performance.now(); - const offset = now % duration; - if (!duration) console.warn(`No animation duration for: ${colorValue}`); - layer.classList.add(`flag-color-${colorValue}`); - layer.style.animationDelay = `-${offset}ms`; - } else { - layer.style.backgroundColor = colorValue; - } - - layer.style.maskImage = `url(${mask})`; - layer.style.maskRepeat = "no-repeat"; - layer.style.maskPosition = "center"; - layer.style.maskSize = "contain"; - - layer.style.webkitMaskImage = `url(${mask})`; - layer.style.webkitMaskRepeat = "no-repeat"; - layer.style.webkitMaskPosition = "center"; - layer.style.webkitMaskSize = "contain"; - - target.appendChild(layer); - } -} diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 07b7f263e..b3504e670 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -1,10 +1,9 @@ -import countries from "resources/countries.json"; import quickChatData from "resources/QuickChat.json"; import { z } from "zod"; import { ColorPaletteSchema, + CosmeticNameSchema, PatternDataSchema, - PatternNameSchema, } from "./CosmeticSchemas"; import type { GameEvent } from "./EventBus"; import { @@ -132,7 +131,6 @@ export type PlayerCosmetics = z.infer; export type PlayerCosmeticRefs = z.infer; export type PlayerPattern = z.infer; export type PlayerColor = z.infer; -export type Flag = z.infer; export type GameStartInfo = z.infer; export type GameInfo = z.infer; export type PublicGames = z.infer; @@ -284,7 +282,6 @@ export const UsernameSchema = z .regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u) .min(3) .max(27); -const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code); export const QuickChatKeySchema = z.enum( Object.entries(quickChatData).flatMap(([category, entries]) => @@ -471,28 +468,23 @@ export const TurnSchema = z.object({ hash: z.number().nullable().optional(), }); -export const FlagSchema = z +export const FlagName = z .string() .max(128) - .optional() .refine( (val) => { if (val === undefined || val === "") return true; - if (val.startsWith("!")) return true; - return countryCodes.includes(val); + return val.startsWith("flag:") || val.startsWith("country:"); + }, + { + message: "Invalid flag: must start with country: or flag:", }, - { message: "Invalid flag: must be a valid country code or start with !" }, ); -export const PlayerCosmeticRefsSchema = z.object({ - flag: FlagSchema.optional(), - color: z.string().optional(), - patternName: PatternNameSchema.optional(), - patternColorPaletteName: z.string().optional(), -}); +export const FlagSchema = z.string(); export const PlayerPatternSchema = z.object({ - name: PatternNameSchema, + name: CosmeticNameSchema, patternData: PatternDataSchema, colorPalette: ColorPaletteSchema.optional(), }); @@ -501,6 +493,16 @@ export const PlayerColorSchema = z.object({ color: z.string(), }); +// Refs contain cosmetics names, will be replaced by the actual +// content in the server +export const PlayerCosmeticRefsSchema = z.object({ + flag: FlagName.optional(), + color: z.string().optional(), + patternName: CosmeticNameSchema.optional(), + patternColorPaletteName: z.string().optional(), +}); + +// Server converts refs to the actual cosmetics here export const PlayerCosmeticsSchema = z.object({ flag: FlagSchema.optional(), pattern: PlayerPatternSchema.optional(), diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d3e3ad87e..5e7df5c0b 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -678,7 +678,7 @@ export class GameView implements GameMap { for (const nation of this._mapData.nations) { // Nations don't have client ids, so we use their name as the key instead. this._cosmetics.set(nation.name, { - flag: nation.flag, + flag: nation.flag ? `/flags/${nation.flag}.svg` : undefined, } satisfies PlayerCosmetics); } } diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 958410a19..fe7a2a481 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -4,13 +4,13 @@ import { PlayerPattern } from "../Schemas"; const PATTERN_KEY = "territoryPattern"; export class UserSettings { - private emitChange(key: string, value: boolean | number): void { + private emitChange(key: string, value: any): void { try { const maybeDispatch = (globalThis as any)?.dispatchEvent; if (typeof maybeDispatch !== "function") return; (globalThis as any).dispatchEvent( - new CustomEvent("user-settings-changed", { - detail: { key, value }, + new CustomEvent(`event:user-settings-changed:${key}`, { + detail: value, }), ); } catch { @@ -192,6 +192,7 @@ export class UserSettings { } else { localStorage.setItem(PATTERN_KEY, patternName); } + this.emitChange("pattern", patternName); } getSelectedColor(): string | undefined { @@ -208,12 +209,31 @@ export class UserSettings { } } - getFlag(): string | undefined { - const flag = localStorage.getItem("flag"); - if (!flag || flag === "xx") return undefined; + getFlag(): string | null { + let flag = localStorage.getItem("flag"); + if (!flag) return null; + // Migrate bare country codes to country: prefix + if (!flag.startsWith("flag:") && !flag.startsWith("country:")) { + flag = `country:${flag}`; + localStorage.setItem("flag", flag); + } return flag; } + setFlag(flag: string): void { + if (flag === "country:xx") { + this.clearFlag(); + } else { + localStorage.setItem("flag", flag); + } + console.log("emitting change!"); + this.emitChange("flag", flag); + } + + clearFlag(): void { + localStorage.removeItem("flag"); + } + backgroundMusicVolume(): number { return this.getFloat("settings.backgroundMusicVolume", 0); } diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index b0584232d..22ef36b5a 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -9,10 +9,11 @@ import { skipNonAlphabeticTransformer, toAsciiLowerCaseTransformer, } from "obscenity"; +import countries from "resources/countries.json"; + import { Cosmetics } from "../core/CosmeticSchemas"; import { decodePatternData } from "../core/PatternDecoder"; import { - FlagSchema, PlayerColor, PlayerCosmeticRefs, PlayerCosmetics, @@ -20,6 +21,8 @@ import { } from "../core/Schemas"; import { getClanTagOriginalCase, simpleHash } from "../core/Util"; +const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code); + export const shadowNames = [ "UnhuggedToday", "DaddysLilChamp", @@ -153,14 +156,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { } } if (refs.flag) { - const result = FlagSchema.safeParse(refs.flag); - if (!result.success) { - return { - type: "forbidden", - reason: "invalid flag: " + result.error.message, - }; + try { + cosmetics.flag = this.isFlagAllowed(flares, refs.flag); + } catch (e) { + return { type: "forbidden", reason: "invalid flag: " + e.message }; } - cosmetics.flag = result.data; } return { type: "allowed", cosmetics }; @@ -207,6 +207,28 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { } } + isFlagAllowed(flares: string[], flagRef: string): string { + if (flagRef.startsWith("flag:")) { + const key = flagRef.slice("flag:".length); + const found = this.cosmetics.flags[key]; + if (!found) throw new Error(`Flag ${key} not found`); + + if (flares.includes("flag:*") || flares.includes(`flag:${found.name}`)) { + return found.url; + } + + throw new Error(`No flares for flag ${key}`); + } else if (flagRef.startsWith("country:")) { + const code = flagRef.slice("country:".length); + if (!countryCodes.includes(code)) { + throw new Error(`invalid country code`); + } + return `/flags/${code}.svg`; + } else { + throw new Error(`invalid flag prefix`); + } + } + isColorAllowed(flares: string[], color: string): PlayerColor { const allowedColors = flares .filter((flare) => flare.startsWith("color:")) diff --git a/tests/CosmeticRelationship.test.ts b/tests/CosmeticRelationship.test.ts new file mode 100644 index 000000000..824c2352e --- /dev/null +++ b/tests/CosmeticRelationship.test.ts @@ -0,0 +1,147 @@ +import { cosmeticRelationship } from "../src/client/Cosmetics"; +import { UserMeResponse } from "../src/core/ApiSchemas"; + +const product = { productId: "prod_123", priceId: "price_123", price: "$4.99" }; + +function makeUserMe(flares: string[]): UserMeResponse { + return { + player: { flares }, + } as unknown as UserMeResponse; +} + +describe("cosmeticRelationship", () => { + it("returns owned when user has wildcard flare", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product, + affiliateCode: null, + itemAffiliateCode: null, + }, + makeUserMe(["flag:*"]), + ), + ).toBe("owned"); + }); + + it("returns owned when user has the specific flare", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product, + affiliateCode: null, + itemAffiliateCode: null, + }, + makeUserMe(["flag:cool"]), + ), + ).toBe("owned"); + }); + + it("returns blocked when no product and user does not own it", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product: null, + affiliateCode: null, + itemAffiliateCode: null, + }, + makeUserMe([]), + ), + ).toBe("blocked"); + }); + + it("returns blocked when affiliate codes do not match", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product, + affiliateCode: "storeA", + itemAffiliateCode: "storeB", + }, + makeUserMe([]), + ), + ).toBe("blocked"); + }); + + it("returns purchasable when product exists and affiliate matches", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product, + affiliateCode: null, + itemAffiliateCode: null, + }, + makeUserMe([]), + ), + ).toBe("purchasable"); + }); + + it("returns purchasable when affiliate codes match", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "pattern:*", + requiredFlare: "pattern:stripes:red", + product, + affiliateCode: "storeA", + itemAffiliateCode: "storeA", + }, + makeUserMe([]), + ), + ).toBe("purchasable"); + }); + + it("returns blocked when user is not logged in and no product", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product: null, + affiliateCode: null, + itemAffiliateCode: null, + }, + false, + ), + ).toBe("blocked"); + }); + + it("returns purchasable when user is not logged in but product exists", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product, + affiliateCode: null, + itemAffiliateCode: null, + }, + false, + ), + ).toBe("purchasable"); + }); + + it("returns owned when user has wildcard flare for patterns", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "pattern:*", + requiredFlare: "pattern:stripes:red", + product, + affiliateCode: null, + itemAffiliateCode: null, + }, + makeUserMe(["pattern:*"]), + ), + ).toBe("owned"); + }); +}); diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index e3acc62b3..d28f8dbbb 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -18,7 +18,7 @@ const bannedWords = [ const matcher = createMatcher(bannedWords); // Create a minimal PrivilegeCheckerImpl for testing censorUsername -const mockCosmetics = { patterns: {}, colorPalettes: {} }; +const mockCosmetics = { patterns: {}, colorPalettes: {}, flags: {} }; const mockDecoder = () => new Uint8Array(); const checker = new PrivilegeCheckerImpl( mockCosmetics, @@ -27,6 +27,24 @@ const checker = new PrivilegeCheckerImpl( ); const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []); +const flagCosmetics = { + patterns: {}, + colorPalettes: {}, + flags: { + cool_flag: { + name: "cool_flag", + url: "https://example.com/cool.png", + affiliateCode: null, + product: { productId: "prod_1", priceId: "price_1", price: "$4.99" }, + }, + }, +}; +const flagChecker = new PrivilegeCheckerImpl( + flagCosmetics, + mockDecoder, + bannedWords, +); + describe("UsernameCensor", () => { describe("isProfane (via matcher.hasMatch)", () => { test("detects exact banned words", () => { @@ -145,3 +163,63 @@ describe("UsernameCensor", () => { }); }); }); + +describe("Flag validation in isAllowed", () => { + test("allows valid country flag and resolves to SVG path", () => { + const result = flagChecker.isAllowed([], { flag: "country:us" }); + expect(result.type).toBe("allowed"); + if (result.type === "allowed") { + expect(result.cosmetics.flag).toBe("/flags/us.svg"); + } + }); + + test("rejects invalid country code", () => { + const result = flagChecker.isAllowed([], { flag: "country:zzzz" }); + expect(result.type).toBe("forbidden"); + }); + + test("rejects flag with no prefix", () => { + const result = flagChecker.isAllowed([], { flag: "us" }); + expect(result.type).toBe("forbidden"); + }); + + test("allows cosmetic flag when user has wildcard flare", () => { + const result = flagChecker.isAllowed(["flag:*"], { + flag: "flag:cool_flag", + }); + expect(result.type).toBe("allowed"); + if (result.type === "allowed") { + expect(result.cosmetics.flag).toBe("https://example.com/cool.png"); + } + }); + + test("allows cosmetic flag when user has specific flare", () => { + const result = flagChecker.isAllowed(["flag:cool_flag"], { + flag: "flag:cool_flag", + }); + expect(result.type).toBe("allowed"); + if (result.type === "allowed") { + expect(result.cosmetics.flag).toBe("https://example.com/cool.png"); + } + }); + + test("rejects cosmetic flag when user lacks flare", () => { + const result = flagChecker.isAllowed([], { flag: "flag:cool_flag" }); + expect(result.type).toBe("forbidden"); + }); + + test("rejects cosmetic flag that does not exist", () => { + const result = flagChecker.isAllowed(["flag:*"], { + flag: "flag:nonexistent", + }); + expect(result.type).toBe("forbidden"); + }); + + test("allows no flag", () => { + const result = flagChecker.isAllowed([], {}); + expect(result.type).toBe("allowed"); + if (result.type === "allowed") { + expect(result.cosmetics.flag).toBeUndefined(); + } + }); +});