diff --git a/resources/lang/en.json b/resources/lang/en.json
index 3ee0af481..82a04a7c5 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -220,6 +220,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",
@@ -235,6 +242,10 @@
"enter_email_address": "Please enter an email address",
"personal_player_id": "Personal Player ID:"
},
+ "achivements": {
+ "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 000000000..30c275e3d
--- /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 e723be4c3..38d6fa349 100644
--- a/src/client/AccountModal.ts
+++ b/src/client/AccountModal.ts
@@ -11,6 +11,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";
@@ -132,6 +133,7 @@ export class AccountModal extends BaseModal {
private renderAccountInfo() {
const me = this.userMeResponse?.user;
const isLinked = me?.discord ?? me?.email;
+ const achievements = this.userMeResponse?.player?.achievements ?? [];
if (!isLinked) {
return this.renderLoginOptions();
@@ -174,6 +176,15 @@ export class AccountModal extends BaseModal {
`
: ""}
+
+
+ ${translateText("account_modal.achievements")}
+
+
+
+
;
+
+@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 = `achivements.${achievementKey}`;
+ const translated = translateText(translationKey);
+ return translated === translationKey ? achievementKey : translated;
+ }
+
+ private resolveDescription(achievementKey: string): string | null {
+ const translationKey = `achivements.${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 1ca8d55c1..20f3ac4e4 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -60,6 +60,29 @@ 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;
+
export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
@@ -69,14 +92,7 @@ export const UserMeResponseSchema = z.object({
publicId: z.string(),
roles: z.string().array().optional(),
flares: z.string().array().optional(),
- achievements: z
- .array(
- z.object({
- type: z.literal("singleplayer-map"), // TODO: change the shape to be more flexible when we have more achievements
- data: z.array(SingleplayerMapAchievementSchema),
- }),
- )
- .optional(),
+ achievements: AchievementsResponseSchema.optional(),
leaderboard: z
.object({
oneVone: z
@@ -127,6 +143,7 @@ export const PlayerProfileSchema = z.object({
user: DiscordUserSchema.optional(),
games: PlayerGameSchema.array(),
stats: PlayerStatsTreeSchema,
+ achievements: AchievementsResponseSchema.optional(),
});
export type PlayerProfile = z.infer;