Add end of game report window (#2598)

Resolves #1664

## Description:

Add a game ranking window, accessible through the player game history:
<img width="508" height="140" alt="image"
src="https://github.com/user-attachments/assets/51a628d9-628d-44c3-9776-d9b359b94e65"
/>

There is a lot of data players could be ranked with.
Three main ranking categories  with their own sub-categories:

<img width="371" height="264" alt="image"
src="https://github.com/user-attachments/assets/8b3b7c53-c52f-4b96-8039-23180c9181cf"
/>

### Duration:
Rank players according to their survival time

<img width="284" height="281" alt="image"
src="https://github.com/user-attachments/assets/6dfa0d11-7f5b-4f4f-81f8-f31e24ade6bf"
/>


### War:
#### Conquests:
Number of conquered players and bots

#### Bombs:
Show all bomb launched by each players. Can be sorted with each
category.
<img width="289" height="193" alt="image"
src="https://github.com/user-attachments/assets/fc0f9663-9a50-4098-b5c6-f434354accff"
/>

### Economy:
Show all gold earned by each players with trade, conquests, pirate or
total:

<img width="276" height="195" alt="image"
src="https://github.com/user-attachments/assets/a925249d-b2d2-4c61-92a5-4dbf5922b32b"
/>


### Responsiveness:


![ranking_showcase_resize](https://github.com/user-attachments/assets/5316d7f4-803f-4223-b834-783040226b7d)


## 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
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

IngloriousTom
This commit is contained in:
DevelopingTom
2025-12-19 04:41:29 +01:00
committed by GitHub
parent b63744834d
commit f532dab704
12 changed files with 1128 additions and 0 deletions
@@ -0,0 +1,150 @@
import { AnalyticsRecord, PlayerRecord } from "../../../../core/Schemas";
import {
GOLD_INDEX_STEAL,
GOLD_INDEX_TRADE,
GOLD_INDEX_WAR,
} from "../../../../core/StatsSchemas";
export enum RankType {
Conquests = "Conquests",
Atoms = "Atoms",
Hydros = "Hydros",
MIRV = "MIRV",
TotalGold = "TotalGold",
StolenGold = "StolenGold",
TradedGold = "TradedGold",
ConqueredGold = "ConqueredGold",
Lifetime = "Lifetime",
}
export interface PlayerInfo {
id: string;
rawUsername: string;
username: string;
tag?: string;
killedAt?: number;
gold: bigint[];
conquests: number;
flag?: string;
winner: boolean;
atoms: number;
hydros: number;
mirv: number;
}
function hasPlayed(player: PlayerRecord): boolean {
return (
player.stats !== undefined &&
(player.stats.units !== undefined ||
player.stats.killedAt !== undefined ||
player.stats.conquests !== undefined)
);
}
export class Ranking {
private readonly duration: number;
private players: PlayerInfo[];
constructor(session: AnalyticsRecord) {
this.duration = session.info.duration;
this.players = this.summarizePlayers(session);
}
get allPlayers() {
return this.players;
}
sortedBy(type: RankType): PlayerInfo[] {
return [...this.players].sort(
(a, b) => this.getAdjustedScore(b, type) - this.getAdjustedScore(a, type),
);
}
score(player: PlayerInfo, type: RankType): number {
return this.getScore(player, type);
}
private summarizePlayers(session: AnalyticsRecord): PlayerInfo[] {
const players: Record<string, PlayerInfo> = {};
for (const player of session.info.players) {
if (player === undefined || !hasPlayed(player)) continue;
const stats = player.stats!;
const match = player.username.match(/^\[(.*?)\]\s*(.*)$/);
let username = player.username;
if (player.clanTag && match) {
username = match[2];
}
const gold = (stats.gold ?? []).map((v) => BigInt(v ?? 0));
players[player.clientID] = {
id: player.clientID,
rawUsername: player.username,
username,
tag: player.clanTag,
conquests: Number(stats.conquests) || 0,
flag: player.cosmetics?.flag ?? undefined,
killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined,
gold,
atoms: Number(stats.bombs?.abomb?.[0]) || 0,
hydros: Number(stats.bombs?.hbomb?.[0]) || 0,
mirv: Number(stats.bombs?.mirv?.[0]) || 0,
winner: false,
};
}
const winnerBlock = session.info.winner;
if (
winnerBlock !== undefined &&
Array.isArray(winnerBlock) &&
winnerBlock.length > 0
) {
if (winnerBlock[0] === "player") {
const id = winnerBlock[1];
if (players[id]) players[id].winner = true;
} else if (winnerBlock[0] === "team") {
// First element is the team color, which we don't care for
for (let i = 2; i < winnerBlock.length; i++) {
const id = winnerBlock[i];
if (players[id]) {
players[id].winner = true;
}
}
}
}
return Object.values(players);
}
private getScore(player: PlayerInfo, type: RankType): number {
switch (type) {
case RankType.Lifetime:
if (player.killedAt) {
return (player.killedAt / Math.max(this.duration, 1)) * 10;
}
return 100;
case RankType.Conquests:
return player.conquests;
case RankType.Atoms:
return player.atoms;
case RankType.Hydros:
return player.hydros;
case RankType.MIRV:
return player.mirv;
case RankType.TotalGold:
return Number(player.gold.reduce((sum, gold) => sum + gold, 0n));
case RankType.StolenGold:
return Number(player.gold[GOLD_INDEX_STEAL] ?? 0n);
case RankType.TradedGold:
return Number(player.gold[GOLD_INDEX_TRADE] ?? 0n);
case RankType.ConqueredGold:
return Number(player.gold[GOLD_INDEX_WAR] ?? 0n);
}
}
private getAdjustedScore(player: PlayerInfo, type: RankType): number {
let score = this.getScore(player, type);
// Other things being equals, winners should be better ranked than other players
if (player.winner) score += 0.1;
return score;
}
}
@@ -0,0 +1,223 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { renderNumber } from "../../../Utils";
import { PlayerInfo, RankType } from "./GameInfoRanking";
@customElement("player-row")
export class PlayerRow extends LitElement {
@property({ type: Object }) player: PlayerInfo;
@property({ type: String }) rankType: RankType;
@property({ type: Number }) bestScore = 1;
@property({ type: Number }) rank = 1;
@property({ type: Number }) score = 0;
@property({ type: Boolean }) currentPlayer = false;
createRenderRoot() {
return this;
}
render() {
if (!this.player) return html``;
const { player } = this;
const visibleBorder = player.winner || this.currentPlayer;
return html`
<li
class="bg-gradient-to-r ${player.winner
? "from-sky-400 to-blue-700"
: "bg-slate-700"} border-[2px]
${player.winner
? "border-yellow-500"
: "border-yellow-50"} ${visibleBorder ? "" : "border-opacity-0"}
relative pt-1 pb-1 pr-2 pl-2 sm:pl-5 sm:pr-5 mb-[5px] rounded-lg flex justify-between items-center hover:bg-slate-500 transition duration-150 ease-in-out"
>
<div
class="font-bold text-right w-[30px] text-lg text-white absolute left-[-40px]"
>
${this.rank}
</div>
${this.renderPlayerInfo()}
</li>
`;
}
private renderPlayerIcon() {
return html`
${this.renderIcon()} ${this.player.winner ? this.renderCrownIcon() : ""}
`;
}
private renderCrownIcon() {
return html`
<img
src="/images/CrownIcon.svg"
class="absolute top-[-3px] left-[16px] w-[15px] h-[15px] sm:top-[-7px] sm:left-[30px] sm:w-[20px] sm:h-[20px]"
/>
`;
}
private renderPlayerInfo() {
switch (this.rankType) {
case RankType.Lifetime:
case RankType.Conquests:
return this.renderScoreAsBar();
case RankType.Atoms:
case RankType.Hydros:
case RankType.MIRV:
return this.renderBombScore();
case RankType.TotalGold:
case RankType.TradedGold:
case RankType.ConqueredGold:
case RankType.StolenGold:
return this.renderGoldScore();
default:
return html``;
}
}
private renderScoreAsBar() {
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="font-bold rounded-[50%] w-[30px] h-[30px] leading-[1.6rem] border text-center bg-white text-black"
>
${Number(this.score).toFixed(0)}
</div>
</div>
`;
}
private renderScoreBar() {
const bestScore = Math.max(this.bestScore, 1);
const width = Math.min(Math.max((this.score / bestScore) * 100, 0), 100);
return html`
<div class="w-full pr-[10px] m-auto">
<div class="h-[7px] bg-neutral-800" style="width: 100%;">
<!-- bar background -->
<div class="h-[7px] bg-white" style="width: ${width}%;"></div>
</div>
</div>
`;
}
private renderBombType(value: number, highlight: boolean) {
return html`
<div
class="${highlight
? "font-bold text-[18px]"
: ""} min-w-[30px] sm:min-w-[60px] inline-block text-center"
>
${value}
</div>
`;
}
private renderAllBombs() {
return html`
<div class="flex justify-between text-sm sm:pr-20">
${this.renderBombType(
this.player.atoms,
this.rankType === RankType.Atoms,
)}
/
${this.renderBombType(
this.player.hydros,
this.rankType === RankType.Hydros,
)}
/
${this.renderBombType(
this.player.mirv,
this.rankType === RankType.MIRV,
)}
</div>
`;
}
private renderBombScore() {
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.renderAllBombs()}
</div>
</div>
`;
}
private renderGoldScore() {
return html`
<div class="flex gap-3 items-center">
${this.renderPlayerIcon()}
<div
class="text-left w-[125px] max-w-[125px] sm:w-[250px] sm:max-w-[250px]"
>
${this.renderPlayerName()}
</div>
</div>
<div class="flex gap-2">
<div
class="font-bold rounded-md w-[60px] max-w-[60px] h-[30px] text-sm sm:w-[100px] sm:h-[30px] leading-[1.9rem] text-center"
>
${renderNumber(this.score)}
</div>
<img
src="/images/GoldCoinIcon.svg"
class="w-[14px] h-[14px] sm:w-[20px] sm:h-[20px] m-auto"
/>
</div>
`;
}
private renderPlayerName() {
return html`
<div class="flex gap-1 items-center max-w-[200px] min-w-[200px]">
${this.player.tag ? this.renderTag(this.player.tag) : ""}
<div
class="text-xs sm:text-sm font-bold text-ellipsis max-w-[150px] min-w-[150px] overflow-hidden whitespace-nowrap"
>
${this.player.username}
</div>
</div>
`;
}
private renderTag(tag: string) {
return html`
<div
class="bg-white text-black rounded-lg sm:rounded-xl border text-xs leading-[12px] sm:leading-[18px] text-blue-900 h-[15px] pr-[4px] pl-[4px] sm:h-[20px] sm:pr-[8px] sm:pl-[8px] font-bold"
>
${tag}
</div>
`;
}
private renderIcon() {
if (this.player.killedAt) {
return html` <div
class="w-[30px] h-[30px] leading-[5px] text-lg sm:min-w-[40px] sm:w-[40px] sm:h-[40px] pt-[12px] sm:leading-[15px] sm:rounded-[50%] sm:border text-center sm:bg-slate-500 sm:text-2xl"
>
💀
</div>`;
} else if (this.player.flag) {
return html`<img
src="/flags/${this.player.flag}.svg"
class="min-w-[30px] h-[30px] sm:min-w-[40px] sm:h-[40px]"
/>`;
}
return html`
<div
class="w-[30px] h-[30px] min-w-[30px] leading-[5px] rounded-[50%] sm:min-w-[40px] sm:w-[40px] sm:h-[40px] sm:pt-[10px] sm:leading-[14px] border text-center bg-slate-500"
>
<img
src="/images/ProfileIcon.svg"
class="w-[20px] h-[20px] mt-[2px] sm:w-[25px] sm:h-[25px] sm:mt-[-5px] m-auto"
/>
</div>
`;
}
}
@@ -0,0 +1,128 @@
import { LitElement, html } 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.TradedGold,
]);
const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]);
const warRankings = new Set([
RankType.Conquests,
RankType.Atoms,
RankType.Hydros,
RankType.MIRV,
]);
const isEconomyRanking = (t: RankType) => economyRankings.has(t);
const isBombRanking = (t: RankType) => bombRankings.has(t);
const isWarRanking = (t: RankType) => warRankings.has(t);
@customElement("ranking-controls")
export class RankingControls extends LitElement {
@property({ type: String }) rankType = RankType.Lifetime;
private onSort(type: RankType) {
this.dispatchEvent(new CustomEvent("sort", { detail: type }));
}
private renderMainButtons() {
return html`
<div class="flex items-end justify-center p-6 pb-2 gap-5">
${this.renderButton(
RankType.Lifetime,
this.rankType === RankType.Lifetime,
"game_info_modal.duration",
)}
${this.renderButton(
RankType.Conquests,
isWarRanking(this.rankType),
"game_info_modal.war",
)}
${this.renderButton(
RankType.TotalGold,
isEconomyRanking(this.rankType),
"game_info_modal.economy",
)}
</div>
`;
}
private renderButton(type: RankType, active: boolean, label: string) {
return html`
<button
class="rounded-lg bg-blue-600 text-white text-lg p-3 hover:bg-blue-400 ${active
? "active"
: ""}"
style="${active ? "outline: solid 2px white; font-weight: bold;" : ""}"
@click=${() => this.onSort(type)}
>
${translateText(label)}
</button>
`;
}
private renderWarSubranking() {
if (!isWarRanking(this.rankType)) return "";
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.Conquests,
this.rankType === RankType.Conquests,
"game_info_modal.conquests",
)}
</div>
`;
}
private renderEconomySubranking() {
if (!isEconomyRanking(this.rankType)) return "";
const econButtons = [
[RankType.TradedGold, "game_info_modal.trade"],
[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">
${econButtons.map(([type, label]) =>
this.renderSubButton(type as RankType, this.rankType === type, label),
)}
</div>
`;
}
private renderSubButton(type: RankType, active: boolean, label: string) {
return html`
<button
@click=${() => this.onSort(type)}
class="rounded-md bg-blue-50 text-black text-sm p-2 hover:bg-blue-200"
style="${active ? "outline: solid 2px white; font-weight: bold;" : ""}"
>
${translateText(label)}
</button>
`;
}
render() {
return html`
${this.renderMainButtons()} ${this.renderWarSubranking()}
${this.renderEconomySubranking()}
`;
}
createRenderRoot() {
return this;
}
}
@@ -0,0 +1,93 @@
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="text-lg bg-gray-800 font-bold relative pt-2 pb-2 pr-5 pl-5 mb-[5px] rounded-md 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.Conquests:
return html`<div class="w-full">
${translateText("game_info_modal.num_of_conquests")}
</div>`;
case RankType.Atoms:
case RankType.Hydros:
case RankType.MIRV:
return html`
<div class="flex justify-between sm:pl-[70px] sm:pr-[70px] w-full">
${this.renderBombHeaderButton(
translateText("game_info_modal.atoms"),
RankType.Atoms,
)}
/
${this.renderBombHeaderButton(
translateText("game_info_modal.hydros"),
RankType.Hydros,
)}
/
${this.renderBombHeaderButton(
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.TradedGold:
return html`<div class="w-full">
${translateText("game_info_modal.trade")}
</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 renderBombHeaderButton(label: string, type: RankType) {
return html`
<button
@click=${() => this.onSort(type)}
style="${this.rankType === type
? "border-bottom: solid 2px white;"
: nothing}"
>
${label}
</button>
`;
}
createRenderRoot() {
return this;
}
}
@@ -2,6 +2,7 @@ import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { PlayerGame } from "../../../../core/ApiSchemas";
import { GameMode } from "../../../../core/game/Game";
import { GameInfoModal } from "../../../GameInfoModal";
import { translateText } from "../../../Utils";
@customElement("game-list")
@@ -62,6 +63,19 @@ export class GameList extends LitElement {
this.expandedGameId = this.expandedGameId === gameId ? null : gameId;
}
private showRanking(gameId: string) {
const gameInfoModal = document.querySelector(
"game-info-modal",
) as GameInfoModal;
if (!gameInfoModal) {
console.warn("Game info modal element not found");
} else {
gameInfoModal.loadGame(gameId);
gameInfoModal.open();
}
}
render() {
return html` <div class="mt-4 w-full max-w-md">
<div class="text-sm text-gray-400 font-semibold mb-1">
@@ -97,6 +111,12 @@ export class GameList extends LitElement {
>
${translateText("game_list.details")}
</button>
<button
class="btn secondary"
@click=${() => this.showRanking(game.gameId)}
>
${translateText("game_list.ranking")}
</button>
</div>
</div>
<div