= {};
+
+ 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;
+ }
+}
diff --git a/src/client/components/baseComponents/ranking/PlayerRow.ts b/src/client/components/baseComponents/ranking/PlayerRow.ts
new file mode 100644
index 000000000..c90606a4a
--- /dev/null
+++ b/src/client/components/baseComponents/ranking/PlayerRow.ts
@@ -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`
+
+
+ ${this.rank}
+
+ ${this.renderPlayerInfo()}
+
+ `;
+ }
+
+ private renderPlayerIcon() {
+ return html`
+ ${this.renderIcon()} ${this.player.winner ? this.renderCrownIcon() : ""}
+ `;
+ }
+
+ private renderCrownIcon() {
+ return html`
+
+ `;
+ }
+
+ 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`
+
+ ${this.renderPlayerIcon()}
+
+ ${this.renderPlayerName()} ${this.renderScoreBar()}
+
+
+
+
+ ${Number(this.score).toFixed(0)}
+
+
+ `;
+ }
+
+ private renderScoreBar() {
+ const bestScore = Math.max(this.bestScore, 1);
+ const width = Math.min(Math.max((this.score / bestScore) * 100, 0), 100);
+ return html`
+
+ `;
+ }
+ private renderBombType(value: number, highlight: boolean) {
+ return html`
+
+ ${value}
+
+ `;
+ }
+
+ private renderAllBombs() {
+ return html`
+
+ ${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,
+ )}
+
+ `;
+ }
+
+ private renderBombScore() {
+ return html`
+
+ ${this.renderPlayerIcon()}
+
+ ${this.renderPlayerName()} ${this.renderAllBombs()}
+
+
+ `;
+ }
+
+ private renderGoldScore() {
+ return html`
+
+ ${this.renderPlayerIcon()}
+
+ ${this.renderPlayerName()}
+
+
+
+
+ ${renderNumber(this.score)}
+
+

+
+ `;
+ }
+
+ private renderPlayerName() {
+ return html`
+
+ ${this.player.tag ? this.renderTag(this.player.tag) : ""}
+
+ ${this.player.username}
+
+
+ `;
+ }
+
+ private renderTag(tag: string) {
+ return html`
+
+ ${tag}
+
+ `;
+ }
+
+ private renderIcon() {
+ if (this.player.killedAt) {
+ return html`
+ 💀
+
`;
+ } else if (this.player.flag) {
+ return html`
`;
+ }
+
+ return html`
+
+

+
+ `;
+ }
+}
diff --git a/src/client/components/baseComponents/ranking/RankingControls.ts b/src/client/components/baseComponents/ranking/RankingControls.ts
new file mode 100644
index 000000000..dcbc07baf
--- /dev/null
+++ b/src/client/components/baseComponents/ranking/RankingControls.ts
@@ -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`
+
+ ${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",
+ )}
+
+ `;
+ }
+
+ private renderButton(type: RankType, active: boolean, label: string) {
+ return html`
+
+ `;
+ }
+
+ private renderWarSubranking() {
+ if (!isWarRanking(this.rankType)) return "";
+
+ return html`
+
+ ${this.renderSubButton(
+ RankType.MIRV,
+ isBombRanking(this.rankType),
+ "game_info_modal.bombs",
+ )}
+ ${this.renderSubButton(
+ RankType.Conquests,
+ this.rankType === RankType.Conquests,
+ "game_info_modal.conquests",
+ )}
+
+ `;
+ }
+
+ 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`
+
+ ${econButtons.map(([type, label]) =>
+ this.renderSubButton(type as RankType, this.rankType === type, label),
+ )}
+
+ `;
+ }
+
+ private renderSubButton(type: RankType, active: boolean, label: string) {
+ return html`
+
+ `;
+ }
+
+ render() {
+ return html`
+ ${this.renderMainButtons()} ${this.renderWarSubranking()}
+ ${this.renderEconomySubranking()}
+ `;
+ }
+
+ createRenderRoot() {
+ return this;
+ }
+}
diff --git a/src/client/components/baseComponents/ranking/RankingHeader.ts b/src/client/components/baseComponents/ranking/RankingHeader.ts
new file mode 100644
index 000000000..c86f511b2
--- /dev/null
+++ b/src/client/components/baseComponents/ranking/RankingHeader.ts
@@ -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`
+
+ ${this.renderHeaderContent()}
+
+ `;
+ }
+
+ private renderHeaderContent() {
+ switch (this.rankType) {
+ case RankType.Lifetime:
+ return html`
+ ${translateText("game_info_modal.survival_time")}
+
`;
+ case RankType.Conquests:
+ return html`
+ ${translateText("game_info_modal.num_of_conquests")}
+
`;
+ case RankType.Atoms:
+ case RankType.Hydros:
+ case RankType.MIRV:
+ return html`
+
+ ${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,
+ )}
+
+ `;
+ case RankType.TotalGold:
+ return html`
+ ${translateText("game_info_modal.all_gold")}
+
`;
+ case RankType.TradedGold:
+ return html`
+ ${translateText("game_info_modal.trade")}
+
`;
+ case RankType.ConqueredGold:
+ return html`
+ ${translateText("game_info_modal.conquest_gold")}
+
`;
+ case RankType.StolenGold:
+ return html`
+ ${translateText("game_info_modal.stolen_gold")}
+
`;
+ default:
+ console.warn("Unhandled RankType", this.rankType);
+ return null;
+ }
+ }
+
+ private renderBombHeaderButton(label: string, type: RankType) {
+ return html`
+
+ `;
+ }
+
+ createRenderRoot() {
+ return this;
+ }
+}
diff --git a/src/client/components/baseComponents/stats/GameList.ts b/src/client/components/baseComponents/stats/GameList.ts
index 83b50489b..7452045ca 100644
--- a/src/client/components/baseComponents/stats/GameList.ts
+++ b/src/client/components/baseComponents/stats/GameList.ts
@@ -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`
@@ -97,6 +111,12 @@ export class GameList extends LitElement {
>
${translateText("game_list.details")}
+
+
diff --git a/tests/GameInfoRanking.test.ts b/tests/GameInfoRanking.test.ts
new file mode 100644
index 000000000..aeb92ddeb
--- /dev/null
+++ b/tests/GameInfoRanking.test.ts
@@ -0,0 +1,188 @@
+import {
+ Ranking,
+ RankType,
+} from "../src/client/components/baseComponents/ranking/GameInfoRanking";
+import {
+ Difficulty,
+ GameMapSize,
+ GameMapType,
+ GameMode,
+ GameType,
+} from "../src/core/game/Game";
+import { AnalyticsRecord } from "../src/core/Schemas";
+import {
+ GOLD_INDEX_STEAL,
+ GOLD_INDEX_TRADE,
+ GOLD_INDEX_WAR,
+} from "../src/core/StatsSchemas";
+
+describe("Ranking class", () => {
+ const mockConfig = {
+ gameMap: GameMapType.Montreal,
+ difficulty: Difficulty.Medium,
+ donateGold: false,
+ donateTroops: false,
+ gameType: GameType.Public,
+ gameMode: GameMode.FFA,
+ gameMapSize: GameMapSize.Normal,
+ disableNPCs: true,
+ bots: 0,
+ infiniteGold: false,
+ infiniteTroops: false,
+ instantBuild: false,
+ maxPlayers: 40,
+ disabledUnits: [],
+ randomSpawn: false,
+ };
+
+ const gameTickDuration = 1000;
+ const gameDuration = gameTickDuration / 10;
+
+ function makeSession(
+ overrides: Partial
= {},
+ ): AnalyticsRecord {
+ return {
+ version: "v0.0.2",
+ info: {
+ duration: gameTickDuration,
+ winner: ["player", "p2"],
+ players: [
+ {
+ clientID: "p1",
+ username: "[X] Alice",
+ clanTag: "X",
+ cosmetics: { flag: "USA" },
+ stats: {
+ units: { port: [2n, 0n, 0n, 2n] },
+ conquests: 5n,
+ gold: [0n, 100n, 20n, 0n], // total 120
+ bombs: {
+ abomb: [1n],
+ hbomb: [1n],
+ mirv: [2n],
+ },
+ },
+ persistentID: null,
+ },
+ {
+ clientID: "p2",
+ username: "Bob",
+ stats: {
+ units: { city: [2n, 0n, 0n, 2n] },
+ conquests: 8n,
+ gold: [0n, 50n, 10n, 5n], // total 65
+ bombs: {
+ abomb: [0n],
+ hbomb: [2n],
+ mirv: [0n],
+ },
+ },
+ persistentID: null,
+ },
+ {
+ clientID: "p3",
+ username: "Charlie",
+ stats: {
+ // no units, but has conquests/killedAt to count as played
+ conquests: 8n,
+ killedAt: BigInt(600),
+ gold: [0n, 10n, 2n, 10n], // total 22
+ bombs: {},
+ },
+ persistentID: null,
+ },
+ ],
+ gameID: "",
+ lobbyCreatedAt: 0,
+ config: { ...mockConfig },
+ start: 0,
+ end: 0,
+ num_turns: 0,
+ lobbyFillTime: 0,
+ },
+ gitCommit: "DEV",
+ subdomain: "",
+ domain: "",
+ };
+ }
+
+ test("summarizes players correctly", () => {
+ const r = new Ranking(makeSession());
+ const players = r.sortedBy(RankType.Conquests);
+
+ expect(players.length).toBe(3);
+
+ const p1 = players.find((p) => p.id === "p1")!;
+ expect(p1.username).toBe("Alice");
+ expect(p1.flag).toBe("USA");
+ expect(p1.conquests).toBe(5);
+ expect(p1.atoms).toBe(1);
+ expect(p1.mirv).toBe(2);
+ });
+
+ test("correctly identifies winner", () => {
+ const r = new Ranking(makeSession());
+ const p2 = r.sortedBy(RankType.Conquests).find((p) => p.id === "p2")!;
+ expect(p2.winner).toBe(true);
+ });
+
+ test("rank by total gold", () => {
+ const r = new Ranking(makeSession());
+ const rankedPlayers = r.sortedBy(RankType.TotalGold);
+ expect(rankedPlayers.length).toBe(3);
+ expect(rankedPlayers[0].id).toBe("p1");
+ expect(rankedPlayers[1].id).toBe("p2");
+ expect(rankedPlayers[2].id).toBe("p3");
+ });
+
+ test("rank by stolen gold", () => {
+ const r = new Ranking(makeSession());
+ const rankedPlayers = r.sortedBy(RankType.StolenGold);
+ expect(rankedPlayers.length).toBe(3);
+ expect(rankedPlayers[0].id).toBe("p3");
+ expect(rankedPlayers[1].id).toBe("p2");
+ expect(rankedPlayers[2].id).toBe("p1");
+ });
+
+ test("rank by hydros", () => {
+ const r = new Ranking(makeSession());
+ const rankedPlayers = r.sortedBy(RankType.Hydros);
+ expect(rankedPlayers.length).toBe(3);
+ expect(rankedPlayers[0].id).toBe("p2");
+ expect(rankedPlayers[1].id).toBe("p1");
+ expect(rankedPlayers[2].id).toBe("p3");
+ });
+
+ test("lifetime score is percentage of duration", () => {
+ const r = new Ranking(makeSession());
+ const p3 = r.sortedBy(RankType.Conquests).find((p) => p.id === "p3")!;
+ const expected = Number(BigInt(600)) / gameDuration;
+ expect(r.score(p3, RankType.Lifetime)).toBe(expected);
+ });
+
+ test("lifetime score gives 100 when alive", () => {
+ const r = new Ranking(makeSession());
+ const p1 = r.allPlayers.find((p) => p.id === "p1")!;
+ expect(r.score(p1, RankType.Lifetime)).toBe(100);
+ });
+
+ test("winners should be ahead of players with same score", () => {
+ const r = new Ranking(makeSession());
+ const sortedPlayers = r.sortedBy(RankType.Conquests);
+ expect(sortedPlayers[0].id).toBe("p2"); // p2 & p3 same score but winner first
+ });
+
+ test("gold scores work correctly", () => {
+ const r = new Ranking(makeSession());
+ const p1 = r.sortedBy(RankType.TotalGold).find((p) => p.id === "p1")!;
+ expect(r.score(p1, RankType.StolenGold)).toBe(
+ Number(p1.gold[GOLD_INDEX_STEAL] ?? 0n),
+ );
+ expect(r.score(p1, RankType.TradedGold)).toBe(
+ Number(p1.gold[GOLD_INDEX_TRADE] ?? 0n),
+ );
+ expect(r.score(p1, RankType.ConqueredGold)).toBe(
+ Number(p1.gold[GOLD_INDEX_WAR] ?? 0n),
+ );
+ });
+});