Skip to content

Commit a5c2d4f

Browse files
committed
test
1 parent 217a2c4 commit a5c2d4f

5 files changed

Lines changed: 286 additions & 8 deletions

File tree

resources/lang/en.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,13 @@
220220
"title": "Account",
221221
"connected_as": "Connected as",
222222
"stats_overview": "Stats Overview",
223+
"achievements": "Achievements",
224+
"achievement_label": "Achievement",
225+
"achieved_on": "Achieved on",
226+
"status": "Status",
227+
"no_achievements": "No player achievements unlocked yet.",
228+
"not_unlocked_yet": "Not unlocked yet",
229+
"unknown_difficulty": "Unknown",
223230
"link_discord": "Link Discord Account",
224231
"log_out": "Log Out",
225232
"sign_in_desc": "Sign in to save your stats and progress",
@@ -235,6 +242,10 @@
235242
"enter_email_address": "Please enter an email address",
236243
"personal_player_id": "Personal Player ID:"
237244
},
245+
"achivements": {
246+
"win_no_nukes": "Win Without Nukes",
247+
"win_no_nukes_desc": "Win a free-for-all match without launching any nukes."
248+
},
238249
"leaderboard_modal": {
239250
"title": "Leaderboard",
240251
"ranked_tab": "1v1 Ranked",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"win_no_nukes": {
3+
"difficulty": "Hard"
4+
}
5+
}

