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` +
+
+ ${this.achievements.map((achievement) => + this.renderAchievementCard(achievement), + )} +
+
+ `; + } +} 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;