From a5c2d4f52fb4e90a98e900afed33aec7b2d522d8 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Sun, 29 Mar 2026 09:58:14 +0900 Subject: [PATCH 1/3] test --- resources/lang/en.json | 11 + resources/playerAchievementMetadata.json | 5 + src/client/AccountModal.ts | 11 + .../stats/PlayerAchievements.ts | 234 ++++++++++++++++++ src/core/ApiSchemas.ts | 33 ++- 5 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 resources/playerAchievementMetadata.json create mode 100644 src/client/components/baseComponents/stats/PlayerAchievements.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 3ee0af4814..82a04a7c5b 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 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 e723be4c36..38d6fa3499 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` +
+
+ ${this.achievements.map((achievement) => + this.renderAchievementCard(achievement), + )} +
+
+ `; + } +} diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 1ca8d55c16..20f3ac4e45 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; From 3c4d1f9c23285ece262320d87a145eae74772d4e Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Sun, 29 Mar 2026 17:18:27 +0900 Subject: [PATCH 2/3] fix --- resources/lang/en.json | 2 +- .../components/baseComponents/stats/PlayerAchievements.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 82a04a7c5b..4378ff4ab7 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -242,7 +242,7 @@ "enter_email_address": "Please enter an email address", "personal_player_id": "Personal Player ID:" }, - "achivements": { + "achievements": { "win_no_nukes": "Win Without Nukes", "win_no_nukes_desc": "Win a free-for-all match without launching any nukes." }, diff --git a/src/client/components/baseComponents/stats/PlayerAchievements.ts b/src/client/components/baseComponents/stats/PlayerAchievements.ts index 677d20620b..c7710c3aea 100644 --- a/src/client/components/baseComponents/stats/PlayerAchievements.ts +++ b/src/client/components/baseComponents/stats/PlayerAchievements.ts @@ -95,13 +95,13 @@ export class PlayerAchievements extends LitElement { } private resolveTitle(achievementKey: string): string { - const translationKey = `achivements.${achievementKey}`; + const translationKey = `achievements.${achievementKey}`; const translated = translateText(translationKey); return translated === translationKey ? achievementKey : translated; } private resolveDescription(achievementKey: string): string | null { - const translationKey = `achivements.${achievementKey}_desc`; + const translationKey = `achievements.${achievementKey}_desc`; const translated = translateText(translationKey); return translated === translationKey ? null : translated; } From a65de0815a1dec4c86eb57ab110694827594ddac Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Fri, 3 Apr 2026 15:26:03 +0900 Subject: [PATCH 3/3] test --- resources/lang/en.json | 2 ++ resources/playerAchievementMetadata.json | 3 ++ .../stats/PlayerAchievements.ts | 30 +++++++++++++++---- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 6433ace693..cb08806820 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -244,6 +244,8 @@ "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." }, diff --git a/resources/playerAchievementMetadata.json b/resources/playerAchievementMetadata.json index 30c275e3d7..38da16ae8b 100644 --- a/resources/playerAchievementMetadata.json +++ b/resources/playerAchievementMetadata.json @@ -1,4 +1,7 @@ { + "test": { + "difficulty": "Medium" + }, "win_no_nukes": { "difficulty": "Hard" } diff --git a/src/client/components/baseComponents/stats/PlayerAchievements.ts b/src/client/components/baseComponents/stats/PlayerAchievements.ts index c7710c3aea..5663dc54f8 100644 --- a/src/client/components/baseComponents/stats/PlayerAchievements.ts +++ b/src/client/components/baseComponents/stats/PlayerAchievements.ts @@ -22,6 +22,13 @@ 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 { @@ -32,13 +39,24 @@ export class PlayerAchievements extends LitElement { @property({ attribute: false }) achievementGroups: AchievementsResponse = []; private get unlockedAchievements(): PlayerAchievementJson[] { - return this.achievementGroups + const unlockedAchievements = this.achievementGroups .flatMap((group) => (group.type === "player" ? group.data : [])) - .slice() - .sort( - (a, b) => - new Date(b.achievedAt).getTime() - new Date(a.achievedAt).getTime(), - ); + .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[] {