Skip to content

Commit e696371

Browse files
evanpelleclaude
andcommitted
feat(client): sell cosmetic packs as store bundles
Client side of the cosmetic-packs handoff (API #564 catalog, #567 purchase): parse `packs` from cosmetics.json, resolve each pack's items and ownership (any owned item blocks the purchase, matching the server's 409), add a Bundles tab to the store, and buy through POST /shop/purchase/pack with every documented response mapped to a player-facing outcome. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 630d269 commit e696371

17 files changed

Lines changed: 1140 additions & 36 deletions

resources/lang/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,8 @@
455455
"free": "+{numFree} BONUS!",
456456
"hard": "Plutonium",
457457
"legendary": "Legendary",
458+
"pack_includes": "Includes:",
459+
"pack_more_items": "+{count} more",
458460
"per_day": "/day",
459461
"public_lobbies": "Public lobbies",
460462
"public_lobbies_info": "Host custom lobbies that are publicly listed for anyone to join.",
@@ -1608,6 +1610,7 @@
16081610
},
16091611
"store": {
16101612
"already_subscribed": "Already subscribed.",
1613+
"bundles": "Bundles",
16111614
"change_tier_failed": "Couldn't update your subscription. Please try again.",
16121615
"change_tier_rate_limited": "You just changed tiers. Please wait a minute before changing again.",
16131616
"change_tier_success": "Switched to {tier}.",
@@ -1630,13 +1633,19 @@
16301633
"merch_blurb": "Get OpenFront merch shipped to your door!",
16311634
"merch_visit_store": "Visit Store",
16321635
"no_affiliate_items": "No affiliate items available. Check back later for new items.",
1636+
"no_bundles": "No bundles available. Check back later for new items.",
16331637
"no_crowns": "No crowns available. Check back later for new items.",
16341638
"no_effects": "No effects available. Check back later for new items.",
16351639
"no_flags": "No flags available. Check back later for new items.",
16361640
"no_packs": "No packs available. Check back later for new items.",
16371641
"no_skins": "No skins available. Check back later for new items.",
16381642
"no_subscriptions": "No subscriptions available. Check back later for new items.",
16391643
"no_tribes": "You haven't bought any tribe names yet.",
1644+
"pack_already_owned": "You already own {items}. Nothing was charged.",
1645+
"pack_debt": "Your Plutonium balance is {debt} in debt. Settle it before buying.",
1646+
"pack_owned": "Owned",
1647+
"pack_partially_owned": "Already own {items}",
1648+
"pack_unavailable": "This bundle is no longer available.",
16401649
"packs": "Packs",
16411650
"patterns": "Skins",
16421651
"plutonium_amount": "Plutonium amount",

src/client/Api.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
PostTribeNameResponseSchema,
2121
PublicPlayerGamesResponse,
2222
PublicPlayerGamesResponseSchema,
23+
PurchasePackResponse,
24+
PurchasePackResponseSchema,
2325
PutUsernameResponse,
2426
PutUsernameResponseSchema,
2527
RankedLeaderboardResponse,
@@ -647,6 +649,99 @@ export async function purchaseWithCurrency(
647649
}
648650
}
649651

652+
export type PurchaseCosmeticPackResult =
653+
| { ok: true; data: PurchasePackResponse }
654+
// 400 "Insufficient balance": the balance moved since the client's
655+
// pre-check. Nothing charged.
656+
| { ok: false; code: "insufficient_balance" }
657+
// 400 insufficient_balance_debt: a refund/chargeback left the wallet
658+
// negative; `debt` (bigint string) must be settled before anything is
659+
// spendable. Nothing charged.
660+
| { ok: false; code: "debt"; debt: string }
661+
// 400 for a stale listing: pack not found / not for sale / zero price /
662+
// all items deleted.
663+
| { ok: false; code: "unavailable" }
664+
// 409: the player already owns one or more items (`ownedFlareNames` says
665+
// which). Also what a retry after a timed-out success returns — treat it as
666+
// "already bought" and refetch /users/@me. Nothing charged.
667+
| { ok: false; code: "already_owned"; ownedFlareNames: string[] }
668+
| { ok: false; code: "failed" };
669+
670+
const PACK_UNAVAILABLE_REASONS = [
671+
"Pack not found",
672+
"Pack is not for sale",
673+
"Pack not available for hard currency",
674+
"Pack has no items",
675+
];
676+
677+
// POST /shop/purchase/pack — buy a cosmetic pack (see CosmeticPackSchema) for
678+
// its hard-currency price, granting every item's flare in one transaction.
679+
// Any error means no debit and no grants.
680+
export async function purchaseCosmeticPack(
681+
packName: string,
682+
): Promise<PurchaseCosmeticPackResult> {
683+
try {
684+
const response = await fetch(`${getApiBase()}/shop/purchase/pack`, {
685+
method: "POST",
686+
headers: {
687+
"Content-Type": "application/json",
688+
Authorization: await getAuthHeader(),
689+
},
690+
body: JSON.stringify({ packName }),
691+
});
692+
if (response.status === 401) {
693+
await logOut();
694+
return { ok: false, code: "failed" };
695+
}
696+
if (response.status === 400) {
697+
const body = await response.json().catch(() => null);
698+
const reason = typeof body?.reason === "string" ? body.reason : "";
699+
if (reason === "Insufficient balance") {
700+
return { ok: false, code: "insufficient_balance" };
701+
}
702+
if (reason === "insufficient_balance_debt") {
703+
return { ok: false, code: "debt", debt: String(body.debt ?? "") };
704+
}
705+
if (PACK_UNAVAILABLE_REASONS.includes(reason)) {
706+
return { ok: false, code: "unavailable" };
707+
}
708+
console.error("purchaseCosmeticPack: bad request", body);
709+
return { ok: false, code: "failed" };
710+
}
711+
if (response.status === 409) {
712+
const body = await response.json().catch(() => null);
713+
const owned: unknown = body?.ownedFlareNames;
714+
return {
715+
ok: false,
716+
code: "already_owned",
717+
ownedFlareNames: Array.isArray(owned)
718+
? owned.filter((f): f is string => typeof f === "string")
719+
: [],
720+
};
721+
}
722+
if (!response.ok) {
723+
console.error(
724+
"purchaseCosmeticPack: request failed",
725+
response.status,
726+
response.statusText,
727+
);
728+
return { ok: false, code: "failed" };
729+
}
730+
const parsed = PurchasePackResponseSchema.safeParse(await response.json());
731+
if (!parsed.success) {
732+
console.error(
733+
"purchaseCosmeticPack: Zod validation failed",
734+
parsed.error,
735+
);
736+
return { ok: false, code: "failed" };
737+
}
738+
return { ok: true, data: parsed.data };
739+
} catch (e) {
740+
console.error("purchaseCosmeticPack: request failed", e);
741+
return { ok: false, code: "failed" };
742+
}
743+
}
744+
650745
// POST /rewards/:rewardId/claim — claims a single unclaimed reward and
651746
// credits the balance atomically. "not_found" covers unknown, already-claimed
652747
// and other players' rewards (indistinguishable by design); the usual cause is

src/client/Cosmetics.ts

Lines changed: 172 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { assetUrl } from "src/core/AssetUrls";
22
import { UserMeResponse } from "../core/ApiSchemas";
33
import {
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";
3134
import { 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+
251337
function 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+
468591
export 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

Comments
 (0)