mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-22 22:58:06 +00:00
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:
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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()}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user