Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 11 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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."
},
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

Typo in translation key: achivements should be achievements.

The key is misspelled (missing 'e'). While the code in PlayerAchievements.ts uses the same misspelling so translations will work, fixing this now avoids confusion and prevents the typo from spreading further.

✏️ Suggested fix
-  "achivements": {
+  "achievements": {
     "win_no_nukes": "Win Without Nukes",
     "win_no_nukes_desc": "Win a free-for-all match without launching any nukes."
   },

Also update PlayerAchievements.ts lines 98 and 104 to use achievements.${achievementKey} instead of achivements.${achievementKey}.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"achivements": {
"win_no_nukes": "Win Without Nukes",
"win_no_nukes_desc": "Win a free-for-all match without launching any nukes."
},
"achievements": {
"win_no_nukes": "Win Without Nukes",
"win_no_nukes_desc": "Win a free-for-all match without launching any nukes."
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/lang/en.json` around lines 245 - 248, Rename the misspelled
translation object "achivements" to "achievements" in en.json and update all
lookup sites to use the corrected key; specifically change the en.json top-level
key from "achivements" to "achievements" and update the translation lookups in
PlayerAchievements.ts (the lines that build the translation key using
achievementKey) from "achivements.${achievementKey}" to
"achievements.${achievementKey}" so the i18n lookup uses the corrected path.

"leaderboard_modal": {
"title": "Leaderboard",
"ranked_tab": "1v1 Ranked",
Expand Down
5 changes: 5 additions & 0 deletions resources/playerAchievementMetadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"win_no_nukes": {
"difficulty": "Hard"
}
}
11 changes: 11 additions & 0 deletions src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -174,6 +176,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
234 changes: 234 additions & 0 deletions src/client/components/baseComponents/stats/PlayerAchievements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
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
>;

@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;
}
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>
`;
}
}
33 changes: 25 additions & 8 deletions src/core/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof PlayerAchievementSchema>;

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<typeof AchievementsResponseSchema>;

export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
Expand All @@ -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
Expand Down Expand Up @@ -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<typeof PlayerProfileSchema>;

Expand Down
Loading