diff --git a/resources/lang/en.json b/resources/lang/en.json
index 299e7228c7..cb08806820 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -221,6 +221,13 @@
"title": "Account",
"connected_as": "Connected as",
"stats_overview": "Stats Overview",
+ "achievements": "Achievements",
+ "achievement_label": "Achievement",
+ "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",
@@ -236,6 +243,12 @@
"enter_email_address": "Please enter an email address",
"personal_player_id": "Personal Player ID:"
},
+ "achievements": {
+ "test": "Test",
+ "test_desc": "This is a test achievement.",
+ "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..38da16ae8b
--- /dev/null
+++ b/resources/playerAchievementMetadata.json
@@ -0,0 +1,8 @@
+{
+ "test": {
+ "difficulty": "Medium"
+ },
+ "win_no_nukes": {
+ "difficulty": "Hard"
+ }
+}
diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts
index e723be4c36..46fc8d6d6c 100644
--- a/src/client/AccountModal.ts
+++ b/src/client/AccountModal.ts
@@ -1,6 +1,7 @@
import { html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
+ AchievementsResponse,
PlayerGame,
PlayerStatsTree,
UserMeResponse,
@@ -11,6 +12,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";
@@ -28,6 +30,7 @@ export class AccountModal extends BaseModal {
private userMeResponse: UserMeResponse | null = null;
private statsTree: PlayerStatsTree | null = null;
private recentGames: PlayerGame[] = [];
+ private achievementGroups: AchievementsResponse = [];
constructor() {
super();
@@ -39,15 +42,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
@@ -132,6 +159,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();
@@ -174,6 +205,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);
}
@@ -406,6 +447,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..5663dc54f8
--- /dev/null
+++ b/src/client/components/baseComponents/stats/PlayerAchievements.ts
@@ -0,0 +1,252 @@
+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 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
+>;
+const MOCK_UNLOCKED_TEST_ACHIEVEMENT = {
+ playerId: "0",
+ achievement: "test",
+ achievedAt: "2025-01-01T00:00:00.000Z",
+ gameId: "0",
+ game: "ui-test",
+} satisfies PlayerAchievementJson;
+
+@customElement("player-achievements")
+export class PlayerAchievements extends LitElement {
+ createRenderRoot() {
+ return this;
+ }
+
+ @property({ attribute: false }) achievementGroups: AchievementsResponse = [];
+
+ private get unlockedAchievements(): PlayerAchievementJson[] {
+ const unlockedAchievements = this.achievementGroups
+ .flatMap((group) => (group.type === "player" ? group.data : []))
+ .slice();
+
+ if (
+ !unlockedAchievements.some(
+ (achievement) =>
+ achievement.achievement ===
+ MOCK_UNLOCKED_TEST_ACHIEVEMENT.achievement,
+ )
+ ) {
+ unlockedAchievements.push(MOCK_UNLOCKED_TEST_ACHIEVEMENT);
+ }
+
+ return unlockedAchievements.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 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-white/10 bg-gradient-to-br from-slate-900/70 via-slate-900/40 to-black/20"
+ : "border-white/6 bg-gradient-to-br from-slate-900/40 via-slate-900/20 to-black/10 opacity-80";
+
+ return html`
+
+
+
+
+ ${translateText("account_modal.achievement_label")}
+
+
+ ${this.resolveTitle(achievement.achievement)}
+
+ ${description
+ ? html`
+
+ ${description}
+
+ `
+ : null}
+
+ ${this.renderDifficultyBadge(difficulty)}
+
+
+
+
+ ${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 419152551c..c11e39e0f6 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -60,6 +60,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(),
@@ -69,9 +97,7 @@ export const UserMeResponseSchema = z.object({
publicId: z.string(),
roles: z.string().array().optional(),
flares: z.string().array().optional(),
- achievements: z.object({
- singleplayerMap: z.array(SingleplayerMapAchievementSchema),
- }),
+ achievements: UserMeAchievementsSchema,
leaderboard: z
.object({
oneVone: z
@@ -122,6 +148,7 @@ export const PlayerProfileSchema = z.object({
user: DiscordUserSchema.optional(),
games: PlayerGameSchema.array(),
stats: PlayerStatsTreeSchema,
+ achievements: AchievementsResponseSchema.optional(),
});
export type PlayerProfile = z.infer;