diff --git a/resources/images/ProfileIcon.svg b/resources/images/ProfileIcon.svg new file mode 100644 index 000000000..9d5d19c8b --- /dev/null +++ b/resources/images/ProfileIcon.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index aa046e58c..90c56a777 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -30,6 +30,7 @@ "join_lobby": "Join Lobby", "single_player": "Single Player", "instructions": "Instructions", + "game_info": "Game info", "wiki": "Wiki", "privacy_policy": "Privacy Policy", "terms_of_service": "Terms of Service", @@ -179,6 +180,29 @@ "win_loss_ratio": "Win/Loss", "rank": "Rank" }, + "game_info_modal": { + "title": "Game info", + "players": "Players", + "atoms": "Atoms", + "hydros": "Hydros", + "mirv": "MIRV", + "bombs": "Bombs", + "total_gold": "Total", + "all_gold": "All gold", + "trade": "Trade", + "conquest_gold": "Conquered player gold", + "stolen_gold": "Stolen with warships", + "num_of_conquests": "Number of conquered players", + "duration": "Duration", + "survival_time": "Survival time", + "war": "War", + "economy": "Economy", + "conquests": "Conquests", + "pirate": "Pirate", + "conquered": "Conquered", + "loading_game_info": "Loading game stats", + "no_winner": "This game ended with no winner" + }, "map": { "map": "Map", "world": "World", @@ -801,6 +825,7 @@ "mode_team": "Team", "view": "View", "details": "Details", + "ranking": "Ranking", "started": "Started", "map": "Map", "difficulty": "Difficulty", diff --git a/src/client/Api.ts b/src/client/Api.ts index a02ebe5c9..9456f88db 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -5,6 +5,7 @@ import { UserMeResponse, UserMeResponseSchema, } from "../core/ApiSchemas"; +import { AnalyticsRecord, AnalyticsRecordSchema } from "../core/Schemas"; import { getAuthHeader, logOut, userAuth } from "./Auth"; export async function fetchPlayerById( @@ -142,3 +143,37 @@ export function hasLinkedAccount( userMeResponse.user?.email !== undefined) ); } + +export async function fetchGameById( + gameId: string, +): Promise { + try { + const url = `${getApiBase()}/game/${encodeURIComponent(gameId)}`; + const res = await fetch(url, { + headers: { + Accept: "application/json", + }, + }); + + if (res.status !== 200) { + console.warn( + "fetchGameById: unexpected status", + res.status, + res.statusText, + ); + return false; + } + + const json = await res.json(); + const parsed = AnalyticsRecordSchema.safeParse(json); + if (!parsed.success) { + console.warn("fetchGameById: Zod validation failed", parsed.error); + return false; + } + + return parsed.data; + } catch (err) { + console.warn("fetchGameById: request failed", err); + return false; + } +} diff --git a/src/client/GameInfoModal.ts b/src/client/GameInfoModal.ts new file mode 100644 index 000000000..c467b7ba1 --- /dev/null +++ b/src/client/GameInfoModal.ts @@ -0,0 +1,214 @@ +import { html, LitElement } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { GameEndInfo } from "../core/Schemas"; +import { GameMapType } from "../core/game/Game"; +import { fetchGameById } from "./Api"; +import { terrainMapFileLoader } from "./TerrainMapFileLoader"; +import { UsernameInput } from "./UsernameInput"; +import { renderDuration, translateText } from "./Utils"; +import { + PlayerInfo, + Ranking, + RankType, +} from "./components/baseComponents/ranking/GameInfoRanking"; +import "./components/baseComponents/ranking/PlayerRow"; +import "./components/baseComponents/ranking/RankingControls"; +import "./components/baseComponents/ranking/RankingHeader"; + +@customElement("game-info-modal") +export class GameInfoModal extends LitElement { + @query("o-modal") private modalEl!: HTMLElement & { + open: () => void; + close: () => void; + }; + + @state() private mapImage: string | null = null; + @state() private gameInfo: GameEndInfo | null = null; + @state() private rankedPlayers: Array = []; + @property({ type: String }) gameId: string | null = null; + @property({ type: String }) rankType = RankType.Lifetime; + + @state() private username: string | null = null; + @state() private isLoadingGame: boolean = true; + + private ranking: Ranking | null = null; + + connectedCallback() { + super.connectedCallback(); + this.updateRanking(); + } + + createRenderRoot() { + return this; + } + + render() { + return html` + +
+
+ ${this.isLoadingGame + ? this.renderLoadingAnimation() + : this.renderRanking()} +
+
+
+ `; + } + + private renderRanking() { + if (this.rankedPlayers.length === 0) { + return html` +
+

❌ ${translateText("game_info_modal.no_winner")}

+
+ `; + } + return html` + ${this.renderGameInfo()} + + ${this.renderSummaryTable()} + `; + } + + private renderLoadingAnimation() { + return html`
+

${translateText("game_info_modal.loading_game_info")}

+
+
`; + } + + private sort(e: CustomEvent) { + this.rankType = e.detail; + this.updateRanking(); + } + + private updateRanking() { + if (this.ranking) { + this.rankedPlayers = this.ranking.sortedBy(this.rankType); + } + } + + private renderGameInfo() { + const info = this.gameInfo; + if (!info) { + return html``; + } + return html` +
+ ${this.mapImage + ? html`` + : html`
`} +
+
+ ${info.config.gameMode} + ${info.config.gameMap} +
+
${renderDuration(info.duration)}
+
+ ${info.players.length} ${translateText("game_info_modal.players")} +
+
+
+ `; + } + + private renderSummaryTable() { + const bestScore = + this.rankedPlayers.length > 0 ? this.score(this.rankedPlayers[0]) : 0; + return html` +
    + + ${this.rankedPlayers.map( + (player: PlayerInfo, index) => html` + + `, + )} +
+ `; + } + + public open() { + this.modalEl?.open(); + } + + public close() { + this.modalEl?.close(); + } + + private score(player: PlayerInfo): number { + if (!this.ranking) return 0; + return this.ranking.score(player, this.rankType); + } + + private async loadMapImage(gameMap: string) { + try { + const mapType = gameMap as GameMapType; + const data = terrainMapFileLoader.getMapData(mapType); + this.mapImage = await data.webpPath(); + } catch (error) { + console.error("Failed to load map image:", error); + } + } + + public loadUserName() { + const usernameInput = document.querySelector( + "username-input", + ) as UsernameInput; + if (usernameInput) { + this.username = usernameInput.getCurrentUsername(); + } + } + + public async loadGame(gameId: string) { + try { + this.isLoadingGame = true; + this.loadUserName(); + const session = await fetchGameById(gameId); + if (!session) return; + + this.gameInfo = session.info; + this.ranking = new Ranking(session); + this.updateRanking(); + this.isLoadingGame = false; + await this.loadMapImage(session.info.config.gameMap); + } catch (err) { + console.error("Failed to load game:", err); + } finally { + this.isLoadingGame = false; + } + } +} diff --git a/src/client/Main.ts b/src/client/Main.ts index 598604cc0..75d54c3a3 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -17,6 +17,7 @@ import { DarkModeButton } from "./DarkModeButton"; import "./FlagInput"; import { FlagInput } from "./FlagInput"; import { FlagInputModal } from "./FlagInputModal"; +import { GameInfoModal } from "./GameInfoModal"; import { GameStartingModal } from "./GameStartingModal"; import "./GoogleAdElement"; import { GutterAds } from "./GutterAds"; @@ -196,6 +197,10 @@ class Client { if (!hlpModal || !(hlpModal instanceof HelpModal)) { console.warn("Help modal element not found"); } + const giModal = document.querySelector("game-info-modal") as GameInfoModal; + if (!giModal || !(giModal instanceof GameInfoModal)) { + console.warn("Game info modal element not found"); + } const helpButton = document.getElementById("help-button"); if (helpButton === null) throw new Error("Missing help-button"); helpButton.addEventListener("click", () => { diff --git a/src/client/components/baseComponents/ranking/GameInfoRanking.ts b/src/client/components/baseComponents/ranking/GameInfoRanking.ts new file mode 100644 index 000000000..015d6ae40 --- /dev/null +++ b/src/client/components/baseComponents/ranking/GameInfoRanking.ts @@ -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 = {}; + + 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), + ); + }); +});