diff --git a/resources/lang/en.json b/resources/lang/en.json
index 065ed7e341..418f26afd9 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -367,6 +367,12 @@
"sub_renews_on": "Renews {date}",
"sub_price_monthly": "{price}/mo",
"stats_overview": "Stats Overview",
+ "achievements": "Achievements",
+ "achieved_on": "Achieved on",
+ "status": "Status",
+ "no_achievements": "No player achievements unlocked yet.",
+ "not_unlocked_yet": "Not unlocked yet",
+ "unknown_difficulty": "Unknown",
"link_discord": "Link Discord Account",
"log_out": "Log Out",
"sign_in_desc": "Sign in to save your stats and progress",
@@ -382,6 +388,10 @@
"enter_email_address": "Please enter an email address",
"public_player_id": "Public Player ID:"
},
+ "achievements": {
+ "win_no_nukes": "Win Without Nukes",
+ "win_no_nukes_desc": "Win a free-for-all match without launching any nukes."
+ },
"leaderboard_modal": {
"title": "Leaderboard",
"ranked_tab": "1v1 Ranked",
diff --git a/resources/playerAchievementMetadata.json b/resources/playerAchievementMetadata.json
new file mode 100644
index 0000000000..30c275e3d7
--- /dev/null
+++ b/resources/playerAchievementMetadata.json
@@ -0,0 +1,5 @@
+{
+ "win_no_nukes": {
+ "difficulty": "Hard"
+ }
+}
diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts
index 2bfae22b34..fdef1e9637 100644
--- a/src/client/AccountModal.ts
+++ b/src/client/AccountModal.ts
@@ -2,6 +2,7 @@ import { html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import {
+ AchievementsResponse,
PlayerGame,
PlayerStatsTree,
UserMeResponse,
@@ -12,6 +13,7 @@ import { fetchPlayerById, getUserMe } from "./Api";
import { discordLogin, logOut, sendMagicLink } from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
import "./components/baseComponents/stats/GameList";
+import "./components/baseComponents/stats/PlayerAchievements";
import "./components/baseComponents/stats/PlayerStatsTable";
import "./components/baseComponents/stats/PlayerStatsTree";
import { BaseModal } from "./components/BaseModal";
@@ -33,6 +35,7 @@ export class AccountModal extends BaseModal {
private userMeResponse: UserMeResponse | null = null;
private statsTree: PlayerStatsTree | null = null;
private recentGames: PlayerGame[] = [];
+ private achievementGroups: AchievementsResponse = [];
private cosmetics: Cosmetics | null = null;
constructor() {
@@ -45,15 +48,39 @@ export class AccountModal extends BaseModal {
if (this.userMeResponse?.player?.publicId === undefined) {
this.statsTree = null;
this.recentGames = [];
+ this.achievementGroups = [];
+ } else {
+ this.achievementGroups = this.getUserMeAchievementGroups(
+ this.userMeResponse,
+ );
}
} else {
this.statsTree = null;
this.recentGames = [];
+ this.achievementGroups = [];
this.requestUpdate();
}
});
}
+ private getUserMeAchievementGroups(
+ userMeResponse: UserMeResponse | null,
+ ): AchievementsResponse {
+ const achievements = userMeResponse?.player?.achievements;
+ if (!achievements) return [];
+
+ return [
+ {
+ type: "singleplayer-map",
+ data: achievements.singleplayerMap,
+ },
+ {
+ type: "player",
+ data: achievements.player ?? [],
+ },
+ ];
+ }
+
private hasAnyStats(): boolean {
if (!this.statsTree) return false;
// Check if statsTree has any data
@@ -110,6 +137,10 @@ export class AccountModal extends BaseModal {
private renderAccountInfo() {
const me = this.userMeResponse?.user;
const isLinked = me?.discord ?? me?.email;
+ const achievements =
+ this.achievementGroups.length > 0
+ ? this.achievementGroups
+ : this.getUserMeAchievementGroups(this.userMeResponse);
if (!isLinked) {
return this.renderLoginOptions();
@@ -154,6 +185,15 @@ export class AccountModal extends BaseModal {
`
: ""}
+
+
+ ${translateText("account_modal.achievements")}
+
+
+
+
{
if (userMe) {
this.userMeResponse = userMe;
+ this.achievementGroups = this.getUserMeAchievementGroups(userMe);
if (this.userMeResponse?.player?.publicId) {
this.loadPlayerProfile(this.userMeResponse.player.publicId);
}
@@ -414,6 +455,9 @@ export class AccountModal extends BaseModal {
this.recentGames = data.games;
this.statsTree = data.stats;
+ this.achievementGroups =
+ data.achievements ??
+ this.getUserMeAchievementGroups(this.userMeResponse);
this.requestUpdate();
} catch (err) {
diff --git a/src/client/components/baseComponents/stats/PlayerAchievements.ts b/src/client/components/baseComponents/stats/PlayerAchievements.ts
new file mode 100644
index 0000000000..9230e8a1c6
--- /dev/null
+++ b/src/client/components/baseComponents/stats/PlayerAchievements.ts
@@ -0,0 +1,246 @@
+import { LitElement, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import playerAchievementMetadataJson from "../../../../../resources/playerAchievementMetadata.json" with { type: "json" };
+import type {
+ AchievementsResponse,
+ PlayerAchievementJson,
+} from "../../../../core/ApiSchemas";
+import { assetUrl } from "../../../../core/AssetUrls";
+import type { Difficulty } from "../../../../core/game/Game";
+import { translateText } from "../../../Utils";
+
+type PlayerAchievementMetadata = {
+ difficulty: Difficulty;
+};
+
+type PlayerAchievementCard = {
+ achievement: string;
+ achievedAt: string | null;
+ isUnlocked: boolean;
+};
+
+const playerAchievementMetadata = playerAchievementMetadataJson as Record<
+ string,
+ PlayerAchievementMetadata
+>;
+
+@customElement("player-achievements")
+export class PlayerAchievements extends LitElement {
+ createRenderRoot() {
+ return this;
+ }
+
+ @property({ attribute: false }) achievementGroups: AchievementsResponse = [];
+
+ private get unlockedAchievements(): PlayerAchievementJson[] {
+ return this.achievementGroups
+ .flatMap((group) => (group.type === "player" ? group.data : []))
+ .slice()
+ .sort(
+ (a, b) =>
+ new Date(b.achievedAt).getTime() - new Date(a.achievedAt).getTime(),
+ );
+ }
+
+ private get achievements(): PlayerAchievementCard[] {
+ const unlockedByKey = new Map(
+ this.unlockedAchievements.map((achievement) => [
+ achievement.achievement,
+ achievement,
+ ]),
+ );
+ const knownKeys = Object.keys(playerAchievementMetadata);
+ const achievementKeys = [
+ ...knownKeys,
+ ...this.unlockedAchievements
+ .map((achievement) => achievement.achievement)
+ .filter((achievement) => !knownKeys.includes(achievement)),
+ ];
+ const originalOrder = new Map(
+ achievementKeys.map((achievement, index) => [achievement, index]),
+ );
+
+ return achievementKeys
+ .map((achievement) => {
+ const unlockedAchievement = unlockedByKey.get(achievement);
+ return {
+ achievement,
+ achievedAt: unlockedAchievement?.achievedAt ?? null,
+ isUnlocked: unlockedAchievement !== undefined,
+ };
+ })
+ .sort((a, b) => {
+ if (a.isUnlocked !== b.isUnlocked) {
+ return Number(b.isUnlocked) - Number(a.isUnlocked);
+ }
+ if (a.achievedAt && b.achievedAt) {
+ return (
+ new Date(b.achievedAt).getTime() - new Date(a.achievedAt).getTime()
+ );
+ }
+ return (
+ (originalOrder.get(a.achievement) ?? 0) -
+ (originalOrder.get(b.achievement) ?? 0)
+ );
+ });
+ }
+
+ private formatDate(achievedAt: string): string {
+ const date = new Date(achievedAt);
+ if (Number.isNaN(date.getTime())) {
+ return achievedAt;
+ }
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ }).format(date);
+ }
+
+ private resolveTitle(achievementKey: string): string {
+ const translationKey = `achievements.${achievementKey}`;
+ const translated = translateText(translationKey);
+ return translated === translationKey ? achievementKey : translated;
+ }
+
+ private resolveDescription(achievementKey: string): string | null {
+ const translationKey = `achievements.${achievementKey}_desc`;
+ const translated = translateText(translationKey);
+ return translated === translationKey ? null : translated;
+ }
+
+ private resolveDifficulty(achievementKey: string): Difficulty | null {
+ return playerAchievementMetadata[achievementKey]?.difficulty ?? null;
+ }
+
+ private difficultyClasses(difficulty: Difficulty): string {
+ switch (difficulty) {
+ case "Easy":
+ return "bg-emerald-500/15 text-emerald-300 border-emerald-400/25";
+ case "Medium":
+ return "bg-amber-500/15 text-amber-200 border-amber-400/25";
+ case "Hard":
+ return "bg-rose-500/15 text-rose-200 border-rose-400/25";
+ case "Impossible":
+ return "bg-violet-500/15 text-violet-200 border-violet-400/25";
+ default:
+ return "bg-white/5 text-white/60 border-white/10";
+ }
+ }
+
+ private renderAchievementIcon(isUnlocked: boolean) {
+ const mask = `url('${assetUrl("images/MedalIconWhite.svg")}') no-repeat center / contain`;
+ const iconClasses = isUnlocked
+ ? "bg-yellow-300 opacity-100"
+ : "bg-white/40 opacity-35";
+
+ return html`
+
+ `;
+ }
+
+ private renderDifficultyBadge(difficulty: Difficulty | null) {
+ if (!difficulty) {
+ return html`
+
+ ${translateText("account_modal.unknown_difficulty")}
+
+ `;
+ }
+
+ const translationKey = `difficulty.${difficulty.toLowerCase()}`;
+ const translated = translateText(translationKey);
+ const label = translated === translationKey ? difficulty : translated;
+
+ return html`
+
+ ${label}
+
+ `;
+ }
+
+ private renderAchievementCard(achievement: PlayerAchievementCard) {
+ const difficulty = this.resolveDifficulty(achievement.achievement);
+ const description = this.resolveDescription(achievement.achievement);
+ const cardClasses = achievement.isUnlocked
+ ? "border-yellow-400/25 bg-gradient-to-br from-yellow-500/10 via-slate-900/55 to-black/20 shadow-yellow-950/20"
+ : "border-white/6 bg-gradient-to-br from-slate-900/40 via-slate-900/20 to-black/10 opacity-80";
+
+ return html`
+
+
+
+ ${this.renderAchievementIcon(achievement.isUnlocked)}
+
+ ${this.resolveTitle(achievement.achievement)}
+
+
+ ${this.renderDifficultyBadge(difficulty)}
+
+
+ ${description
+ ? html`
+ ${description}
+ `
+ : null}
+
+
+
+
+ ${achievement.isUnlocked
+ ? translateText("account_modal.achieved_on")
+ : translateText("account_modal.status")}
+
+ ${achievement.isUnlocked && achievement.achievedAt
+ ? html`
+
+ `
+ : html`
+
+ ${translateText("account_modal.not_unlocked_yet")}
+
+ `}
+
+
+
+ `;
+ }
+
+ render() {
+ if (this.achievements.length === 0) {
+ return html`
+
+ ${translateText("account_modal.no_achievements")}
+
+ `;
+ }
+
+ return html`
+
+ `;
+ }
+}
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index 44a19e2f54..0d44e79393 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -70,6 +70,34 @@ const SingleplayerMapAchievementSchema = z.object({
difficulty: z.enum(Difficulty),
});
+export const PlayerAchievementSchema = z.object({
+ playerId: z.string(),
+ achievement: z.string(),
+ achievedAt: z.iso.datetime(),
+ gameId: z.string(),
+ game: z.string(),
+});
+export type PlayerAchievementJson = z.infer;
+
+export const AchievementsResponseSchema = z.array(
+ z.discriminatedUnion("type", [
+ z.object({
+ type: z.literal("singleplayer-map"),
+ data: z.array(SingleplayerMapAchievementSchema),
+ }),
+ z.object({
+ type: z.literal("player"),
+ data: z.array(PlayerAchievementSchema),
+ }),
+ ]),
+);
+export type AchievementsResponse = z.infer;
+
+const UserMeAchievementsSchema = z.object({
+ singleplayerMap: z.array(SingleplayerMapAchievementSchema),
+ player: z.array(PlayerAchievementSchema).optional(),
+});
+
export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
@@ -79,9 +107,7 @@ export const UserMeResponseSchema = z.object({
publicId: z.string(),
adfree: z.boolean(),
flares: z.string().array().optional(),
- achievements: z.object({
- singleplayerMap: z.array(SingleplayerMapAchievementSchema),
- }),
+ achievements: UserMeAchievementsSchema,
leaderboard: z
.object({
oneVone: z
@@ -169,6 +195,7 @@ export const PlayerProfileSchema = z.object({
user: DiscordUserSchema.optional(),
games: PlayerGameSchema.array(),
stats: PlayerStatsTreeSchema,
+ achievements: AchievementsResponseSchema.optional(),
});
export type PlayerProfile = z.infer;