src/client/AccountModal.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { fetchPlayerById, getUserMe } from "./Api";
1111
import { discordLogin, logOut, sendMagicLink } from "./Auth";
1212
import "./components/baseComponents/stats/DiscordUserHeader";
1313
import "./components/baseComponents/stats/GameList";
14+
import "./components/baseComponents/stats/PlayerAchievements";
1415
import "./components/baseComponents/stats/PlayerStatsTable";
1516
import "./components/baseComponents/stats/PlayerStatsTree";
1617
import { BaseModal } from "./components/BaseModal";
@@ -132,6 +133,7 @@ export class AccountModal extends BaseModal {
132133
private renderAccountInfo() {
133134
const me = this.userMeResponse?.user;
134135
const isLinked = me?.discord ?? me?.email;
136+
const achievements = this.userMeResponse?.player?.achievements ?? [];
135137

136138
if (!isLinked) {
137139
return this.renderLoginOptions();
@@ -174,6 +176,15 @@ export class AccountModal extends BaseModal {
174176
</div>`
175177
: ""}
176178
179+
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
180+
<h3 class="text-lg font-bold text-white mb-4">
181+
${translateText("account_modal.achievements")}
182+
</h3>
183+
<player-achievements
184+
.achievementGroups=${achievements}
185+
></player-achievements>
186+
</div>
187+
177188
<!-- Bottom Row: Recent Games Section -->
178189
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
179190
<h3
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { LitElement, html } from "lit";
2+
import { customElement, property } from "lit/decorators.js";
3+
import playerAchievementMetadataJson from "../../../../../resources/playerAchievementMetadata.json" with { type: "json" };
4+
import type {
5+
AchievementsResponse,
6+
PlayerAchievementJson,
7+
} from "../../../../core/ApiSchemas";
8+
import type { Difficulty } from "../../../../core/game/Game";
9+
import { translateText } from "../../../Utils";
10+
11+
type PlayerAchievementMetadata = {
12+
difficulty: Difficulty;
13+
};
14+
15+
type PlayerAchievementCard = {
16+
achievement: string;
17+
achievedAt: string | null;
18+
isUnlocked: boolean;
19+
};
20+
21+
const playerAchievementMetadata = playerAchievementMetadataJson as Record<
22+
string,
23+
PlayerAchievementMetadata
24+
>;
25+
26+
@customElement("player-achievements")
27+
export class PlayerAchievements extends LitElement {
28+
createRenderRoot() {
29+
return this;
30+
}
31+
32+
@property({ attribute: false }) achievementGroups: AchievementsResponse = [];
33+
34+
private get unlockedAchievements(): PlayerAchievementJson[] {
35+
return this.achievementGroups
36+
.flatMap((group) => (group.type === "player" ? group.data : []))
37+
.slice()
38+
.sort(
39+
(a, b) =>
40+
new Date(b.achievedAt).getTime() - new Date(a.achievedAt).getTime(),
41+
);
42+
}
43+
44+
private get achievements(): PlayerAchievementCard[] {
45+
const unlockedByKey = new Map(
46+
this.unlockedAchievements.map((achievement) => [
47+
achievement.achievement,
48+
achievement,
49+
]),
50+
);
51+
const knownKeys = Object.keys(playerAchievementMetadata);
52+
const achievementKeys = [
53+
...knownKeys,
54+
...this.unlockedAchievements
55+
.map((achievement) => achievement.achievement)
56+
.filter((achievement) => !knownKeys.includes(achievement)),
57+
];
58+
const originalOrder = new Map(
59+
achievementKeys.map((achievement, index) => [achievement, index]),
60+
);
61+
62+
return achievementKeys
63+
.map((achievement) => {
64+
const unlockedAchievement = unlockedByKey.get(achievement);
65+
return {
66+
achievement,
67+
achievedAt: unlockedAchievement?.achievedAt ?? null,
68+
isUnlocked: unlockedAchievement !== undefined,
69+
};
70+
})
71+
.sort((a, b) => {
72+
if (a.isUnlocked !== b.isUnlocked) {
73+
return Number(b.isUnlocked) - Number(a.isUnlocked);
74+
}
75+
if (a.achievedAt && b.achievedAt) {
76+
return (
77+
new Date(b.achievedAt).getTime() - new Date(a.achievedAt).getTime()
78+
);
79+
}
80+
return (
81+
(originalOrder.get(a.achievement) ?? 0) -
82+
(originalOrder.get(b.achievement) ?? 0)
83+
);
84+
});
85+
}
86+
87+
private formatDate(achievedAt: string): string {
88+
const date = new Date(achievedAt);
89+
if (Number.isNaN(date.getTime())) {
90+
return achievedAt;
91+
}
92+
return new Intl.DateTimeFormat(undefined, {
93+
dateStyle: "medium",
94+
}).format(date);
95+
}
96+
97+
private resolveTitle(achievementKey: string): string {
98+
const translationKey = `achivements.${achievementKey}`;
99+
const translated = translateText(translationKey);
100+
return translated === translationKey ? achievementKey : translated;
101+
}
102+
103+
private resolveDescription(achievementKey: string): string | null {
104+
const translationKey = `achivements.${achievementKey}_desc`;
105+
const translated = translateText(translationKey);
106+
return translated === translationKey ? null : translated;
107+
}
108+
109+
private resolveDifficulty(achievementKey: string): Difficulty | null {
110+
return playerAchievementMetadata[achievementKey]?.difficulty ?? null;
111+
}
112+
113+
private difficultyClasses(difficulty: Difficulty): string {
114+
switch (difficulty) {
115+
case "Easy":
116+
return "bg-emerald-500/15 text-emerald-300 border-emerald-400/25";
117+
case "Medium":
118+
return "bg-amber-500/15 text-amber-200 border-amber-400/25";
119+
case "Hard":
120+
return "bg-rose-500/15 text-rose-200 border-rose-400/25";
121+
case "Impossible":
122+
return "bg-violet-500/15 text-violet-200 border-violet-400/25";
123+
default:
124+
return "bg-white/5 text-white/60 border-white/10";
125+
}
126+
}
127+
128+
private renderDifficultyBadge(difficulty: Difficulty | null) {
129+
if (!difficulty) {
130+
return html`
131+
<span
132+
class="inline-flex items-center rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-white/50"
133+
>
134+
${translateText("account_modal.unknown_difficulty")}
135+
</span>
136+
`;
137+
}
138+
139+
const translationKey = `difficulty.${difficulty.toLowerCase()}`;
140+
const translated = translateText(translationKey);
141+
const label = translated === translationKey ? difficulty : translated;
142+
143+
return html`
144+
<span
145+
class="inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wider ${this.difficultyClasses(
146+
difficulty,
147+
)}"
148+
>
149+
${label}
150+
</span>
151+
`;
152+
}
153+
154+
private renderAchievementCard(achievement: PlayerAchievementCard) {
155+
const difficulty = this.resolveDifficulty(achievement.achievement);
156+
const description = this.resolveDescription(achievement.achievement);
157+
const cardClasses = achievement.isUnlocked
158+
? "border-white/10 bg-gradient-to-br from-slate-900/70 via-slate-900/40 to-black/20"
159+
: "border-white/6 bg-gradient-to-br from-slate-900/40 via-slate-900/20 to-black/10 opacity-80";
160+
161+
return html`
162+
<article
163+
class="rounded-2xl border p-5 shadow-lg shadow-black/20 ${cardClasses}"
164+
>
165+
<div class="flex items-start justify-between gap-4">
166+
<div class="min-w-0">
167+
<div
168+
class="text-[11px] font-bold uppercase tracking-[0.24em] text-white/35"
169+
>
170+
${translateText("account_modal.achievement_label")}
171+
</div>
172+
<h4 class="mt-2 text-lg font-semibold text-white">
173+
${this.resolveTitle(achievement.achievement)}
174+
</h4>
175+
${description
176+
? html`
177+
<p class="mt-2 text-sm leading-6 text-white/60">
178+
${description}
179+
</p>
180+
`
181+
: null}
182+
</div>
183+
${this.renderDifficultyBadge(difficulty)}
184+
</div>
185+
186+
<div class="mt-5 rounded-xl border border-white/10 bg-black/20 p-4">
187+
<div
188+
class="text-[11px] font-bold uppercase tracking-[0.24em] text-white/35"
189+
>
190+
${achievement.isUnlocked
191+
? translateText("account_modal.achieved_on")
192+
: translateText("account_modal.status")}
193+
</div>
194+
${achievement.isUnlocked && achievement.achievedAt
195+
? html`
196+
<time
197+
class="mt-2 block text-sm font-medium text-white/80"
198+
datetime=${achievement.achievedAt}
199+
>
200+
${this.formatDate(achievement.achievedAt)}
201+
</time>
202+
`
203+
: html`
204+
<div class="mt-2 text-sm font-medium text-white/50">
205+
${translateText("account_modal.not_unlocked_yet")}
206+
</div>
207+
`}
208+
</div>
209+
</article>
210+
`;
211+
}
212+
213+
render() {
214+
if (this.achievements.length === 0) {
215+
return html`
216+
<div
217+
class="rounded-2xl border border-dashed border-white/10 bg-black/10 px-5 py-6 text-sm text-white/45"
218+
>
219+
${translateText("account_modal.no_achievements")}
220+
</div>
221+
`;
222+
}
223+
224+
return html`
225+
<div class="max-h-[36rem] overflow-y-auto pr-1 custom-scrollbar">
226+
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
227+
${this.achievements.map((achievement) =>
228+
this.renderAchievementCard(achievement),
229+
)}
230+
</div>
231+
</div>
232+
`;
233+
}
234+
}

src/core/ApiSchemas.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,29 @@ const SingleplayerMapAchievementSchema = z.object({
6060
difficulty: z.enum(Difficulty),
6161
});
6262

63+
export const PlayerAchievementSchema = z.object({
64+
playerId: z.string(),
65+
achievement: z.string(),
66+
achievedAt: z.iso.datetime(),
67+
gameId: z.string(),
68+
game: z.string(),
69+
});
70+
export type PlayerAchievementJson = z.infer<typeof PlayerAchievementSchema>;
71+
72+
export const AchievementsResponseSchema = z.array(
73+
z.discriminatedUnion("type", [
74+
z.object({
75+
type: z.literal("singleplayer-map"),
76+
data: z.array(SingleplayerMapAchievementSchema),
77+
}),
78+
z.object({
79+
type: z.literal("player"),
80+
data: z.array(PlayerAchievementSchema),
81+
}),
82+
]),
83+
);
84+
export type AchievementsResponse = z.infer<typeof AchievementsResponseSchema>;
85+
6386
export const UserMeResponseSchema = z.object({
6487
user: z.object({
6588
discord: DiscordUserSchema.optional(),
@@ -69,14 +92,7 @@ export const UserMeResponseSchema = z.object({
6992
publicId: z.string(),
7093
roles: z.string().array().optional(),
7194
flares: z.string().array().optional(),
72-
achievements: z
73-
.array(
74-
z.object({
75-
type: z.literal("singleplayer-map"), // TODO: change the shape to be more flexible when we have more achievements
76-
data: z.array(SingleplayerMapAchievementSchema),
77-
}),
78-
)
79-
.optional(),
95+
achievements: AchievementsResponseSchema.optional(),
8096
leaderboard: z
8197
.object({
8298
oneVone: z
@@ -127,6 +143,7 @@ export const PlayerProfileSchema = z.object({
127143
user: DiscordUserSchema.optional(),
128144
games: PlayerGameSchema.array(),
129145
stats: PlayerStatsTreeSchema,
146+
achievements: AchievementsResponseSchema.optional(),
130147
});
131148
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
132149

0 commit comments

Comments
 (0)