mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
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:  ## 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:
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
overflow="hidden"
|
||||
shape-rendering="geometricPrecision"
|
||||
image-rendering="optimizeQuality"
|
||||
fill-rule="evenodd"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1">
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath4">
|
||||
<rect
|
||||
style="fill:#ffffff"
|
||||
id="rect4"
|
||||
width="28.25"
|
||||
height="10.0625"
|
||||
x="-2.125"
|
||||
y="13.9375" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g
|
||||
id="g6"
|
||||
style="display:inline">
|
||||
<circle
|
||||
style="display:inline;fill:#ffffff"
|
||||
id="path1"
|
||||
cx="12.186869"
|
||||
cy="8.7946405"
|
||||
r="5.6568542" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke-width:1.59306"
|
||||
id="path1-8"
|
||||
cx="12.419416"
|
||||
cy="23.649515"
|
||||
rx="9.5459414"
|
||||
ry="8.5073786"
|
||||
clip-path="url(#clipPath4)"
|
||||
transform="translate(-0.1875)" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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",
|
||||
|
||||
@@ -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<AnalyticsRecord | false> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PlayerInfo> = [];
|
||||
@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`
|
||||
<o-modal
|
||||
id="gameInfoModal"
|
||||
title="${translateText("game_info_modal.title")}"
|
||||
translationKey="main.game_info"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center pl-[100px] pr-[100px] text-center mb-4"
|
||||
>
|
||||
<div class="w-[300px] sm:w-[500px]">
|
||||
${this.isLoadingGame
|
||||
? this.renderLoadingAnimation()
|
||||
: this.renderRanking()}
|
||||
</div>
|
||||
</div>
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
${this.renderGameInfo()}
|
||||
<ranking-controls
|
||||
.rankType=${this.rankType}
|
||||
@sort=${this.sort}
|
||||
></ranking-controls>
|
||||
${this.renderSummaryTable()}
|
||||
`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
private sort(e: CustomEvent<RankType>) {
|
||||
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`
|
||||
<div
|
||||
class="h-[150px] flex relative justify-between rounded-xl bg-blue-600 items-center"
|
||||
>
|
||||
${this.mapImage
|
||||
? html`<img
|
||||
src="${this.mapImage}"
|
||||
class="absolute place-self-start col-span-full row-span-full h-full rounded-xl"
|
||||
style="mask-image: linear-gradient(to left, transparent, #fff)"
|
||||
/>`
|
||||
: 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"
|
||||
>${info.config.gameMode}</span
|
||||
>
|
||||
<span class="font-bold">${info.config.gameMap}</span>
|
||||
</div>
|
||||
<div>${renderDuration(info.duration)}</div>
|
||||
<div>
|
||||
${info.players.length} ${translateText("game_info_modal.players")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSummaryTable() {
|
||||
const bestScore =
|
||||
this.rankedPlayers.length > 0 ? this.score(this.rankedPlayers[0]) : 0;
|
||||
return html`
|
||||
<ul class="">
|
||||
<ranking-header
|
||||
.rankType=${this.rankType}
|
||||
@sort=${this.sort}
|
||||
></ranking-header>
|
||||
${this.rankedPlayers.map(
|
||||
(player: PlayerInfo, index) => html`
|
||||
<player-row
|
||||
.player=${player}
|
||||
.rank=${index + 1}
|
||||
.score=${this.ranking?.score(player, this.rankType) ?? 0}
|
||||
.rankType=${this.rankType}
|
||||
.bestScore=${bestScore}
|
||||
.currentPlayer=${this.username === player.rawUsername}
|
||||
></player-row>
|
||||
`,
|
||||
)}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
<player-panel></player-panel>
|
||||
<spawn-timer></spawn-timer>
|
||||
<help-modal></help-modal>
|
||||
<game-info-modal></game-info-modal>
|
||||
<dark-mode-button></dark-mode-button>
|
||||
<stats-button></stats-button>
|
||||
<alert-frame></alert-frame>
|
||||
|
||||
@@ -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> = {},
|
||||
): 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user