feat: Achievement medal overview (#4487)

## Description:

In the spirit of achievement hunting, seeing how many medals a player
has achieved helps show overall progress. Overview only toggles on when
user clicks toggle achievements button. Works on mobile too.


https://github.com/user-attachments/assets/ea77075b-5e91-4e62-8ac9-52bcdf95cc64

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

bijx
This commit is contained in:
bijx
2026-07-02 21:31:19 -04:00
committed by GitHub
parent 64a6111fd4
commit ad760a0f3d
4 changed files with 184 additions and 42 deletions
+2
View File
@@ -1253,6 +1253,8 @@
"max_timer": "Game length (minutes)",
"max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)",
"max_timer_placeholder": "Mins",
"medals_earned": "Medals",
"medals_of_maps": "out of {total} maps",
"nations": "Nations: ",
"nations_disabled": "Disabled",
"options_changed_no_achievements": "Custom settings achievements disabled",
+138 -16
View File
@@ -1,4 +1,4 @@
import { TemplateResult, html } from "lit";
import { html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { UserMeResponse } from "../core/ApiSchemas";
@@ -9,6 +9,7 @@ import {
GameMapType,
GameMode,
GameType,
maps,
UnitType,
} from "../core/game/Game";
import { TeamCountConfig } from "../core/Schemas";
@@ -18,6 +19,7 @@ import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import "./components/GameConfigSettings";
import { MEDAL_ORDER, medalIcon } from "./components/map/Medals";
import "./components/ToggleInputCard";
import { modalHeader } from "./components/ui/ModalHeader";
import { getPlayerCosmetics } from "./Cosmetics";
@@ -61,6 +63,48 @@ const DEFAULT_OPTIONS = {
waterNukes: false,
} as const;
// A map earns achievements only if it has nations to conquer — the same rule
// MapDisplay uses to decide whether to draw medals. Maps without nations (e.g.
// Baikal Nuke Wars) must be excluded from the medal totals. The complete set is
// cached for the page session and concurrent callers share the in-flight
// promise so we never fetch the manifests twice. A load that hits any fetch
// error resolves to null (not a partial set) and clears the shared promise, so
// a transient failure retries on the next call rather than locking in an
// undercount for the whole session.
let eligibleMapsCache: Set<GameMapType> | null = null;
let eligibleMapsPromise: Promise<Set<GameMapType> | null> | null = null;
async function loadAchievementEligibleMaps(): Promise<Set<GameMapType> | null> {
if (eligibleMapsCache) return eligibleMapsCache;
eligibleMapsPromise ??= (async () => {
const eligible = new Set<GameMapType>();
let hadFailure = false;
await Promise.all(
maps.map(async (m) => {
try {
const manifest = await terrainMapFileLoader
.getMapData(m.type)
.manifest();
if (manifest.nations.length > 0) {
eligible.add(m.type);
}
} catch {
// A missing manifest would undercount the total; remember the failure
// so we don't cache this incomplete set below.
hadFailure = true;
}
}),
);
if (hadFailure) {
eligibleMapsPromise = null; // allow a later call to retry
return null;
}
eligibleMapsCache = eligible;
return eligible;
})();
return eligibleMapsPromise;
}
@customElement("single-player-modal")
export class SinglePlayerModal extends BaseModal {
protected routerName = "single-player";
@@ -84,6 +128,9 @@ export class SinglePlayerModal extends BaseModal {
@state() private teamCount: TeamCountConfig = DEFAULT_OPTIONS.teamCount;
@state() private showAchievements: boolean = false;
@state() private mapWins: Map<GameMapType, Set<Difficulty>> = new Map();
// Maps that support achievements (have nations). null until loaded — the
// medal overview shows a placeholder total meanwhile.
@state() private eligibleMaps: Set<GameMapType> | null = null;
@state() private userMeResponse: UserMeResponse | false = false;
@state() private goldMultiplier: boolean = DEFAULT_OPTIONS.goldMultiplier;
@state() private goldMultiplierValue: number | undefined =
@@ -119,8 +166,35 @@ export class SinglePlayerModal extends BaseModal {
private toggleAchievements = () => {
this.showAchievements = !this.showAchievements;
if (this.showAchievements) void this.ensureEligibleMaps();
};
private async ensureEligibleMaps() {
if (this.eligibleMaps) return;
const eligible = await loadAchievementEligibleMaps();
// Leave eligibleMaps null on a failed/incomplete load so the overview keeps
// its placeholder total and the next toggle retries.
if (eligible) this.eligibleMaps = eligible;
}
// Medals earned per difficulty, counted only on achievement-eligible maps.
private medalCounts(): Record<Difficulty, number> {
const counts: Record<Difficulty, number> = {
[Difficulty.Easy]: 0,
[Difficulty.Medium]: 0,
[Difficulty.Hard]: 0,
[Difficulty.Impossible]: 0,
};
// Until eligibility is loaded, count nothing — otherwise the overview would
// briefly include wins on non-eligible maps before the manifests resolve.
if (!this.eligibleMaps) return counts;
for (const [map, difficulties] of this.mapWins) {
if (!this.eligibleMaps.has(map)) continue;
for (const difficulty of difficulties) counts[difficulty]++;
}
return counts;
}
private handleUserMeResponse = (
event: CustomEvent<UserMeResponse | false>,
) => {
@@ -178,26 +252,74 @@ export class SinglePlayerModal extends BaseModal {
ariaLabel: translateText("common.back"),
rightContent: hasLinkedAccount(this.userMeResponse)
? html`<button
@click=${this.toggleAchievements}
class="flex items-center gap-2 px-3 py-2 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-all shrink-0 ${this
.showAchievements
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-400"
: "text-white/60"}"
>
<img
src=${assetUrl("images/MedalIconWhite.svg")}
class="w-4 h-4 opacity-80 shrink-0"
style="${this.showAchievements ? "" : "filter: grayscale(1);"}"
/>
<span
class="text-xs font-bold uppercase tracking-wider whitespace-nowrap"
>${translateText("single_modal.toggle_achievements")}</span
@click=${this.toggleAchievements}
class="flex items-center gap-2 px-3 py-2 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-all shrink-0 ${this
.showAchievements
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-400"
: "text-white/60"}"
>
</button>`
<img
src=${assetUrl("images/MedalIconWhite.svg")}
class="w-4 h-4 opacity-80 shrink-0"
style="${this.showAchievements ? "" : "filter: grayscale(1);"}"
/>
<span
class="text-xs font-bold uppercase tracking-wider whitespace-nowrap"
>${translateText("single_modal.toggle_achievements")}</span
>
</button>
${this.showAchievements ? this.renderMedalOverview() : null}`
: this.renderNotLoggedInBanner(),
});
}
// Compact summary that expands under the header while achievements are on:
// each colored medal with how many maps you've earned it on, plus the shared
// "out of N maps" total (N = achievement-eligible maps).
private renderMedalOverview(): TemplateResult {
const counts = this.medalCounts();
const total = this.eligibleMaps?.size ?? null;
return html`<div class="basis-full w-full">
<div
class="flex flex-wrap items-center gap-x-5 gap-y-2 px-4 py-2.5 rounded-xl border border-yellow-500/20 bg-yellow-500/5"
>
<span
class="text-[11px] font-bold uppercase tracking-wider text-yellow-400/80 shrink-0"
>
${translateText("single_modal.medals_earned")}
</span>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5">
${MEDAL_ORDER.map((difficulty) =>
this.renderMedalStat(difficulty, counts[difficulty]),
)}
</div>
<span
class="ml-auto text-[11px] font-semibold uppercase tracking-wider text-white/40 shrink-0"
>
${translateText("single_modal.medals_of_maps", {
total: total ?? "…",
})}
</span>
</div>
</div>`;
}
private renderMedalStat(
difficulty: Difficulty,
count: number,
): TemplateResult {
return html`<div
class="flex items-center gap-1.5"
title=${translateText(`difficulty.${difficulty.toLowerCase()}`)}
>
${medalIcon(difficulty, "w-4 h-4")}
<span class="text-xs font-medium text-white/50 hidden sm:inline"
>${translateText(`difficulty.${difficulty.toLowerCase()}`)}</span
>
<span class="text-sm font-bold text-white tabular-nums">${count}</span>
</div>`;
}
protected renderBody() {
const inputCards = [
html`<toggle-input-card
+4 -26
View File
@@ -1,12 +1,10 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import medalIconRaw from "../../../../resources/images/MedalIconWhite.svg?raw";
import { Difficulty, GameMapType } from "../../../core/game/Game";
import { terrainMapFileLoader } from "../../TerrainMapFileLoader";
import { translateText } from "../../Utils";
import { starIcon } from "./MapFavorites";
const medalMaskUrl = `url('data:image/svg+xml;utf8,${encodeURIComponent(medalIconRaw)}') no-repeat center / contain`;
import { MEDAL_ORDER, medalIcon } from "./Medals";
@customElement("map-display")
export class MapDisplay extends LitElement {
@@ -177,30 +175,10 @@ export class MapDisplay extends LitElement {
}
private renderMedals() {
const medalOrder: Difficulty[] = [
Difficulty.Easy,
Difficulty.Medium,
Difficulty.Hard,
Difficulty.Impossible,
];
const colors: Record<Difficulty, string> = {
[Difficulty.Easy]: "var(--medal-easy)",
[Difficulty.Medium]: "var(--medal-medium)",
[Difficulty.Hard]: "var(--medal-hard)",
[Difficulty.Impossible]: "var(--medal-impossible)",
};
const wins = this.readWins();
return medalOrder.map((medal) => {
const earned = wins.has(medal);
const mask = medalMaskUrl;
return html`<div
class="w-5 h-5 ${earned ? "opacity-100" : "opacity-25"}"
style="background-color:${colors[
medal
]}; mask: ${mask}; -webkit-mask: ${mask};"
title=${translateText(`difficulty.${medal.toLowerCase()}`)}
></div>`;
});
return MEDAL_ORDER.map((medal) =>
medalIcon(medal, "w-5 h-5", wins.has(medal)),
);
}
private readWins(): Set<Difficulty> {
+40
View File
@@ -0,0 +1,40 @@
import { html, TemplateResult } from "lit";
import medalIconRaw from "../../../../resources/images/MedalIconWhite.svg?raw";
import { Difficulty } from "../../../core/game/Game";
import { translateText } from "../../Utils";
// CSS mask that renders the medal glyph; tint it via `background-color`.
export const MEDAL_MASK = `url('data:image/svg+xml;utf8,${encodeURIComponent(medalIconRaw)}') no-repeat center / contain`;
// Difficulty medals, easiest to hardest — the canonical display order.
export const MEDAL_ORDER: readonly Difficulty[] = [
Difficulty.Easy,
Difficulty.Medium,
Difficulty.Hard,
Difficulty.Impossible,
];
export const MEDAL_COLORS: Record<Difficulty, string> = {
[Difficulty.Easy]: "var(--medal-easy)",
[Difficulty.Medium]: "var(--medal-medium)",
[Difficulty.Hard]: "var(--medal-hard)",
[Difficulty.Impossible]: "var(--medal-impossible)",
};
/**
* A single colored medal glyph. Pass `earned=false` to dim it so it reads as
* "not yet won" (used on map cards); the overview keeps them full-color.
*/
export function medalIcon(
difficulty: Difficulty,
sizeClass = "w-5 h-5",
earned = true,
): TemplateResult {
return html`<div
class="${sizeClass} ${earned ? "opacity-100" : "opacity-25"}"
style="background-color:${MEDAL_COLORS[
difficulty
]}; mask:${MEDAL_MASK}; -webkit-mask:${MEDAL_MASK};"
title=${translateText(`difficulty.${difficulty.toLowerCase()}`)}
></div>`;
}