@@ -2,6 +2,8 @@ import { assetUrl } from "src/core/AssetUrls";
22import { UserMeResponse } from "../core/ApiSchemas" ;
33import {
44 ColorPalette ,
5+ CosmeticPack ,
6+ CosmeticPackItem ,
57 Cosmetics ,
68 CosmeticsSchema ,
79 Crown ,
@@ -26,6 +28,7 @@ import {
2628 getApiBase ,
2729 getUserMe ,
2830 invalidateUserMe ,
31+ purchaseCosmeticPack ,
2932 purchaseWithCurrency ,
3033} from "./Api" ;
3134import { showInGameAlert , showInGameConfirm } from "./InGameModal" ;
@@ -158,15 +161,17 @@ export async function purchaseCosmetic(
158161 }
159162 }
160163
164+ if ( resolved . type === "cosmeticPack" ) {
165+ return purchasePack ( c as CosmeticPack , method ) ;
166+ }
167+
161168 if ( method === "dollar" ) {
162- if ( ! c . product ) {
169+ const product = "product" in c ? c . product : null ;
170+ if ( ! product ) {
163171 await showInGameAlert ( translateText ( "store.checkout_failed" ) ) ;
164172 return ;
165173 }
166- const url = await createCheckoutSession (
167- c . product . priceId ,
168- colorPaletteName ,
169- ) ;
174+ const url = await createCheckoutSession ( product . priceId , colorPaletteName ) ;
170175 if ( url === false ) {
171176 await showInGameAlert ( translateText ( "store.checkout_failed" ) ) ;
172177 return ;
@@ -248,6 +253,87 @@ export async function purchaseCosmetic(
248253 window . location . reload ( ) ;
249254}
250255
256+ /**
257+ * Buys a cosmetic pack (plutonium only). Mirrors the single-cosmetic currency
258+ * flow: a local balance pre-check surfaces the insufficient-funds dialog
259+ * before any request; a success reloads so every granted item shows as owned.
260+ */
261+ async function purchasePack (
262+ pack : CosmeticPack ,
263+ method : PaymentMethod ,
264+ ) : Promise < PurchaseResult > {
265+ if ( method !== "hard" ) {
266+ console . error ( "purchaseCosmetic: packs are only sold for hard currency" ) ;
267+ return ;
268+ }
269+ const userMe = await getUserMe ( ) ;
270+ if ( userMe === false ) {
271+ alert ( translateText ( "store.login_required" ) ) ;
272+ return ;
273+ }
274+ const insufficient = ( balance : number ) : InsufficientCurrency => ( {
275+ currency : translateText ( "cosmetics.hard" ) ,
276+ shortfall : pack . priceHard - balance ,
277+ item : pack . displayName ,
278+ canTopUp : true ,
279+ } ) ;
280+ const balance = userMe . player . currency ?. hard ?? 0 ;
281+ if ( balance < pack . priceHard ) {
282+ return insufficient ( balance ) ;
283+ }
284+
285+ const result = await purchaseCosmeticPack ( pack . name ) ;
286+ if ( result . ok ) {
287+ alert ( translateText ( "store.purchase_success" , { name : pack . displayName } ) ) ;
288+ invalidateUserMe ( ) ;
289+ window . location . reload ( ) ;
290+ return ;
291+ }
292+ switch ( result . code ) {
293+ case "insufficient_balance" : {
294+ // The balance moved since the pre-check: re-read it for the shortfall.
295+ invalidateUserMe ( ) ;
296+ const fresh = await getUserMe ( ) ;
297+ return insufficient (
298+ fresh === false ? 0 : ( fresh . player . currency ?. hard ?? 0 ) ,
299+ ) ;
300+ }
301+ case "debt" :
302+ alert ( translateText ( "store.pack_debt" , { debt : result . debt } ) ) ;
303+ return ;
304+ case "already_owned" :
305+ // Either a genuine conflict or a retry of a purchase that did go
306+ // through: both mean the local ownership state is stale, so refetch.
307+ alert (
308+ translateText ( "store.pack_already_owned" , {
309+ items : result . ownedFlareNames . map ( flareDisplayName ) . join ( ", " ) ,
310+ } ) ,
311+ ) ;
312+ invalidateUserMe ( ) ;
313+ window . location . reload ( ) ;
314+ return ;
315+ case "unavailable" :
316+ alert ( translateText ( "store.pack_unavailable" ) ) ;
317+ return ;
318+ default :
319+ alert ( translateText ( "store.purchase_failed" ) ) ;
320+ return ;
321+ }
322+ }
323+
324+ /** The translated name of the cosmetic a flare ("<type>:<name>") refers to. */
325+ function flareDisplayName ( flare : string ) : string {
326+ const [ type , name ] = flare . split ( ":" , 2 ) ;
327+ const prefix = {
328+ pattern : "territory_patterns.pattern" ,
329+ skin : "territory_patterns.pattern" ,
330+ flag : "flags" ,
331+ crown : "crowns" ,
332+ effect : "effects" ,
333+ } [ type ] ;
334+ return prefix && name ? translateCosmetic ( prefix , name ) : flare ;
335+ }
336+
251337function simpleHash ( str : string ) : string {
252338 let hash = 0 ;
253339 for ( let i = 0 ; i < str . length ; i ++ ) {
@@ -465,6 +551,43 @@ export function effectRelationship(
465551 ) ;
466552}
467553
554+ /** The flare a pack item's purchase grants, e.g. "pattern:camo". */
555+ export function packItemFlare ( item : CosmeticPackItem ) : string {
556+ return `${ item . type } :${ item . name } ` ;
557+ }
558+
559+ /**
560+ * The pack's items the player already owns — by the item's own flare or the
561+ * type wildcard. Any owned item blocks buying the pack (the server answers
562+ * 409; there is no partial grant), so callers use this to explain why.
563+ */
564+ export function ownedPackItems (
565+ pack : CosmeticPack ,
566+ userMeResponse : UserMeResponse | false ,
567+ ) : CosmeticPackItem [ ] {
568+ const flares =
569+ userMeResponse === false ? [ ] : ( userMeResponse . player . flares ?? [ ] ) ;
570+ return pack . items . filter (
571+ ( item ) =>
572+ flares . includes ( packItemFlare ( item ) ) || flares . includes ( `${ item . type } :*` ) ,
573+ ) ;
574+ }
575+
576+ export function cosmeticPackRelationship (
577+ pack : CosmeticPack ,
578+ userMeResponse : UserMeResponse | false ,
579+ affiliateCode : string | null ,
580+ ) : "owned" | "purchasable" | "blocked" {
581+ if ( pack . items . length === 0 ) return "blocked" ;
582+ const owned = ownedPackItems ( pack , userMeResponse ) . length ;
583+ if ( owned === pack . items . length ) return "owned" ;
584+ // Pack revenue isn't attributed to affiliates: hidden in affiliate mode.
585+ if ( affiliateCode !== null ) return "blocked" ;
586+ // Partially owned packs can't be bought (see ownedPackItems).
587+ if ( owned > 0 ) return "blocked" ;
588+ return pack . priceHard > 0 ? "purchasable" : "blocked" ;
589+ }
590+
468591export type ResolvedCosmetic = {
469592 type :
470593 | "pattern"
@@ -473,14 +596,30 @@ export type ResolvedCosmetic = {
473596 | "crown"
474597 | "effect"
475598 | "pack"
599+ | "cosmeticPack"
476600 | "subscription" ;
477- cosmetic : Pattern | Skin | Flag | Crown | Effect | Pack | Subscription | null ;
601+ cosmetic :
602+ | Pattern
603+ | Skin
604+ | Flag
605+ | Crown
606+ | Effect
607+ | Pack
608+ | CosmeticPack
609+ | Subscription
610+ | null ;
478611 colorPalette : ColorPalette | null ;
479612 relationship : "owned" | "purchasable" | "blocked" ;
480613 /** Unique key for selection/identity, e.g. "pattern:hearts:red" or "skin:mountain" */
481614 key : string ;
482615 /** For effects only: the effectType (also the catalog's outer key). */
483616 effectType ?: string ;
617+ /**
618+ * For cosmetic packs only: the pack's items resolved against this catalog,
619+ * in pack order. An item whose cosmetic is no longer in the catalog is
620+ * skipped (the server still sells whatever remains in the pack).
621+ */
622+ packItems ?: ResolvedCosmetic [ ] ;
484623} ;
485624
486625/**
@@ -596,6 +735,33 @@ export function resolveCosmetics(
596735 } ) ;
597736 }
598737
738+ // Cosmetic packs. Items reference cosmetics resolved above by (type, name);
739+ // a pattern item is its uncoloured entry — the "pattern:<key>" one, with
740+ // no palette segment — since the pack grants "pattern:<name>".
741+ for ( const [ packKey , pack ] of Object . entries ( cosmetics . packs ?? { } ) ) {
742+ const packItems = pack . items . flatMap ( ( item ) => {
743+ const found = result . find (
744+ ( r ) =>
745+ r . type === item . type &&
746+ r . cosmetic ?. name === item . name &&
747+ ( item . type !== "pattern" || r . key . split ( ":" ) . length === 2 ) ,
748+ ) ;
749+ return found ? [ found ] : [ ] ;
750+ } ) ;
751+ result . push ( {
752+ type : "cosmeticPack" ,
753+ cosmetic : pack ,
754+ colorPalette : null ,
755+ relationship : cosmeticPackRelationship (
756+ pack ,
757+ userMeResponse ,
758+ affiliateCode ,
759+ ) ,
760+ key : `cosmeticPack:${ packKey } ` ,
761+ packItems,
762+ } ) ;
763+ }
764+
599765 // Subscriptions
600766 const flares =
601767 userMeResponse === false ? [ ] : ( userMeResponse . player . flares ?? [ ] ) ;
0 commit comments