Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions resources/playerAchievementMetadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"test": {
"difficulty": "Medium"
},
"win_no_nukes": {
"difficulty": "Hard"
}
}
44 changes: 44 additions & 0 deletions src/client/AccountModal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
AchievementsResponse,
PlayerGame,
PlayerStatsTree,
UserMeResponse,
Expand All @@ -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";
Expand All @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -174,6 +205,15 @@ export class AccountModal extends BaseModal {
</div>`
: ""}

<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3 class="text-lg font-bold text-white mb-4">
${translateText("account_modal.achievements")}
</h3>
<player-achievements
.achievementGroups=${achievements}
></player-achievements>
</div>

<!-- Bottom Row: Recent Games Section -->
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3
Expand Down Expand Up @@ -368,6 +408,7 @@ export class AccountModal extends BaseModal {
.then((userMe) => {
if (userMe) {
this.userMeResponse = userMe;
this.achievementGroups = this.getUserMeAchievementGroups(userMe);
if (this.userMeResponse?.player?.publicId) {
this.loadPlayerProfile(this.userMeResponse.player.publicId);
}
Expand Down Expand Up @@ -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) {
Expand Down
252 changes: 252 additions & 0 deletions src/client/components/baseComponents/stats/PlayerAchievements.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +115 to +125
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the typo: achivements should be achievements.

This mirrors the typo in en.json. Both should be fixed together.

✏️ Suggested fix
   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;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/components/baseComponents/stats/PlayerAchievements.ts` around
lines 97 - 107, In resolveTitle and resolveDescription change the translation
key prefix from "achivements." to the correct "achievements." (i.e., update the
template strings in resolveTitle(achievementKey) and
resolveDescription(achievementKey)); also update the corresponding keys in the
en.json translations to use "achievements" instead of "achivements" so the
translateText lookups resolve correctly.


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`
<span
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"
>
${translateText("account_modal.unknown_difficulty")}
</span>
`;
}

const translationKey = `difficulty.${difficulty.toLowerCase()}`;
const translated = translateText(translationKey);
const label = translated === translationKey ? difficulty : translated;

return html`
<span
class="inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wider ${this.difficultyClasses(
difficulty,
)}"
>
${label}
</span>
`;
}

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`
<article
class="rounded-2xl border p-5 shadow-lg shadow-black/20 ${cardClasses}"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div
class="text-[11px] font-bold uppercase tracking-[0.24em] text-white/35"
>
${translateText("account_modal.achievement_label")}
</div>
<h4 class="mt-2 text-lg font-semibold text-white">
${this.resolveTitle(achievement.achievement)}
</h4>
${description
? html`
<p class="mt-2 text-sm leading-6 text-white/60">
${description}
</p>
`
: null}
</div>
${this.renderDifficultyBadge(difficulty)}
</div>

<div class="mt-5 rounded-xl border border-white/10 bg-black/20 p-4">
<div
class="text-[11px] font-bold uppercase tracking-[0.24em] text-white/35"
>
${achievement.isUnlocked
? translateText("account_modal.achieved_on")
: translateText("account_modal.status")}
</div>
${achievement.isUnlocked && achievement.achievedAt
? html`
<time
class="mt-2 block text-sm font-medium text-white/80"
datetime=${achievement.achievedAt}
>
${this.formatDate(achievement.achievedAt)}
</time>
`
: html`
<div class="mt-2 text-sm font-medium text-white/50">
${translateText("account_modal.not_unlocked_yet")}
</div>
`}
</div>
</article>
`;
}

render() {
if (this.achievements.length === 0) {
return html`
<div
class="rounded-2xl border border-dashed border-white/10 bg-black/10 px-5 py-6 text-sm text-white/45"
>
${translateText("account_modal.no_achievements")}
</div>
`;
}

return html`
<div class="max-h-[36rem] overflow-y-auto pr-1 custom-scrollbar">
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
${this.achievements.map((achievement) =>
this.renderAchievementCard(achievement),
)}
</div>
</div>
`;
}
}
Loading
Loading