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