From 616ba1c794c238a3b07872d84191f50a980b77d8 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 13 Apr 2026 10:19:43 -0700 Subject: [PATCH] Add support to purchase cosmetics with in-game currency (#3648) ## Description: Caps & Plutonium can be used to purchase different cosmetics. * The cosmetic button can display pluto/caps/dollars * Create a "purchaseCosmetic" helper function that handles purchase logic ## 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 | 7 +- src/client/Api.ts | 43 ++++++++ src/client/Cosmetics.ts | 85 +++++++++++++-- src/client/Store.ts | 12 +-- src/client/components/CosmeticButton.ts | 22 +++- src/client/components/CosmeticContainer.ts | 38 +++++-- src/client/components/PurchaseButton.ts | 114 ++++++++++++++++++--- src/client/graphics/layers/WinModal.ts | 6 +- src/core/CosmeticSchemas.ts | 2 + tests/CosmeticRelationship.test.ts | 51 +++++++++ tests/Privilege.test.ts | 2 + tests/ResolveCosmetics.test.ts | 8 ++ 12 files changed, 342 insertions(+), 48 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 218739e03..eeea5f18f 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -948,7 +948,12 @@ "no_flags": "No flags available. Check back later for new items.", "no_skins": "No skins available. Check back later for new items.", "no_packs": "No packs available. Check back later for new items.", - "currency_pack_purchase_success": "Currency pack purchase successful!" + "currency_pack_purchase_success": "Currency pack purchase successful!", + "checkout_failed": "Failed to create checkout session.", + "login_required": "You must be logged in to purchase with currency.", + "not_enough_currency": "Not enough currency for this purchase.", + "purchase_failed": "Purchase failed. Please try again.", + "purchase_success": "Purchase succeeded: {name}" }, "territory_patterns": { "title": "Skins", diff --git a/src/client/Api.ts b/src/client/Api.ts index 347ba1837..a74179ce0 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -92,6 +92,49 @@ export async function getUserMe(): Promise { return __userMe; } +export function invalidateUserMe() { + __userMe = null; +} + +export async function purchaseWithCurrency( + cosmeticType: "pattern" | "skin" | "flag", + cosmeticName: string, + currencyType: "hard" | "soft", + colorPaletteName?: string, +): Promise { + try { + const response = await fetch(`${getApiBase()}/shop/purchase`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: await getAuthHeader(), + }, + body: JSON.stringify({ + cosmeticType, + cosmeticName, + currencyType, + colorPaletteName, + }), + }); + if (response.status === 401) { + await logOut(); + return false; + } + if (!response.ok) { + console.error( + "purchaseWithCurrency: request failed", + response.status, + response.statusText, + ); + return false; + } + return true; + } catch (e) { + console.error("purchaseWithCurrency: request failed", e); + return false; + } +} + export async function createCheckoutSession( priceId: string, colorPaletteName?: string, diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 963538a5d..cc11a08c1 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -15,7 +15,13 @@ import { PlayerPattern, } from "../core/Schemas"; import { UserSettings } from "../core/game/UserSettings"; -import { createCheckoutSession, getApiBase, getUserMe } from "./Api"; +import { + createCheckoutSession, + getApiBase, + getUserMe, + invalidateUserMe, + purchaseWithCurrency, +} from "./Api"; import { translateText } from "./Utils"; export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute @@ -23,17 +29,63 @@ export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute let __cosmetics: Promise | null = null; let __cosmeticsHash: string | null = 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."); +export type PaymentMethod = "dollar" | "hard" | "soft"; + +export async function purchaseCosmetic( + resolved: ResolvedCosmetic, + method: PaymentMethod, +): Promise { + if (!resolved.cosmetic) return; + const c = resolved.cosmetic; + const colorPaletteName = resolved.colorPalette?.name; + + if (method === "dollar") { + if (!c.product) { + alert(translateText("store.checkout_failed")); + return; + } + const url = await createCheckoutSession( + c.product.priceId, + colorPaletteName, + ); + if (url === false) { + alert(translateText("store.checkout_failed")); + return; + } + window.location.href = url; return; } - window.location.href = url; + // Currency purchase (hard or soft) + const price = method === "hard" ? (c.priceHard ?? 0) : (c.priceSoft ?? 0); + const userMe = await getUserMe(); + if (userMe === false) { + alert(translateText("store.login_required")); + return; + } + const balance = + method === "hard" + ? (userMe.player.currency?.hard ?? 0) + : (userMe.player.currency?.soft ?? 0); + if (balance < price) { + alert(translateText("store.not_enough_currency")); + return; + } + + const cosmeticType = resolved.type as "pattern" | "skin" | "flag"; + const success = await purchaseWithCurrency( + cosmeticType, + c.name, + method, + colorPaletteName, + ); + if (!success) { + alert(translateText("store.purchase_failed")); + return; + } + alert(translateText("store.purchase_success", { name: c.name })); + invalidateUserMe(); + window.location.reload(); } function simpleHash(str: string): string { @@ -102,6 +154,8 @@ export function cosmeticRelationship( wildcardFlare: string; requiredFlare: string; product: Product | null; + priceSoft?: number; + priceHard?: number; affiliateCode: string | null; itemAffiliateCode: string | null; }, @@ -118,11 +172,16 @@ export function cosmeticRelationship( return "owned"; } - if (opts.product === null) { + if (opts.affiliateCode !== opts.itemAffiliateCode) { return "blocked"; } - if (opts.affiliateCode !== opts.itemAffiliateCode) { + // Purchasable if any purchase method is available + if (opts.priceSoft !== undefined || opts.priceHard !== undefined) { + return "purchasable"; + } + + if (opts.product === null) { return "blocked"; } @@ -166,6 +225,8 @@ export function patternRelationship( wildcardFlare: "pattern:*", requiredFlare: `pattern:${pattern.name}:${colorPalette.name}`, product: pattern.product, + priceSoft: pattern.priceSoft, + priceHard: pattern.priceHard, affiliateCode, itemAffiliateCode: pattern.affiliateCode, }, @@ -183,6 +244,8 @@ export function flagRelationship( wildcardFlare: "flag:*", requiredFlare: `flag:${flag.name}`, product: flag.product, + priceSoft: flag.priceSoft, + priceHard: flag.priceHard, affiliateCode, itemAffiliateCode: flag.affiliateCode, }, diff --git a/src/client/Store.ts b/src/client/Store.ts index 2c48fae75..39ee0dc03 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -10,9 +10,8 @@ import "./components/NotLoggedInWarning"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics, - handlePurchase, + purchaseCosmetic, resolveCosmetics, - ResolvedCosmetic, } from "./Cosmetics"; import { translateText } from "./Utils"; @@ -109,8 +108,7 @@ export class StoreModal extends BaseModal { (r) => html` - handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)} + .onPurchase=${purchaseCosmetic} > `, )} @@ -148,8 +146,7 @@ export class StoreModal extends BaseModal { - handlePurchase(rc.cosmetic!.product!)} + .onPurchase=${purchaseCosmetic} > `, )} @@ -180,8 +177,7 @@ export class StoreModal extends BaseModal { (r) => html` - handlePurchase(rc.cosmetic!.product!)} + .onPurchase=${purchaseCosmetic} > `, )} diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts index 9b1c3274b..8799aaef8 100644 --- a/src/client/components/CosmeticButton.ts +++ b/src/client/components/CosmeticButton.ts @@ -2,7 +2,11 @@ import { html, LitElement, nothing, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { Flag, Pack, Pattern } from "../../core/CosmeticSchemas"; import { PlayerPattern } from "../../core/Schemas"; -import { ResolvedCosmetic, translateCosmetic } from "../Cosmetics"; +import { + PaymentMethod, + ResolvedCosmetic, + translateCosmetic, +} from "../Cosmetics"; import { translateText } from "../Utils"; import "./CapIcon"; import "./CosmeticContainer"; @@ -22,7 +26,7 @@ export class CosmeticButton extends LitElement { onSelect?: (resolved: ResolvedCosmetic) => void; @property({ type: Function }) - onPurchase?: (resolved: ResolvedCosmetic) => void; + onPurchase?: (resolved: ResolvedCosmetic, method: PaymentMethod) => void; createRenderRoot() { return this; @@ -118,7 +122,17 @@ export class CosmeticButton extends LitElement { .rarity=${c?.rarity ?? "common"} .selected=${this.selected} .product=${isPurchasable && c?.product ? c.product : null} - .onPurchase=${() => this.onPurchase?.(this.resolved)} + .priceHard=${isPurchasable ? (c?.priceHard ?? null) : null} + .priceSoft=${isPurchasable ? (c?.priceSoft ?? null) : null} + .onPurchaseDollar=${isPurchasable && c?.product + ? () => this.onPurchase?.(this.resolved, "dollar") + : undefined} + .onPurchaseHard=${isPurchasable && c?.priceHard !== undefined + ? () => this.onPurchase?.(this.resolved, "hard") + : undefined} + .onPurchaseSoft=${isPurchasable && c?.priceSoft !== undefined + ? () => this.onPurchase?.(this.resolved, "soft") + : undefined} .name=${this.displayName} > + `; + } + + private renderHardButton() { + return html` + + `; + } + + private renderSoftButton() { + return html` + + `; + } + render() { + const hasDollar = this.product && this.onPurchaseDollar; + const hasHard = this.priceHard !== null && this.onPurchaseHard; + const hasSoft = this.priceSoft !== null && this.onPurchaseSoft; + + if (!hasDollar && !hasHard && !hasSoft) return nothing; + return html`
${this.rarity !== "common" @@ -190,15 +280,11 @@ export class PurchaseButton extends LitElement { >`, )}` : null} - +
+ ${hasDollar ? this.renderDollarButton() : null} + ${hasHard ? this.renderHardButton() : null} + ${hasSoft ? this.renderSoftButton() : null} +
`; } diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index f4283ca0d..8e8d85fd7 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -14,9 +14,8 @@ import { getUserMe } from "../../Api"; import "../../components/CosmeticButton"; import { fetchCosmetics, - handlePurchase, + purchaseCosmetic, resolveCosmetics, - ResolvedCosmetic, } from "../../Cosmetics"; import { crazyGamesSDK } from "../../CrazyGamesSDK"; import { Platform } from "../../Platform"; @@ -179,8 +178,7 @@ export class WinModal extends LitElement implements Layer { (r) => html` - handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)} + .onPurchase=${purchaseCosmetic} > `, )} diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index ee6121cb0..ae6196294 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -56,6 +56,8 @@ const CosmeticSchema = z.object({ name: CosmeticNameSchema, affiliateCode: z.string().nullable(), product: ProductSchema.nullable(), + priceSoft: z.number().optional(), + priceHard: z.number().optional(), artist: z.string().optional(), rarity: z .enum(["common", "uncommon", "rare", "epic", "legendary"]) diff --git a/tests/CosmeticRelationship.test.ts b/tests/CosmeticRelationship.test.ts index 824c2352e..0de67e9f9 100644 --- a/tests/CosmeticRelationship.test.ts +++ b/tests/CosmeticRelationship.test.ts @@ -17,6 +17,8 @@ describe("cosmeticRelationship", () => { wildcardFlare: "flag:*", requiredFlare: "flag:cool", product, + priceSoft: undefined, + priceHard: undefined, affiliateCode: null, itemAffiliateCode: null, }, @@ -32,6 +34,8 @@ describe("cosmeticRelationship", () => { wildcardFlare: "flag:*", requiredFlare: "flag:cool", product, + priceSoft: undefined, + priceHard: undefined, affiliateCode: null, itemAffiliateCode: null, }, @@ -47,6 +51,8 @@ describe("cosmeticRelationship", () => { wildcardFlare: "flag:*", requiredFlare: "flag:cool", product: null, + priceSoft: undefined, + priceHard: undefined, affiliateCode: null, itemAffiliateCode: null, }, @@ -62,6 +68,8 @@ describe("cosmeticRelationship", () => { wildcardFlare: "flag:*", requiredFlare: "flag:cool", product, + priceSoft: undefined, + priceHard: undefined, affiliateCode: "storeA", itemAffiliateCode: "storeB", }, @@ -77,6 +85,8 @@ describe("cosmeticRelationship", () => { wildcardFlare: "flag:*", requiredFlare: "flag:cool", product, + priceSoft: undefined, + priceHard: undefined, affiliateCode: null, itemAffiliateCode: null, }, @@ -92,6 +102,8 @@ describe("cosmeticRelationship", () => { wildcardFlare: "pattern:*", requiredFlare: "pattern:stripes:red", product, + priceSoft: undefined, + priceHard: undefined, affiliateCode: "storeA", itemAffiliateCode: "storeA", }, @@ -107,6 +119,8 @@ describe("cosmeticRelationship", () => { wildcardFlare: "flag:*", requiredFlare: "flag:cool", product: null, + priceSoft: undefined, + priceHard: undefined, affiliateCode: null, itemAffiliateCode: null, }, @@ -122,6 +136,8 @@ describe("cosmeticRelationship", () => { wildcardFlare: "flag:*", requiredFlare: "flag:cool", product, + priceSoft: undefined, + priceHard: undefined, affiliateCode: null, itemAffiliateCode: null, }, @@ -130,6 +146,39 @@ describe("cosmeticRelationship", () => { ).toBe("purchasable"); }); + it("returns purchasable when item has currency price and no product", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product: null, + priceSoft: 100, + affiliateCode: null, + itemAffiliateCode: null, + }, + makeUserMe([]), + ), + ).toBe("purchasable"); + }); + + it("returns blocked when item has currency price but affiliate codes do not match", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product: null, + priceSoft: 100, + priceHard: 50, + affiliateCode: "storeA", + itemAffiliateCode: "storeB", + }, + makeUserMe([]), + ), + ).toBe("blocked"); + }); + it("returns owned when user has wildcard flare for patterns", () => { expect( cosmeticRelationship( @@ -137,6 +186,8 @@ describe("cosmeticRelationship", () => { wildcardFlare: "pattern:*", requiredFlare: "pattern:stripes:red", product, + priceSoft: undefined, + priceHard: undefined, affiliateCode: null, itemAffiliateCode: null, }, diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index 09802211b..8a9e0dc17 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -44,6 +44,8 @@ const flagCosmetics = { url: "https://example.com/cool.png", affiliateCode: null, product: { productId: "prod_1", priceId: "price_1", price: "$4.99" }, + priceSoft: undefined, + priceHard: undefined, rarity: "common", }, }, diff --git a/tests/ResolveCosmetics.test.ts b/tests/ResolveCosmetics.test.ts index 0ed6099f7..5b9697d30 100644 --- a/tests/ResolveCosmetics.test.ts +++ b/tests/ResolveCosmetics.test.ts @@ -47,6 +47,8 @@ describe("resolveCosmetics", () => { pattern: "AAAAAA", affiliateCode: null, product, + priceSoft: undefined, + priceHard: undefined, rarity: "common", colorPalettes: [ { name: "red", isArchived: false }, @@ -226,6 +228,8 @@ describe("resolveCosmetics", () => { url: "https://example.com/cool.png", affiliateCode: null, product, + priceSoft: undefined, + priceHard: undefined, rarity: "rare", }; @@ -292,6 +296,8 @@ describe("resolveCosmetics", () => { pattern: "AAAAAA", affiliateCode: null, product, + priceSoft: null, + priceHard: null, rarity: "common", } as any, }, @@ -302,6 +308,8 @@ describe("resolveCosmetics", () => { url: "/flags/heart.svg", affiliateCode: null, product, + priceSoft: null, + priceHard: null, rarity: "common", } as any, },