Skip to content
Open
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."
},
Comment thread
Aotumuri marked this conversation as resolved.
Outdated
"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 thread
coderabbitai[bot] marked this conversation as resolved.

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(),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why gameid? shouldn't game ids be private? just the game nmame

});
export type PlayerAchievementJson = z.infer<typeof PlayerAchievementSchema>;

export const AchievementsResponseSchema = z.array(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just call this: AchievementsSchema

z.discriminatedUnion("type", [
z.object({
type: z.literal("singleplayer-map"),
data: z.array(SingleplayerMapAchievementSchema),
}),
z.object({
type: z.literal("player"),
data: z.array(PlayerAchievementSchema),
}),
]),
);
Comment on lines +81 to +92
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i think it should look like:

{
"singleplayer-map": [...],
"player": [...]
}

cleaner than a discriminatedunion

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