diff --git a/resources/images/BottleCapIcon.svg b/resources/images/BottleCapIcon.svg
new file mode 100644
index 000000000..7a4066f0b
--- /dev/null
+++ b/resources/images/BottleCapIcon.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/resources/images/PlutoniumIcon.svg b/resources/images/PlutoniumIcon.svg
new file mode 100644
index 000000000..ea3e0ac2a
--- /dev/null
+++ b/resources/images/PlutoniumIcon.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/resources/lang/en.json b/resources/lang/en.json
index fd977f7c3..987133225 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -942,8 +942,11 @@
"title": "Store",
"patterns": "Skins",
"flags": "Flags",
+ "packs": "Packs",
"no_flags": "No flags available. Check back later for new items.",
- "no_skins": "No skins 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!"
},
"territory_patterns": {
"title": "Skins",
@@ -963,7 +966,9 @@
"rare": "Rare",
"epic": "Epic",
"legendary": "Legendary",
- "adfree": "ad-free for life!"
+ "adfree": "ad-free for life!",
+ "hard": "Plutonium",
+ "soft": "Caps"
},
"flag_input": {
"title": "Select Flag",
diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts
index fe76dfd6b..a1375f12d 100644
--- a/src/client/AccountModal.ts
+++ b/src/client/AccountModal.ts
@@ -15,6 +15,7 @@ import "./components/baseComponents/stats/PlayerStatsTable";
import "./components/baseComponents/stats/PlayerStatsTree";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
+import "./components/CurrencyDisplay";
import "./components/Difficulties";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
@@ -191,12 +192,24 @@ export class AccountModal extends BaseModal {
`;
}
+ private renderCurrency(): TemplateResult {
+ const currency = this.userMeResponse?.player?.currency;
+ if (!currency) return html``;
+
+ return html`
+
+ `;
+ }
+
private renderLoggedInAs(): TemplateResult {
const me = this.userMeResponse?.user;
if (me?.discord) {
return html`
- ${this.renderLogoutButton()}
+ ${this.renderCurrency()} ${this.renderLogoutButton()}
`;
} else if (me?.email) {
@@ -207,7 +220,7 @@ export class AccountModal extends BaseModal {
account_name: me.email,
})}
- ${this.renderLogoutButton()}
+ ${this.renderCurrency()} ${this.renderLogoutButton()}
`;
}
@@ -265,6 +278,7 @@ export class AccountModal extends BaseModal {
${translateText("account_modal.sign_in_desc")}
+ ${this.renderCurrency()}
diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts
index 32c6fbcd0..963538a5d 100644
--- a/src/client/Cosmetics.ts
+++ b/src/client/Cosmetics.ts
@@ -5,6 +5,7 @@ import {
Cosmetics,
CosmeticsSchema,
Flag,
+ Pack,
Pattern,
Product,
} from "../core/CosmeticSchemas";
@@ -190,8 +191,8 @@ export function flagRelationship(
}
export type ResolvedCosmetic = {
- type: "pattern" | "flag";
- cosmetic: Pattern | Flag | null;
+ type: "pattern" | "flag" | "pack";
+ cosmetic: Pattern | Flag | Pack | null;
colorPalette: ColorPalette | null;
relationship: "owned" | "purchasable" | "blocked";
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "flag:cool_flag" */
@@ -257,6 +258,18 @@ export function resolveCosmetics(
});
}
+ // Packs
+ for (const [packKey, pack] of Object.entries(cosmetics.currencyPacks ?? {})) {
+ const rel = pack.product ? "purchasable" : "blocked";
+ result.push({
+ type: "pack",
+ cosmetic: pack,
+ colorPalette: null,
+ relationship: rel,
+ key: `pack:${packKey}`,
+ });
+ }
+
return result;
}
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 4c15d5684..f4fb076e9 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -636,6 +636,12 @@ class Client {
return;
}
+ const type = params.get("type");
+ if (type === "currency_pack") {
+ alertAndStrip(translateText("store.currency_pack_purchase_success"));
+ return;
+ }
+
const cosmeticName = params.get("cosmetic");
if (!cosmeticName) {
alert("Something went wrong. Please contact support.");
diff --git a/src/client/Store.ts b/src/client/Store.ts
index 32af6d2f8..2c48fae75 100644
--- a/src/client/Store.ts
+++ b/src/client/Store.ts
@@ -18,10 +18,9 @@ import { translateText } from "./Utils";
@customElement("store-modal")
export class StoreModal extends BaseModal {
- @state() private activeTab: "patterns" | "flags" = "patterns";
+ @state() private activeTab: "patterns" | "flags" | "packs" = "patterns";
private cosmetics: Cosmetics | null = null;
- private userSettings: UserSettings = new UserSettings();
private isActive = false;
private affiliateCode: string | null = null;
private userMeResponse: UserMeResponse | false = false;
@@ -51,6 +50,15 @@ export class StoreModal extends BaseModal {
rightContent: html`
`,
})}
+
`;
diff --git a/src/client/components/CapIcon.ts b/src/client/components/CapIcon.ts
new file mode 100644
index 000000000..ac6d84603
--- /dev/null
+++ b/src/client/components/CapIcon.ts
@@ -0,0 +1,29 @@
+import { html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { assetUrl } from "../../core/AssetUrls";
+
+@customElement("cap-icon")
+export class CapIcon extends LitElement {
+ @property({ type: Number })
+ size: number = 48;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ render() {
+ return html`
+
+
})
+
+ `;
+ }
+}
diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts
index 33284c50f..9b1c3274b 100644
--- a/src/client/components/CosmeticButton.ts
+++ b/src/client/components/CosmeticButton.ts
@@ -1,12 +1,14 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
-import { Flag, Pattern } from "../../core/CosmeticSchemas";
+import { Flag, Pack, Pattern } from "../../core/CosmeticSchemas";
import { PlayerPattern } from "../../core/Schemas";
import { ResolvedCosmetic, translateCosmetic } from "../Cosmetics";
import { translateText } from "../Utils";
+import "./CapIcon";
import "./CosmeticContainer";
import "./CosmeticInfo";
import { renderPatternPreview } from "./PatternPreview";
+import "./PlutoniumIcon";
@customElement("cosmetic-button")
export class CosmeticButton extends LitElement {
@@ -38,6 +40,9 @@ export class CosmeticButton extends LitElement {
if (this.resolved.type === "pattern") {
return translateCosmetic("territory_patterns.pattern", c.name);
}
+ if (this.resolved.type === "pack") {
+ return (c as Pack).displayName;
+ }
return translateCosmetic("flags", c.name);
}
@@ -55,6 +60,33 @@ export class CosmeticButton extends LitElement {
return renderPatternPreview(playerPattern, 150, 150);
}
+ if (this.resolved.type === "pack") {
+ const pack = this.resolved.cosmetic as Pack;
+ const isHard = pack.currency === "hard";
+ const icon = isHard
+ ? html`
`
+ : html`
`;
+ const colorClass = isHard ? "text-green-400" : "text-amber-700";
+ const currencyKey = isHard ? "cosmetics.hard" : "cosmetics.soft";
+ return html`
+ ${icon}
+ ${pack.amount.toLocaleString()}
+ ${translateText(currencyKey)}
+
`;
+ }
+
const c = this.resolved.cosmetic as Flag;
return html`

