stats modal: change to the UI (#4629)

## Description:

update to the stats modal to make it look better...

now:


## 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:

w.o.n
This commit is contained in:
Ryan
2026-07-17 21:05:05 +00:00
committed by GitHub
parent c4447cd434
commit c269354c74
13 changed files with 1133 additions and 449 deletions
+2 -3
View File
@@ -546,10 +546,8 @@
"game_info_modal": {
"all_gold": "All gold",
"atoms": "Atoms",
"bombs": "Bombs",
"conquered": "Conquered",
"conquest_gold": "Conquered player gold",
"conquests": "Conquests",
"duration": "Duration",
"economy": "Economy",
"hydros": "Hydros",
@@ -560,14 +558,15 @@
"no_winner": "This game ended with no winner (or a Nation won)",
"num_of_conquests_bots": "Tribe kills",
"num_of_conquests_humans": "Player kills",
"num_of_conquests_nations": "Nation kills",
"pirate": "Pirate",
"players": "Players",
"retry": "Retry",
"stolen_gold": "Stolen with warships",
"survival_time": "Survival time",
"total_gold": "Total",
"trade": "Trade",
"train_trade": "Train",
"unknown_game_type": "Unknown game type",
"war": "War"
},
"game_list": {
+18 -4
View File
@@ -2,6 +2,7 @@ import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./components/baseComponents/stats/GameInfoView";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
@@ -12,20 +13,33 @@ export class GameStatsModal extends BaseModal {
@state() private gameId: string | null = null;
private openedFrom: "account" | "clan" | null = null;
protected modalConfig() {
return { maxWidth: "960px" };
}
protected renderHeaderSlot() {
return modalHeader({
title: translateText("game_list.stats"),
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
rightContent: this.gameId
? html`
<copy-button
compact
class="shrink-0"
.copyText=${this.gameId}
.displayText=${this.gameId}
.showVisibilityToggle=${false}
></copy-button>
`
: undefined,
});
}
protected renderBody() {
return html`
<div class="custom-scrollbar mr-1">
<div class="p-6">
<game-info-view .gameId=${this.gameId}></game-info-view>
</div>
<div class="px-3 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-7">
<game-info-view .gameId=${this.gameId}></game-info-view>
</div>
`;
}
-1
View File
@@ -236,7 +236,6 @@ export class LangSelector extends LitElement {
"game-stats-modal",
"game-info-view",
"ranking-controls",
"ranking-header",
"leaderboard-modal",
"flag-input-modal",
"flag-input",
@@ -12,6 +12,7 @@ import {
export enum RankType {
ConquestHumans = "ConquestHumans",
ConquestNations = "ConquestNations",
ConquestBots = "ConquestBots",
Atoms = "Atoms",
Hydros = "Hydros",
@@ -24,6 +25,21 @@ export enum RankType {
Lifetime = "Lifetime",
}
export const RANK_TYPE_LABEL_KEYS: Record<RankType, string> = {
[RankType.Lifetime]: "game_info_modal.survival_time",
[RankType.ConquestHumans]: "game_info_modal.num_of_conquests_humans",
[RankType.ConquestNations]: "game_info_modal.num_of_conquests_nations",
[RankType.ConquestBots]: "game_info_modal.num_of_conquests_bots",
[RankType.Atoms]: "game_info_modal.atoms",
[RankType.Hydros]: "game_info_modal.hydros",
[RankType.MIRV]: "game_info_modal.mirv",
[RankType.TotalGold]: "game_info_modal.all_gold",
[RankType.StolenGold]: "game_info_modal.stolen_gold",
[RankType.NavalTrade]: "game_info_modal.naval_trade",
[RankType.TrainTrade]: "game_info_modal.train_trade",
[RankType.ConqueredGold]: "game_info_modal.conquest_gold",
};
export interface PlayerInfo {
id: string;
username: string;
@@ -84,7 +100,10 @@ export class Ranking {
clanTag: player.clanTag,
conquests,
flag: player.cosmetics?.flag ?? undefined,
killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined,
killedAt:
stats.killedAt === undefined || stats.killedAt === null
? undefined
: Number(stats.killedAt),
gold,
atoms: Number(stats.bombs?.abomb?.[0]) || 0,
hydros: Number(stats.bombs?.hbomb?.[0]) || 0,
@@ -119,17 +138,16 @@ export class Ranking {
private getScore(player: PlayerInfo, type: RankType): number {
switch (type) {
case RankType.Lifetime:
if (player.killedAt) {
if (player.killedAt !== undefined) {
return (player.killedAt / Math.max(this.duration, 1)) * 10;
}
return 100;
case RankType.ConquestHumans:
return Number(player.conquests[PLAYER_INDEX_HUMAN] ?? 0n);
case RankType.ConquestNations:
return Number(player.conquests[PLAYER_INDEX_NATION] ?? 0n);
case RankType.ConquestBots:
return (
Number(player.conquests[PLAYER_INDEX_BOT] ?? 0n) +
Number(player.conquests[PLAYER_INDEX_NATION] ?? 0n)
);
return Number(player.conquests[PLAYER_INDEX_BOT] ?? 0n);
case RankType.Atoms:
return player.atoms;
case RankType.Hydros:
@@ -1,13 +1,15 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import {
GOLD_INDEX_TRADE,
GOLD_INDEX_TRAIN_OTHER,
GOLD_INDEX_TRAIN_SELF,
} from "src/core/StatsSchemas";
LitElement,
html,
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators.js";
import { assetUrl } from "../../../../core/AssetUrls";
import { renderNumber } from "../../../Utils";
import { PlayerInfo, RankType } from "./GameInfoRanking";
import { renderNumber, translateText } from "../../../Utils";
import { PlayerInfo, RANK_TYPE_LABEL_KEYS, RankType } from "./GameInfoRanking";
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
@customElement("player-row")
export class PlayerRow extends LitElement {
@@ -18,6 +20,8 @@ export class PlayerRow extends LitElement {
@property({ type: Number }) score = 0;
@property({ type: Boolean }) currentPlayer = false;
private failedFlag: string | null = null;
createRenderRoot() {
return this;
}
@@ -25,39 +29,125 @@ export class PlayerRow extends LitElement {
render() {
if (!this.player) return html``;
const { player } = this;
const visibleBorder = player.winner || this.currentPlayer;
return html`
<li
class="${player.winner ? "bg-black/20" : "bg-black/20"} border-b-1
${player.winner
? "border-yellow-500 border-1 box-content"
: visibleBorder
? "border-white/5"
: "border-transparent"}
relative pt-1 pb-1 pr-2 pl-2 sm:pl-5 sm:pr-5 flex justify-between items-center hover:bg-white/[0.07] transition-colors duration-150 ease-in-out"
data-player-row
class="group relative grid grid-cols-[2rem_minmax(0,1fr)] items-center gap-x-3 gap-y-2 px-3 py-3 transition-colors duration-150 hover:bg-white/[0.055] sm:grid-cols-[2.5rem_minmax(0,1fr)_minmax(13rem,0.9fr)] sm:px-5 sm:py-2.5 ${player.winner
? "bg-gradient-to-r from-yellow-400/[0.08] via-yellow-400/[0.025] to-transparent"
: this.currentPlayer
? "bg-malibu-blue/10"
: "bg-transparent"}"
>
<div
class="font-bold text-right w-7.5 text-lg text-white absolute -left-10"
>
${this.rank}
${player.winner
? html`<div
class="absolute inset-y-0 left-0 w-0.5 bg-yellow-400/70"
></div>`
: ""}
${this.renderRank()} ${this.renderIdentity()}
<div class="col-start-2 min-w-0 sm:col-start-auto">
${this.renderPlayerInfo()}
</div>
${this.renderPlayerInfo()}
</li>
`;
}
protected willUpdate(changed: PropertyValues<this>): void {
if (changed.has("player")) {
const previous = changed.get("player") as PlayerInfo | undefined;
if (previous?.flag !== this.player?.flag) this.failedFlag = null;
}
}
private renderRank(): TemplateResult {
const rankClass =
{
1: "border-yellow-400/20 bg-yellow-400/10 text-yellow-300",
2: "border-slate-300/15 bg-slate-300/10 text-slate-300",
3: "border-amber-600/20 bg-amber-600/10 text-amber-500",
}[this.rank] ?? "border-white/[0.06] bg-white/[0.035] text-white/35";
return html`
<div
class="flex size-8 items-center justify-center rounded-lg border font-mono text-xs font-bold tabular-nums sm:size-9 sm:text-sm ${rankClass}"
aria-label=${String(this.rank)}
>
${this.rank}
</div>
`;
}
private renderPlayerIcon() {
return html`
${this.renderIcon()} ${this.player.winner ? this.renderCrownIcon() : ""}
<div class="relative shrink-0">
${this.renderIcon()}
${this.player.winner
? this.renderCrownIcon()
: this.player.killedAt !== undefined
? this.renderEliminatedIcon()
: ""}
</div>
`;
}
private renderCrownIcon() {
return html`
<img
src=${assetUrl("images/CrownIcon.svg")}
class="absolute -top-0.75 left-4 size-3.75 sm:-top-1.75 sm:left-7.5 sm:size-5"
/>
<span
data-player-status="winner"
class="absolute -right-1.5 -top-1.5 flex size-5 items-center justify-center rounded-full border border-yellow-300/40 bg-yellow-400 text-yellow-950 shadow-lg"
role="img"
aria-label=${translateText("clan_modal.history_result_victory")}
>
<svg
viewBox="0 0 24 24"
fill="currentColor"
class="size-3"
aria-hidden="true"
>
<path
d="m4 7 4.2 3L12 5l3.8 5L20 7l-1.5 10h-13L4 7Zm2 12h12v2H6v-2Z"
/>
</svg>
</span>
`;
}
private renderEliminatedIcon(): TemplateResult {
return html`
<span
data-player-status="eliminated"
class="absolute -bottom-1 -right-1 leading-none drop-shadow-md"
role="img"
aria-label=${translateText("clan_modal.history_result_defeat")}
>
<span class="text-sm leading-none" aria-hidden="true">💀</span>
</span>
`;
}
private renderIdentity(): TemplateResult {
return html`
<div class="flex min-w-0 items-center gap-3">
${this.renderPlayerIcon()}
<div
data-player-identity
class="flex min-w-0 flex-1 items-center gap-2 text-left"
>
${this.player.clanTag
? html`<div
data-player-clan-tag
class="inline-flex min-w-0 max-w-[40%] shrink-0 rounded-md border border-malibu-blue/20 bg-malibu-blue/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-aquarius/85"
>
<span class="truncate">${this.player.clanTag}</span>
</div>`
: ""}
<div
data-player-name
class="min-w-0 flex-1 truncate text-sm font-semibold tracking-wide text-white/85 sm:text-[15px]"
title=${this.player.username}
>
${this.player.username}
</div>
</div>
</div>
`;
}
@@ -65,6 +155,7 @@ export class PlayerRow extends LitElement {
switch (this.rankType) {
case RankType.Lifetime:
case RankType.ConquestHumans:
case RankType.ConquestNations:
case RankType.ConquestBots:
return this.renderScoreAsBar();
case RankType.Atoms:
@@ -84,32 +175,42 @@ export class PlayerRow extends LitElement {
}
private renderScoreAsBar() {
const isLifetime = this.rankType === RankType.Lifetime;
const formattedScore = `${Number(this.score).toFixed(0)}${
isLifetime ? "%" : ""
}`;
return html`
<div class="flex gap-3 items-center w-full">
${this.renderPlayerIcon()}
<div class="flex flex-col sm:flex-row gap-1 text-left w-full">
${this.renderPlayerName()} ${this.renderScoreBar()}
</div>
</div>
<div>
<div class="flex w-full items-center gap-3">
${this.renderScoreBar(formattedScore)}
<div
class="font-bold rounded-[50%] size-7.5 leading-[1.6rem] border border-white/10 text-center bg-white/5 text-white/80"
data-player-score
aria-hidden="true"
class="flex min-h-10 shrink-0 items-center justify-end gap-2 px-3 py-2 font-mono text-sm font-bold tabular-nums text-white/75"
>
${Number(this.score).toFixed(0)}
${formattedScore}
</div>
</div>
`;
}
private renderScoreBar() {
private renderScoreBar(formattedScore: string) {
const bestScore = Math.max(this.bestScore, 1);
const currentScore = Math.max(this.score, 0);
const accessibleMax = Math.max(bestScore, currentScore);
const width = Math.min(Math.max((this.score / bestScore) * 100, 0), 100);
return html`
<div class="w-full pr-2.5 m-auto">
<div class="h-1.75 bg-white/10 w-full">
<!-- bar background -->
<div class="w-full">
<div
role="progressbar"
aria-label=${this.scoreLabel()}
aria-valuemin="0"
aria-valuemax=${accessibleMax}
aria-valuenow=${currentScore}
aria-valuetext=${formattedScore}
class="h-2 w-full overflow-hidden rounded-full bg-white/[0.07]"
>
<div
class="h-1.75 bg-blue-500/50 w-(--width)"
class="h-full rounded-full bg-gradient-to-r from-malibu-blue/65 to-aquarius shadow-[0_0_10px_rgba(63,169,245,0.2)] w-(--width)"
style="--width: ${width}%;"
></div>
</div>
@@ -117,159 +218,90 @@ export class PlayerRow extends LitElement {
`;
}
private renderMultiScoreType(value: number, highlight: boolean) {
return html`
<div
class="${highlight
? "font-bold text-[18px] text-white/80"
: "leading-[24px] text-white/40"} min-w-7.5 sm:min-w-15 inline-block text-center"
>
${renderNumber(value)}
</div>
`;
}
private renderAllBombs() {
return html`
<div class="flex justify-between text-sm sm:pr-20">
${this.renderMultiScoreType(
this.player.atoms,
this.rankType === RankType.Atoms,
)}
/
${this.renderMultiScoreType(
this.player.hydros,
this.rankType === RankType.Hydros,
)}
/
${this.renderMultiScoreType(
this.player.mirv,
this.rankType === RankType.MIRV,
)}
</div>
`;
}
private renderAllTrades() {
const navalTrade = this.player.gold[GOLD_INDEX_TRADE] ?? 0n;
const ownTrainTrade = this.player.gold[GOLD_INDEX_TRAIN_SELF] ?? 0n;
const otherTrainTrade = this.player.gold[GOLD_INDEX_TRAIN_OTHER] ?? 0n;
return html`
<div class="flex justify-between text-sm align-baseline">
${this.renderMultiScoreType(
Number(ownTrainTrade + otherTrainTrade),
this.rankType === RankType.TrainTrade,
)}
/
${this.renderMultiScoreType(
Number(navalTrade),
this.rankType === RankType.NavalTrade,
)}
</div>
`;
}
private renderBombScore() {
return html`
<div class="flex gap-3 items-center align-baseline w-full">
${this.renderPlayerIcon()}
<div class="flex flex-col sm:flex-row gap-1 text-left w-full">
${this.renderPlayerName()} ${this.renderAllBombs()}
</div>
</div>
`;
return this.renderValueScore(false);
}
private renderGoldScore() {
return html`
<div class="flex gap-3 items-center">
${this.renderPlayerIcon()}
<div class="text-left w-31.25 sm:w-50">${this.renderPlayerName()}</div>
</div>
<div class="flex gap-2">
<div
class="font-bold rounded-md w-15 text-white/80 text-sm sm:w-25 leading-[1.9rem] text-center"
>
${renderNumber(this.score)}
</div>
<img
src=${assetUrl("images/GoldCoinIcon.svg")}
class="size-3.5 sm:size-5 m-auto"
/>
</div>
`;
return this.renderValueScore(true);
}
private renderTradeScore() {
return html`
<div class="flex flex-col sm:flex-row gap-1 text-left w-full">
<div class="flex gap-3 items-center">
${this.renderPlayerIcon()}
<div class="text-left w-31.25 sm:w-50">
${this.renderPlayerName()}
</div>
</div>
<div class="flex gap-2 justify-between items-center w-full">
<div class="rounded-md text-sm leading-[1.9rem] text-center w-full">
${this.renderAllTrades()}
</div>
<img
src=${assetUrl("images/GoldCoinIcon.svg")}
class="w-5 size-3.5 sm:size-5"
/>
</div>
</div>
`;
return this.renderValueScore(true);
}
private renderPlayerName() {
return html`
<div class="flex gap-1 items-center w-50 shrink-0">
${this.player.clanTag ? this.renderTag(this.player.clanTag) : ""}
<div
class="text-xs sm:text-sm font-bold tracking-wide text-white/80 text-ellipsis w-37.5 shrink-0 overflow-hidden whitespace-nowrap"
>
${this.player.username}
</div>
</div>
`;
}
private renderTag(tag: string) {
private renderValueScore(showCoin: boolean) {
const formattedScore = renderNumber(this.score);
return html`
<div
class="px-2.5 py-1 rounded bg-malibu-blue/10 border border-malibu-blue/20 text-aquarius font-bold text-xs tracking-wide group-hover:bg-malibu-blue/20 transition-colors"
data-player-score
aria-label=${`${this.scoreLabel()}, ${formattedScore}`}
class="flex min-h-10 items-center justify-end gap-2 px-3 py-2 font-mono text-sm font-bold tabular-nums text-white/75"
>
${tag}
${showCoin ? this.renderCoinIcon() : ""}
<div>${formattedScore}</div>
</div>
`;
}
private scoreLabel(): string {
return `${this.player.username}: ${translateText(
RANK_TYPE_LABEL_KEYS[this.rankType],
)}`;
}
private renderIcon() {
if (this.player.killedAt) {
return html` <div
class="size-7.5 leading-1.25 shrink-0 text-lg sm:size-10 pt-3 sm:leading-3.75 sm:rounded-[50%] sm:border sm:border-gray-200 text-center sm:bg-slate-500 sm:text-2xl"
>
💀
</div>`;
} else if (this.player.flag) {
const flagUrl = this.getFlagUrl();
if (flagUrl) {
return html`<img
src=${assetUrl(`${this.player.flag}`)}
class="min-w-7.5 h-7.5 sm:min-w-10 sm:h-10 shrink-0"
data-player-avatar="flag"
src=${flagUrl}
alt=""
draggable="false"
decoding="async"
@error=${() => this.handleFlagError()}
class="size-10 rounded-xl border border-white/10 bg-white/[0.055] object-contain p-1"
/>`;
}
return html`
<div
class="size-7.5 leading-1.25 shrink-0 rounded-[50%] sm:size-10 sm:pt-2.5 sm:leading-3.5 border border-gray-200 text-center bg-slate-500"
data-player-avatar="fallback"
class="flex size-10 items-center justify-center rounded-xl border border-malibu-blue/15 bg-gradient-to-br from-malibu-blue/20 to-white/[0.04] text-xs font-bold uppercase tracking-wide text-aquarius/80"
aria-hidden="true"
>
<img
src=${assetUrl("images/ProfileIcon.svg")}
class="size-5 mt-0.5 sm:size-6.25 sm:-mt-1.25 m-auto"
/>
${this.initials()}
</div>
`;
}
private getFlagUrl(): string | null {
if (!this.player.flag || this.failedFlag === this.player.flag) return null;
try {
return assetUrl(this.player.flag);
} catch {
return null;
}
}
private handleFlagError(): void {
this.failedFlag = this.player.flag ?? null;
this.requestUpdate();
}
private initials(): string {
const normalized = this.player.username.trim();
return normalized.length > 0 ? normalized.slice(0, 2).toUpperCase() : "?";
}
private renderCoinIcon(): TemplateResult {
return html`<img
src=${goldCoinIcon}
width="18"
height="18"
class="size-[18px] shrink-0"
alt=""
aria-hidden="true"
/>`;
}
}
@@ -1,34 +1,42 @@
import { LitElement, html } from "lit";
import { LitElement, html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../../../Utils";
import { RankType } from "./GameInfoRanking";
const economyRankings = new Set([
RankType.TotalGold,
RankType.StolenGold,
RankType.ConqueredGold,
RankType.NavalTrade,
RankType.TrainTrade,
]);
const warRankings = new Set([
RankType.ConquestHumans,
RankType.ConquestBots,
RankType.Atoms,
RankType.Hydros,
RankType.MIRV,
]);
const tradeRankings = new Set([RankType.NavalTrade, RankType.TrainTrade]);
const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]);
const conquestRankings = new Set([
RankType.ConquestHumans,
RankType.ConquestBots,
]);
type Metric = { type: RankType; label: string };
const isEconomyRanking = (t: RankType) => economyRankings.has(t);
const isTradeRanking = (t: RankType) => tradeRankings.has(t);
const isBombRanking = (t: RankType) => bombRankings.has(t);
const isWarRanking = (t: RankType) => warRankings.has(t);
const isConquestRanking = (t: RankType) => conquestRankings.has(t);
const warMetrics: readonly Metric[] = [
{
type: RankType.ConquestHumans,
label: "game_info_modal.num_of_conquests_humans",
},
{
type: RankType.ConquestNations,
label: "game_info_modal.num_of_conquests_nations",
},
{
type: RankType.ConquestBots,
label: "game_info_modal.num_of_conquests_bots",
},
{ type: RankType.Atoms, label: "game_info_modal.atoms" },
{ type: RankType.Hydros, label: "game_info_modal.hydros" },
{ type: RankType.MIRV, label: "game_info_modal.mirv" },
];
const economyMetrics: readonly Metric[] = [
{ type: RankType.TotalGold, label: "game_info_modal.total_gold" },
{ type: RankType.ConqueredGold, label: "game_info_modal.conquered" },
{ type: RankType.StolenGold, label: "game_info_modal.pirate" },
{ type: RankType.TrainTrade, label: "game_info_modal.train_trade" },
{ type: RankType.NavalTrade, label: "game_info_modal.naval_trade" },
];
const includesMetric = (metrics: readonly Metric[], type: RankType) =>
metrics.some((metric) => metric.type === type);
const isEconomyRanking = (type: RankType) =>
includesMetric(economyMetrics, type);
const isWarRanking = (type: RankType) => includesMetric(warMetrics, type);
@customElement("ranking-controls")
export class RankingControls extends LitElement {
@@ -40,98 +48,159 @@ export class RankingControls extends LitElement {
private renderMainButtons() {
return html`
<div class="flex items-end justify-center p-6 pb-2 gap-5">
<div
role="group"
aria-label=${translateText("game_list.stats")}
class="grid grid-cols-3 gap-1 rounded-xl border border-white/10 bg-white/[0.04] p-1"
>
${this.renderButton(
RankType.Lifetime,
this.rankType === RankType.Lifetime,
"game_info_modal.duration",
this.renderClockIcon(),
)}
${this.renderButton(
RankType.ConquestHumans,
isWarRanking(this.rankType),
"game_info_modal.war",
this.renderWarIcon(),
)}
${this.renderButton(
RankType.TotalGold,
isEconomyRanking(this.rankType),
"game_info_modal.economy",
this.renderEconomyIcon(),
)}
</div>
`;
}
private renderButton(type: RankType, active: boolean, label: string) {
private renderButton(
type: RankType,
active: boolean,
label: string,
icon: TemplateResult,
) {
return html`
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest hover:text-white hover:bg-white/5 border ${active
? "bg-malibu-blue/20 text-aquarius border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 border-transparent"}"
type="button"
data-ranking-category=${type}
aria-pressed=${active}
class="flex min-h-11 min-w-0 items-center justify-center gap-1.5 rounded-lg border px-2 py-2.5 text-[10px] font-bold uppercase tracking-wider transition-all duration-200 sm:gap-2 sm:px-4 sm:text-xs sm:tracking-widest ${active
? "border-malibu-blue/30 bg-malibu-blue/20 text-aquarius shadow-(--shadow-malibu-blue-soft)"
: "border-transparent text-white/40 hover:bg-white/5 hover:text-white/75"} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-aquarius/70"
@click=${() => this.onSort(type)}
>
${translateText(label)}
<span class="hidden sm:block" aria-hidden="true">${icon}</span>
<span class="truncate">${translateText(label)}</span>
</button>
`;
}
private renderWarSubranking() {
if (!isWarRanking(this.rankType)) return "";
private renderMetricSelector(metrics: readonly Metric[], ariaLabel: string) {
const hasSixMetrics = metrics.length === 6;
return html`
<div class="flex justify-center gap-3 pb-1">
${this.renderSubButton(
RankType.MIRV,
isBombRanking(this.rankType),
"game_info_modal.bombs",
)}
${this.renderSubButton(
RankType.ConquestHumans,
isConquestRanking(this.rankType),
"game_info_modal.conquests",
<div
role="group"
aria-label=${ariaLabel}
class="mt-2 grid grid-cols-6 gap-1 rounded-xl border border-white/10 bg-white/[0.03] p-1 ${hasSixMetrics
? "sm:grid-cols-6"
: "sm:grid-cols-5"}"
>
${metrics.map((metric, index) =>
this.renderMetricButton(
metric.type,
metric.label,
hasSixMetrics
? "col-span-2"
: index < 2
? "col-span-3"
: "col-span-2",
),
)}
</div>
`;
}
private renderEconomySubranking() {
if (!isEconomyRanking(this.rankType)) return "";
const econButtons = [
[RankType.StolenGold, "game_info_modal.pirate"],
[RankType.ConqueredGold, "game_info_modal.conquered"],
[RankType.TotalGold, "game_info_modal.total_gold"],
];
return html`
<div class="flex justify-center gap-3 pb-1">
${this.renderSubButton(
RankType.NavalTrade,
isTradeRanking(this.rankType),
"game_info_modal.trade",
)}
${econButtons.map(([type, label]) =>
this.renderSubButton(type as RankType, this.rankType === type, label),
)}
</div>
`;
}
private renderSubButton(type: RankType, active: boolean, label: string) {
private renderMetricButton(
type: RankType,
label: string,
mobileSpan: string,
) {
const active = this.rankType === type;
return html`
<button
type="button"
data-ranking-metric=${type}
aria-pressed=${active}
@click=${() => this.onSort(type)}
class="text-[10px] font-bold uppercase tracking-wider bg-white/5 border border-white/10 hover:bg-white/20 px-3 py-1 rounded text-white/60 hover:text-white transition-colors ${active
? "outline-1 outline-white/80 font-bold"
: ""}"
title=${translateText(label)}
class="${mobileSpan} min-h-11 min-w-0 rounded-lg border px-1.5 py-2 text-[10px] font-bold uppercase leading-tight tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-aquarius/70 sm:col-span-1 sm:px-2 ${active
? "border-malibu-blue/30 bg-malibu-blue/15 text-aquarius"
: "border-transparent text-white/40 hover:border-white/10 hover:bg-white/[0.06] hover:text-white/75"}"
>
${translateText(label)}
<span class="block min-w-0 break-words">${translateText(label)}</span>
</button>
`;
}
private renderClockIcon(): TemplateResult {
return html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class="size-4"
>
<circle cx="12" cy="12" r="8.5" stroke-width="1.7" />
<path d="M12 7.5V12l3 2" stroke-width="1.7" stroke-linecap="round" />
</svg>`;
}
private renderWarIcon(): TemplateResult {
return html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class="size-4"
>
<path
d="m6 4 12 12m0-12L6 16m-2 0 4 4m12-4-4 4M5 3l4 1-5 5-1-4 2-2Zm14 0-4 1 5 5 1-4-2-2Z"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>`;
}
private renderEconomyIcon(): TemplateResult {
return html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class="size-4"
>
<ellipse cx="12" cy="7" rx="7.5" ry="3.5" stroke-width="1.6" />
<path
d="M4.5 7v5c0 2 3.4 3.5 7.5 3.5s7.5-1.5 7.5-3.5V7m-15 5v5c0 2 3.4 3.5 7.5 3.5s7.5-1.5 7.5-3.5v-5"
stroke-width="1.6"
/>
</svg>`;
}
render() {
return html`
${this.renderMainButtons()} ${this.renderWarSubranking()}
${this.renderEconomySubranking()}
${this.renderMainButtons()}
${isWarRanking(this.rankType)
? this.renderMetricSelector(
warMetrics,
translateText("game_info_modal.war"),
)
: isEconomyRanking(this.rankType)
? this.renderMetricSelector(
economyMetrics,
translateText("game_info_modal.economy"),
)
: ""}
`;
}
@@ -1,115 +0,0 @@
import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../../../Utils";
import { RankType } from "./GameInfoRanking";
@customElement("ranking-header")
export class RankingHeader extends LitElement {
@property({ type: String }) rankType = RankType.Lifetime;
private onSort(type: RankType) {
this.dispatchEvent(new CustomEvent("sort", { detail: type }));
}
render() {
return html`
<li
class="h-[30px] text-lg border-white/5 bg-white/[0.02] text-white/60 text-xs uppercase tracking-wider relative pt-2 pb-2 pr-5 pl-5 flex justify-between items-center"
>
${this.renderHeaderContent()}
</li>
`;
}
private renderHeaderContent() {
switch (this.rankType) {
case RankType.Lifetime:
return html`<div class="w-full">
${translateText("game_info_modal.survival_time")}
</div>`;
case RankType.ConquestHumans:
case RankType.ConquestBots:
return html`
<div class="flex justify-between sm:px-17.5 w-full">
${this.renderMultipleChoiceHeaderButton(
translateText("game_info_modal.num_of_conquests_humans"),
RankType.ConquestHumans,
)}
/
${this.renderMultipleChoiceHeaderButton(
translateText("game_info_modal.num_of_conquests_bots"),
RankType.ConquestBots,
)}
</div>
`;
case RankType.Atoms:
case RankType.Hydros:
case RankType.MIRV:
return html`
<div class="flex justify-between sm:px-17.5 w-full">
${this.renderMultipleChoiceHeaderButton(
translateText("game_info_modal.atoms"),
RankType.Atoms,
)}
/
${this.renderMultipleChoiceHeaderButton(
translateText("game_info_modal.hydros"),
RankType.Hydros,
)}
/
${this.renderMultipleChoiceHeaderButton(
translateText("game_info_modal.mirv"),
RankType.MIRV,
)}
</div>
`;
case RankType.TotalGold:
return html`<div class="w-full">
${translateText("game_info_modal.all_gold")}
</div>`;
case RankType.NavalTrade:
case RankType.TrainTrade:
return html`
<div class="flex justify-between sm:px-17.5 w-full">
${this.renderMultipleChoiceHeaderButton(
translateText("game_info_modal.train_trade"),
RankType.TrainTrade,
)}
/
${this.renderMultipleChoiceHeaderButton(
translateText("game_info_modal.naval_trade"),
RankType.NavalTrade,
)}
</div>
`;
case RankType.ConqueredGold:
return html`<div class="w-full">
${translateText("game_info_modal.conquest_gold")}
</div>`;
case RankType.StolenGold:
return html`<div class="w-full">
${translateText("game_info_modal.stolen_gold")}
</div>`;
default:
console.warn("Unhandled RankType", this.rankType);
return null;
}
}
private renderMultipleChoiceHeaderButton(label: string, type: RankType) {
return html`
<button
@click=${() => this.onSort(type)}
class="${this.rankType === type
? "border-b-2 border-b-white"
: nothing}"
>
${label}
</button>
`;
}
createRenderRoot() {
return this;
}
}
@@ -1,14 +1,29 @@
import { html, LitElement, type PropertyValues } from "lit";
import {
html,
LitElement,
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { type GameEndInfo } from "../../../../core/Schemas";
import { GameMode, type GameMapType } from "../../../../core/game/Game";
import {
GameMode,
GameType,
type GameMapType,
} from "../../../../core/game/Game";
import { fetchGameById } from "../../../Api";
import { terrainMapFileLoader } from "../../../TerrainMapFileLoader";
import { getMapName, renderDuration, translateText } from "../../../Utils";
import { Ranking, RankType, type PlayerInfo } from "../ranking/GameInfoRanking";
import { renderLoadingSpinner } from "../../BaseModal";
import {
RANK_TYPE_LABEL_KEYS,
Ranking,
RankType,
type PlayerInfo,
} from "../ranking/GameInfoRanking";
import "../ranking/PlayerRow";
import "../ranking/RankingControls";
import "../ranking/RankingHeader";
import { formatAbsoluteTime } from "./GameHistoryDates";
/**
* Game-stats content for the Account > Games > Stats drill-down.
@@ -54,7 +69,7 @@ export class GameInfoView extends LitElement {
render() {
if (!this.gameId) return html``;
return html`
<div class="w-full max-w-[500px] mx-auto text-center">
<div class="w-full max-w-[800px] mx-auto">
${this.isLoadingGame
? this.renderLoadingAnimation()
: this.loadFailed
@@ -67,31 +82,65 @@ export class GameInfoView extends LitElement {
private renderRanking() {
if (this.rankedPlayers.length === 0) {
return html`
<div class="flex flex-col items-center justify-center p-6 text-white">
<p class="mb-2">${translateText("game_info_modal.no_winner")}</p>
${this.renderGameInfo()}
<div
class="mt-5 flex min-h-56 flex-col items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] p-8 text-center text-white"
>
<div
class="mb-4 flex size-12 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-white/35"
aria-hidden="true"
>
${this.renderTrophyIcon("size-6")}
</div>
<p class="max-w-md text-sm leading-relaxed text-white/60">
${translateText("game_info_modal.no_winner")}
</p>
</div>
`;
}
return html`
${this.renderGameInfo()}
<ranking-controls
.rankType=${this.rankType}
@sort=${this.sort}
></ranking-controls>
${this.renderSummaryTable()}
<div class="mt-5 flex flex-col gap-3">
<ranking-controls
.rankType=${this.rankType}
@sort=${this.sort}
></ranking-controls>
${this.renderSummaryTable()}
</div>
`;
}
private renderError() {
return html`
<div
class="flex flex-col items-center justify-center gap-3 p-6 text-white"
role="alert"
class="flex min-h-80 flex-col items-center justify-center rounded-2xl border border-red-400/15 bg-red-400/[0.04] p-8 text-center text-white"
>
<p class="mb-1">${translateText("game_info_modal.load_failed")}</p>
<div
class="mb-4 flex size-12 items-center justify-center rounded-xl border border-red-300/15 bg-red-400/10 text-red-200/70"
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class="size-6"
>
<path
d="M12 9v4m0 4h.01M10.3 3.8 2.4 17.5A2 2 0 0 0 4.1 20h15.8a2 2 0 0 0 1.7-2.5L13.7 3.8a2 2 0 0 0-3.4 0Z"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<p class="mb-5 text-sm text-white/65">
${translateText("game_info_modal.load_failed")}
</p>
<button
type="button"
@click=${() => this.retry()}
class="px-3 py-1.5 text-xs font-bold text-white/80 uppercase tracking-wider bg-white/10 hover:bg-white/20 border border-white/10 rounded-lg transition-colors"
class="rounded-lg border border-malibu-blue/40 bg-malibu-blue/20 px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-aquarius transition-colors hover:border-malibu-blue/60 hover:bg-malibu-blue/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-aquarius/70"
>
${translateText("game_info_modal.retry")}
</button>
@@ -104,16 +153,9 @@ export class GameInfoView extends LitElement {
}
private renderLoadingAnimation() {
return html`
<div class="flex flex-col items-center justify-center p-6 text-white">
<p class="mb-2">
${translateText("game_info_modal.loading_game_info")}
</p>
<div
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
</div>
`;
return renderLoadingSpinner(
translateText("game_info_modal.loading_game_info"),
);
}
private sort(e: CustomEvent<RankType>) {
@@ -130,64 +172,239 @@ export class GameInfoView extends LitElement {
private renderGameInfo() {
const info = this.gameInfo;
if (!info) return html``;
const mapName = getMapName(info.config.gameMap) ?? info.config.gameMap;
const startDate = new Date(info.start).toISOString();
return html`
<div
class="h-37.5 flex relative justify-between rounded-xl bg-black/20 items-center"
data-game-summary
class="relative overflow-hidden rounded-2xl border border-white/10 bg-gradient-to-br from-white/[0.08] to-white/[0.025] shadow-[0_18px_50px_rgba(0,0,0,0.2)]"
>
${this.mapImage
? html`<img
src=${this.mapImage}
class="absolute place-self-start col-span-full row-span-full h-full rounded-xl mask-[linear-gradient(to_left,transparent,#fff)] object-cover object-center"
/>`
: html`<div
class="place-self-start col-span-full row-span-full h-full rounded-xl bg-gray-300"
></div>`}
<div class="text-right p-3 w-full">
<div class="font-normal pl-1 pr-1">
<span class="bg-white text-blue-800 font-normal pl-1 pr-1"
>${translateText(
info.config.gameMode === GameMode.Team
? "game_mode.teams"
: "game_mode.ffa",
)}</span
>
<span class="font-bold"
>${getMapName(info.config.gameMap) ?? info.config.gameMap}</span
>
<div class="grid sm:grid-cols-[240px_minmax(0,1fr)]">
<div
class="relative min-h-36 overflow-hidden border-b border-white/10 bg-deep-navy sm:min-h-44 sm:border-b-0 sm:border-r"
>
${this.mapImage
? html`<img
data-map-image
src=${this.mapImage}
alt=${mapName}
draggable="false"
decoding="async"
@error=${() => this.handleMapImageError()}
class="absolute inset-0 h-full w-full object-cover"
/>`
: this.renderMapFallback()}
<div
class="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/65 via-black/5 to-black/10 sm:bg-gradient-to-r sm:from-transparent sm:via-transparent sm:to-black/25"
></div>
</div>
<div>${renderDuration(info.duration)}</div>
<div>
${info.players.length} ${translateText("game_info_modal.players")}
<div class="flex min-w-0 flex-col justify-between p-4 sm:p-5">
<div>
<div class="mb-2 flex flex-wrap items-center gap-2">
<span
class="rounded-md border border-malibu-blue/25 bg-malibu-blue/15 px-2.5 py-1 text-[10px] font-bold uppercase tracking-widest text-aquarius"
>
${translateText(
info.config.gameMode === GameMode.Team
? "game_mode.teams"
: "game_mode.ffa",
)}
</span>
<span
class="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-bold uppercase tracking-widest text-white/45"
>
${this.gameTypeLabel(info.config.gameType)}
</span>
</div>
<h2
class="truncate text-xl font-bold tracking-wide text-white sm:text-2xl"
title=${mapName}
>
${mapName}
</h2>
<div
data-game-date
class="mt-2 flex items-center gap-1.5 text-xs font-medium tabular-nums text-white/50"
>
<span class="text-aquarius/70" aria-hidden="true">
${this.renderCalendarIcon("size-3.5")}
</span>
<time datetime=${startDate}
>${formatAbsoluteTime(startDate)}</time
>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-3">
${this.renderInfoMetric(
this.renderClockIcon("size-4"),
translateText("game_info_modal.duration"),
renderDuration(info.duration),
)}
${this.renderInfoMetric(
this.renderPlayersIcon("size-4"),
translateText("game_info_modal.players"),
String(info.players.length),
)}
</div>
</div>
</div>
</div>
`;
}
private renderSummaryTable() {
const bestScore =
this.rankedPlayers.length > 0 ? this.score(this.rankedPlayers[0]) : 0;
private renderInfoMetric(
icon: TemplateResult,
label: string,
value: string,
): TemplateResult {
return html`
<ul>
<ranking-header
.rankType=${this.rankType}
@sort=${this.sort}
></ranking-header>
${this.rankedPlayers.map(
(player, index) => html`
<player-row
.player=${player}
.rank=${index + 1}
.score=${this.ranking?.score(player, this.rankType) ?? 0}
.rankType=${this.rankType}
.bestScore=${bestScore}
></player-row>
`,
)}
</ul>
<div class="rounded-xl border border-white/[0.08] bg-black/15 p-2.5">
<div
class="mb-1.5 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-white/35"
>
<span class="text-aquarius/70" aria-hidden="true">${icon}</span>
${label}
</div>
<div class="text-sm font-semibold tabular-nums text-white/85">
${value}
</div>
</div>
`;
}
private renderMapFallback(): TemplateResult {
return html`
<div
data-map-fallback
class="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_50%_40%,rgba(0,132,209,0.18),transparent_65%)] text-aquarius/35"
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class="size-12"
>
<path
d="m3 6 5-2 8 3 5-2v13l-5 2-8-3-5 2V6Z"
stroke-width="1.3"
stroke-linejoin="round"
/>
<path d="M8 4v13m8-10v13" stroke-width="1.3" />
</svg>
</div>
`;
}
private handleMapImageError(): void {
this.mapImage = null;
}
private gameTypeLabel(gameType: GameType): string {
switch (gameType) {
case GameType.Public:
return translateText("account_modal.games_type_public");
case GameType.Private:
return translateText("account_modal.games_type_private");
case GameType.Singleplayer:
return translateText("account_modal.games_type_singleplayer");
default:
return translateText("game_info_modal.unknown_game_type");
}
}
private renderSummaryTable() {
const bestScore = this.rankedPlayers.reduce(
(best, player) => Math.max(best, this.score(player)),
0,
);
return html`
<section
aria-label=${translateText(RANK_TYPE_LABEL_KEYS[this.rankType])}
class="overflow-hidden rounded-2xl border border-white/10 bg-black/15 shadow-[0_12px_35px_rgba(0,0,0,0.16)]"
>
<ol class="divide-y divide-white/[0.06]">
${this.rankedPlayers.map(
(player, index) => html`
<player-row
class="block"
.player=${player}
.rank=${index + 1}
.score=${this.ranking?.score(player, this.rankType) ?? 0}
.rankType=${this.rankType}
.bestScore=${bestScore}
></player-row>
`,
)}
</ol>
</section>
`;
}
private renderClockIcon(className: string): TemplateResult {
return html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class=${className}
>
<circle cx="12" cy="12" r="8.5" stroke-width="1.7" />
<path
d="M12 7.5V12l3 2"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>`;
}
private renderCalendarIcon(className: string): TemplateResult {
return html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class=${className}
>
<rect x="3.5" y="5" width="17" height="15.5" rx="2" stroke-width="1.7" />
<path
d="M8 3v4m8-4v4M3.5 9.5h17"
stroke-width="1.7"
stroke-linecap="round"
/>
</svg>`;
}
private renderPlayersIcon(className: string): TemplateResult {
return html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class=${className}
>
<path
d="M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19m14-7.8a3 3 0 0 1 2 2.8v1M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm6-5.7a3 3 0 0 1 0 5.4"
stroke-width="1.7"
stroke-linecap="round"
/>
</svg>`;
}
private renderTrophyIcon(className: string): TemplateResult {
return html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class=${className}
>
<path
d="M8 4h8v3.5a4 4 0 0 1-8 0V4Zm4 7.5V16m-3 4h6m-5-4h4M8 6H5v1a4 4 0 0 0 4 4m7-5h3v1a4 4 0 0 1-4 4"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>`;
}
private score(player: PlayerInfo): number {
if (!this.ranking) return 0;
return this.ranking.score(player, this.rankType);
@@ -201,6 +418,7 @@ export class GameInfoView extends LitElement {
this.gameInfo = null;
this.ranking = null;
this.rankedPlayers = [];
this.rankType = RankType.Lifetime;
try {
const session = await fetchGameById(gameId);
+25
View File
@@ -122,6 +122,20 @@ describe("Ranking class", () => {
expect(p1.conquests).toStrictEqual([5n]);
expect(p1.atoms).toBe(1);
expect(p1.mirv).toBe(2);
expect(p1.killedAt).toBeUndefined();
const p3 = players.find((p) => p.id === "p3")!;
expect(p3.killedAt).toBe(600);
});
test("preserves a zero death timestamp", () => {
const session = makeSession();
session.info.players[0]!.stats!.killedAt = 0n;
const r = new Ranking(session);
const p1 = r.allPlayers.find((p) => p.id === "p1")!;
expect(p1.killedAt).toBe(0);
expect(r.score(p1, RankType.Lifetime)).toBe(0);
});
test("correctly identifies winner", () => {
@@ -130,6 +144,17 @@ describe("Ranking class", () => {
expect(p2.winner).toBe(true);
});
test("scores player, nation, and tribe kills separately", () => {
const session = makeSession();
session.info.players[0]!.stats!.conquests = [5n, 3n, 2n];
const ranking = new Ranking(session);
const player = ranking.allPlayers.find((entry) => entry.id === "p1")!;
expect(ranking.score(player, RankType.ConquestHumans)).toBe(5);
expect(ranking.score(player, RankType.ConquestNations)).toBe(3);
expect(ranking.score(player, RankType.ConquestBots)).toBe(2);
});
test("rank by total gold", () => {
const r = new Ranking(makeSession());
const rankedPlayers = r.sortedBy(RankType.TotalGold);
+57 -8
View File
@@ -30,6 +30,7 @@ vi.mock("../../src/client/Utils", async (importOriginal) => {
});
import { fetchGameById } from "../../src/client/Api";
import { formatAbsoluteTime } from "../../src/client/components/baseComponents/stats/GameHistoryDates";
import { GameInfoView } from "../../src/client/components/baseComponents/stats/GameInfoView";
import { LangSelector } from "../../src/client/LangSelector";
@@ -137,11 +138,64 @@ describe("GameInfoView", () => {
await waitForRender(view, () => {
expect(view!.textContent).toContain(GameMapType.Montreal);
expect(view!.querySelector("ranking-controls")).not.toBeNull();
expect(view!.querySelector("ranking-header")).not.toBeNull();
expect(view!.querySelector("section")?.getAttribute("aria-label")).toBe(
"game_info_modal.survival_time",
);
expect(view!.querySelectorAll("player-row")).toHaveLength(1);
});
});
it("shows the game start using the history-card date format", async () => {
const session = makeSession("game-1", GameMapType.Montreal);
session.info.start = Date.UTC(2026, 3, 20, 16, 38, 22);
fetchMock.mockResolvedValue(session);
view = mountView("game-1");
const startDate = new Date(session.info.start).toISOString();
await waitForRender(view, () => {
const date = view!.querySelector<HTMLTimeElement>(
"[data-game-date] time",
);
expect(date?.dateTime).toBe(startDate);
expect(date?.textContent).toBe(formatAbsoluteTime(startDate));
});
});
it("uses a translated fallback for an unknown game type", async () => {
const session = makeSession("game-1", GameMapType.Montreal);
session.info.config.gameType = "future-game-type" as GameType;
fetchMock.mockResolvedValue(session);
view = mountView("game-1");
await waitForRender(view, () => {
expect(view!.textContent).toContain("game_info_modal.unknown_game_type");
expect(view!.textContent).not.toContain("future-game-type");
});
});
it("replaces a failed map image with the map fallback", async () => {
fetchMock.mockResolvedValue(makeSession("game-1", GameMapType.Montreal));
view = mountView("game-1");
await waitForRender(view, () => {
const image = view!.querySelector<HTMLImageElement>("[data-map-image]");
expect(image?.getAttribute("src")).toBe(
`/maps/${GameMapType.Montreal}.webp`,
);
expect(image?.alt).toBe(GameMapType.Montreal);
expect(view!.querySelector("[data-map-fallback]")).toBeNull();
});
view
.querySelector<HTMLImageElement>("[data-map-image]")!
.dispatchEvent(new Event("error"));
await waitForRender(view, () => {
expect(view!.querySelector("[data-map-image]")).toBeNull();
expect(view!.querySelector("[data-map-fallback]")).not.toBeNull();
});
});
it("renders the no-winner state for a game without ranked players", async () => {
fetchMock.mockResolvedValue(
makeSession("empty", GameMapType.Montreal, false),
@@ -150,6 +204,7 @@ describe("GameInfoView", () => {
await waitForRender(view, () => {
expect(view!.textContent).toContain("game_info_modal.no_winner");
expect(view!.querySelector("[data-game-summary]")).not.toBeNull();
expect(view!.querySelector("player-row")).toBeNull();
});
});
@@ -234,24 +289,19 @@ describe("GameInfoView", () => {
expect(view.textContent?.trim()).toBe("");
});
it("refreshes the stats view and translated ranking children", async () => {
it("refreshes the stats view and translated ranking controls", async () => {
fetchMock.mockResolvedValue(makeSession("game-1", GameMapType.Montreal));
view = mountView("game-1");
await waitForRender(view, () => {
expect(view!.querySelector("ranking-controls")).not.toBeNull();
expect(view!.querySelector("ranking-header")).not.toBeNull();
});
const controls = view.querySelector<
HTMLElement & { requestUpdate(): void }
>("ranking-controls")!;
const header = view.querySelector<HTMLElement & { requestUpdate(): void }>(
"ranking-header",
)!;
const viewUpdate = vi.spyOn(view, "requestUpdate");
const controlsUpdate = vi.spyOn(controls, "requestUpdate");
const headerUpdate = vi.spyOn(header, "requestUpdate");
const selector = new LangSelector();
selector.translations = { "main.title": "OpenFront" };
selector.defaultTranslations = selector.translations;
@@ -264,7 +314,6 @@ describe("GameInfoView", () => {
expect(viewUpdate).toHaveBeenCalled();
expect(controlsUpdate).toHaveBeenCalled();
expect(headerUpdate).toHaveBeenCalled();
});
it("ignores a stale response when a newer game resolves first", async () => {
+33
View File
@@ -9,8 +9,13 @@ import {
vi,
} from "vitest";
const copyToClipboardMock = vi.hoisted(() =>
vi.fn(async (_text: string, onSuccess?: () => void) => onSuccess?.()),
);
vi.mock("../../src/client/Utils", () => ({
translateText: vi.fn((key: string) => key),
copyToClipboard: copyToClipboardMock,
}));
vi.mock("../../src/client/components/baseComponents/stats/GameInfoView", () => {
@@ -21,6 +26,7 @@ vi.mock("../../src/client/components/baseComponents/stats/GameInfoView", () => {
return { GameInfoView: FakeGameInfoView };
});
import type { CopyButton } from "../../src/client/components/CopyButton";
import { GameStatsModal } from "../../src/client/GameStatsModal";
import { modalRouter } from "../../src/client/ModalRouter";
import { initNavigation } from "../../src/client/Navigation";
@@ -43,6 +49,8 @@ describe("public game stats route", () => {
});
beforeEach(async () => {
copyToClipboardMock.mockClear();
vi.stubGlobal("localStorage", { getItem: vi.fn(() => null) });
history.replaceState(null, "", "/");
modalRouter.register("stats", {
tag: "game-stats-modal",
@@ -64,6 +72,7 @@ describe("public game stats route", () => {
window.showPage?.("page-play");
modal.remove();
history.replaceState(null, "", "/");
vi.unstubAllGlobals();
});
it("opens a shared gameID without mounting the authenticated account", async () => {
@@ -82,6 +91,30 @@ describe("public game stats route", () => {
expect(statsView?.gameId).toBe("public-game");
});
const copyButton = modal.querySelector<CopyButton>("copy-button")!;
await copyButton.updateComplete;
expect(copyButton.copyText).toBe("public-game");
expect(copyButton.displayText).toBe("public-game");
expect(copyButton.compact).toBe(true);
expect(copyButton.showVisibilityToggle).toBe(false);
const copyActions = copyButton.querySelectorAll("button");
expect(copyActions).toHaveLength(1);
expect(copyActions[0].textContent).toContain("public-game");
expect(copyActions[0].getAttribute("aria-label")).toBe(
"common.click_to_copy",
);
copyActions[0].click();
await vi.waitFor(() =>
expect(copyToClipboardMock).toHaveBeenCalledWith(
"public-game",
expect.any(Function),
expect.any(Function),
),
);
await copyButton.updateComplete;
expect(copyButton.textContent).toContain("common.copied");
expect(document.querySelector("account-modal")).toBeNull();
expect(window.location.hash).toBe("#modal=stats&gameID=public-game");
+261
View File
@@ -0,0 +1,261 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
type PlayerInfo,
RankType,
} from "../../src/client/components/baseComponents/ranking/GameInfoRanking";
import { PlayerRow } from "../../src/client/components/baseComponents/ranking/PlayerRow";
const basePlayer: PlayerInfo = {
id: "player-1",
username: "Ada Lovelace",
clanTag: null,
gold: [],
conquests: [],
winner: false,
atoms: 0,
hydros: 0,
mirv: 0,
};
type RowOptions = {
player?: Partial<PlayerInfo>;
rankType?: RankType;
score?: number;
bestScore?: number;
};
async function mountRow({
player = {},
rankType = RankType.Lifetime,
score = 100,
bestScore = 100,
}: RowOptions = {}): Promise<PlayerRow> {
const row = document.createElement("player-row") as PlayerRow;
row.player = { ...basePlayer, ...player };
row.rankType = rankType;
row.score = score;
row.bestScore = bestScore;
document.body.appendChild(row);
await row.updateComplete;
return row;
}
describe("PlayerRow", () => {
let row: PlayerRow | null = null;
beforeAll(() => {
if (!customElements.get("player-row")) {
customElements.define("player-row", PlayerRow);
}
});
beforeEach(() => {
window.BOOTSTRAP_CONFIG = undefined;
});
afterEach(() => {
row?.remove();
window.BOOTSTRAP_CONFIG = undefined;
});
it("resolves a valid flag through the asset manifest", async () => {
window.BOOTSTRAP_CONFIG = {
assetManifest: {
"flags/test.svg": "/_assets/flags/test.abc123.svg",
},
cdnBase: "https://cdn.example.test/game-assets",
};
row = await mountRow({ player: { flag: "/flags/test.svg" } });
const flag = row.querySelector<HTMLImageElement>(
'[data-player-avatar="flag"]',
);
expect(flag?.getAttribute("src")).toBe(
"https://cdn.example.test/game-assets/_assets/flags/test.abc123.svg",
);
expect(row.querySelector('[data-player-avatar="fallback"]')).toBeNull();
});
it("replaces a failed flag with the player's initials", async () => {
row = await mountRow({
player: { username: " alice wonder ", flag: "/flags/missing.svg" },
});
row
.querySelector<HTMLImageElement>('[data-player-avatar="flag"]')!
.dispatchEvent(new Event("error"));
await row.updateComplete;
expect(row.querySelector('[data-player-avatar="flag"]')).toBeNull();
expect(
row.querySelector('[data-player-avatar="fallback"]')?.textContent?.trim(),
).toBe("AL");
});
it("uses the initials fallback for an invalid asset path", async () => {
row = await mountRow({ player: { flag: "../invalid.svg" } });
expect(row.querySelector('[data-player-avatar="flag"]')).toBeNull();
expect(
row.querySelector('[data-player-avatar="fallback"]')?.textContent?.trim(),
).toBe("AD");
});
it("renders the clan tag inline before the username", async () => {
row = await mountRow({
player: { clanTag: "UN", username: "kazz" },
});
const identity = row.querySelector("[data-player-identity]")!;
const clanTag = row.querySelector("[data-player-clan-tag]")!;
const username = row.querySelector("[data-player-name]")!;
expect(identity.children[0]).toBe(clanTag);
expect(identity.children[1]).toBe(username);
expect(clanTag.textContent?.trim()).toBe("UN");
expect(username.textContent?.trim()).toBe("kazz");
});
it("preserves an eliminated player's flag and adds eliminated status", async () => {
row = await mountRow({
player: { flag: "/flags/test.svg", killedAt: 42 },
});
expect(row.querySelector('[data-player-avatar="flag"]')).not.toBeNull();
expect(
row.querySelector('[data-player-status="eliminated"]')?.textContent,
).toContain("💀");
});
it("treats killedAt zero as eliminated while leaving absent values active", async () => {
row = await mountRow({ player: { killedAt: 0 } });
expect(
row.querySelector('[data-player-status="eliminated"]'),
).not.toBeNull();
row.player = { ...basePlayer };
await row.updateComplete;
expect(row.querySelector('[data-player-status="eliminated"]')).toBeNull();
});
it("shows the winner crown instead of eliminated status", async () => {
row = await mountRow({
player: { winner: true, killedAt: 42 },
});
expect(row.querySelector('[data-player-status="winner"]')).not.toBeNull();
expect(row.querySelector('[data-player-status="eliminated"]')).toBeNull();
});
it("displays lifetime scores as rounded percentages", async () => {
row = await mountRow({
rankType: RankType.Lifetime,
score: 87.6,
bestScore: 100,
});
expect(row.textContent).toContain("88%");
const progress = row.querySelector('[role="progressbar"]');
expect(progress?.getAttribute("aria-valuenow")).toBe("87.6");
expect(progress?.getAttribute("aria-valuetext")).toBe("88%");
expect(progress?.getAttribute("aria-label")).toContain("Ada Lovelace");
const score = row.querySelector("[data-player-score]")!;
expect(score.classList.contains("border")).toBe(false);
expect(score.classList.contains("rounded-lg")).toBe(false);
expect(score.classList.contains("text-sm")).toBe(true);
expect(score.classList.contains("text-white/75")).toBe(true);
});
it.each([
RankType.ConquestHumans,
RankType.ConquestNations,
RankType.ConquestBots,
])("renders %s with the plain metric typography", async (rankType) => {
row = await mountRow({ rankType, score: 7 });
const score = row.querySelector("[data-player-score]")!;
expect(score.textContent?.trim()).toBe("7");
expect(score.classList.contains("border")).toBe(false);
expect(score.classList.contains("text-sm")).toBe(true);
expect(score.classList.contains("font-mono")).toBe(true);
});
it("uses the same typography for kill, bomb, and economy values", async () => {
const sharedClasses = ["text-sm", "font-mono", "font-bold", "tabular-nums"];
const rankTypes = [
RankType.ConquestHumans,
RankType.Atoms,
RankType.TotalGold,
];
for (const rankType of rankTypes) {
row?.remove();
row = await mountRow({ rankType, score: 7 });
const score = row.querySelector("[data-player-score]")!;
expect(
sharedClasses.every((name) => score.classList.contains(name)),
).toBe(true);
}
});
it("shows only the selected bomb metric", async () => {
row = await mountRow({
player: { atoms: 11, hydros: 22, mirv: 33 },
rankType: RankType.Hydros,
score: 22,
});
const scores = row.querySelectorAll("[data-player-score]");
expect(scores).toHaveLength(1);
expect(scores[0].textContent?.trim()).toBe("22");
expect(scores[0].getAttribute("aria-label")).toContain(
"game_info_modal.hydros",
);
expect(scores[0].textContent).not.toContain("11");
expect(scores[0].textContent).not.toContain("33");
expect(scores[0].classList.contains("border")).toBe(false);
});
it("renders economy values without a surrounding card", async () => {
row = await mountRow({ rankType: RankType.TotalGold, score: 12_345 });
const score = row.querySelector("[data-player-score]")!;
expect(score.textContent).toContain("12.3K");
expect(score.classList.contains("border")).toBe(false);
expect(score.classList.contains("rounded-lg")).toBe(false);
const coin = score.querySelector("img")!;
expect(coin.getAttribute("src")).toContain("GoldCoinIcon.svg");
expect(coin.getAttribute("width")).toBe("18");
expect(coin.getAttribute("height")).toBe("18");
});
it.each([RankType.TrainTrade, RankType.NavalTrade])(
"shows one selected trade score for %s",
async (rankType) => {
row = await mountRow({ rankType, score: 12_345 });
const scores = row.querySelectorAll("[data-player-score]");
expect(scores).toHaveLength(1);
expect(scores[0].textContent).toContain("12.3K");
expect(scores[0].getAttribute("aria-label")).toContain(
rankType === RankType.TrainTrade
? "game_info_modal.train_trade"
: "game_info_modal.naval_trade",
);
},
);
it.each([RankType.Atoms, RankType.Hydros, RankType.MIRV])(
"labels the selected bomb score for %s",
async (rankType) => {
row = await mountRow({ rankType, score: 7 });
expect(
row.querySelector("[data-player-score]")?.getAttribute("aria-label"),
).toContain(`game_info_modal.${rankType.toLowerCase()}`);
},
);
});
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { RankType } from "../../src/client/components/baseComponents/ranking/GameInfoRanking";
import { RankingControls } from "../../src/client/components/baseComponents/ranking/RankingControls";
async function mountControls(rankType: RankType): Promise<RankingControls> {
const controls = document.createElement(
"ranking-controls",
) as RankingControls;
controls.rankType = rankType;
document.body.appendChild(controls);
await controls.updateComplete;
return controls;
}
describe("RankingControls", () => {
let controls: RankingControls | null = null;
beforeAll(() => {
if (!customElements.get("ranking-controls")) {
customElements.define("ranking-controls", RankingControls);
}
});
afterEach(() => controls?.remove());
it("shows every war metric in one selector and dispatches the selected type", async () => {
controls = await mountControls(RankType.ConquestHumans);
const metrics = [
...controls.querySelectorAll<HTMLButtonElement>("[data-ranking-metric]"),
];
expect(metrics.map((button) => button.dataset.rankingMetric)).toEqual([
RankType.ConquestHumans,
RankType.ConquestNations,
RankType.ConquestBots,
RankType.Atoms,
RankType.Hydros,
RankType.MIRV,
]);
expect(metrics[0].getAttribute("aria-pressed")).toBe("true");
expect(
metrics.filter(
(button) => button.getAttribute("aria-pressed") === "true",
),
).toHaveLength(1);
const onSort = vi.fn();
controls.addEventListener("sort", onSort);
metrics.forEach((metric) => metric.click());
expect(onSort.mock.calls.map(([event]) => event.detail)).toEqual(
metrics.map((metric) => metric.dataset.rankingMetric),
);
});
it("shows every economy metric in one selector", async () => {
controls = await mountControls(RankType.TotalGold);
expect(
[
...controls.querySelectorAll<HTMLButtonElement>(
"[data-ranking-metric]",
),
].map((button) => button.dataset.rankingMetric),
).toEqual([
RankType.TotalGold,
RankType.ConqueredGold,
RankType.StolenGold,
RankType.TrainTrade,
RankType.NavalTrade,
]);
const metrics = [
...controls.querySelectorAll<HTMLButtonElement>("[data-ranking-metric]"),
];
expect(metrics[0].getAttribute("aria-pressed")).toBe("true");
expect(
metrics.filter(
(button) => button.getAttribute("aria-pressed") === "true",
),
).toHaveLength(1);
});
});