+
+
+
${this.hard.toLocaleString()}
+
+
+
+ ${this.soft.toLocaleString()}
+
+
+ `;
+ }
+}
diff --git a/src/client/components/PlutoniumIcon.ts b/src/client/components/PlutoniumIcon.ts
new file mode 100644
index 000000000..17cf3a242
--- /dev/null
+++ b/src/client/components/PlutoniumIcon.ts
@@ -0,0 +1,55 @@
+import { html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { assetUrl } from "../../core/AssetUrls";
+
+const STYLE_ID = "plutonium-icon-styles";
+if (!document.getElementById(STYLE_ID)) {
+ const style = document.createElement("style");
+ style.id = STYLE_ID;
+ style.textContent = `
+ @keyframes plutonium-pulse {
+ 0% { filter: drop-shadow(0 0 4px rgba(34,197,94,0.6)) drop-shadow(0 0 8px rgba(34,197,94,0.3)); scale: 1; }
+ 50% { filter: drop-shadow(0 0 10px rgba(34,197,94,0.9)) drop-shadow(0 0 20px rgba(34,197,94,0.5)) drop-shadow(0 0 30px rgba(34,197,94,0.2)); scale: 1.04; }
+ 100% { filter: drop-shadow(0 0 4px rgba(34,197,94,0.6)) drop-shadow(0 0 8px rgba(34,197,94,0.3)); scale: 1; }
+ }
+ @keyframes plutonium-rotate {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+ }
+ @keyframes plutonium-jiggle {
+ 0%, 100% { translate: 0 0; }
+ 25% { translate: -0.4px 0.3px; }
+ 50% { translate: 0.3px -0.4px; }
+ 75% { translate: -0.3px -0.3px; }
+ }
+ `;
+ document.head.appendChild(style);
+}
+
+@customElement("plutonium-icon")
+export class PlutoniumIcon extends LitElement {
+ @property({ type: Number })
+ size: number = 48;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ render() {
+ return html`
+
+
})
+
+ `;
+ }
+}
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index 74e7bf8c3..cb968b576 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -81,6 +81,12 @@ export const UserMeResponseSchema = z.object({
.optional(),
})
.optional(),
+ currency: z
+ .object({
+ soft: z.coerce.number(),
+ hard: z.coerce.number(),
+ })
+ .optional(),
}),
});
export type UserMeResponse = z.infer;
diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts
index bd808cdcc..ee6121cb0 100644
--- a/src/core/CosmeticSchemas.ts
+++ b/src/core/CosmeticSchemas.ts
@@ -6,6 +6,7 @@ import { PlayerPattern } from "./Schemas";
export type Cosmetics = z.infer;
export type Pattern = z.infer;
export type Flag = z.infer;
+export type Pack = z.infer;
export type PatternName = z.infer;
export type Product = z.infer;
export type ColorPalette = z.infer;
@@ -76,11 +77,18 @@ export const FlagSchema = CosmeticSchema.extend({
url: z.string(),
});
+export const PackSchema = CosmeticSchema.extend({
+ displayName: z.string(),
+ currency: z.enum(["hard", "soft"]),
+ amount: z.number().int().positive(),
+});
+
// Schema for resources/cosmetics/cosmetics.json
export const CosmeticsSchema = z.object({
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
patterns: z.record(z.string(), PatternSchema),
flags: z.record(z.string(), FlagSchema),
+ currencyPacks: z.record(z.string(), PackSchema).optional(),
});
export const DefaultPattern = {